From ca19b7686d22560d846047ebe1ac5a8c1ea79dd6 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 07:53:18 -0700 Subject: [PATCH 01/48] A reopened cell editor shows the cell it landed on (improvise-fy9o) The cell-editing commits own their buffer clear at their head instead of the keymap welding one after them, so the re-seed survives and Enter, Enter writes nothing. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- .../src/command/cmd/commit.rs | 73 ++++++++++++++++++- .../improvise-command/src/command/keymap.rs | 41 ++++++++--- crates/improvise-protocol/src/keymap.rs | 15 +++- crates/improvise-protocol/src/resolve.rs | 38 +++++++--- .../improvise-wasm-client/tests/lockstep.rs | 65 +++++++++++++++++ 5 files changed, 209 insertions(+), 23 deletions(-) diff --git a/crates/improvise-command/src/command/cmd/commit.rs b/crates/improvise-command/src/command/cmd/commit.rs index f235177c..2d97e428 100644 --- a/crates/improvise-command/src/command/cmd/commit.rs +++ b/crates/improvise-command/src/command/cmd/commit.rs @@ -5,7 +5,7 @@ use crate::ui::effect::{self, Effect}; use crate::view::RecordCol; use improvise_protocol::{OpenedEditor, untouched}; -use super::core::{Cmd, CmdContext, read_buffer}; +use super::core::{Cmd, CmdContext, read_buffer, write_buffer}; use super::grid::AddRecordRow; use super::navigation::{CursorState, EnterAdvance, viewport_effects}; @@ -805,6 +805,72 @@ mod tests { "the reopened editor did not capture the cell it landed on" ); } + + /// Every cell of the 2×2 fixture, by its key. + fn every_cell(app: &crate::ui::app::App) -> Vec> { + use crate::model::cell::CellKey; + let mut cells = Vec::new(); + for region in ["North", "West"] { + for measure in ["Cost", "Revenue"] { + let key = CellKey::new(vec![ + ("Region".into(), region.into()), + ("_Measure".into(), measure.into()), + ]); + cells.push(app.model_state.workbook.model.get_cell(&key).cloned()); + } + } + cells + } + + /// BUG (improvise-fy9o) — see `context/test-evidence/command.md`. + #[test] + fn enter_enter_leaves_every_cell_unchanged() { + use improvise_protocol::key::KeyCode; + for (records, start) in [(false, (1, 0)), (true, (1, 2))] { + let mut app = grid_holding(CellValue::Number(4.0), records); + if records { + app.view_state.mode = AppMode::RecordsNormal; + } + app.cursor_mut().selected = start; + let before = every_cell(&app); + press(&mut app, KeyCode::Char('i')); + press(&mut app, KeyCode::Enter); + let reopened = app.view_state.buffers.get("edit").cloned(); + press(&mut app, KeyCode::Enter); + assert_eq!( + every_cell(&app), + before, + "records={records}: i, Enter, Enter changed a cell (the reopened editor held {reopened:?})" + ); + } + } + + /// The reopened editor shows the cell it landed on, so typing extends it. + /// BUG (improvise-fy9o) — see `context/test-evidence/command.md`. + #[test] + fn typing_after_the_reopen_still_writes() { + use crate::model::cell::CellKey; + use improvise_protocol::key::KeyCode; + let mut app = grid_holding(CellValue::Number(4.0), false); + app.cursor_mut().selected = (1, 0); + press(&mut app, KeyCode::Char('i')); + press(&mut app, KeyCode::Enter); + assert_eq!( + app.view_state.buffers.get("edit").map(String::as_str), + Some("2"), + "the editor reopened on (North, Revenue) is not seeded with its value" + ); + press(&mut app, KeyCode::Char('5')); + press(&mut app, KeyCode::Enter); + let landed = CellKey::new(vec![ + ("Region".into(), "North".into()), + ("_Measure".into(), "Revenue".into()), + ]); + assert_eq!( + app.model_state.workbook.model.get_cell(&landed), + Some(&CellValue::Number(25.0)) + ); + } } // ── Commit commands (mode-specific buffer consumers) ──────────────────────── @@ -951,6 +1017,11 @@ impl Cmd for CommitAndAdvance { // Drop to normal **before** the commit, which can abort: tidy-up goes where // an abort cannot reach it (improvise-2zq, gotcha 21). effects.push(effect::change_mode(exit_mode_for(&self.edit_mode))); + // This command owns its buffer's clear, at the head, so the re-entry + // below re-seeds after it rather than being blanked (improvise-fy9o). + if let Some(mb) = self.edit_mode.minibuffer() { + effects.push(write_buffer(&mb.buffer_key, String::new())); + } // An untouched editor writes nothing; everything around the write still runs. let writes = !untouched(self.opened.as_ref(), &self.value); if writes { diff --git a/crates/improvise-command/src/command/keymap.rs b/crates/improvise-command/src/command/keymap.rs index d98b693c..300e4ef8 100644 --- a/crates/improvise-command/src/command/keymap.rs +++ b/crates/improvise-command/src/command/keymap.rs @@ -690,6 +690,22 @@ mod tests { } /// The buffer `append-char` is bound to in `mode`'s keymap. + /// The effects `name` builds in `mode` with `buffer` holding text, as Debug. + fn clears_itself(mode: &AppMode, name: &str, args: &[String], buffer: &str) -> String { + use crate::command::cmd::test_helpers::*; + let wb = two_cat_model(); + let layout = make_layout(&wb); + let reg = make_registry(); + let bufs: HashMap = [(buffer.to_string(), "typed".to_string())].into(); + let mut ctx = make_ctx(&wb, &layout, ®); + ctx.mode = mode; + ctx.buffers = &bufs; + let cmd = reg + .interactive(name, args, &ctx) + .unwrap_or_else(|e| panic!("{name} {args:?} does not build: {e}")); + effects_debug(&cmd.execute(&ctx)) + } + fn append_target(ks: &KeymapSet, mode: &AppMode) -> String { let key = ModeKey::from_app_mode(mode, false).expect("a text-entry mode key"); let km = ks.maps().get(&key).expect("every mode has a keymap"); @@ -764,15 +780,22 @@ mod tests { let km = ks.maps().get(&mode).unwrap(); for key in exits { - let Some(Binding::Sequence(steps)) = km.lookup(*key, none) else { - panic!("{mode:?}: {key:?} should be a commit/exit sequence"); - }; - assert!( - steps - .iter() - .any(|(n, a)| *n == "clear-buffer" && a.as_slice() == [buffer.to_string()]), - "{mode:?}: {key:?} must clear \"{buffer}\", got {steps:?}" - ); + match km.lookup(*key, none) { + Some(Binding::Sequence(steps)) => assert!( + steps.iter().any(|(n, a)| *n == "clear-buffer" + && a.as_slice() == [buffer.to_string()]), + "{mode:?}: {key:?} must clear \"{buffer}\", got {steps:?}" + ), + // A reopening commit owns its clear (improvise-fy9o): ask the command. + Some(Binding::Cmd { name, args }) => { + let dbg = clears_itself(&app_mode, name, args, &buffer); + assert!( + dbg.contains(&format!("SetBuffer {{ name: \"{buffer}\", value: \"\" }}")), + "{mode:?}: {key:?} runs {name} alone, which must clear \"{buffer}\": {dbg}" + ); + } + other => panic!("{mode:?}: {key:?} should commit or exit, got {other:?}"), + } } } } diff --git a/crates/improvise-protocol/src/keymap.rs b/crates/improvise-protocol/src/keymap.rs index 6b7aef43..aefd9027 100644 --- a/crates/improvise-protocol/src/keymap.rs +++ b/crates/improvise-protocol/src/keymap.rs @@ -347,6 +347,13 @@ impl TextEntry { self } + /// Bind `key` to a commit that clears the buffer **itself**, first, because + /// it reopens an editor a trailing clear would blank (improvise-fy9o). + pub fn commit_reopening(mut self, key: KeyCode, cmd: &'static str, args: Vec) -> Self { + self.km.bind_args(key, KeyModifiers::NONE, cmd, args); + self + } + /// Replace a spine binding — used only by CommandMode, whose Backspace /// exits the mode on an empty buffer instead of popping a character. pub fn rebind(mut self, key: KeyCode, cmd: &'static str) -> Self { @@ -851,8 +858,8 @@ impl Keymaps { // edit-mode as an argument, so the command stays mode-agnostic. ── let ed = Arc::new( TextEntry::new(&AppMode::editing(), "normal", global.clone()) - .commit(KeyCode::Enter, "commit-cell-edit", vec!["editing".into()]) - .commit( + .commit_reopening(KeyCode::Enter, "commit-cell-edit", vec!["editing".into()]) + .commit_reopening( KeyCode::Tab, "commit-and-advance-right", vec!["editing".into()], @@ -867,12 +874,12 @@ impl Keymaps { ModeKey::RecordsEditing, Arc::new( TextEntry::new(&AppMode::records_editing(), "records-normal", ed) - .commit( + .commit_reopening( KeyCode::Enter, "commit-cell-edit", vec!["records-editing".into()], ) - .commit( + .commit_reopening( KeyCode::Tab, "commit-and-advance-right", vec!["records-editing".into()], diff --git a/crates/improvise-protocol/src/resolve.rs b/crates/improvise-protocol/src/resolve.rs index 0d964ec7..fb6d4bff 100644 --- a/crates/improvise-protocol/src/resolve.rs +++ b/crates/improvise-protocol/src/resolve.rs @@ -896,10 +896,19 @@ fn commit(state: &ClientState, edit_mode: AppMode, advance: Advance) -> Option Option { + edit_mode + .minibuffer() + .map(|mb| write_buffer(&mb.buffer_key, String::new())) +} + /// The write a commit makes: none when the buffer is the seed the editor /// captured as it opened, and only the write is skipped (improvise-ixxx). fn written(state: &ClientState, id: &CellId, text: &str) -> Option> { @@ -924,6 +933,7 @@ fn create_column( // `exit_mode_for`: the tidy-up goes FIRST, where an aborting // `AddCategory` cannot reach it (gotcha 21). let mut commands = vec![Command::ChangeMode(AppMode::Normal)]; + commands.extend(cleared(&edit_mode)); let name = text.trim(); if !name.is_empty() { commands.push(model( @@ -3355,18 +3365,28 @@ mod tests { } ); assert_eq!(commands[1], Command::ChangeMode(AppMode::Normal)); - assert_eq!(commands[2], Command::SetSelected { row: 1, col: 0 }); - assert!( - commands.contains(&Command::ChangeMode(AppMode::editing())), - "the advance re-enters editing on the cell it landed on: {commands:?}" - ); + // The commit owns its clear, at the head (improvise-fy9o). assert_eq!( - commands.last(), - Some(&Command::SetBuffer { + commands[2], + Command::SetBuffer { name: "edit".into(), value: String::new(), - }), - "the keymap welds `clear-buffer` to every commit: {commands:?}" + } + ); + assert_eq!(commands[3], Command::SetSelected { row: 1, col: 0 }); + assert_eq!( + commands.last(), + Some(&Command::ChangeMode(AppMode::editing())), + "the advance re-enters editing on the cell it landed on: {commands:?}" + ); + let last_write = commands.iter().rev().find_map(|c| match c { + Command::SetBuffer { name, value } if name == "edit" => Some(value.as_str()), + _ => None, + }); + assert_eq!( + last_write, + Some("900"), + "the reopened editor must show the cell it landed on, not blank: {commands:?}" ); } diff --git a/crates/improvise-wasm-client/tests/lockstep.rs b/crates/improvise-wasm-client/tests/lockstep.rs index 3dad0c9a..cb1b9a34 100644 --- a/crates/improvise-wasm-client/tests/lockstep.rs +++ b/crates/improvise-wasm-client/tests/lockstep.rs @@ -1893,6 +1893,71 @@ fn an_untouched_browser_edit_of_a_misread_value_writes_nothing() { } } +/// BUG (improvise-fy9o) — see `context/test-evidence/clients.md`. +#[test] +fn enter_enter_leaves_every_cell_unchanged_on_both_ends() { + let every_cell = |app: &App| -> Vec> { + [("North", "Cost"), ("North", "Revenue"), ("South", "Cost"), ("South", "Revenue")] + .iter() + .map(|(r, m)| app.model_state.workbook.model.get_cell(®ion_measure(r, m)).cloned()) + .collect() + }; + let mut client_host = precise_host(improvise_protocol::CellValue::Number(4.0)); + client_host.cursor_mut().selected = (1, 0); + let mut key_host = precise_host(improvise_protocol::CellValue::Number(4.0)); + key_host.cursor_mut().selected = (1, 0); + let before = every_cell(&client_host); + let mut client = client_ready_to_edit(&client_host, (2, 2)); + for key in ["i", "Enter", "Enter"] { + let event = BrowserKey::new(key).to_key_event().unwrap(); + let KeyOutcome::Local(cmds) = resolve(&client, event) else { + panic!("{key}: the client must answer this key locally"); + }; + for cmd in &cmds { + client.fold(cmd); + } + apply_on(&mut client_host, &cmds); + reduce_key(&mut key_host, event); + assert_eq!( + client.view.buffers, key_host.view_state.buffers, + "{key}: client and host disagree about the buffers" + ); + } + assert_eq!(every_cell(&client_host), before, "the browser's Enter, Enter wrote"); + assert_eq!(every_cell(&key_host), before, "the host's Enter, Enter wrote"); +} + +/// BUG (improvise-fy9o) — see `context/test-evidence/clients.md`. +#[test] +fn typing_into_the_reopened_editor_extends_the_cell_on_both_ends() { + let mut client_host = precise_host(improvise_protocol::CellValue::Number(4.0)); + client_host.cursor_mut().selected = (1, 0); + let mut key_host = precise_host(improvise_protocol::CellValue::Number(4.0)); + key_host.cursor_mut().selected = (1, 0); + let mut client = client_ready_to_edit(&client_host, (2, 2)); + for key in ["i", "Enter", "5", "Enter"] { + let event = BrowserKey::new(key).to_key_event().unwrap(); + let KeyOutcome::Local(cmds) = resolve(&client, event) else { + panic!("{key}: the client must answer this key locally"); + }; + for cmd in &cmds { + client.fold(cmd); + } + apply_on(&mut client_host, &cmds); + reduce_key(&mut key_host, event); + } + for (end, app) in [("browser", &client_host), ("host", &key_host)] { + assert_eq!( + app.model_state + .workbook + .model + .get_cell(®ion_measure("North", "Revenue")), + Some(&improvise_protocol::CellValue::Number(25.0)), + "{end}: the digit typed into the reopened editor did not extend its seed" + ); + } +} + /// `i` then `Enter` on (South, Revenue) holding `value`, through the resolver /// and a real host: the seed is the stored value's, and the cell is unchanged. fn untouched_browser_edit(value: improvise_protocol::CellValue) { From 8ee5fade27dfc6b3b2cae3611373b2fc625d4e2b Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 07:57:01 -0700 Subject: [PATCH 02/48] fy9o: corner lockstep guard, evidence and protocol.md; file improvise-jmti Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/protocol.md | 27 ++++++- context/test-evidence/clients.md | 42 +++++++++-- context/test-evidence/command.md | 31 +++++++- .../improvise-command/src/command/keymap.rs | 16 +++-- .../improvise-wasm-client/tests/lockstep.rs | 70 +++++++++++++++++-- 5 files changed, 166 insertions(+), 20 deletions(-) diff --git a/context/code/protocol.md b/context/code/protocol.md index 9dcfe3a5..ceaf182a 100644 --- a/context/code/protocol.md +++ b/context/code/protocol.md @@ -1976,7 +1976,32 @@ 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 +can bind a commit without its clear (improvise-w3q). + +**The two cell-editing commit keys are the exception, and the clear moved rather +than disappeared** (improvise-fy9o). `commit-cell-edit` and +`commit-and-advance-right` reopen an editor on the cell they advance to, and the +welded clear ran **after** them, so it blanked the re-seeded buffer. The +reopened editor looked empty, and a second Enter committed `""` and cleared the +landed cell. The user's ruling is that the reopened editor shows the landed +cell's current value, so with improvise-ixxx's untouched rule an unchanged Enter +writes nothing. `commit_reopening(key, cmd, args)` therefore binds the command +alone, and the command clears its own buffer **at its head**: +- in the TUI, `CommitAndAdvance` does it right after the mode change; +- in the browser, `resolve::cleared` does it, reached from both `commit` and + `create_column`. + +Being at the head is also gotcha 21's rule: a head clear survives an abort +where the trailing weld did not. The corner commit and the `+cat` insertion +reopen nothing, and they still leave no text behind. + +Reordering the weld to `[clear-buffer, cmd]` was tried first and rejected. Four +readers take a `Sequence`'s **first** step as its identity: `keymap::runs`, +`help.rs`, and two resolver audits. So every hint on those keys started +reporting `clear-buffer`. `text_entry_modes_clear_the_buffer_they_type_into` +keeps its guarantee by asking a plain-`Cmd` commit key's command what it emits. + +`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. diff --git a/context/test-evidence/clients.md b/context/test-evidence/clients.md index 69b9bd5e..4fc118e8 100644 --- a/context/test-evidence/clients.md +++ b/context/test-evidence/clients.md @@ -273,11 +273,14 @@ with an empty vec, so that would be a keypress the user sees do nothing at all ### 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. +Enter in `Editing` used to be `[commit-cell-edit editing, clear-buffer edit]`, +and the trailing clear emptied the buffer the advance had just re-seeded. That +was improvise-fy9o. Since the fix, Enter is `commit-cell-edit` alone, and the +commit clears its own buffer at its head. The test pins that order: the write, +then `ChangeMode(Normal)`, then `SetBuffer(edit, "")`, then `SetSelected`. It +ends with the re-entry, and the **last** write to `edit` is the landed cell's +`"900"`. **Rival:** `resolve::commit` without its head clear fails this test, +and `a_commit_at_the_corner_leaves_no_buffer_behind_on_both_ends` too. --- @@ -4908,3 +4911,32 @@ the keyboard paths a trimmed comparison cannot be seen through the keys, since any whitespace edit also sets `touched`, so this unit test and the mouse path are where it shows. The mouse test now also asserts that `"7 "` against the seed `"7"` is a write. + +### `enter_enter_leaves_every_cell_unchanged_on_both_ends` + +**improvise-fy9o**, browser and host in lockstep. On the 2×2 `precise_host`, +starting at (South, Cost), row 1 and column 0, the test presses `i`, Enter, +Enter. The client resolves each key, folds it and applies it to one host, and +`reduce_key` drives a second host. After each key the client's buffers must +equal the key host's, and at the end every cell must be unchanged on both +hosts. + +Failed before the fix: (North, Revenue) was cleared, because the welded clear +had blanked the reopened editor. **Rival:** the editing commit keys bound +through the welding `.commit` again fails this test, the typing test below, and +both TUI tests. + +### `typing_into_the_reopened_editor_extends_the_cell_on_both_ends` + +The other half of the ruling: the reopened editor shows the landed cell, so +typing extends it. `i`, Enter, `5`, Enter must store `25` at (North, Revenue) +on both ends. It fails under the weld rival, because the digit went into a +blank buffer and stored `5`. + +### `a_commit_at_the_corner_leaves_no_buffer_behind_on_both_ends` + +Moving the clear from the keymap into the command must not leave text behind +where nothing reopens. At the bottom-right corner, `i`, `9`, Enter must leave +`edit` as `""` on both ends, with the mode no longer editing. **Rival:** the +browser without `cleared` leaves `"49"` and fails here. The equivalent TUI rival +fails `text_entry_modes_clear_the_buffer_they_type_into`. diff --git a/context/test-evidence/command.md b/context/test-evidence/command.md index 4037430c..776bfe3a 100644 --- a/context/test-evidence/command.md +++ b/context/test-evidence/command.md @@ -2861,6 +2861,11 @@ 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). +**Amended by improvise-fy9o.** A commit key bound to a plain command now passes only if that command, built in +the mode with text in the buffer, emits `SetBuffer { name: , value: "" }` +itself. **Rival:** `CommitAndAdvance` without its head clear. Only this test +fails, which makes it the TUI's guard against leftover text. + ### minibuffer_enter_dispatches_to_a_registered_commit improvise-6t3: the three commit commands collapsed into a single @@ -5636,8 +5641,9 @@ The buffer after `Enter` is **not** asserted. The keymap welds `clear-buffer` after every commit key, so it empties the buffer `EnterEditAtCursor` has just seeded, and the reopened editor opens blank while its captured seed is `"2"`. That is existing behaviour (`enter_commits_the_cell_advances_and_re_enters_editing` -in `clients.md`). An `Enter` there with nothing typed therefore clears the landed cell, -measured here; that is filed as improvise-fy9o rather than fixed here. +in `clients.md`). An `Enter` there with nothing typed therefore cleared the landed +cell. It was filed as improvise-fy9o and is now fixed (see +`enter_enter_leaves_every_cell_unchanged`). ### `an_editor_typed_back_to_its_seed_still_writes` @@ -5678,3 +5684,24 @@ editor's seed. This test is only possible because `clear-buffer` and keys typed into another buffer do not mark the editor. A rule that marked on every buffer write would make the guard redundant, and no test could pin it (`protocol.md` §3). + +### `enter_enter_leaves_every_cell_unchanged` + +**improvise-fy9o.** The keymap welded `clear-buffer` after every commit key, so +it blanked the editor `CommitAndAdvance` had just reopened. The next Enter then +committed `""` and cleared the landed cell. The test presses `i`, Enter, Enter +on the 2×2 fixture: +- in pivot, from (1, 0); +- in records, from (1, 2), the West row's Cost column. + +Every cell must be unchanged. Both cases failed before the fix, clearing +(North, Revenue). The records case was confirmed on its own, because the loop +stops at the first failure. **Rival:** the editing keys bound through the +welding `.commit` again fails both. + +### `typing_after_the_reopen_still_writes` + +After `i` and Enter from (1, 0), the reopened editor must hold `"2"`, the value +of (North, Revenue). Typing `5` and pressing Enter must then store `25`. Before +the fix the buffer was `""`, so the typed digit replaced the value instead of +extending it. diff --git a/crates/improvise-command/src/command/keymap.rs b/crates/improvise-command/src/command/keymap.rs index 300e4ef8..5c741c75 100644 --- a/crates/improvise-command/src/command/keymap.rs +++ b/crates/improvise-command/src/command/keymap.rs @@ -781,16 +781,20 @@ mod tests { for key in exits { match km.lookup(*key, none) { - Some(Binding::Sequence(steps)) => assert!( - steps.iter().any(|(n, a)| *n == "clear-buffer" - && a.as_slice() == [buffer.to_string()]), - "{mode:?}: {key:?} must clear \"{buffer}\", got {steps:?}" - ), + Some(Binding::Sequence(steps)) => { + assert!( + steps.iter().any(|(n, a)| *n == "clear-buffer" + && a.as_slice() == [buffer.to_string()]), + "{mode:?}: {key:?} must clear \"{buffer}\", got {steps:?}" + ) + } // A reopening commit owns its clear (improvise-fy9o): ask the command. Some(Binding::Cmd { name, args }) => { let dbg = clears_itself(&app_mode, name, args, &buffer); assert!( - dbg.contains(&format!("SetBuffer {{ name: \"{buffer}\", value: \"\" }}")), + dbg.contains(&format!( + "SetBuffer {{ name: \"{buffer}\", value: \"\" }}" + )), "{mode:?}: {key:?} runs {name} alone, which must clear \"{buffer}\": {dbg}" ); } diff --git a/crates/improvise-wasm-client/tests/lockstep.rs b/crates/improvise-wasm-client/tests/lockstep.rs index cb1b9a34..2b1f95a8 100644 --- a/crates/improvise-wasm-client/tests/lockstep.rs +++ b/crates/improvise-wasm-client/tests/lockstep.rs @@ -1897,10 +1897,21 @@ fn an_untouched_browser_edit_of_a_misread_value_writes_nothing() { #[test] fn enter_enter_leaves_every_cell_unchanged_on_both_ends() { let every_cell = |app: &App| -> Vec> { - [("North", "Cost"), ("North", "Revenue"), ("South", "Cost"), ("South", "Revenue")] - .iter() - .map(|(r, m)| app.model_state.workbook.model.get_cell(®ion_measure(r, m)).cloned()) - .collect() + [ + ("North", "Cost"), + ("North", "Revenue"), + ("South", "Cost"), + ("South", "Revenue"), + ] + .iter() + .map(|(r, m)| { + app.model_state + .workbook + .model + .get_cell(®ion_measure(r, m)) + .cloned() + }) + .collect() }; let mut client_host = precise_host(improvise_protocol::CellValue::Number(4.0)); client_host.cursor_mut().selected = (1, 0); @@ -1923,8 +1934,55 @@ fn enter_enter_leaves_every_cell_unchanged_on_both_ends() { "{key}: client and host disagree about the buffers" ); } - assert_eq!(every_cell(&client_host), before, "the browser's Enter, Enter wrote"); - assert_eq!(every_cell(&key_host), before, "the host's Enter, Enter wrote"); + assert_eq!( + every_cell(&client_host), + before, + "the browser's Enter, Enter wrote" + ); + assert_eq!( + every_cell(&key_host), + before, + "the host's Enter, Enter wrote" + ); +} + +/// A commit that reopens nothing still leaves no text behind, on either end: +/// the clear moved from the keymap into the commit (improvise-fy9o). +#[test] +fn a_commit_at_the_corner_leaves_no_buffer_behind_on_both_ends() { + let mut client_host = precise_host(improvise_protocol::CellValue::Number(4.0)); + let mut key_host = precise_host(improvise_protocol::CellValue::Number(4.0)); + assert_eq!( + key_host.cursor().selected, + (1, 1), + "the fixture is not at the corner" + ); + let mut client = client_ready_to_edit(&client_host, (2, 2)); + for key in ["i", "9", "Enter"] { + let event = BrowserKey::new(key).to_key_event().unwrap(); + let KeyOutcome::Local(cmds) = resolve(&client, event) else { + panic!("{key}: the client must answer this key locally"); + }; + for cmd in &cmds { + client.fold(cmd); + } + apply_on(&mut client_host, &cmds); + reduce_key(&mut key_host, event); + } + assert!( + !key_host.view_state.mode.is_editing(), + "the corner must not reopen" + ); + for (end, buffers) in [ + ("browser", &client.view.buffers), + ("host", &key_host.view_state.buffers), + ] { + assert_eq!( + buffers.get("edit").map(String::as_str), + Some(""), + "{end}: the committed text was left in the buffer" + ); + } } /// BUG (improvise-fy9o) — see `context/test-evidence/clients.md`. From eb061391fe1280ace54b66ddee518b7c04a9e71b Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 08:06:45 -0700 Subject: [PATCH 03/48] Pin that every aborting model effect also refuses (improvise-we1a) Host::mutating_frame rejects on refused || aborted, and deleting the aborted arm left the workspace green. The wire table cannot see an abort-without-refuse because that arm is what rejects it, so the new table applies each failing ModelCommand's effects to a bare App and asserts it refused, pinning which cases abort so it is not vacuous. The arm is kept as defence: AbortChain shows an abort need not be an error, so making abort-without-refuse unconstructible is declined. Also keeps rtud's three key-path table rows (load, import-json of an absent JSON, a formula that does not parse) as added coverage; rtud's prose fix lands separately. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/command.md | 24 ++++++ context/test-evidence/command.md | 41 +++++++++ crates/improvise-command/src/ui/app.rs | 39 +++++++++ crates/improvise-command/src/ui/host.rs | 106 ++++++++++++++++++++++++ 4 files changed, 210 insertions(+) diff --git a/context/code/command.md b/context/code/command.md index 60ac7de9..5b40a017 100644 --- a/context/code/command.md +++ b/context/code/command.md @@ -609,6 +609,30 @@ the client was told nothing at all. `AddItem` case the same way and found none, so `AddItem` does not stop either. An abort is added where a later effect depends on the failed one, never for symmetry. +- **The `|| last_batch_aborted()` arm is kept, and the invariant it guards is + pinned (improvise-we1a).** Every model effect that aborts today also refuses, + so deleting the arm left the workspace green: nothing tested it. There were + two ways to close that. One was to make an abort without a refusal + impossible to write. The other, taken here, was to test the invariant with + the arm left in place. `every_failing_model_effect_that_aborts_also_refuses` + applies each failing `ModelCommand`'s own effects to an `App`, bypassing + `Host`, and asserts it refused. It also pins which cases abort, so it cannot + pass on a table that aborts nothing. The wire table cannot hold this + invariant, because the arm itself rejects a frame that aborted without + refusing. With AddCategory's arm reverted to status-only, the wire table + stays green. The option not taken is right for today's rules and wrong for + the future: + - `AbortChain` aborts without refusing, correctly. It is `ViewOnly`, and it + stops a chain whose premise no longer holds, which is not an error. A + type-level rule of "every abort refuses" would first have to exempt it. + - Making it refuse would change which `:` lines the headless CLI reports as + failures. + - Keeping the raw `abort_effects` setter away from `Model` effects alone would + be a new visibility split, and today no effect needs it. + + So the arm stays, as defence for a future `Model` effect that aborts on + something other than an error. The test is what says the arm is currently + redundant, and it will fail when that stops being true. In a client's frame, any refusal becomes `Rejected(MutationRefused)`. The frame is undone whole, and the client is told, which is the channel the narrated diff --git a/context/test-evidence/command.md b/context/test-evidence/command.md index a4c92338..26fc0255 100644 --- a/context/test-evidence/command.md +++ b/context/test-evidence/command.md @@ -5697,3 +5697,44 @@ host's `Normal`, and nothing else different. `install_workbook` needs no change of its own, because both of its callers (`LoadModel`, `AcceptWizardModel`) run inside `apply_effects`, which settles after the batch. + +## `ui/host.rs` — the abort arm of `Host::mutating_frame` (improvise-we1a) + +### every_failing_model_effect_that_aborts_also_refuses + +What the gap was: `Host::mutating_frame` rejects a frame when +`last_batch_refused() || last_batch_aborted()`. Every model effect that aborted +also refused, so deleting the `|| last_batch_aborted()` arm left all tests +passing. The wire table (`every_failing_model_command_refuses_its_whole_frame`) +cannot hold the invariant, because that arm rejects a frame that aborted +without refusing. The test applies each failing `ModelCommand`'s own effects, +from `model_effects`, straight to an `App`, and asserts that the batch refused. +Each case also carries whether it aborts. That catches a table in which nothing +aborts, and a site that quietly stops aborting. + +Faults injected: +- `AddCategory`'s failure arm set `status_msg` and aborted, but did not refuse. + - This test failed, and so did the key-path table. + - The wire table passed, which is the gap. + - With the arm also deleted, `a_refused_mutation_rolls_back_its_predecessor_and_the_abort_flag` + failed as well. So the arm is what makes the wire table look correct under + this fault. +- `refuse_and_stop` wrote the status and aborted without refusing. This test + failed, as did the key-path table, + `a_failed_load_or_import_does_not_run_the_quit_after_it` and + `a_formula_that_does_not_parse_keeps_the_user_in_the_editor_with_their_text`. + The wire table passed. + +### every_refusal_site_refuses_its_failure_and_only_its_failure — three cases (improvise-rtud) + +This entry covers the rows `load of an absent file`, `import-json of an absent +JSON` and `a formula that does not parse`. Each has a succeeding twin +(`load doc.improv` from `MemoryFs`, `import-json good.json`, `Total = 1`). +Each row was checked by a rival at its own site: the `refuse_and_stop` call in +`AddFormula`'s parse arm, in `LoadModel`, or in the JSON read arm of +`ImportJsonHeadless`, replaced by a status write plus the abort. Under each +rival, the table fails at that row. The matching row of +`every_failing_model_effect_that_aborts_also_refuses` fails too. A rival in +`refuse_and_stop` itself is no use here, because the table stops at its first +failing row, which is the older `import-json of an absent CSV`. The rtud prose correction is landing separately, so these +rows are coverage only. diff --git a/crates/improvise-command/src/ui/app.rs b/crates/improvise-command/src/ui/app.rs index df25400b..b3c32a7e 100644 --- a/crates/improvise-command/src/ui/app.rs +++ b/crates/improvise-command/src/ui/app.rs @@ -3244,6 +3244,21 @@ mod tests { app } + /// Storage holding one loadable document, `doc.improv`, and nothing else. + fn with_a_document_to_load() -> App { + let storage = crate::storage::MemoryFs::new(); + let text = crate::persistence::format_md(&Workbook::new("Doc")); + crate::storage::Storage::write( + &storage, + std::path::Path::new("doc.improv"), + text.as_bytes(), + ) + .expect("fixture writes"); + let mut app = two_col_model(); + app.storage = Box::new(storage); + app + } + fn with_a_second_view(mut app: App) -> App { app.model_state.workbook.create_view("Second"); app @@ -3437,6 +3452,30 @@ mod tests { run_line(app, "search-navigate forward") }, ), + ( + "load of an absent file", + with_a_document_to_load, + |app| run_line(app, "load absent.improv"), + "Load error", + with_a_document_to_load, + |app| run_line(app, "load doc.improv"), + ), + ( + "import-json of an absent JSON", + || with_import_fixtures(two_col_model()), + |app| run_line(app, "import-json absent.json"), + "Cannot read 'absent.json'", + || with_import_fixtures(two_col_model()), + |app| run_line(app, "import-json good.json"), + ), + ( + "a formula that does not parse", + two_col_model, + |app| commit_in(app, AppMode::formula_edit(), "formula", "Total = ("), + "Formula error", + two_col_model, + |app| commit_in(app, AppMode::formula_edit(), "formula", "Total = 1"), + ), ( "a : line that does not parse", two_col_model, diff --git a/crates/improvise-command/src/ui/host.rs b/crates/improvise-command/src/ui/host.rs index 78aac189..23b15008 100644 --- a/crates/improvise-command/src/ui/host.rs +++ b/crates/improvise-command/src/ui/host.rs @@ -1985,6 +1985,112 @@ mod tests { } } + /// improvise-we1a: `mutating_frame`'s `|| last_batch_aborted()` arm is redundant + /// only while this holds. Evidence: `context/test-evidence/command.md`. + #[test] + fn every_failing_model_effect_that_aborts_also_refuses() { + let fresh = || { + let mut wb = workbook(); + wb.create_view("Scratch"); + (0..12).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(); + app + }; + let cases: Vec<(&str, ModelCommand, bool)> = vec![ + ( + "AddCategory past the limit", + ModelCommand::AddCategory("Extra".into()), + true, + ), + ( + "AddFormula that does not parse", + ModelCommand::AddFormula { + raw: "Total = (".into(), + target_category: "_Measure".into(), + }, + true, + ), + ( + "LoadModel of an absent file", + ModelCommand::LoadModel(PathBuf::from("absent.improv")), + true, + ), + ( + "import of an absent JSON", + import("absent.json", None), + true, + ), + ("import of an absent CSV", import("absent.csv", None), true), + ( + "import of a CSV that is not text", + import("bad.csv", None), + true, + ), + ( + "import of JSON that does not parse", + import("bad.json", None), + true, + ), + ( + "import at a path with no array", + import("nested.json", Some("nope")), + true, + ), + ( + "import that builds no model", + import("measures.json", None), + true, + ), + ( + "AddItem into an absent category", + ModelCommand::AddItem { + category: "Ghost".into(), + item: "East".into(), + }, + false, + ), + ( + "AddItemInGroup into an absent category", + ModelCommand::AddItemInGroup { + category: "Ghost".into(), + item: "East".into(), + group: "Coastal".into(), + }, + false, + ), + ( + "AddFormula into an absent category", + ModelCommand::AddFormula { + raw: "Total = 1".into(), + target_category: "Ghost".into(), + }, + false, + ), + ( + "DeleteView of an absent view", + ModelCommand::DeleteView("Ghost".into()), + false, + ), + ]; + for (what, cmd, aborts) in cases { + let mut app = fresh(); + let effects = crate::ui::reduce::model_effects(&app, &cmd); + app.apply_effects(effects); + assert_eq!( + app.last_batch_aborted(), + aborts, + "{what}: which failures abort is part of what this table pins" + ); + assert!( + app.last_batch_refused(), + "{what}: failed without refusing, so a frame carrying it is rejected \ + only by mutating_frame's `|| last_batch_aborted()` arm" + ); + } + } + /// improvise-9fpl: one case per refusal #181 converted that no other test /// witnesses. Evidence: `context/test-evidence/command.md`. #[test] From 425a517426e4e29bb7ec36bfd2771b6d3d5d960e Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 08:29:16 -0700 Subject: [PATCH 04/48] Check in apply_effects that a model effect never aborts without refusing (improvise-lasd) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- crates/improvise-command/src/ui/app.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/improvise-command/src/ui/app.rs b/crates/improvise-command/src/ui/app.rs index b3c32a7e..01f01530 100644 --- a/crates/improvise-command/src/ui/app.rs +++ b/crates/improvise-command/src/ui/app.rs @@ -944,6 +944,10 @@ impl App { if self.abort_effects { // An abort-setting effect asked for the rest to be skipped. Reset // the flag, and record the outcome where a caller can read it. + debug_assert!( + matches!(kind, EffectKind::ViewOnly) || self.batch_refused, + "a model effect aborted without refusing: {effect:?} (improvise-lasd)" + ); self.abort_effects = false; self.batch_aborted = true; break; From 60179150b8e452607809c2a2b7dc7afa606948f3 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 08:35:06 -0700 Subject: [PATCH 05/48] =?UTF-8?q?improvise-wm20:=20red=20=E2=80=94=20the?= =?UTF-8?q?=20totals=20strip=20sums=20rows=20whatever=20the=20column=20is?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ratio 1.40 (pooled 0.80), Avg 45 (records mean 20), text 0.00, an error row dropped, a strip under measures-on-rows, and a Max total that moves on z. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- crates/improvise-command/src/ui/project.rs | 182 +++++++++++++++++++++ 1 file changed, 182 insertions(+) diff --git a/crates/improvise-command/src/ui/project.rs b/crates/improvise-command/src/ui/project.rs index 9eaff47b..366fce21 100644 --- a/crates/improvise-command/src/ui/project.rs +++ b/crates/improvise-command/src/ui/project.rs @@ -5804,3 +5804,185 @@ mod basis_check { assert_eq!(value_at(&app, 1), "99.00"); } } + +/// The totals strip: a column's total is its cell as if every drawn row were one +/// fold (improvise-wm20). The rule and the rulings: `context/code/command.md`. +#[cfg(test)] +mod column_totals { + use super::*; + use crate::model::cell::{CellKey, CellValue}; + use crate::workbook::Workbook; + use improvise_core::view::Axis; + + const WHOLE: Viewport = Viewport { + row_offset: 0, + col_offset: 0, + rows: 20, + cols: 20, + }; + + fn key(coords: &[(&str, &str)]) -> CellKey { + CellKey::new( + coords + .iter() + .map(|(c, i)| (c.to_string(), i.to_string())) + .collect(), + ) + } + + /// Region on Row, `_Measure` on Column, one record per cell. + fn regions(cells: &[(&str, &str, CellValue)]) -> Workbook { + let mut wb = Workbook::new("T"); + wb.add_category("Region").unwrap(); + for (region, measure, value) in cells { + wb.add_item("Region", *region).unwrap(); + wb.add_item("_Measure", *measure).unwrap(); + let at = key(&[("Region", region), ("_Measure", measure)]); + wb.model.set_cell(at, value.clone()); + } + wb.active_view_mut().set_axis("Region", Axis::Row); + wb.active_view_mut().set_axis("_Measure", Axis::Column); + wb + } + + fn total(wb: Workbook, measure: &str) -> CellDisplay { + let mut app = App::new(wb, None); + app.rebuild_layout(); + let col = (0..app.layout.col_count()) + .find(|&c| app.layout.col_label(c) == measure) + .unwrap_or_else(|| panic!("no column {measure}")); + grid_of(&app, WHOLE).totals[col].clone() + } + + fn money(rows: &[(&str, f64, f64)]) -> Workbook { + let cells: Vec<_> = rows + .iter() + .flat_map(|(r, rev, cost)| { + [ + (*r, "Revenue", CellValue::Number(*rev)), + (*r, "Cost", CellValue::Number(*cost)), + ] + }) + .collect(); + regions(&cells) + } + + /// improvise-wm20 — see context/test-evidence/command.md. + #[test] + fn a_ratio_columns_total_is_the_ratio_of_the_totalled_inputs() { + let mut wb = money(&[("North", 10.0, 5.0), ("South", 30.0, 3.0)]); + let margin = + crate::formula::parse_formula("Margin = (Revenue - Cost) / Revenue", "_Measure"); + wb.model.add_formula(margin.unwrap()); + // Rows are 0.50 and 0.90: summed 1.40, averaged 0.70, pooled 32/40. + assert_eq!(total(wb, "Margin").text, "0.80"); + } + + /// improvise-wm20: an Avg measure totals as the mean over its records, not + /// the sum or mean of the rows' means. context/test-evidence/command.md. + #[test] + fn an_average_measures_total_is_the_mean_over_its_records() { + let mut wb = Workbook::new("T"); + wb.add_category("Region").unwrap(); + wb.add_category("Product").unwrap(); + wb.add_item("_Measure", "Price").unwrap(); + for (region, product, price) in [ + ("North", "A", 10.0), + ("North", "B", 20.0), + ("South", "A", 30.0), + ] { + wb.add_item("Region", region).unwrap(); + wb.add_item("Product", product).unwrap(); + let at = key(&[ + ("Region", region), + ("Product", product), + ("_Measure", "Price"), + ]); + wb.model.set_cell(at, CellValue::Number(price)); + } + let avg = crate::formula::AggFunc::Avg; + wb.model.measure_agg.insert("Price".into(), avg); + wb.active_view_mut().set_axis("Region", Axis::Row); + wb.active_view_mut().set_axis("_Measure", Axis::Column); + wb.active_view_mut().set_axis("Product", Axis::None); + // Rows are 15 and 30: summed 45, their mean 22.5, the records' mean 20. + assert_eq!(total(wb, "Price").text, "20.00"); + } + + /// improvise-wm20, ruling 1: a text column is unanimous or `mixed`. + #[test] + fn a_text_columns_total_is_its_text_when_every_row_agrees() { + let text = |s: &str| CellValue::Text(s.into()); + let agreed = regions(&[ + ("North", "Status", text("ok")), + ("South", "Status", text("ok")), + ]); + let t = total(agreed, "Status"); + assert_eq!((t.text.as_str(), t.error), ("ok", false)); + let split = regions(&[ + ("North", "Status", text("ok")), + ("South", "Status", text("no")), + ]); + let t = total(split, "Status"); + assert!(t.error, "differing text totals as an error: {t:?}"); + } + + /// improvise-wm20, ruling 2: an error in any row makes the total an error. + #[test] + fn an_error_in_any_row_makes_the_total_an_error() { + let wb = regions(&[ + ("North", "Revenue", CellValue::Number(10.0)), + ("South", "Revenue", CellValue::Error("bad".into())), + ]); + let t = total(wb, "Revenue"); + assert!(t.error, "a row's error was dropped from the total: {t:?}"); + } + + /// improvise-wm20, ruling 3: with `_Measure` on the row axis a column adds + /// different measures together, so there is no strip at all. + #[test] + fn measures_on_the_row_axis_leave_no_totals_strip() { + let mut wb = money(&[("North", 10.0, 5.0), ("South", 30.0, 3.0)]); + wb.active_view_mut().set_axis("Region", Axis::Column); + wb.active_view_mut().set_axis("_Measure", Axis::Row); + let mut app = App::new(wb, None); + app.rebuild_layout(); + assert!(grid_of(&app, WHOLE).totals.is_empty()); + } + + /// improvise-wm20 with #203's folds: a Max measure's total does not move when + /// a row group collapses. context/test-evidence/command.md. + #[test] + fn a_max_measures_total_does_not_move_when_a_row_group_collapses() { + let book = |collapsed: bool| { + let mut wb = Workbook::new("T"); + wb.add_category("Month").unwrap(); + wb.add_category("Region").unwrap(); + wb.add_item("_Measure", "Revenue").unwrap(); + for (month, n, s) in [("Jan", 5.0, 1.0), ("Feb", 2.0, 7.0)] { + wb.add_item_in_group("Month", month, "Q1").unwrap(); + for (region, v) in [("North", n), ("South", s)] { + wb.add_item("Region", region).unwrap(); + let at = key(&[ + ("Month", month), + ("Region", region), + ("_Measure", "Revenue"), + ]); + wb.model.set_cell(at, CellValue::Number(v)); + } + } + 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 + }; + // Expanded rows are 5 and 7, which a strip that sums reads as 12. + assert_eq!(total(book(false), "Revenue").text, "7.00"); + assert_eq!(total(book(true), "Revenue").text, "7.00"); + } +} From 85c483bd24b54f0d5356e23cbecf936c2127119e Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 08:38:25 -0700 Subject: [PATCH 06/48] Tab at the rightmost column reopens on the value just written; pin the +cat clear (improvise-3tt4, improvise-5wai) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/test-evidence/clients.md | 37 +++++++++ crates/improvise-protocol/src/resolve.rs | 26 ++++-- .../improvise-wasm-client/tests/lockstep.rs | 83 +++++++++++++++++++ 3 files changed, 140 insertions(+), 6 deletions(-) diff --git a/context/test-evidence/clients.md b/context/test-evidence/clients.md index 826f8cd4..cb64a664 100644 --- a/context/test-evidence/clients.md +++ b/context/test-evidence/clients.md @@ -5035,3 +5035,40 @@ where nothing reopens. At the bottom-right corner, `i`, `9`, Enter must leave `edit` as `""` on both ends, with the mode no longer editing. **Rival:** the browser without `cleared` leaves `"49"` and fails here. The equivalent TUI rival fails `text_entry_modes_clear_the_buffer_they_type_into`. + +### `tab_at_the_rightmost_column_reopens_on_the_value_just_written` + +**improvise-3tt4**, found by the #227 review at `a1b23c05`. `resolve::advanced` +re-seeded the reopened editor from the client's cache, on the argument that the +advance always lands on a *different* cell. That is false for Tab at the +rightmost column: `Advance::Right` clamps back onto the cell the frame has just +written, and that cell's cache entry is the pre-write value. + +The test uses the lockstep 2×2 fixture and starts at (0, 1), which is North / +Revenue and holds 2. It presses `i 9 Tab 5 Enter` on a browser client and on a +key-driven host, and compares the buffers after every key. It failed at +`a1b23c05` on `Tab`: the browser buffer was `"2"` and the host's was `"29"`. +Pressing `5 Enter` would then have written 25 over the 29 the user had just +committed. Both ends must end at 295. + +**Fix:** `landed_seed` seeds the landing cell from the committed value's edit +text when the advance lands on the cursor's own cell and the commit wrote. In +every other case it uses the cache. No other advance lands on the written cell: +`Down` moves to the next row or the next column, or reopens nothing at the +corner. + +### `tab_at_the_rightmost_column_reopens_on_the_stored_spelling` + +This pins what "the committed value" means in the fix above: the stored value's +edit text, as `typed()` would store it, not the raw typed string. `i Backspace 0 +0 7 Tab` at (0, 1) must leave `"7"` in both buffers. **Rival:** seeding from the +raw text fails with `"007"` against the host's `"7"`. + +### `a_category_committed_at_plus_cat_leaves_no_buffer_behind_on_both_ends` + +**improvise-5wai**, found by the #227 review. `+cat` reopens nothing, so the +commit's own head clear (`resolve::cleared` in `create_column`) is the only +thing that empties the buffer. The test types `Season` at the `+cat` column of +the records pair, presses Enter on both ends, and requires the category to have +landed and both buffers to be `""`. **Rival:** `let _ = cleared(&edit_mode)` in +`create_column` fails only this test, with the browser keeping `"Season"`. diff --git a/crates/improvise-protocol/src/resolve.rs b/crates/improvise-protocol/src/resolve.rs index 330a37e9..2659b1f9 100644 --- a/crates/improvise-protocol/src/resolve.rs +++ b/crates/improvise-protocol/src/resolve.rs @@ -898,7 +898,8 @@ fn commit(state: &ClientState, edit_mode: AppMode, advance: Advance) -> Option Option> { +fn advanced( + state: &ClientState, + edit_mode: AppMode, + advance: Advance, + wrote: Option<&str>, +) -> Option> { let GridExtent { rows, cols } = state.grid.extent; let row_max = rows.saturating_sub(1); let col_max = cols.saturating_sub(1); @@ -983,12 +989,20 @@ fn advanced(state: &ClientState, edit_mode: AppMode, advance: Advance) -> Option return Some(Vec::new()); }; let mut commands = scrolled_to(state, nr, nc); - // `EnterEditAtCursor`: seed from where we landed, then change mode. The - // new cell is a different cell, so a stale cache is correct here. - commands.extend(opened_on(seed_at(state, nr, nc), edit_mode)); + // `EnterEditAtCursor`: seed from where we landed, then change mode. + commands.extend(opened_on(landed_seed(state, (nr, nc), wrote), edit_mode)); Some(commands) } +/// The seed at the cell an advance lands on. The cache is stale only where the +/// frame just wrote, which is the cursor's own cell (improvise-3tt4). +fn landed_seed(state: &ClientState, (row, col): (usize, usize), wrote: Option<&str>) -> String { + match wrote.filter(|_| state.view.cursor().selected == (row, col)) { + Some(text) => typed(text).map(|v| v.edit_text()).unwrap_or_default(), + None => seed_at(state, row, col), + } +} + /// The surrogate naming the record under the cursor, or `None` when the /// cursor is not on a writable cell. fn record_at_cursor(state: &ClientState) -> Option { diff --git a/crates/improvise-wasm-client/tests/lockstep.rs b/crates/improvise-wasm-client/tests/lockstep.rs index 89353591..ae224fe8 100644 --- a/crates/improvise-wasm-client/tests/lockstep.rs +++ b/crates/improvise-wasm-client/tests/lockstep.rs @@ -1680,6 +1680,30 @@ fn a_locally_committed_creating_column_agrees_with_the_host() { } } +/// improvise-5wai: `+cat` reopens nothing, so its commit's own clear is all +/// that empties the buffer, on both ends. Evidence file. +#[test] +fn a_category_committed_at_plus_cat_leaves_no_buffer_behind_on_both_ends() { + let (mut app, mut twin) = records_pair(); + let client = furnished_client(&app, (12, 12)); + let at = creating_cell(&client, improvise_protocol::CreateKind::Category); + let mut client = client; + typing_at(&mut app, &mut twin, &mut client, at, "Season"); + client = furnished_client(&app, (12, 12)); + client.view = app.view_state.clone(); + assert_eq!(read_edit(&client.view.buffers), "Season", "nothing was typed, so this proves nothing"); + + both_ends(&mut app, &mut twin, &mut client, "Enter"); + + assert!(document_text(&app).contains("Season"), "the commit did not land"); + assert_eq!(read_edit(&app.view_state.buffers), "", "the host left the name behind"); + assert_eq!(read_edit(&client.view.buffers), "", "the browser left the name behind"); +} + +fn read_edit(buffers: &std::collections::HashMap) -> &str { + buffers.get("edit").map(String::as_str).unwrap_or("") +} + /// The twin of the commit test above, for the door rather than the exit /// (improvise-bd9o). Evidence file. #[test] @@ -2228,6 +2252,65 @@ fn a_commit_at_the_corner_leaves_no_buffer_behind_on_both_ends() { } } +/// BUG (improvise-3tt4) — see `context/test-evidence/clients.md`. +#[test] +fn tab_at_the_rightmost_column_reopens_on_the_value_just_written() { + let mut client_host = precise_host(improvise_protocol::CellValue::Number(4.0)); + client_host.cursor_mut().selected = (0, 1); + let mut key_host = precise_host(improvise_protocol::CellValue::Number(4.0)); + key_host.cursor_mut().selected = (0, 1); + let mut client = client_ready_to_edit(&client_host, (2, 2)); + for key in ["i", "9", "Tab", "5", "Enter"] { + let event = BrowserKey::new(key).to_key_event().unwrap(); + let KeyOutcome::Local(cmds) = resolve(&client, event) else { + panic!("{key}: the client must answer this key locally"); + }; + for cmd in &cmds { + client.fold(cmd); + } + apply_on(&mut client_host, &cmds); + reduce_key(&mut key_host, event); + assert_eq!( + client.view.buffers, key_host.view_state.buffers, + "{key}: client and host disagree about the buffers" + ); + } + for (end, app) in [("browser", &client_host), ("host", &key_host)] { + assert_eq!( + app.model_state + .workbook + .model + .get_cell(®ion_measure("North", "Revenue")), + Some(&improvise_protocol::CellValue::Number(295.0)), + "{end}: the reopened editor did not extend the value Tab had just written" + ); + } +} + +/// The reopened seed is the STORED value's edit text, not the typed string: +/// `007` is stored as 7 on both ends (improvise-3tt4). Evidence file. +#[test] +fn tab_at_the_rightmost_column_reopens_on_the_stored_spelling() { + let mut client_host = precise_host(improvise_protocol::CellValue::Number(4.0)); + client_host.cursor_mut().selected = (0, 1); + let mut key_host = precise_host(improvise_protocol::CellValue::Number(4.0)); + key_host.cursor_mut().selected = (0, 1); + let mut client = client_ready_to_edit(&client_host, (2, 2)); + for key in ["i", "Backspace", "0", "0", "7", "Tab"] { + let event = BrowserKey::new(key).to_key_event().unwrap(); + let KeyOutcome::Local(cmds) = resolve(&client, event) else { + panic!("{key}: the client must answer this key locally"); + }; + for cmd in &cmds { + client.fold(cmd); + } + apply_on(&mut client_host, &cmds); + reduce_key(&mut key_host, event); + } + assert_eq!(key_host.view_state.buffers.get("edit").map(String::as_str), Some("7")); + assert_eq!(client.view.buffers, key_host.view_state.buffers); +} + /// BUG (improvise-fy9o) — see `context/test-evidence/clients.md`. #[test] fn typing_into_the_reopened_editor_extends_the_cell_on_both_ends() { From a093d126bc13547f04a9c72f5b20ad6001f1e9f5 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 08:44:56 -0700 Subject: [PATCH 07/48] Let the client bundle's wasm-opt accept saturating float conversion; rustfmt landed_seed parses committed text with typed(), which puts i64.trunc_sat_f64_s in the client bundle; the bundled wasm-opt rejects it unless the feature is named, as the worker bundle already does. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- crates/improvise-wasm-client/Cargo.toml | 5 ++-- .../improvise-wasm-client/tests/lockstep.rs | 28 +++++++++++++++---- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/crates/improvise-wasm-client/Cargo.toml b/crates/improvise-wasm-client/Cargo.toml index 1abfbb14..131e552c 100644 --- a/crates/improvise-wasm-client/Cargo.toml +++ b/crates/improvise-wasm-client/Cargo.toml @@ -45,6 +45,7 @@ improvise-ws-server = { path = "../improvise-ws-server" } # The `wasm-opt` wasm-pack downloads predates bulk-memory being enabled by # default, while LLVM emits `memory.copy` regardless — so the validator # rejects the module unless the feature is named. Naming it here rather than -# turning `wasm-opt` off keeps the optimizer in the loop. +# turning `wasm-opt` off keeps the optimizer in the loop. `nontrapping-float-to-int` +# is the worker bundle's second flag, needed here once the client parses typed text. [package.metadata.wasm-pack.profile.release] -wasm-opt = ["-O", "--enable-bulk-memory"] +wasm-opt = ["-O", "--enable-bulk-memory", "--enable-nontrapping-float-to-int"] diff --git a/crates/improvise-wasm-client/tests/lockstep.rs b/crates/improvise-wasm-client/tests/lockstep.rs index ae224fe8..af63c60f 100644 --- a/crates/improvise-wasm-client/tests/lockstep.rs +++ b/crates/improvise-wasm-client/tests/lockstep.rs @@ -1691,13 +1691,28 @@ fn a_category_committed_at_plus_cat_leaves_no_buffer_behind_on_both_ends() { typing_at(&mut app, &mut twin, &mut client, at, "Season"); client = furnished_client(&app, (12, 12)); client.view = app.view_state.clone(); - assert_eq!(read_edit(&client.view.buffers), "Season", "nothing was typed, so this proves nothing"); + assert_eq!( + read_edit(&client.view.buffers), + "Season", + "nothing was typed, so this proves nothing" + ); both_ends(&mut app, &mut twin, &mut client, "Enter"); - assert!(document_text(&app).contains("Season"), "the commit did not land"); - assert_eq!(read_edit(&app.view_state.buffers), "", "the host left the name behind"); - assert_eq!(read_edit(&client.view.buffers), "", "the browser left the name behind"); + assert!( + document_text(&app).contains("Season"), + "the commit did not land" + ); + assert_eq!( + read_edit(&app.view_state.buffers), + "", + "the host left the name behind" + ); + assert_eq!( + read_edit(&client.view.buffers), + "", + "the browser left the name behind" + ); } fn read_edit(buffers: &std::collections::HashMap) -> &str { @@ -2307,7 +2322,10 @@ fn tab_at_the_rightmost_column_reopens_on_the_stored_spelling() { apply_on(&mut client_host, &cmds); reduce_key(&mut key_host, event); } - assert_eq!(key_host.view_state.buffers.get("edit").map(String::as_str), Some("7")); + assert_eq!( + key_host.view_state.buffers.get("edit").map(String::as_str), + Some("7") + ); assert_eq!(client.view.buffers, key_host.view_state.buffers); } From 785202b8f06f920ed5a76e67fc25c64984e86b2a Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 08:46:41 -0700 Subject: [PATCH 08/48] tooling: pr-reviews timeout case asserts WAIT+1 polls; a hang fails (7fl8) improvise-7fl8: on the one-tick-per-read clock --wait 5 polls exactly 6 times, but the test asserted at least 5. So a loop capped at 5 polls, or a deadline of now + WAIT - 1, passed at 25/25. It now asserts polls = WAIT + 1, and both rivals fail it. With the deadline check deleted, the suite used to hang at the first real-clock timeout case. Every pr-reviews call now runs beside a /bin/sleep watchdog (wait -n -p), and loses to it as exit 124. The same rival now finishes in 93s with four FAILs. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/test-evidence/tooling.md | 33 ++++++++++++++++++++++++++++++++ scripts/pr-reviews-test.sh | 28 +++++++++++++++++++++------ 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/context/test-evidence/tooling.md b/context/test-evidence/tooling.md index 820e622c..23d6f60e 100644 --- a/context/test-evidence/tooling.md +++ b/context/test-evidence/tooling.md @@ -810,3 +810,36 @@ byte for byte (`cmp` against the committed file). With the committed lock, to corrupt a tracked file and call cargo, which needs the crates.io index, from `checkers.sh`, whose environment is scrubbed. The by-hand run above is the evidence. + +### The timeout case asserts the exact poll count, and a hang fails — `scripts/pr-reviews-test.sh` (improvise-7fl8) + +**What was unguarded.** "--wait keeps polling until its deadline" asserted *at +least* 5 polls for `--wait 5`. On the one-tick-per-read clock the correct loop +polls exactly 6 times: one initial poll, then re-polls until the post-poll clock +read reaches the deadline. The 7epn entry above says the poll count tells a +loop that stopped early from one that reached the deadline. That was true only +for a loop stopping two or more ticks early. Both rivals from the #218 review +passed the whole suite at 25/25, re-run here before the change: +- a loop capped at 5 polls whatever the deadline; +- `deadline = now + WAIT - 1`. + +**The fix** asserts `polls = WAIT + 1`, which the fake clock makes exact. It +also catches an over-polling `-le`. Against the new assertion both rivals give +24 passed, 1 failed, each "exit 1 after 5 polls, wanted 1 after 6". + +**The hang, which was wider than the ticket said.** 7epn left open that +deleting the deadline check hangs the suite. It does not hang only at the +fake-clock case. It hangs first at "an inline comment at an EARLIER head does +not satisfy --wait", a real-clock `--wait 1` case, and at every other case that +should time out. So the guard wraps *every* call. `"$REVIEWS"` is now a function +that runs the script alongside a `/bin/sleep 20` watchdog and waits for +whichever finishes first (`wait -n -p`, bash 5.1+). If the watchdog wins, it +kills the script and returns 124. `/bin/sleep` is spelled out because the +fake-clock cases put a no-op `sleep` first on `PATH`. A first version that +killed a watchdog subshell left an orphaned `sleep`, and could signal a +recycled pid. + +**Rival: the deadline check deleted.** The suite now finishes in 93 s with 21 +passed and 4 failed. The four timeout cases fail with exit 124; the fake-clock +one has made about 400 polls by then. Before the guard the same rival never +finished, and an outer `timeout 30` killed it at exit 124. diff --git a/scripts/pr-reviews-test.sh b/scripts/pr-reviews-test.sh index 0bda68f8..443be281 100755 --- a/scripts/pr-reviews-test.sh +++ b/scripts/pr-reviews-test.sh @@ -5,10 +5,25 @@ set -uo pipefail here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REVIEWS="$here/pr-reviews" work="$(mktemp -d)" trap 'rm -rf "$work"' EXIT +# Every run is bounded, so a loop that never stops FAILS (exit 124) rather than +# hanging the suite. /bin/sleep, because some cases put a no-op `sleep` first on PATH. +BOUND=20 +bounded_reviews() { + "$here/pr-reviews" "$@" & + local pid=$! dog done rc + /bin/sleep "$BOUND" & + dog=$! + wait -n -p done "$pid" "$dog"; rc=$? + if [ "$done" = "$pid" ]; then kill "$dog" 2>/dev/null; wait "$dog" 2>/dev/null; return "$rc"; fi + kill -TERM "$pid" 2>/dev/null; wait "$pid" 2>/dev/null + echo "pr-reviews-test: killed after ${BOUND}s; the --wait loop never stopped" >&2 + return 124 +} +REVIEWS=bounded_reviews + pass=0; fail=0 ok() { printf ' ok %s\n' "$1"; pass=$((pass+1)); } bad() { printf ' FAIL %s (%s)\n' "$1" "$2"; fail=$((fail+1)); } @@ -240,15 +255,16 @@ polls=$(cat "$d3/.poll" 2>/dev/null || echo 0) [ "$got" = 0 ] && [ "$polls" = 3 ] && ok "a review arriving on the third poll is seen" \ || bad "a review arriving on the third poll is seen" "exit $got after $polls polls, wanted 0 after 3" -# The timeout path: nothing ever arrives, so --wait 5 on a clock that ticks -# once per read must keep polling until the deadline, then report exit 1. +# The timeout path: nothing arrives, and on a clock that ticks once per read the +# count is exact: 1 poll, then one per tick until the deadline, so WAIT + 1. +WAIT=5 rm -f "$work/empty/.poll" "$work/ticks" CLOCK_TICKS="$work/ticks" PATH="$clock:$PATH" PR_REVIEWS_FIXTURE_DIR="$work/empty" \ - "$REVIEWS" 1 --wait 5 --interval 1 > "$work/out" 2>&1 + "$REVIEWS" 1 --wait "$WAIT" --interval 1 > "$work/out" 2>&1 got=$? polls=$(cat "$work/empty/.poll" 2>/dev/null || echo 0) -[ "$got" = 1 ] && [ "$polls" -ge 5 ] && ok "--wait keeps polling until its deadline" \ - || bad "--wait keeps polling until its deadline" "exit $got after $polls polls, wanted 1 after 5 or more" +[ "$got" = 1 ] && [ "$polls" = $((WAIT + 1)) ] && ok "--wait keeps polling until its deadline" \ + || bad "--wait keeps polling until its deadline" "exit $got after $polls polls, wanted 1 after $((WAIT + 1))" # --------------------------------------------------------------------------- # `gh api --paginate` emits one array PER PAGE, so a thread past the page size From fb3da78741e8e9a1f4b831ae3cd7320ffd61bd41 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 08:42:11 -0700 Subject: [PATCH 09/48] improvise-wm20: a column total is its cell as if every drawn row were one fold GridLayout::column_total asks Model::evaluate_spanning, whose Span adds to #203's fold the categories a key omits. A formula is evaluated over the pooled rows, a stored measure folds its records under its own aggregator, text is unanimous or mixed, an error propagates, and _Measure on rows leaves no strip (the user's rulings). Hidden rows stay out. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/command.md | 42 +++++++++++++ context/code/core.md | 15 +++-- context/test-evidence/command.md | 41 ++++++++++++ crates/improvise-command/src/ui/project.rs | 36 +++++++++-- crates/improvise-command/src/ui/viewport.rs | 20 +++--- crates/improvise-core/src/model/types.rs | 70 +++++++++++++++------ crates/improvise-core/src/view/layout.rs | 9 +++ 7 files changed, 194 insertions(+), 39 deletions(-) diff --git a/context/code/command.md b/context/code/command.md index ad8557b3..d4d7663d 100644 --- a/context/code/command.md +++ b/context/code/command.md @@ -1753,6 +1753,48 @@ 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 totals left the walk (improvise-wm20).** A column's total used to be the +walk's running sum of its cells, which is wrong for everything except a stored +measure under Sum or Count. It gave a ratio column the sum of its rows' ratios, +an Avg measure the sum of its rows' means, and a text column `0.00`. The user's +rule is that **a column's total is its cell as if every drawn row were one +fold**. `GridLayout::column_total` asks `Model::evaluate_spanning` for the +column key with the row categories spanned, so a formula is evaluated over the +pooled rows (Margin = total Profit / total Revenue) and a stored measure folds +its records under its own aggregator. The user ruled on three cases: + +- **Text** follows the unanimity rule (gotcha 7): the text if every row agrees, + otherwise the `mixed` error. +- **An error** in any row makes the total an error, which is what that rule + answers for an error among numbers. A formula column is evaluated over the + pooled inputs, so a row's `div/0` is not an input and does not propagate. + Only the fold's own error does. +- **With `_Measure` on the row axis** the strip is empty, because a column adds + different measures together. An empty strip already means "no totals" + (gotcha 33). + +The coverage is the **drawn** rows, as a fold's is (improvise-f2oo): a hidden +row stays out, and so does a record that lacks a row coordinate. That +deliberately differs from `Axis::None`, which counts both. The browser and the +TUI agree by construction, since both draw the projected `GridCache.totals`. + +The cost is one more evaluation per column, and it is not free. The same +`cargo bench --bench grid_projection` on the orchestrator's Mac (Apple M4 Max), +before and after: + +| case | whole grid before | after | `col_widths` before | after | +|---|---|---|---|---| +| typical (400) | 0.86 ms | 1.24 ms | 0.53 ms | 0.92 ms | +| 18k | 114 ms | 177 ms | 75 ms | 142 ms | +| pathological (90,000) | 1.54 s | 3.49 s | 1.07 s | 1.96 s | + +`col_widths` is the keypress path, and it pays too, because a total's text sizes +its column. The fold rebuilds a key per drawn row and reads it again rather than +reusing the walk's value, since a pooled Avg or a formula needs records, not the +rows' answers. A grid-wide fold memo (improvise-h6z1) is the lever. The walk +no longer carries a sum, so the window-into-totals fault below cannot happen any +more. The test still guards the widths half. + 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 diff --git a/context/code/core.md b/context/code/core.md index 42a3e59c..2b20feaf 100644 --- a/context/code/core.md +++ b/context/code/core.md @@ -933,12 +933,15 @@ every member. Five decisions: memo scoped to the whole grid would remove it; it is not built. - **A fold covers the members the grid draws** (improvise-f2oo). Hidden members are not in it. So a fold is exactly the rows it replaces, the total of a - **stored** measure under Sum or Count does not move on `z`, and a group whose - every member is hidden has no row. Under Avg, Min or Max a stored measure's - total does move, and so does a **formula** column's (PR #203's review measured - Margin 1.90 expanded, 1.30 collapsed). The strip sums the rows' values rather - than folding the column the way a fold does, so it sums a Max row's maximum - and a formula row's result. That is improvise-wm20, and it is not this rule. That last + column's total does not move on `z`, and a group whose every member is hidden + has no row. That holds for every aggregator and for a formula column only + since improvise-wm20, because the strip used to **sum** the drawn rows. That + moved a Max measure's total on `z`, and a ratio's (PR #203's review measured + Margin 1.90 expanded and 1.30 collapsed). A total is now the column's cell as + if every drawn row were one fold, so collapsing a group changes nothing it + reads. The fold's machinery is shared through `Model::evaluate_spanning`: its + `Span` adds, to the groups a key names, the categories a key **omits**, which + it expands to their covered items. That last point was already true, and it contradicted the old rule, under which a hidden value appeared only while a sibling was visible. This deliberately **differs from `Axis::None`**, where hidden items still count, because the two replace diff --git a/context/test-evidence/command.md b/context/test-evidence/command.md index d679c2cf..39eb8463 100644 --- a/context/test-evidence/command.md +++ b/context/test-evidence/command.md @@ -4512,6 +4512,8 @@ Two faults injected: assertion (`left: Some("0.50") right: Some("0.5000")`). - Dropping only the `col_formats` write in `viewport::grid_body`, leaving the cells correct, fails the totals assertion (`left: "1.50" right: "1.5000"`). + Those numbers predate improvise-wm20. The total is now the formula over the + pooled rows, `0.5000`, and the same rival reads `0.50`. The second is the one that earns the test its length: without it, the totals strip could round every formula column to the view's format and no assertion in @@ -5958,3 +5960,42 @@ from the landed cell and untouched. flag. This test fails alone. Every other test stays green, which was the gap. Once improvise-fy9o re-seeds the reopened editor, a stale `touched` would make every untouched Tab or Enter write its seed. + +## `ui/project.rs` — the totals strip (improvise-wm20) + +### a_ratio_columns_total_is_the_ratio_of_the_totalled_inputs + +improvise-wm20. `Margin = (Revenue - Cost) / Revenue` over North (10, 5) and +South (30, 3). The rows read 0.50 and 0.90, so the three candidate rules give +three different answers: summed **1.40**, averaged 0.70, and pooled +(40 − 8) / 40 = **0.80**. A fixture whose rows share a ratio cannot tell them +apart; the old `a_formulas_format_reaches_its_own_column_and_no_other` is one +such fixture. **Red at `60179150`** with 1.40. Rival: the red commit itself, +where the walk sums. + +### an_average_measures_total_is_the_mean_over_its_records + +improvise-wm20. `Price` under Avg, with `Product` on None, so North's row is the +mean of two records (10, 20) and South's of one (30). Summing the rows gives +**45**, the mean of the rows' means 22.5, and the mean of the records **20**. +The ruling is the last. **Red at `60179150`** with 45. + +### a_hidden_rows_values_stay_out_of_the_total + +improvise-wm20. `East` (100, 100) is hidden, so both the Revenue total (40) and +the pooled Margin (0.80) must leave it out. Pooled in, the Margin would read +32/140. This test earns its place through two rivals, each injected into a full +workspace run where each fails this test and nothing else: + +- The span ignores `covers`, so hidden rows are pooled in. +- A formula's references are read **unspanned**. The ratio test cannot see this + one: an unspanned reference reads the column key over every record, which is + the right answer whenever nothing is hidden. Only a hidden row separates + "every drawn row" from "every record". + +### a_max_measures_total_does_not_move_when_a_row_group_collapses + +improvise-wm20, from the #203 re-review's comment on the bead. `Revenue` under +Max, with Region on None and Month's Jan and Feb in Q1. Expanded, the rows are +5 and 7, which a summing strip reads as **12**. Collapsed, the fold reads 7. The +total must be 7 both ways. **Red at `60179150`** with 12. diff --git a/crates/improvise-command/src/ui/project.rs b/crates/improvise-command/src/ui/project.rs index 366fce21..9a023820 100644 --- a/crates/improvise-command/src/ui/project.rs +++ b/crates/improvise-command/src/ui/project.rs @@ -256,11 +256,22 @@ fn column_totals(body: &GridBody, cols: std::ops::Range) -> Vec Vec::new(), false => cols .filter_map(|c| Some((body.totals.get(c)?, body.col_formats.get(c)?))) - .map(|(total, fmt)| CellDisplay::number(fmt.number(*total))) + .map(|(total, fmt)| total_display(total.as_ref(), fmt)) .collect(), } } +/// One total as the strip draws it: a number, a unanimous text, an error, or +/// nothing where the column holds no record (improvise-wm20). +fn total_display(total: Option<&CellValue>, fmt: &crate::format::NumberFormat) -> CellDisplay { + match total { + Some(CellValue::Number(n)) => CellDisplay::number(fmt.number(*n)), + Some(v @ CellValue::Error(_)) => CellDisplay::error(fmt.value(Some(v))), + Some(v @ CellValue::Text(_)) => CellDisplay::text(fmt.value(Some(v))), + None => CellDisplay::default(), + } +} + /// One cell as the client draws it. The server knows whether a value was a number /// and whether the cell is settable, so it says both (ADR 002, [`cell_id`]). fn cell_display( @@ -2935,7 +2946,7 @@ mod tests { ); assert_eq!( one_row.totals, - vec![1.0], + vec![Some(CellValue::Number(1.0))], "a column total sums every row, including the ones scrolled past" ); assert_eq!( @@ -4838,8 +4849,8 @@ mod cell_identity { // The totals strip is formatted on its own path (`col_formats`), so it // can disagree with the cells above it without any cell being wrong. assert_eq!( - grid.totals[margin].text, "1.5000", - "three rows of 0.5, under the column's own format" + grid.totals[margin].text, "0.5000", + "the formula over the pooled rows (3 / 6), under the column's own format" ); assert_eq!(grid.totals[revenue].text, "3.00"); } @@ -5878,6 +5889,23 @@ mod column_totals { assert_eq!(total(wb, "Margin").text, "0.80"); } + /// improvise-wm20: the fold is of the DRAWN rows, so a hidden row's values stay + /// out of every total, a formula's included. context/test-evidence/command.md. + #[test] + fn a_hidden_rows_values_stay_out_of_the_total() { + let mut wb = money(&[ + ("North", 10.0, 5.0), + ("South", 30.0, 3.0), + ("East", 100.0, 100.0), + ]); + let margin = + crate::formula::parse_formula("Margin = (Revenue - Cost) / Revenue", "_Measure"); + wb.model.add_formula(margin.unwrap()); + wb.active_view_mut().hide_item("Region", "East"); + assert_eq!(total(wb.clone(), "Revenue").text, "40.00"); + assert_eq!(total(wb, "Margin").text, "0.80", "East's 0 margin pooled in"); + } + /// improvise-wm20: an Avg measure totals as the mean over its records, not /// the sum or mean of the rows' means. context/test-evidence/command.md. #[test] diff --git a/crates/improvise-command/src/ui/viewport.rs b/crates/improvise-command/src/ui/viewport.rs index 1eb87d84..45c3860c 100644 --- a/crates/improvise-command/src/ui/viewport.rs +++ b/crates/improvise-command/src/ui/viewport.rs @@ -7,6 +7,7 @@ use improvise_protocol::header_level_widths; use crate::format::{FormatPlan, NumberFormat}; use crate::model::Model; +use crate::model::category::MEASURE_CATEGORY; use crate::model::cell::{CellKey, CellValue}; use crate::view::{AxisEntry, GridLayout}; @@ -26,9 +27,9 @@ pub struct GridBody { /// The format each column's TOTAL is drawn with, resolved from the /// coordinates that survive the row aggregation — `context/code/core.md`. pub col_formats: Vec, - /// Column sums over every row. **Empty** in records mode or an empty grid, - /// which is "this grid has no totals" rather than a strip of zeroes. - pub totals: Vec, + /// Each column's total over every drawn row (improvise-wm20). **Empty** in + /// records mode, an empty grid, or with `_Measure` on the row axis. + pub totals: Vec>, /// The requested window, row-major. `None` is a cell the layout has not got. pub window: Vec>>, } @@ -45,9 +46,11 @@ pub fn grid_body( let n = layout.col_count(); let records = layout.is_records_mode(); let mut widths = header_level_seed(layout, n); - let totalled = !records && layout.row_count() > 0 && n > 0; - let mut totals = match totalled { - true => vec![0.0f64; n], + // A column mixing measures has no total to show: `context/code/command.md`. + let measures_on_rows = layout.row_cats.iter().any(|c| c == MEASURE_CATEGORY); + let totalled = !records && !measures_on_rows && layout.row_count() > 0 && n > 0; + let totals: Vec> = match totalled { + true => (0..n).map(|ci| layout.column_total(model, ci)).collect(), false => Vec::new(), }; let mut window: Vec>> = Vec::with_capacity(rows.len()); @@ -71,9 +74,6 @@ pub fn grid_body( false => fmt.value(value.as_ref()), }; widen(&mut widths, ci, text.width()); - if let Some(sum) = totals.get_mut(ci) { - *sum += value.as_ref().and_then(|v| v.as_f64()).unwrap_or(0.0); - } if drawn && cols.contains(&ci) { row_out.push(value); } @@ -84,7 +84,7 @@ pub fn grid_body( } for (ci, total) in totals.iter().enumerate() { let fmt = col_formats.get(ci).copied().unwrap_or(plan.view_format()); - widen(&mut widths, ci, fmt.number(*total).width()); + widen(&mut widths, ci, fmt.value(total.as_ref()).width()); } GridBody { diff --git a/crates/improvise-core/src/model/types.rs b/crates/improvise-core/src/model/types.rs index b3b5c6b7..de10008a 100644 --- a/crates/improvise-core/src/model/types.rs +++ b/crates/improvise-core/src/model/types.rs @@ -152,6 +152,14 @@ type FoldMemo = std::cell::RefCell>>>; /// view, so the grid passes the members it draws (core.md §14). pub type Covers<'a> = &'a dyn Fn(&str, &str) -> bool; +/// What a fold reaches: the covered members of each group its key names, and +/// every covered item of each category in `over` its key omits (improvise-wm20). +#[derive(Clone, Copy)] +struct Span<'a> { + covers: Covers<'a>, + over: &'a [String], +} + /// Where a formula's references are read: at one record, or across the members /// a collapsed group's fold covers, as a None axis reads them (core.md §14). #[derive(Clone, Copy)] @@ -159,7 +167,7 @@ enum Reach<'a> { Record, Fold { agg: Aggregation, - covers: Covers<'a>, + span: Span<'a>, memo: &'a FoldMemo, }, } @@ -185,9 +193,7 @@ fn eval_expr( let new_key = context.clone().with(cat, name); let value = match reach { Reach::Record => model.evaluate_depth(&new_key, depth), - Reach::Fold { agg, covers, memo } => { - model.evaluate_fold(&new_key, agg, covers, memo) - } + Reach::Fold { agg, span, memo } => model.evaluate_fold(&new_key, agg, span, memo), }; match value { Some(CellValue::Number(n)) => Ok(n), @@ -596,7 +602,21 @@ impl Model { agg: Aggregation, covers: Covers, ) -> Option { - self.evaluate_fold(key, agg, covers, &FoldMemo::default()) + let span = Span { covers, over: &[] }; + self.evaluate_fold(key, agg, span, &FoldMemo::default()) + } + + /// The value `key` has as ONE fold over every covered item of the `over` + /// categories it omits — a column's total (improvise-wm20, core.md §14). + pub fn evaluate_spanning( + &self, + key: &CellKey, + over: &[String], + agg: Aggregation, + covers: Covers, + ) -> Option { + let span = Span { covers, over }; + self.evaluate_fold(key, agg, span, &FoldMemo::default()) } /// A key naming a group folds; any other key reads as before. A formula is @@ -605,14 +625,14 @@ impl Model { &self, key: &CellKey, agg: Aggregation, - covers: Covers, + span: Span, memo: &FoldMemo, ) -> Option { - let members = self.group_expansion(key, covers); + let members = self.group_expansion(key, span); match (members.is_empty(), self.formula_for(key)) { (true, _) => self.evaluate_unfolded(key, agg), (false, Some(formula)) => { - self.fold_formula_once(formula, key, &members, (agg, covers, memo)) + self.fold_formula_once(formula, key, &members, (agg, span, memo)) } (false, None) => self.fold_stored(key, &members, agg), } @@ -625,7 +645,7 @@ impl Model { formula: &Formula, key: &CellKey, members: &[CellKey], - (agg, covers, memo): (Aggregation, Covers, &FoldMemo), + (agg, span, memo): (Aggregation, Span, &FoldMemo), ) -> Option { let seen = memo.borrow().get(key).cloned(); match seen { @@ -635,7 +655,7 @@ impl Model { memo.borrow_mut().insert(key.clone(), None); #[cfg(test)] FOLD_FORMULA_EVALUATIONS.with(|n| n.set(n.get() + 1)); - let reach = Reach::Fold { agg, covers, memo }; + let reach = Reach::Fold { agg, span, memo }; let value = self.eval_formula_folded(formula, key, members, agg, reach); memo.borrow_mut().insert(key.clone(), Some(value.clone())); value @@ -655,24 +675,36 @@ impl Model { } /// One key per covered member where `key` names a collapsed GROUP rather than - /// an item — the cross product over every such coordinate. Empty when none does. - fn group_expansion(&self, key: &CellKey, covers: Covers) -> Vec { - let mut expanded = vec![key.clone()]; - for (cat_name, name) in &key.0 { + /// an item, or omits a spanned category — the cross product. Empty when neither. + fn group_expansion(&self, key: &CellKey, span: Span) -> Vec { + let grouped = key.0.iter().map(|(cat_name, name)| { let members: Vec<&str> = self .category(cat_name) .filter(|cat| !cat.items.contains_key(name.as_str())) .map(|cat| cat.items_in_group(name)) - .unwrap_or_default() + .unwrap_or_default(); + (cat_name.as_str(), members) + }); + let spanned = span + .over + .iter() + .filter(|cat_name| key.get(cat_name).is_none()) + .map(|cat_name| { + let items = self.category(cat_name).map(|cat| cat.ordered_item_names()); + (cat_name.as_str(), items.unwrap_or_default()) + }); + let mut expanded = vec![key.clone()]; + for (cat_name, members) in grouped.chain(spanned) { + let covered: Vec<&str> = members .into_iter() - .filter(|m| covers(cat_name, m)) + .filter(|m| (span.covers)(cat_name, m)) .collect(); - if members.is_empty() { + if covered.is_empty() { continue; } expanded = expanded .iter() - .flat_map(|k| members.iter().map(|m| k.clone().with(cat_name, *m))) + .flat_map(|k| covered.iter().map(|m| k.clone().with(cat_name, *m))) .collect(); } match expanded.as_slice() { @@ -706,7 +738,7 @@ impl Model { fn contexts_of(&self, context: &CellKey, reach: Reach) -> Vec { let members = match reach { Reach::Record => Vec::new(), - Reach::Fold { covers, .. } => self.group_expansion(context, covers), + Reach::Fold { span, .. } => self.group_expansion(context, span), }; match members.is_empty() { true => vec![context.clone()], diff --git a/crates/improvise-core/src/view/layout.rs b/crates/improvise-core/src/view/layout.rs index a1754645..96a6c218 100644 --- a/crates/improvise-core/src/view/layout.rs +++ b/crates/improvise-core/src/view/layout.rs @@ -661,6 +661,15 @@ impl GridLayout { model.evaluate_covering(key, Aggregation::of(&self.none_cats), &drawn) } + /// A column's total: its cell as if every drawn row were ONE fold, so a + /// formula is evaluated over the pooled rows (improvise-wm20, core.md §14). + pub fn column_total(&self, model: &Model, col: usize) -> Option { + let key = self.column_key(col)?; + let drawn = |cat: &str, item: &str| !self.hidden.get(cat).is_some_and(|h| h.contains(item)); + let agg = Aggregation::of(&self.none_cats); + model.evaluate_spanning(&key, &self.row_cats, agg, &drawn) + } + /// Display text for (row, col) in either mode. pub fn display_text( &self, From 9948aaf55a6921d4ce1778c88848360c35c35d9d Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 08:51:01 -0700 Subject: [PATCH 10/48] rustfmt the wm20 totals tests Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- crates/improvise-command/src/ui/project.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/improvise-command/src/ui/project.rs b/crates/improvise-command/src/ui/project.rs index ca5f2c3a..51a1a5ae 100644 --- a/crates/improvise-command/src/ui/project.rs +++ b/crates/improvise-command/src/ui/project.rs @@ -5908,7 +5908,11 @@ mod column_totals { wb.model.add_formula(margin.unwrap()); wb.active_view_mut().hide_item("Region", "East"); assert_eq!(total(wb.clone(), "Revenue").text, "40.00"); - assert_eq!(total(wb, "Margin").text, "0.80", "East's 0 margin pooled in"); + assert_eq!( + total(wb, "Margin").text, + "0.80", + "East's 0 margin pooled in" + ); } /// improvise-wm20: an Avg measure totals as the mean over its records, not From 4f75fffcb9960d6511dfa64f519102d04232c64a Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 09:03:39 -0700 Subject: [PATCH 11/48] improvise-h6z1: one fold memo per grid build, keyed by fold key and span viewport::grid_body makes one FoldMemo and passes it to every cell and total through GridLayout::evaluate_in / column_total, so a fold row's formula chain is evaluated once across the row (9, not 45). The bench gains a folded five-formula chain case; stored-only grids are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- benches/grid_projection.rs | 59 ++++++++++++++++++++- context/code/command.md | 7 ++- context/code/core.md | 14 ++++- context/test-evidence/core.md | 14 +++++ crates/improvise-command/src/ui/viewport.rs | 15 ++++-- crates/improvise-core/src/model/types.rs | 57 +++++++++++++++++--- crates/improvise-core/src/view/layout.rs | 53 ++++++++++++++++-- 7 files changed, 201 insertions(+), 18 deletions(-) diff --git a/benches/grid_projection.rs b/benches/grid_projection.rs index 17699856..132a7c31 100644 --- a/benches/grid_projection.rs +++ b/benches/grid_projection.rs @@ -54,6 +54,57 @@ fn dense_app(regions: usize, products: usize, measures: usize) -> App { app } +/// Collapsed groups on the row axis and a formula chain on the column axis, so +/// every fold cell and every total re-derives the chain (improvise-h6z1). +fn chain_app(regions: usize, products: usize) -> App { + let mut wb = Workbook::new("Chain"); + wb.add_category("Region").unwrap(); + wb.add_category("Product").unwrap(); + for r in 0..regions { + wb.add_item_in_group("Region", format!("Region{r}"), format!("G{}", r / 10)) + .unwrap(); + } + for p in 0..products { + wb.add_item("Product", format!("Product{p}")).unwrap(); + } + wb.add_item("_Measure", "Revenue").unwrap(); + let chain = [ + "F1 = Revenue * 2", + "F2 = F1 + Revenue", + "F3 = F2 + F1", + "F4 = F3 + F2", + "F5 = F4 / F1", + ]; + for text in chain { + let formula = improvise::formula::parse_formula(text, "_Measure").unwrap(); + wb.model.add_formula(formula); + } + { + let v = wb.active_view_mut(); + 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 { + for p in 0..products { + n += 1.0; + let key = CellKey::new(vec![ + ("Region".to_string(), format!("Region{r}")), + ("Product".to_string(), format!("Product{p}")), + ("_Measure".to_string(), "Revenue".to_string()), + ]); + wb.model.set_cell(key, CellValue::Number(n)); + } + } + let mut app = App::new(wb, None); + app.rebuild_layout(); + app +} + fn whole_grid(app: &App) -> Viewport { Viewport { row_offset: 0, @@ -105,7 +156,10 @@ fn time_widths(iters: u32, app: &App) -> Duration { } fn case(label: &str, regions: usize, products: usize, measures: usize, iters: u32) { - let app = dense_app(regions, products, measures); + report(label, dense_app(regions, products, measures), iters); +} + +fn report(label: &str, app: App, iters: u32) { let rows = app.layout.row_count(); let cols = app.layout.col_count(); let cells = rows * cols; @@ -133,4 +187,7 @@ fn main() { case("large", 60, 20, 8, 50); case("18k cells (gotcha 41)", 150, 20, 6, 20); case("pathological", 300, 30, 10, 5); + println!("\nfolded rows and a five-formula chain (improvise-h6z1)\n"); + report("folded chain ~18k", chain_app(1000, 30), 5); + report("folded chain ~90k", chain_app(5000, 30), 3); } diff --git a/context/code/command.md b/context/code/command.md index f058e052..3e5cd669 100644 --- a/context/code/command.md +++ b/context/code/command.md @@ -1791,7 +1791,12 @@ before and after: `col_widths` is the keypress path, and it pays too, because a total's text sizes its column. The fold rebuilds a key per drawn row and reads it again rather than reusing the walk's value, since a pooled Avg or a formula needs records, not the -rows' answers. A grid-wide fold memo (improvise-h6z1) is the lever. The walk +rows' answers. A grid-wide fold memo (improvise-h6z1) now exists, and it +removes the repeated **formula** chains a fold row and a total re-derive: about +2x on a folded five-formula chain. It does **not** move this table. Those grids +hold only stored measures, a stored fold is never memoised, and each total +reads its column's records once regardless. What is left of this cost is that +one read per column. The walk no longer carries a sum, so the window-into-totals fault below cannot happen any more. The test still guards the widths half. diff --git a/context/code/core.md b/context/code/core.md index 2b20feaf..6ab014a7 100644 --- a/context/code/core.md +++ b/context/code/core.md @@ -929,8 +929,18 @@ every member. Five decisions: without the memo, about 60 ms with it, and about 10 ms on gh/main. gh/main was cheap because it read the members out of the formula cache, which is what gave the wrong ratios. The remaining ~6× is each column's cell - re-deriving the chain below it (45 evaluations per row where 9 would do). A - memo scoped to the whole grid would remove it; it is not built. + re-deriving the chain below it (45 evaluations per row where 9 would do). + **The memo is now shared across one grid build** (improvise-h6z1). + `viewport::grid_body` makes one `FoldMemo` and passes it through + `GridLayout::evaluate_in` and `column_total` for every cell and total, so a + fold row's chain is evaluated 9 times, not 45. A memo is valid only for the + model state it read. That is why it is made per build and never stored on + `GridLayout`, which outlives cell writes. + The memo is keyed by the fold key **and** the span's categories. Within one + grid build the span half cannot yet differ for one key, because a cell's key + always names the row categories and a total's never does. It is kept because + `evaluate_spanning_in` is public, and a caller sharing one memo across two + spans would otherwise read the wrong fold. No test can see it today. - **A fold covers the members the grid draws** (improvise-f2oo). Hidden members are not in it. So a fold is exactly the rows it replaces, the total of a column's total does not move on `z`, and a group whose every member is hidden diff --git a/context/test-evidence/core.md b/context/test-evidence/core.md index 26c16963..a91b2fb5 100644 --- a/context/test-evidence/core.md +++ b/context/test-evidence/core.md @@ -1511,3 +1511,17 @@ are each compared with the same document with Month on `Axis::None`. At `Flag` at `Some(0.0)`, false rather than absent, so a fix that dropped the cell cannot pass. Rival: `read_coordinate` ignoring the reach (`if false && …`). This test fails. + +### a_fold_row_evaluates_each_formula_once_across_the_row + +improvise-h6z1. `F0 = Revenue`, `F{i} = F{i-1} + F{i-1}` up to F8, as columns, +with Q1 collapsed so the Q1 row is a fold. The test evaluates every cell of that +row twice and compares the two runs: once with a fresh memo per cell (`evaluate`), +where each column re-derives the chain below it (1 + 2 + … + 9 = **45**), and +once with one memo shared across the row (`evaluate_in`), which gives **9**. It +also asserts the two runs produce identical values, so a memo that returned a +wrong entry cannot pass. The 45 is asserted as a setup, so a fixture whose chain +had collapsed into one column could not pass the 9 by accident. Rival: +`evaluate_covering_in` ignoring the memo it is given and making a fresh one. +Injected into a full workspace run, it fails this test (45 against 9) and +nothing else. diff --git a/crates/improvise-command/src/ui/viewport.rs b/crates/improvise-command/src/ui/viewport.rs index 45c3860c..4c8c48d6 100644 --- a/crates/improvise-command/src/ui/viewport.rs +++ b/crates/improvise-command/src/ui/viewport.rs @@ -9,6 +9,7 @@ use crate::format::{FormatPlan, NumberFormat}; use crate::model::Model; use crate::model::category::MEASURE_CATEGORY; use crate::model::cell::{CellKey, CellValue}; +use crate::model::types::FoldMemo; use crate::view::{AxisEntry, GridLayout}; /// Minimum column width — enough for short numbers/labels + 1 char gap. @@ -46,11 +47,16 @@ pub fn grid_body( let n = layout.col_count(); let records = layout.is_records_mode(); let mut widths = header_level_seed(layout, n); + // One memo for the whole build: a fold every cell and total reaches is + // evaluated once (improvise-h6z1). The model does not change during it. + let memo = FoldMemo::default(); // A column mixing measures has no total to show: `context/code/command.md`. let measures_on_rows = layout.row_cats.iter().any(|c| c == MEASURE_CATEGORY); let totalled = !records && !measures_on_rows && layout.row_count() > 0 && n > 0; let totals: Vec> = match totalled { - true => (0..n).map(|ci| layout.column_total(model, ci)).collect(), + true => (0..n) + .map(|ci| layout.column_total(model, ci, &memo)) + .collect(), false => Vec::new(), }; let mut window: Vec>> = Vec::with_capacity(rows.len()); @@ -67,7 +73,7 @@ pub fn grid_body( false => Vec::new(), }; for ci in 0..n { - let (key, value) = cell_value(model, layout, records, ri, ci); + let (key, value) = cell_value(model, layout, records, ri, ci, &memo); let fmt = plan.at(key.as_ref()); let text = match records { true => value.as_ref().map(|v| v.to_string()).unwrap_or_default(), @@ -105,12 +111,15 @@ fn cell_value( records: bool, row: usize, col: usize, + memo: &FoldMemo, ) -> (Option, Option) { match records { true => (None, layout.records_value(row, col)), false => { let key = layout.cell_key(row, col); - let value = key.as_ref().and_then(|k| layout.evaluate(model, k)); + let value = key + .as_ref() + .and_then(|k| layout.evaluate_in(model, k, memo)); (key, value) } } diff --git a/crates/improvise-core/src/model/types.rs b/crates/improvise-core/src/model/types.rs index fc5e61b0..cdb39642 100644 --- a/crates/improvise-core/src/model/types.rs +++ b/crates/improvise-core/src/model/types.rs @@ -140,12 +140,28 @@ fn agg_values( // How many formulas a fold has evaluated: what improvise-o1y0's regression counts. #[cfg(test)] thread_local! { - static FOLD_FORMULA_EVALUATIONS: std::cell::Cell = const { std::cell::Cell::new(0) }; + pub(crate) static FOLD_FORMULA_EVALUATIONS: std::cell::Cell = const { std::cell::Cell::new(0) }; } -/// One fold evaluation's memory: each formula key is evaluated once, and a key met -/// while still in progress (`None`) is a cycle (improvise-o1y0). -type FoldMemo = std::cell::RefCell>>>; +/// Each (fold key, span) evaluated once across one grid build (improvise-o1y0, +/// improvise-h6z1). Valid for one model state only: `context/code/core.md` §14. +#[derive(Default)] +pub struct FoldMemo(std::cell::RefCell), MemoEntry>>); + +/// `None` while in progress, which is how a cycle is seen (improvise-o1y0). +type MemoEntry = Option>; + +impl FoldMemo { + fn get(&self, key: &CellKey, span: Span) -> Option { + let at = (key.clone(), span.over.to_vec()); + self.0.borrow().get(&at).cloned() + } + + fn put(&self, key: &CellKey, span: Span, entry: MemoEntry) { + let at = (key.clone(), span.over.to_vec()); + self.0.borrow_mut().insert(at, entry); + } +} /// Which members of a collapsed group its fold covers. The model knows no /// view, so the grid passes the members it draws (core.md §14). @@ -623,6 +639,19 @@ impl Model { self.evaluate_fold(key, agg, span, &FoldMemo::default()) } + /// [`Self::evaluate_covering`] sharing `memo` with every other evaluation of one + /// grid build, which must not outlive the model state it read (improvise-h6z1). + pub fn evaluate_covering_in( + &self, + key: &CellKey, + agg: Aggregation, + covers: Covers, + memo: &FoldMemo, + ) -> Option { + let span = Span { covers, over: &[] }; + self.evaluate_fold(key, agg, span, memo) + } + /// The value `key` has as ONE fold over every covered item of the `over` /// categories it omits — a column's total (improvise-wm20, core.md §14). pub fn evaluate_spanning( @@ -631,9 +660,21 @@ impl Model { over: &[String], agg: Aggregation, covers: Covers, + ) -> Option { + self.evaluate_spanning_in(key, over, agg, covers, &FoldMemo::default()) + } + + /// [`Self::evaluate_spanning`] sharing one grid build's `memo` (improvise-h6z1). + pub fn evaluate_spanning_in( + &self, + key: &CellKey, + over: &[String], + agg: Aggregation, + covers: Covers, + memo: &FoldMemo, ) -> Option { let span = Span { covers, over }; - self.evaluate_fold(key, agg, span, &FoldMemo::default()) + self.evaluate_fold(key, agg, span, memo) } /// A key naming a group folds; any other key reads as before. A formula is @@ -664,17 +705,17 @@ impl Model { members: &[CellKey], (agg, span, memo): (Aggregation, Span, &FoldMemo), ) -> Option { - let seen = memo.borrow().get(key).cloned(); + let seen = memo.get(key, span); match seen { Some(Some(value)) => value, Some(None) => Some(CellValue::Error("circular".into())), None => { - memo.borrow_mut().insert(key.clone(), None); + memo.put(key, span, None); #[cfg(test)] FOLD_FORMULA_EVALUATIONS.with(|n| n.set(n.get() + 1)); let reach = Reach::Fold { agg, span, memo }; let value = self.eval_formula_folded(formula, key, members, agg, reach); - memo.borrow_mut().insert(key.clone(), Some(value.clone())); + memo.put(key, span, Some(value.clone())); value } } diff --git a/crates/improvise-core/src/view/layout.rs b/crates/improvise-core/src/view/layout.rs index 96a6c218..3e03f924 100644 --- a/crates/improvise-core/src/view/layout.rs +++ b/crates/improvise-core/src/view/layout.rs @@ -3,6 +3,7 @@ use std::rc::Rc; use crate::model::category::{DIM_CATEGORY, INDEX_CATEGORY, MEASURE_CATEGORY}; use crate::model::cell::{CellKey, CellValue}; +use crate::model::types::FoldMemo; use crate::model::{Aggregation, Model}; use crate::view::{Axis, View}; @@ -657,17 +658,22 @@ impl GridLayout { /// The value of a pivot key as THIS grid reads it: aggregated over its None /// axis, and a fold over the members it draws (core.md §14). One evaluation. pub fn evaluate(&self, model: &Model, key: &CellKey) -> Option { + self.evaluate_in(model, key, &FoldMemo::default()) + } + + /// [`Self::evaluate`] sharing one grid build's `memo` (improvise-h6z1). + pub fn evaluate_in(&self, model: &Model, key: &CellKey, memo: &FoldMemo) -> Option { let drawn = |cat: &str, item: &str| !self.hidden.get(cat).is_some_and(|h| h.contains(item)); - model.evaluate_covering(key, Aggregation::of(&self.none_cats), &drawn) + model.evaluate_covering_in(key, Aggregation::of(&self.none_cats), &drawn, memo) } /// A column's total: its cell as if every drawn row were ONE fold, so a /// formula is evaluated over the pooled rows (improvise-wm20, core.md §14). - pub fn column_total(&self, model: &Model, col: usize) -> Option { + pub fn column_total(&self, model: &Model, col: usize, memo: &FoldMemo) -> Option { let key = self.column_key(col)?; let drawn = |cat: &str, item: &str| !self.hidden.get(cat).is_some_and(|h| h.contains(item)); let agg = Aggregation::of(&self.none_cats); - model.evaluate_spanning(&key, &self.row_cats, agg, &drawn) + model.evaluate_spanning_in(&key, &self.row_cats, agg, &drawn, memo) } /// Display text for (row, col) in either mode. @@ -904,6 +910,7 @@ mod tests { prune_axis_items, synthetic_record_info, }; use crate::model::cell::{CellKey, CellValue}; + use crate::model::types::{FOLD_FORMULA_EVALUATIONS, FoldMemo}; use crate::view::Axis; use crate::workbook::Workbook; @@ -1841,6 +1848,46 @@ mod tests { .and_then(|v| v.as_f64()) } + /// improvise-h6z1: one grid build evaluates each formula a fold row reaches + /// once, not once per column. context/test-evidence/core.md. + #[test] + fn a_fold_row_evaluates_each_formula_once_across_the_row() { + let chain: Vec = std::iter::once("F0 = Revenue".to_string()) + .chain((1..=8).map(|i| format!("F{i} = F{p} + F{p}", p = i - 1))) + .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.model.recompute_formulas(&wb.active_view().none_cats()); + let layout = GridLayout::new(&wb.model, wb.active_view()); + let q1 = (0..layout.row_count()) + .find(|&r| layout.row_label(r) == "Q1") + .expect("a Q1 fold row"); + let row_keys: Vec = (0..layout.col_count()) + .filter_map(|c| layout.cell_key(q1, c)) + .collect(); + let evaluations = |shared: bool| { + let memo = FoldMemo::default(); + FOLD_FORMULA_EVALUATIONS.with(|n| n.set(0)); + let values: Vec<_> = row_keys + .iter() + .map(|k| match shared { + true => layout.evaluate_in(&wb.model, k, &memo), + false => layout.evaluate(&wb.model, k), + }) + .collect(); + (FOLD_FORMULA_EVALUATIONS.with(|n| n.get()), values) + }; + let (apart, separately) = evaluations(false); + let (together, shared) = evaluations(true); + assert_eq!(shared, separately, "sharing the memo changed a value"); + assert_eq!( + apart, 45, + "setup: each column re-derives the chain below it" + ); + assert_eq!(together, 9, "F0..F8 once each across the whole row"); + } + /// improvise-eccl — see context/test-evidence/core.md. #[test] fn a_folds_ratio_formula_is_the_ratio_of_its_folded_inputs() { From 96ecfd6018d6e609b0bf796511ebfe7d1ef9761f Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 10:53:59 -0700 Subject: [PATCH 12/48] After a write, the reopened editor takes its seed from the host's answer (improvise-3uzg) landed_seed assumed the client's cache was stale only at the cell the frame wrote. Under prune_empty a write can take its row away and renumber the rest, so the advance landed on a position the cache still filled with another record. The advance after a write now opens the editor awaiting its seed, and the client settles it from the grid it holds once nothing it sent is unanswered. A seed never overwrites typing that arrived first. This also removes CellValue::edit_text from the client bundle, so the nontrapping-float-to-int wasm-opt flag a093d126 added is no longer needed and is reverted (improvise-xbgm). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- crates/improvise-protocol/src/client.rs | 91 +++++++++++++++++++ crates/improvise-protocol/src/resolve.rs | 61 +++++++++---- crates/improvise-wasm-client/Cargo.toml | 5 +- .../improvise-wasm-client/tests/lockstep.rs | 85 ++++++++++++++--- 4 files changed, 207 insertions(+), 35 deletions(-) diff --git a/crates/improvise-protocol/src/client.rs b/crates/improvise-protocol/src/client.rs index 790e4994..6d7d3fa8 100644 --- a/crates/improvise-protocol/src/client.rs +++ b/crates/improvise-protocol/src/client.rs @@ -113,6 +113,8 @@ impl ClientState { } } self.settle_sent(); + self.settle_editor(); + self.revision += 1; } /// The host applied **none** of the oldest awaited frame (ADR 001 clause @@ -175,6 +177,22 @@ impl ClientState { self.view.buffers.insert(key, seed.clone()); self.view.opened_editor = Some(crate::OpenedEditor::on(seed)); } + self.settle_awaited_seed(); + } + + /// An editor opened after a write takes its seed from the grid the host + /// answered with, never from the cache the write made stale (improvise-3uzg). + fn settle_awaited_seed(&mut self) { + let waiting = self + .view + .opened_editor + .as_ref() + .is_some_and(|opened| opened.awaiting && !opened.touched); + let answered = !self.in_flight.iter().any(|sent| sent.awaited); + if let (true, true, Some(seed)) = (waiting, answered, crate::resolve::edit_seed(self)) { + self.view.buffers.insert("edit".to_string(), seed.clone()); + self.view.opened_editor = Some(crate::OpenedEditor::on(seed)); + } } /// The widget dispatch: a projection names the widget it is addressed to. @@ -397,6 +415,79 @@ mod tests { ); } + /// A write, then the editor its advance reopens with the seed still to come. + fn write_then_awaiting_editor() -> Vec { + let mut frame = commit_like("written"); + frame.extend([ + Command::SetBuffer { + name: "edit".into(), + value: String::new(), + }, + Command::SetOpenedEditor(Some(crate::OpenedEditor::awaiting())), + Command::ChangeMode(crate::AppMode::editing()), + ]); + frame + } + + /// One row, one column, the cell's stored text `edit_text`. + fn answered_with(state: &mut ClientState, edit_text: &str) { + let mut cell = CellDisplay::number(edit_text); + cell.edit_text = Some(edit_text.into()); + for cmd in [ + GridUpdate::RowFrame(AxisFrame::flat(["North"])), + GridUpdate::ColFrame(AxisFrame::flat(["Revenue"])), + GridUpdate::Cells(vec![((0, 0), cell)]), + ] { + state.fold(&Command::Project(Projection::Grid(cmd))); + } + } + + /// BUG (improvise-3uzg) — see `context/test-evidence/clients.md`. + #[test] + fn an_awaited_seed_waits_for_the_answer_and_never_overwrites_typing() { + let mut state = ClientState::default(); + answered_with(&mut state, "2"); + state.fold_outgoing(&write_then_awaiting_editor()); + assert_eq!(state.view.buffers.get("edit").map(String::as_str), Some("")); + answered_with(&mut state, "3"); + assert_eq!( + state.view.buffers.get("edit").map(String::as_str), + Some(""), + "seeded while the write was still unanswered" + ); + state.accepted(); + assert_eq!( + state.view.buffers.get("edit").map(String::as_str), + Some("3"), + "the answered editor must open on the host's grid" + ); + assert_eq!( + state.view.opened_editor, + Some(crate::OpenedEditor::on("3".into())) + ); + + let mut typed_ahead = ClientState::default(); + answered_with(&mut typed_ahead, "2"); + typed_ahead.fold_outgoing(&write_then_awaiting_editor()); + typed_ahead.fold_outgoing(&[ + Command::SetBuffer { + name: "edit".into(), + value: "5".into(), + }, + Command::SetOpenedEditor(crate::resolve::edited( + typed_ahead.view.opened_editor.as_ref(), + "edit", + )), + ]); + answered_with(&mut typed_ahead, "3"); + typed_ahead.accepted(); + assert_eq!( + typed_ahead.view.buffers.get("edit").map(String::as_str), + Some("5"), + "a seed arriving after the user typed must not overwrite the typing" + ); + } + /// A frame the host does not answer must not consume an answer, or the /// queue addresses the wrong frame. Evidence file. #[test] diff --git a/crates/improvise-protocol/src/resolve.rs b/crates/improvise-protocol/src/resolve.rs index 2659b1f9..51e6ad34 100644 --- a/crates/improvise-protocol/src/resolve.rs +++ b/crates/improvise-protocol/src/resolve.rs @@ -898,8 +898,8 @@ fn commit(state: &ClientState, edit_mode: AppMode, advance: Advance) -> Option, + writes: bool, ) -> Option> { let GridExtent { rows, cols } = state.grid.extent; let row_max = rows.saturating_sub(1); @@ -990,17 +990,26 @@ fn advanced( }; let mut commands = scrolled_to(state, nr, nc); // `EnterEditAtCursor`: seed from where we landed, then change mode. - commands.extend(opened_on(landed_seed(state, (nr, nc), wrote), edit_mode)); + // After a write the cache may name other cells than the host's grid does, so + // the seed waits for the host's answer (improvise-3uzg, `protocol.md` §3). + commands.extend(match writes { + true => awaiting_seed(edit_mode), + false => opened_on(seed_at(state, nr, nc), edit_mode), + }); Some(commands) } -/// The seed at the cell an advance lands on. The cache is stale only where the -/// frame just wrote, which is the cursor's own cell (improvise-3tt4). -fn landed_seed(state: &ClientState, (row, col): (usize, usize), wrote: Option<&str>) -> String { - match wrote.filter(|_| state.view.cursor().selected == (row, col)) { - Some(text) => typed(text).map(|v| v.edit_text()).unwrap_or_default(), - None => seed_at(state, row, col), - } +/// Open the cell editor with its seed still to come: the client settles it from +/// the host's answer, once nothing this client sent is unanswered (improvise-3uzg). +fn awaiting_seed(mode: AppMode) -> Vec { + vec![ + Command::SetBuffer { + name: EDIT_BUFFER.to_string(), + value: String::new(), + }, + Command::SetOpenedEditor(Some(OpenedEditor::awaiting())), + Command::ChangeMode(mode), + ] } /// The surrogate naming the record under the cursor, or `None` when the @@ -1043,6 +1052,9 @@ pub struct OpenedEditor { pub seed: String, /// Whether the user has edited the buffer since it opened (improvise-2054). pub touched: bool, + /// The seed is still to come from the host's answer (improvise-3uzg). + #[serde(default)] + pub awaiting: bool, } impl OpenedEditor { @@ -1051,6 +1063,16 @@ impl OpenedEditor { Self { seed, touched: false, + awaiting: false, + } + } + + /// An editor opened before its seed is known: empty until it settles. + pub fn awaiting() -> Self { + Self { + seed: String::new(), + touched: false, + awaiting: true, } } } @@ -3394,14 +3416,17 @@ mod tests { Some(&Command::ChangeMode(AppMode::editing())), "the advance re-enters editing on the cell it landed on: {commands:?}" ); - let last_write = commands.iter().rev().find_map(|c| match c { - Command::SetBuffer { name, value } if name == "edit" => Some(value.as_str()), - _ => None, - }); + assert!( + commands.contains(&Command::SetOpenedEditor(Some(OpenedEditor::awaiting()))), + "after a write the reopened editor awaits the host's seed: {commands:?}" + ); + for command in &commands { + client.fold(command); + } assert_eq!( - last_write, + client.view.buffers.get("edit").map(String::as_str), Some("900"), - "the reopened editor must show the cell it landed on, not blank: {commands:?}" + "an answered client seeds the reopened editor from the cell it landed on" ); } diff --git a/crates/improvise-wasm-client/Cargo.toml b/crates/improvise-wasm-client/Cargo.toml index 131e552c..1abfbb14 100644 --- a/crates/improvise-wasm-client/Cargo.toml +++ b/crates/improvise-wasm-client/Cargo.toml @@ -45,7 +45,6 @@ improvise-ws-server = { path = "../improvise-ws-server" } # The `wasm-opt` wasm-pack downloads predates bulk-memory being enabled by # default, while LLVM emits `memory.copy` regardless — so the validator # rejects the module unless the feature is named. Naming it here rather than -# turning `wasm-opt` off keeps the optimizer in the loop. `nontrapping-float-to-int` -# is the worker bundle's second flag, needed here once the client parses typed text. +# turning `wasm-opt` off keeps the optimizer in the loop. [package.metadata.wasm-pack.profile.release] -wasm-opt = ["-O", "--enable-bulk-memory", "--enable-nontrapping-float-to-int"] +wasm-opt = ["-O", "--enable-bulk-memory"] diff --git a/crates/improvise-wasm-client/tests/lockstep.rs b/crates/improvise-wasm-client/tests/lockstep.rs index af63c60f..0ed4ba92 100644 --- a/crates/improvise-wasm-client/tests/lockstep.rs +++ b/crates/improvise-wasm-client/tests/lockstep.rs @@ -2277,13 +2277,7 @@ fn tab_at_the_rightmost_column_reopens_on_the_value_just_written() { let mut client = client_ready_to_edit(&client_host, (2, 2)); for key in ["i", "9", "Tab", "5", "Enter"] { let event = BrowserKey::new(key).to_key_event().unwrap(); - let KeyOutcome::Local(cmds) = resolve(&client, event) else { - panic!("{key}: the client must answer this key locally"); - }; - for cmd in &cmds { - client.fold(cmd); - } - apply_on(&mut client_host, &cmds); + sent_and_answered(&mut client, &mut client_host, event, (2, 2)); reduce_key(&mut key_host, event); assert_eq!( client.view.buffers, key_host.view_state.buffers, @@ -2313,13 +2307,7 @@ fn tab_at_the_rightmost_column_reopens_on_the_stored_spelling() { let mut client = client_ready_to_edit(&client_host, (2, 2)); for key in ["i", "Backspace", "0", "0", "7", "Tab"] { let event = BrowserKey::new(key).to_key_event().unwrap(); - let KeyOutcome::Local(cmds) = resolve(&client, event) else { - panic!("{key}: the client must answer this key locally"); - }; - for cmd in &cmds { - client.fold(cmd); - } - apply_on(&mut client_host, &cmds); + sent_and_answered(&mut client, &mut client_host, event, (2, 2)); reduce_key(&mut key_host, event); } assert_eq!( @@ -2444,3 +2432,72 @@ fn every_projected_edit_text_commits_back_to_its_value() { } } } + +/// North/South/West = 1/2/3 on one measure, with `prune_empty` on: emptying a +/// row's only value takes the row away and renumbers the rest. +fn pruned_host() -> App { + let mut wb = Workbook::new("Test"); + wb.add_category("Region").unwrap(); + for region in ["North", "South", "West"] { + wb.add_item("Region", region).unwrap(); + } + 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().prune_empty = true; + for (region, value) in [("North", 1.0), ("South", 2.0), ("West", 3.0)] { + wb.model.set_cell( + region_measure(region, "Revenue"), + improvise_protocol::CellValue::Number(value), + ); + } + let mut app = App::new(wb, None); + app.term_height = 3 + 8; + app.rebuild_layout(); + app +} + +/// One key the way a real browser sends it: folded as an outgoing frame, applied +/// by the host, then answered with the host's own projection and `Accepted`. +fn sent_and_answered( + client: &mut ClientState, + host: &mut App, + event: KeyEvent, + window: (usize, usize), +) { + let KeyOutcome::Local(cmds) = resolve(client, event) else { + panic!("{event:?}: the client must answer this key locally"); + }; + client.fold_outgoing(&cmds); + apply_on(host, &cmds); + client.grid = client_of(host, window).grid; + improvise_protocol::WidgetCache::apply(&mut client.basis, &published_basis(host, window)); + client.accepted(); +} + +/// BUG (improvise-3uzg) — see `context/test-evidence/clients.md`. +#[test] +fn a_commit_that_prunes_its_row_reopens_on_the_row_that_moved_up() { + let window = (3, 3); + let mut client_host = pruned_host(); + let mut key_host = pruned_host(); + let mut client = client_ready_to_edit(&client_host, window); + for key in ["i", "Backspace", "Enter", "5", "Enter"] { + let event = BrowserKey::new(key).to_key_event().unwrap(); + sent_and_answered(&mut client, &mut client_host, event, window); + reduce_key(&mut key_host, event); + assert_eq!( + client.view.buffers, key_host.view_state.buffers, + "{key}: client and host disagree about the buffers" + ); + } + assert_eq!( + document_text(&client_host), + document_text(&key_host), + "the browser wrote a value built from another record's number" + ); + assert!( + document_text(&key_host).contains("= 35"), + "setup: the host's reopened editor extends West's 3" + ); +} From baabe1a9b10c6750bd193a6d8710d6545dee1658 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 10:59:41 -0700 Subject: [PATCH 13/48] Evidence and the argument for seeding a reopened editor from the host's answer (improvise-3uzg, improvise-xbgm) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/protocol.md | 36 ++++++++++++++++ context/test-evidence/clients.md | 54 ++++++++++++++++++++---- crates/improvise-protocol/src/resolve.rs | 1 - 3 files changed, 81 insertions(+), 10 deletions(-) diff --git a/context/code/protocol.md b/context/code/protocol.md index 4e4354ff..0355e861 100644 --- a/context/code/protocol.md +++ b/context/code/protocol.md @@ -2016,6 +2016,42 @@ Being at the head is also gotcha 21's rule: a head clear survives an abort where the trailing weld did not. The corner commit and the `+cat` insertion reopen nothing, and they still leave no text behind. +**After a write, the browser's reopened editor takes its seed from the host's +answer, not from its cache** (improvise-3uzg). The first fix (improvise-3tt4) +seeded from the cache everywhere except the cell the frame had just written. +That assumed the write changed the meaning of only that one cell, which is +false. Under `prune_empty`, emptying the last value in a row takes the row +away. The advance then lands on a renumbered position, and the cache still +holds the old record there. The browser opened on South's `2` while the TUI +opened on West's `3`, and typing `5` wrote 25 into West. Any write can change +what another position means, and the client cannot tell which. So the rule is +stated once, over the write, never over a list of cases: +- **A commit that writes** reopens its editor as `OpenedEditor::awaiting()`, an + empty buffer whose seed is still to come. `create_column` is included, + because `AddItem` adds a column. +- **`ClientState::settle_awaited_seed`** fills the seed from the grid the + client holds, but only once no frame it sent is still unanswered. The answer + folds the host's projection before `Accepted`, so the grid is the host's by + then. `accepted()` therefore settles. +- **A commit that writes nothing** still seeds from the cache at once. Nothing + changed, so the cache is current. + +Asking the host to send the seed was the alternative. It would have needed a +new message, and the host already sends it: the landed cell's `edit_text` in +the projection it owes this client anyway. Two things are accepted costs: +- Until the answer arrives, the editor is empty. Over a worker that takes + well under a frame. +- A key typed before the answer arrives marks the editor touched, and the late + seed then does not overwrite it. What the user sees is then what gets + written, which is never another record's number. + +The TUI is unchanged: its `RebuildLayout` in front of `EnterEditAtCursor` +already reads the renumbered grid (gotcha 23). This also took +`CellValue::edit_text` out of the client bundle. That call formats an `f64`, +which is what compiled to `i64.trunc_sat_f64_s`, so `a093d126`'s +`nontrapping-float-to-int` flag on the wasm-client is reverted. A `?ws=` page +loads only the client bundle, so it works without the feature. + Reordering the weld to `[clear-buffer, cmd]` was tried first and rejected. Four readers take a `Sequence`'s **first** step as its identity: `keymap::runs`, `help.rs`, and two resolver audits. So every hint on those keys started diff --git a/context/test-evidence/clients.md b/context/test-evidence/clients.md index c5ec8721..a846ea1e 100644 --- a/context/test-evidence/clients.md +++ b/context/test-evidence/clients.md @@ -5146,18 +5146,21 @@ key-driven host, and compares the buffers after every key. It failed at Pressing `5 Enter` would then have written 25 over the 29 the user had just committed. Both ends must end at 295. -**Fix:** `landed_seed` seeds the landing cell from the committed value's edit -text when the advance lands on the cursor's own cell and the commit wrote. In -every other case it uses the cache. No other advance lands on the written cell: -`Down` moves to the next row or the next column, or reopens nothing at the -corner. +**Fix, superseded by improvise-3uzg:** the first fix, `landed_seed`, seeded +from the committed text on the cursor's own cell. It assumed that cell was the +only stale one, which prune disproved. After a write the editor now awaits the +host's answer, so this test drives each key through `sent_and_answered`: the +key is folded as an outgoing frame, the host applies it and projects, then +`Accepted` arrives. Both rivals below still fail it. ### `tab_at_the_rightmost_column_reopens_on_the_stored_spelling` -This pins what "the committed value" means in the fix above: the stored value's -edit text, as `typed()` would store it, not the raw typed string. `i Backspace 0 -0 7 Tab` at (0, 1) must leave `"7"` in both buffers. **Rival:** seeding from the -raw text fails with `"007"` against the host's `"7"`. +This pins what "the committed value" means: the stored value's edit text, not +the raw typed string. `i Backspace 0 0 7 Tab` at (0, 1) must leave `"7"` in both +buffers. **Rival** (under the 3tt4 fix): seeding from the raw text failed with +`"007"` against `"7"`. Under the 3uzg fix the seed comes from the host's +projection, which has only the stored value to give. Opening the editor at once +from the cache fails this test. ### `a_category_committed_at_plus_cat_leaves_no_buffer_behind_on_both_ends` @@ -5186,3 +5189,36 @@ Rival injected: `claim_as_read` ignoring the captured generation and claiming the current one. That is the pre-fix behaviour, restated after the fix. Only this test failed in the whole workspace. The layout tier staying current is still pinned by `an_edit_writes_the_cell_the_user_clicked_after_the_rows_renumber`. + +### `a_commit_that_prunes_its_row_reopens_on_the_row_that_moved_up` + +**improvise-3uzg**, found by the #227 review at `a093d126`. Fixture: +North/South/West = 1/2/3 on one measure, `prune_empty` on, window 3×3. Keys +`i Backspace Enter 5 Enter`, each through `sent_and_answered` on the browser and +through `reduce_key` on the TUI host. The buffers must agree after every key, +and the two documents must be identical at the end. The Backspace-Enter empties +North, which prunes its row, and the advance lands on row 1, which is now West. + +Red at `a093d126`: after the first Enter the browser buffer was `"2"` (South, the +stale cache) and the host's was `"3"`. At gh/main both reopened blank. +**Rivals** (each fails this test and the two `tab_at_the_rightmost_column_*` +tests): +- `settle_awaited_seed` not waiting for unanswered frames, so it seeds while + folding its own outgoing frame. +- `accepted()` not settling, so the seed never arrives. +- The resolver seeding from the cache immediately after a write. + +### `client::tests::an_awaited_seed_waits_for_the_answer_and_never_overwrites_typing` + +The fold half of improvise-3uzg, without a host. The steps: +1. A write frame with an awaiting editor is sent over a cache that says `2`. +2. The host's projection saying `3` arrives. The buffer must still be empty. +3. `Accepted` arrives. The buffer and the opened editor must be seeded `3`. +4. In a second client, a keystroke `5` arrives before the answer. After + `Accepted` the buffer must still be `5`. + +**Rivals:** +- Seeding while a frame is unanswered fails this test and the three lockstep + tests. +- `accepted()` not settling fails the same four. +- Ignoring `touched` fails this test alone. It is the one guard on type-ahead. diff --git a/crates/improvise-protocol/src/resolve.rs b/crates/improvise-protocol/src/resolve.rs index 7efa0885..cdfbe558 100644 --- a/crates/improvise-protocol/src/resolve.rs +++ b/crates/improvise-protocol/src/resolve.rs @@ -1009,7 +1009,6 @@ fn advanced( return Some(Vec::new()); }; let mut commands = scrolled_to(state, nr, nc); - // `EnterEditAtCursor`: seed from where we landed, then change mode. // After a write the cache may name other cells than the host's grid does, so // the seed waits for the host's answer (improvise-3uzg, `protocol.md` §3). commands.extend(match writes { From f5cfc7edd32424fb95137c011add3dcf84252fdf Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 11:10:11 -0700 Subject: [PATCH 14/48] Isolate shipped_documents_load's scratch repos from the caller's git env improvise-53zz. The scratch-repo shape tests inherited the caller's git environment. A hook in a linked worktree exports GIT_DIR (and GIT_INDEX_FILE), which override -C, so `git -C add` staged the scratch files into the outer checkout's index. The user's global excludesFile applied too, so a `*.gz` line there broke the gzip test. One helper, git(root), now removes every name that `git rev-parse --local-env-vars` prints. run_git also sets GIT_CONFIG_GLOBAL=/dev/null and GIT_CONFIG_NOSYSTEM=1. Two new tests re-run a shape test in a child process of this binary with the variables set. Both were red before the fix. Five rivals are recorded in context/test-evidence/tui.md. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/test-evidence/tui.md | 55 ++++++++++++++++++++ tests/shipped_documents_load.rs | 91 ++++++++++++++++++++++++++++++--- 2 files changed, 140 insertions(+), 6 deletions(-) diff --git a/context/test-evidence/tui.md b/context/test-evidence/tui.md index e19a8690..0ad20193 100644 --- a/context/test-evidence/tui.md +++ b/context/test-evidence/tui.md @@ -490,6 +490,61 @@ in a scratch repository and asserts the walk lists it. All four were red against the `.gitignore` walk. The account, including the rivals, is under `every_shipped_document_loads` above. +### a_scratch_repo_ignores_an_inherited_git_dir + +improvise-53zz. The scratch repositories inherited the caller's git +environment, and two parts of it change what `git -C ` does. + +**The repo-local variables beat `-C`.** A hook running in a linked worktree +exports `GIT_DIR` (and, during a commit, `GIT_INDEX_FILE`). Under those, +`git -C init` re-initialised the *outer* repository and `git add` +staged the scratch files into the *outer* index, so the test suite wrote into +the checkout that ran it. Three of the four shape tests still passed that way, +because `ls-files` read the same wrong index that `add` had just written. So a +green run was no evidence the walk had looked at the scratch repository. + +The fix is one helper, `git(root)`, which removes every name that +`git rev-parse --local-env-vars` prints before adding `-C`. It asks git rather +than hard-coding a list, so the list follows the installed git. Both +`shipped_documents` and the scratch helpers go through it, so the production +walk ignores an inherited `GIT_DIR` too. + +The test must set the variable, and a test thread cannot set it without +setting it for every other thread. So the test re-runs this same binary as a +child, restricted to `a_single_file_un_ignore_is_walked`, with `GIT_DIR` and +`GIT_INDEX_FILE` naming a throwaway outer repository. It asserts two things: +the child passed, and the outer index is still empty. Before the fix the second +assertion failed with `the outer index gained: .gitignore`. + +Rivals, each run against the fixed file: + +- Remove only `GIT_DIR`: red, because `GIT_INDEX_FILE` still sends the add to + the outer index. +- Remove only `GIT_INDEX_FILE`: red, because `GIT_DIR` does. +- Rename the child test so the child runs nothing: red on + `… did not run`. `rerun_with_env` asserts `running 1 test`, because + libtest exits 0 when a filter matches nothing, and both of these tests would + then pass without running anything. + +### a_scratch_repo_ignores_the_users_global_and_system_excludes + +improvise-53zz. The scratch repositories also read the user's own git config. A +`*.gz` line in a global `core.excludesFile` made `git add` refuse +`docs/model.improv.gz`, so `a_gzipped_document_is_walked` failed on that +machine and passed on others. `run_git` now sets `GIT_CONFIG_GLOBAL=/dev/null` +and `GIT_CONFIG_NOSYSTEM=1`. `shipped_documents` does not need them: `ls-files` +reads the index, and excludes never apply to a tracked file. + +The test writes a config whose `excludesFile` holds `*.gz`, and re-runs the +gzip shape test in a child once under `GIT_CONFIG_GLOBAL` and once under +`GIT_CONFIG_SYSTEM`. Before the fix the global run failed at `git add`. + +Rivals: + +- Drop `GIT_CONFIG_GLOBAL=/dev/null`: red, in the global run. +- Drop `GIT_CONFIG_NOSYSTEM=1`: red, in the system run. The system scope is + there so that this half of the fix has a witness at all. + --- ## `tests/web_demo_model.rs` diff --git a/tests/shipped_documents_load.rs b/tests/shipped_documents_load.rs index 777b1b1b..c2c28add 100644 --- a/tests/shipped_documents_load.rs +++ b/tests/shipped_documents_load.rs @@ -6,9 +6,7 @@ use std::path::{Path, PathBuf}; /// Every tracked `.improv` and `.improv.gz` under `root`. Outside a checkout this /// panics rather than answering empty (improvise-c0aj). fn shipped_documents(root: &Path) -> Vec { - let out = std::process::Command::new("git") - .arg("-C") - .arg(root) + let out = git(root) .args(["ls-files", "-z", "--", "*.improv", "*.improv.gz"]) .output() .expect("git runs: this test reads the index, so it needs a checkout"); @@ -50,6 +48,21 @@ fn every_shipped_document_loads() { } } +/// `git -C root`, with every repo-local variable a caller exported removed, +/// since `GIT_DIR` and friends override `-C` (improvise-53zz). +fn git(root: &Path) -> std::process::Command { + let names = std::process::Command::new("git") + .args(["rev-parse", "--local-env-vars"]) + .output() + .expect("git runs"); + let mut git = std::process::Command::new("git"); + for name in String::from_utf8(names.stdout).expect("UTF-8").lines() { + git.env_remove(name); + } + git.arg("-C").arg(root); + git +} + /// A scratch repository, so each way of shipping a document is set up for real. fn scratch_repo() -> tempfile::TempDir { let dir = tempfile::tempdir().expect("a temp dir"); @@ -58,9 +71,9 @@ fn scratch_repo() -> tempfile::TempDir { } fn run_git(root: &Path, args: &[&str]) { - let status = std::process::Command::new("git") - .arg("-C") - .arg(root) + let status = git(root) + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_CONFIG_NOSYSTEM", "1") .args(args) .status() .expect("git runs"); @@ -137,3 +150,69 @@ fn a_force_added_document_is_walked() { run_git(root, &["add", "-f", "secret/forced.improv"]); assert!(walked(root, "secret/forced.improv")); } + +/// Runs one of this binary's own tests in a child process with `env` added, +/// since the environment is what is under test and threads share ours. +fn rerun_with_env(test: &str, env: &[(&str, &Path)]) -> std::process::Output { + let mut child = std::process::Command::new(std::env::current_exe().expect("test binary")); + child.args([test, "--exact", "--nocapture"]); + for (name, value) in env { + child.env(name, value); + } + let out = child.output().expect("the test binary runs"); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("running 1 test"), + "{test} did not run: {stdout}" + ); + out +} + +/// improvise-53zz: a hook in a linked worktree exports `GIT_DIR`, which beats +/// `-C`. Evidence: `context/test-evidence/tui.md`. +#[test] +fn a_scratch_repo_ignores_an_inherited_git_dir() { + let outer = scratch_repo(); + let dot_git = outer.path().join(".git"); + let out = rerun_with_env( + "a_single_file_un_ignore_is_walked", + &[ + ("GIT_DIR", &dot_git), + ("GIT_INDEX_FILE", &dot_git.join("index")), + ], + ); + assert!( + out.status.success(), + "the shape test fails under an inherited GIT_DIR: {}", + String::from_utf8_lossy(&out.stdout) + ); + let staged = git(outer.path()) + .args(["ls-files"]) + .output() + .expect("git runs"); + assert!( + staged.stdout.is_empty(), + "the outer index gained: {}", + String::from_utf8_lossy(&staged.stdout) + ); +} + +/// improvise-53zz: a user's `*.gz` exclude refused the gzip shape test's `add`. +/// Evidence: `context/test-evidence/tui.md`. +#[test] +fn a_scratch_repo_ignores_the_users_global_and_system_excludes() { + let home = tempfile::tempdir().expect("a temp dir"); + let excludes = home.path().join("ignore"); + std::fs::write(&excludes, "*.gz\n").expect("writes"); + let config = home.path().join("gitconfig"); + let text = format!("[core]\n\texcludesFile = {}\n", excludes.display()); + std::fs::write(&config, text).expect("writes"); + for scope in ["GIT_CONFIG_GLOBAL", "GIT_CONFIG_SYSTEM"] { + let out = rerun_with_env("a_gzipped_document_is_walked", &[(scope, &config)]); + assert!( + out.status.success(), + "the gzip shape test fails under a `*.gz` exclude in {scope}: {}", + String::from_utf8_lossy(&out.stdout) + ); + } +} From e45a9c42062d64572fe8c9ce25f646467b537a4d Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 11:10:15 -0700 Subject: [PATCH 15/48] checkers: the bash floor is 5.1, where wait -n -p arrived (fd0j) find_modern_bash accepted any 5.x, so a bash 5.0 was pinned and pr-reviews-test.sh then failed on an invalid option. The probe now reads major and minor; a 5.0 case and a 5.1 control guard it. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/tooling.md | 10 ++++++---- context/test-evidence/tooling.md | 24 ++++++++++++++++++++++++ scripts/checkers-test.sh | 13 +++++++++++++ scripts/checkers.sh | 17 ++++++++++------- 4 files changed, 53 insertions(+), 11 deletions(-) diff --git a/context/code/tooling.md b/context/code/tooling.md index 6b09fc0f..53f0d77e 100644 --- a/context/code/tooling.md +++ b/context/code/tooling.md @@ -645,10 +645,12 @@ one place, with no per-invocation nix cost: calls it and the local gate calls it, so there is no second invocation to drift. It does three things a bare `./scripts/X-test.sh` does not: -1. **Pins a bash ≥ 5.** The ambient one is tried first, so a runner that already - has bash 5.2 pays nothing; nix supplies one only when the ambient bash is - older, which on macOS outside the dev shell it is. No bash ≥ 5 and no nix is - **exit 3** — a refusal, never a quiet run under 3.2. +1. **Pins a bash ≥ 5.1.** The ambient one is tried first, so a runner that + already has bash 5.2 pays nothing; nix supplies one only when the ambient + bash is older, which on macOS outside the dev shell it is. No bash ≥ 5.1 and + no nix is **exit 3** — a refusal, never a quiet run under 3.2. The floor is + 5.1 and not 5 because `pr-reviews-test.sh` uses `wait -n -p`, and `-p` + arrived in 5.1. Under 5.0 it is an invalid option (improvise-fd0j). 2. **Scrubs the environment to an allowlist** (`env -i` plus `PATH`, `HOME`, `TMPDIR`, the `NIX_*` and `GITHUB_*` names, extensible through `CHECKERS_KEEP`). This is the part that closes the direnv hole above, and it diff --git a/context/test-evidence/tooling.md b/context/test-evidence/tooling.md index 23d6f60e..45f36d8a 100644 --- a/context/test-evidence/tooling.md +++ b/context/test-evidence/tooling.md @@ -843,3 +843,27 @@ recycled pid. passed and 4 failed. The four timeout cases fail with exit 124; the fake-clock one has made about 400 polls by then. Before the guard the same rival never finished, and an outer `timeout 30` killed it at exit 124. + +### The bash floor is 5.1 — `scripts/checkers-test.sh` (improvise-fd0j) + +**What broke.** `pr-reviews-test.sh` calls `wait -n -p`, and `-p` arrived in +bash 5.1. `checkers.sh`'s `find_modern_bash` accepted any major version 5. So a +bash 5.0 on `PATH` was pinned, and that suite then failed on an invalid option +rather than on anything it tests. + +**Fix.** The probe reads major and minor in one call and requires +`major*100 + minor >= 501`. The exit-3 message and the header say 5.1. +`context/code/tooling.md` states why the floor is 5.1. + +**Cases** (section 5b). A fake bash answering `5 0` with no nix → exit 3. The +control, a fake answering `5 1` → exit 0, proves that refusal comes from the +version and not from the fake. + +**Rivals**, run against the real suite in a copied sandbox: +- **Floor 5.0** (`-ge 500`) fails "a bash 5.0 with no nix is exit 3, not 5.x" + and nothing else. +- **The pre-fix runner** fails only the 5.1 control. Its probe asked for the + major version alone, so the fake's `5 1` was not a number and was refused. + The 5.0 case passes against it for the same reason, so that case alone would + not have distinguished the old code. The 5.0-floor rival is the one that + does. diff --git a/scripts/checkers-test.sh b/scripts/checkers-test.sh index 01995e07..46581f46 100755 --- a/scripts/checkers-test.sh +++ b/scripts/checkers-test.sh @@ -141,6 +141,19 @@ got=$? [ "$got" = 3 ] && ok "an old bash with no nix is exit 3" \ || bad "an old bash with no nix is exit 3" "exit $got, wanted 3" +# 5b. The floor is 5.1, not 5: `wait -n -p` is 5.1 (improvise-fd0j). The 5.1 +# control proves the 5.0 refusal is the version and not the fake itself. +for v in "5 0:3:a bash 5.0 with no nix is exit 3, not 5.x" \ + "5 1:0:a bash 5.1 is accepted"; do + printf '#!/bin/sh\ncase "$*" in *BASH_VERSINFO*) echo %s; exit 0 ;; esac\nexit 0\n' \ + "${v%%:*}" > "$work/fakebin/bash" + want="${v#*:}"; name="${want#*:}"; want="${want%%:*}" + env PATH="$work/fakebin:/usr/bin:/bin" HOME="$HOME" \ + "${BASH:-bash}" "$d/checkers.sh" any > "$work/out" 2>&1 + got=$? + [ "$got" = "$want" ] && ok "$name" || bad "$name" "exit $got, wanted $want" +done + # --------------------------------------------------------------------------- # 6. A named suite that does not exist is a usage error, not a silent no-op. d="$(sandbox missing)" diff --git a/scripts/checkers.sh b/scripts/checkers.sh index f598cfdc..7cf099d7 100755 --- a/scripts/checkers.sh +++ b/scripts/checkers.sh @@ -5,7 +5,7 @@ # # It does three things a bare `./scripts/X-test.sh` does not: # -# 1. pins a bash >= 5, taken from nix when the ambient one is older, so the +# 1. pins a bash >= 5.1, taken from nix when the ambient one is older, so the # macOS system bash 3.2 can never be the shell a suite passes under; # 2. scrubs the environment down to an allowlist, because `nix develop` # exports `name`, `out`, `system`, `shell`, `builder` and two dozen more @@ -30,7 +30,7 @@ usage: checkers.sh [--list] [--self-test] [name ...] exit 0 every suite passed exit 1 a suite failed, or a shebang is not `#!/usr/bin/env bash` -exit 3 no bash >= 5 could be found and nix could not supply one +exit 3 no bash >= 5.1 could be found and nix could not supply one exit 4 usage error THIS SCRIPT'S `$?` MUST BE THE ONE YOU READ. Nothing may follow it in the same @@ -46,14 +46,17 @@ ALLOW="HOME TMPDIR TMP TEMP TERM LANG LC_ALL USER LOGNAME NIX_PATH NIX_REMOTE NIX_SSL_CERT_FILE SSL_CERT_FILE XDG_CACHE_HOME XDG_CONFIG_HOME XDG_DATA_HOME" -# Prints the absolute path of a bash >= 5, or nothing. The ambient one is tried +# Prints the absolute path of a bash >= 5.1, or nothing. The ambient one is tried # first so a machine that already has a modern bash pays no nix startup at all. find_modern_bash() { - local candidate major + local candidate major minor candidate="$(command -v bash || true)" if [ -n "$candidate" ]; then - major="$("$candidate" -c 'echo "${BASH_VERSINFO[0]}"' 2>/dev/null || echo 0)" - if [ "$major" -ge 5 ] 2>/dev/null; then echo "$candidate"; return 0; fi + read -r major minor < <("$candidate" -c 'echo "${BASH_VERSINFO[0]} ${BASH_VERSINFO[1]}"' \ + 2>/dev/null || echo 0 0) + if [ "$(( ${major:-0} * 100 + ${minor:-0} ))" -ge 501 ] 2>/dev/null; then + echo "$candidate"; return 0 + fi fi command -v nix >/dev/null 2>&1 || return 0 nix shell nixpkgs#bash --command bash -c 'command -v bash' 2>/dev/null || true @@ -122,7 +125,7 @@ main() { local pinned; pinned="$(find_modern_bash)" if [ -z "$pinned" ]; then - echo 'checkers: no bash >= 5 on PATH and nix cannot supply one.' >&2 + echo 'checkers: no bash >= 5.1 on PATH and nix cannot supply one.' >&2 echo ' Enter the dev shell (direnv allow / nix develop) and retry.' >&2 return 3 fi From c0442e68da5b01288f178acce1d8b4dfca8236f0 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 11:12:40 -0700 Subject: [PATCH 16/48] improvise-m6op: the cross-instance fixture carries measure_agg entries, so its sort is pinned Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/test-evidence/command.md | 8 ++++++++ crates/improvise-command/src/ui/reduce.rs | 10 ++++++++++ 2 files changed, 18 insertions(+) diff --git a/context/test-evidence/command.md b/context/test-evidence/command.md index 5960c542..3f0871af 100644 --- a/context/test-evidence/command.md +++ b/context/test-evidence/command.md @@ -227,6 +227,14 @@ checked by adding a `probe_field` to `Model` and to `Model::new`. The build then failed with `E0027: pattern does not mention field probe_field` at the `Debug` impl, and nowhere else. +**`Model.measure_agg` needed the same treatment (improvise-m6op).** `Model`'s +`Debug` sorts `measure_agg` through a `BTreeMap`, but the fixture's +`measure_agg` was empty, so nothing saw the sort. With the sort replaced by the +raw `HashMap` (`.field("measure_agg", &measure_agg)`), this test stayed green in +3 of 3 runs. The fixture now carries four entries: `Revenue` Avg, `Cost` Min, +`Units` Max and `Orders` Count. Under the same injection it is red in 3 of 3 +runs, and it is green with the sort restored. + ### a_stale_wire_model_command_cannot_be_applied A wire `Command::Model` cannot reach the document without its basis being checked diff --git a/crates/improvise-command/src/ui/reduce.rs b/crates/improvise-command/src/ui/reduce.rs index a598f68b..4cbf2fe6 100644 --- a/crates/improvise-command/src/ui/reduce.rs +++ b/crates/improvise-command/src/ui/reduce.rs @@ -959,6 +959,16 @@ pub(crate) mod tests { ]); wb.model.set_cell(key, CellValue::Number(n as f64 + 1.0)); } + // Several entries, so two instances' HashMaps can disagree on order (improvise-m6op). + use crate::formula::AggFunc; + for (measure, agg) in [ + ("Revenue", AggFunc::Avg), + ("Cost", AggFunc::Min), + ("Units", AggFunc::Max), + ("Orders", AggFunc::Count), + ] { + wb.model.measure_agg.insert(measure.into(), agg); + } let view = wb.active_view_mut(); view.set_axis("Region", Axis::Row); view.set_axis("Month", Axis::Column); From b13355c74b648b6a64a0626af161db73058b91ae Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 11:19:54 -0700 Subject: [PATCH 17/48] improvise-9pfs/1qaa: pin a red under BLOCKED; a merged or closed PR ends the watch with exit 5 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- .agents/skills/pr-watch/SKILL.md | 6 ++++-- context/code/tooling.md | 10 +++++++++- context/test-evidence/tooling.md | 30 ++++++++++++++++++++++++++++++ scripts/pr-watch | 10 ++++++++-- scripts/pr-watch-test.sh | 20 ++++++++++++++++++++ 5 files changed, 71 insertions(+), 5 deletions(-) diff --git a/.agents/skills/pr-watch/SKILL.md b/.agents/skills/pr-watch/SKILL.md index b397ce9a..18ee787a 100644 --- a/.agents/skills/pr-watch/SKILL.md +++ b/.agents/skills/pr-watch/SKILL.md @@ -12,12 +12,14 @@ scripts/pr-watch [--expect ] [--timeout ] [--interval /dev/null 2>&1 && gh --version 2>/dev/null | grep -q '^gh version'; then - gh pr view "$PR" --json headRefOid,mergeable,mergeStateStatus,statusCheckRollup + gh pr view "$PR" --json state,headRefOid,mergeable,mergeStateStatus,statusCheckRollup else nix shell nixpkgs#gh --command gh pr view "$PR" \ - --json headRefOid,mergeable,mergeStateStatus,statusCheckRollup + --json state,headRefOid,mergeable,mergeStateStatus,statusCheckRollup fi } @@ -74,6 +75,11 @@ while :; do echo "pr-watch: head is $head, expected $EXPECT*" >&2 exit 2 fi + # A merged or closed PR reports UNKNOWN/UNKNOWN for good (improvise-1qaa). + pr_state="$(printf '%s' "$json" | jqf '.state')" + case "$pr_state" in + MERGED|CLOSED) echo "pr-watch: PR $PR is $pr_state, nothing to wait for" >&2; exit 5 ;; + esac if [ -z "$pinned" ]; then pinned="$head" elif [ "$head" != "$pinned" ]; then diff --git a/scripts/pr-watch-test.sh b/scripts/pr-watch-test.sh index 172d5717..cc2e9b25 100755 --- a/scripts/pr-watch-test.sh +++ b/scripts/pr-watch-test.sh @@ -153,6 +153,26 @@ run_case "the needs-gap before the required check registers is not a failure" 0 gap_state "$work/gap-never" last.json MERGEABLE BLOCKED "$not_yet" run_case "a required check that never registers is a timeout" 3 "$work/gap-never" +# improvise-9pfs. Here a failed REQUIRED check reports MERGEABLE/BLOCKED, not +# UNSTABLE. The table above holds only green rollups, so this red was unpinned. +gap_state "$work/blocked-red" last.json MERGEABLE BLOCKED \ + 'map(if .name == "Test and lint (host)" then .conclusion = "FAILURE" else . end)' +run_case "a failed required check under MERGEABLE/BLOCKED fails without waiting" 1 \ + "$work/blocked-red" --timeout 0 + +# improvise-1qaa. GitHub reports a merged or closed PR as UNKNOWN/UNKNOWN, so +# waiting on UNKNOWN slept the full timeout there. One poll, then exit 5. +for s in MERGED CLOSED; do + d="$work/ended-$s" + mkdir -p "$d" + printf '{"headRefOid":"aaaaaaaaaaaa","state":"%s","mergeable":"UNKNOWN","mergeStateStatus":"UNKNOWN","statusCheckRollup":%s}\n' \ + "$s" "$green_rollup" > "$d/last.json" + run_case "a $s PR ends the watch as no longer open" 5 "$d" --timeout 5 + polls="$(cat "$d/.poll" 2>/dev/null || echo 0)" + [ "$polls" = 1 ] && ok "a $s PR is read once, not waited on" \ + || bad "a $s PR is read once, not waited on (polls)" "$polls" 1 +done + # improvise-h3ni. The head must stay pinned across the UNKNOWN wait, or a # verdict about b is reported as a verdict about a. state "$work/unk-moved" 1.json '"UNKNOWN"' '"UNKNOWN"' "$green_rollup" From 47b166eed94b38f0a3a7bf62c553f275ad338b28 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 11:20:51 -0700 Subject: [PATCH 18/48] Refuse a wire write unless every coordinate names an item improvise-nacd and improvise-pr8t. The b114 rule refused a coordinate that is a group. The invariant it protects (improvise-gw18) is positive: every coordinate names an item. Two writes onto a stored record fell in the gap and were accepted: a measure that is no item at all (Bogus), and a parent group that holds no item directly (Totals), where items_in_group is empty. App::group_refusal becomes App::non_item_refusal. It refuses any coordinate that is not in Model::effective_item_names for its category. Formula targets count as _Measure items, so a write under a formula is still refused by the Derived rule, with the true reason. Tests: - pr8t pins the same-name case: an item named like a group of its category is writable. Removing the old exemption fails it. - nacd is red at 202e7eac for both Bogus and Totals. - A third test pins that a formula cell is refused for the formula. Two reduce.rs fixtures wrote _Measure=Revenue without the item. They now add it. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/command.md | 30 ++++++++-- context/test-evidence/command.md | 72 ++++++++++++++++++++++- crates/improvise-command/src/ui/app.rs | 14 ++--- crates/improvise-command/src/ui/host.rs | 67 +++++++++++++++++++++ crates/improvise-command/src/ui/reduce.rs | 5 ++ 5 files changed, 174 insertions(+), 14 deletions(-) diff --git a/context/code/command.md b/context/code/command.md index 2122603e..185beca6 100644 --- a/context/code/command.md +++ b/context/code/command.md @@ -3607,10 +3607,32 @@ the refusal marks the batch, `Host::mutating_frame` undoes the frame, the asker gets `Rejected(MutationRefused)`, and the reason is on the host's status line. The question it asks is the projection's own, never a second derivation of it: -- A coordinate that names a **group** rather than an item is refused, in any - view (improvise-b114). A group has no cell of its own, so writing one stores a - non-item coordinate. This rule asks the document, not the layout, and runs - first, because a fold in the measure half hides from both rules below. Take +- A coordinate that names **no item of its category** is refused, in any view + (improvise-b114, then improvise-nacd). This rule asks the document, not the + layout, and runs first, because a fold in the measure half hides from both + rules below. + + **It is stated positively, and that is the point of the nacd change.** The + b114 rule was negative: refuse a name that *is a group*. The invariant it + protects (improvise-gw18) is positive: every coordinate names an item. A + negative rule covers only the ways of not being an item that someone listed, + and review found two more within a day. `Bogus` is no item and no group. A + parent group such as `Totals`, above `Money`, holds no item directly, so + `items_in_group("Totals")` is empty and the group test missed it. Both wrote + `{Month=Feb, _Measure=…}` onto a stored record and were accepted. The + positive rule covers both, and any shape not yet thought of, because it asks + whether the name *is* an item rather than what else it might be. + + "Item" means `Model::effective_item_names`: the category's items, plus the + formula targets for `_Measure`. The formula targets are included so that a + write under a formula passes this rule and is refused by the next one with + the reason that is actually true ("computed by a formula"), not as a non-item. + The same-name case falls out: an item called `Money` beside a group called + `Money` is an item, and is writable (improvise-pr8t). The b114 rule needed an + explicit exemption for that case, and nothing tested the exemption. + + A group has no cell of its own, so writing one stores a non-item + coordinate. Take `{Month=Feb, _Measure=Money}`, where `Money` groups Revenue and Cost. Its record `{Month=Feb}` is stored, so the second rule below accepts it and never asks the projection. And with `Money` expanded the host draws no such cell at diff --git a/context/test-evidence/command.md b/context/test-evidence/command.md index 5960c542..031b74ec 100644 --- a/context/test-evidence/command.md +++ b/context/test-evidence/command.md @@ -5919,8 +5919,10 @@ is stored, so the stored arm accepted it without asking the projection. The test runs with `Money` collapsed, where the host draws that cell as the fold, and expanded, where the host draws no such cell. It asserts `MutationRefused` and an unchanged document in both, so a rule that asks the host's layout cannot pass -the expanded half. Red at `5eaafbae`. The fix is `App::group_refusal`, which asks -the document whether any coordinate names a group rather than an item. Rival: +the expanded half. Red at `5eaafbae`. The fix was `App::group_refusal`, which +asked the document whether any coordinate names a group rather than an item. +Since improvise-nacd it is `App::non_item_refusal`, which asks whether every +coordinate names an item. Rival: the group rule removed. Injected into a full workspace run, it fails this test and nothing else. @@ -5941,6 +5943,72 @@ improvise-b114 rule is: no coordinate names a group, asked of the document. Rival: the group rule removed. Injected into a full workspace run, it fails this test and the b114 test, and nothing else. +### a_wire_write_to_an_item_named_like_its_categorys_group_is_accepted + +improvise-pr8t, found in the adversarial review of PR #235 at `5c19fdd4`. +`Category::add_item` checks only the items, so a category may hold an item +`Money` and a group `Money` at once. The b114 rule exempted that case with +`!category.items.contains_key(name)`, and nothing pinned the exemption: with it +removed, the whole workspace stayed green while a caught-up write to +`{Month=Feb, _Measure=Money}`, a real item's cell, was refused. + +The test builds exactly that case on the stored record `{Month=Feb}`. It asserts +`Accepted` and the stored value, so a rule that refuses and a rule that accepts +but drops the write both fail. + +The test passed on the unfixed code, as a pin should. Two rivals were run against it: + +- On the b114 code, the exemption removed (`let group = true && …`): red, + `a real item was refused: … Rejected(MutationRefused)`. +- On the nacd code, a rule that also refuses any name that is a group, even + when it is an item: red, and in a full `improvise-command` run this test is + the only failure. + +### a_wire_write_naming_no_item_of_its_category_is_rejected + +improvise-nacd, found in the same review. The b114 rule refused a coordinate +that *is a group*. The invariant it protects is wider: every coordinate names an +item (improvise-gw18). Two writes fell in the gap. The fixture puts Revenue and +Cost in `Money`, puts `Money` under the parent group `Totals`, and stores +`{Month=Feb}`. + +- `_Measure=Bogus` is no item and no group. +- `_Measure=Totals` is a parent group that holds no item directly, so + `items_in_group("Totals")` is empty and the group test did not see it. + +Both were answered `Accepted` and stored the coordinate. The test asserts +`Rejected(MutationRefused)` and an unchanged document for each. Both were +confirmed red separately at `202e7eac`: the loop stops at its first failure, so +the second name was checked by swapping the order. + +The fix is `App::non_item_refusal`: every coordinate must be one of +`Model::effective_item_names` of its category. The argument for a positive rule +is in `context/code/command.md`, in the section on wire writes. + +Rival: restrict the rule to `_Measure`, the ticket's first suggested fix shape. +It fails `a_wire_write_to_a_fold_record_with_an_undrawn_measure_is_rejected`, +because `Month=Q2` is a group of a regular category. + +The fix also moved two fixtures in `ui/reduce.rs`. `model_app` and +`a_wire_model_command_marks_the_document_dirty` stored or wrote +`_Measure=Revenue` without adding `Revenue` as an item. So the cell they wrote +was exactly the non-item coordinate this rule refuses. Both now add the item. +That changes nothing they assert. It is the fixture being made a document that +could exist. + +### a_write_under_a_formula_is_refused_for_the_formula_not_as_a_non_item + +improvise-nacd. A formula target is a `_Measure` name but not an item of the +category, so the non-item rule could refuse a write under a formula before the +`Derived` rule saw it. The write would still be refused, but for a reason that +is false. The rule uses `Model::effective_item_names`, which includes formula +targets, so the formula rule gives the reason. + +Rival: the rule written with the category's raw `items`. Before this test, that +rival passed the whole workspace, because every other formula test asserts only +that the write is refused. It fails this test with +`refused for another reason: Profit is no item of _Measure, …`. + ### a_stored_record_the_hosts_view_aggregates_is_writable improvise-sjdb, from the #217 review. South/Revenue is stored, then `Product` is diff --git a/crates/improvise-command/src/ui/app.rs b/crates/improvise-command/src/ui/app.rs index 00fcf442..66472d45 100644 --- a/crates/improvise-command/src/ui/app.rs +++ b/crates/improvise-command/src/ui/app.rs @@ -587,19 +587,17 @@ impl App { f.target ) }); - let refusal = self.group_refusal(&key).or(derived); + let refusal = self.non_item_refusal(&key).or(derived); refusal.or_else(|| self.aggregate_refusal(cell.record, &key)) } - /// A coordinate naming a group rather than an item is no cell in ANY view, so - /// this asks the document, never the layout (improvise-b114). - fn group_refusal(&self, key: &CellKey) -> Option { + /// Every coordinate must name an item of its category, asked of the document + /// (improvise-b114, improvise-nacd). See `context/code/command.md`. + fn non_item_refusal(&self, key: &CellKey) -> Option { let model = &self.model_state.workbook.model; key.0.iter().find_map(|(cat, name)| { - let category = model.category(cat)?; - let group = !category.items.contains_key(name.as_str()) - && !category.items_in_group(name).is_empty(); - group.then(|| format!("{name} is a group of {cat}, so there is no one cell to write")) + let item = model.effective_item_names(cat).contains(name); + (!item).then(|| format!("{name} is no item of {cat}, so there is no one cell to write")) }) } diff --git a/crates/improvise-command/src/ui/host.rs b/crates/improvise-command/src/ui/host.rs index 8245456a..480577fd 100644 --- a/crates/improvise-command/src/ui/host.rs +++ b/crates/improvise-command/src/ui/host.rs @@ -1468,6 +1468,20 @@ mod tests { } } + /// improvise-nacd: a formula target is a measure, so the refusal names the + /// formula. Evidence: context/test-evidence/command.md. + #[test] + fn a_write_under_a_formula_is_refused_for_the_formula_not_as_a_non_item() { + let host = host_on_a_fold(); + let key = month_cell("Feb", "Profit"); + let cell = RecordCell { + record: RecordHash::of(&key), + measure: Some("Profit".into()), + }; + let why = host.app().unwritable(&cell).expect("refused"); + assert!(why.contains("formula"), "refused for another reason: {why}"); + } + /// improvise-ciw8: a formula's result is `CellId::Derived`, no write's target. #[test] fn a_wire_write_under_a_formula_is_rejected_and_stores_nothing() { @@ -1639,6 +1653,59 @@ mod tests { } } + /// `Month` Jan/Feb, `_Measure` Revenue and Cost in `Money` under the parent + /// group `Totals`, and the record `{Month=Feb}` stored. + fn host_on_a_stored_record_beside_groups() -> Host { + let mut wb = Workbook::new("T"); + wb.add_category("Month").unwrap(); + for month in ["Jan", "Feb"] { + wb.add_item("Month", month).unwrap(); + } + for measure in ["Revenue", "Cost"] { + wb.add_item_in_group("_Measure", measure, "Money").unwrap(); + } + let measures = wb.model.category_mut("_Measure").unwrap(); + measures.add_group(crate::model::category::Group::new("Money").with_parent("Totals")); + wb.model + .set_cell(month_cell("Feb", "Revenue"), CellValue::Number(10.0)); + wb.active_view_mut().set_axis("Month", Axis::Row); + wb.active_view_mut().set_axis("_Measure", Axis::Column); + let mut app = App::new(wb, None); + app.rebuild_layout(); + Host::new(app) + } + + /// improvise-pr8t: an item that shares its name with a group of its category + /// is still an item. Evidence: context/test-evidence/command.md. + #[test] + fn a_wire_write_to_an_item_named_like_its_categorys_group_is_accepted() { + let mut host = host_on_a_stored_record_beside_groups(); + let measures = host.app.model_state.workbook.model.category_mut("_Measure"); + measures.unwrap().add_item("Money"); + host.app.rebuild_layout(); + let target = month_cell("Feb", "Money"); + let answer = wire_write(&mut host, &target, false); + let accepted = answer + .iter() + .any(|a| matches!(a.msg, ServerFrame::Accepted)); + assert!(accepted, "a real item was refused: {answer:?}"); + let model = &host.app().model_state.workbook.model; + assert_eq!(model.get_cell(&target), Some(&CellValue::Number(7.0))); + } + + /// improvise-nacd: a stored record's measure must name an item, not merely + /// avoid naming a group. Evidence: context/test-evidence/command.md. + #[test] + fn a_wire_write_naming_no_item_of_its_category_is_rejected() { + for measure in ["Bogus", "Totals"] { + let mut host = host_on_a_stored_record_beside_groups(); + let before = document_text(&host); + let answer = wire_write(&mut host, &month_cell("Feb", measure), false); + assert!(refused(&answer), "{measure} accepted: {answer:?}"); + assert_eq!(document_text(&host), before, "{measure} was stored"); + } + } + /// improvise-xur5 (the reviewer's `probe_fold_hidden_measure`): a fold's record /// with a measure the host draws nowhere — see context/test-evidence/command.md. #[test] diff --git a/crates/improvise-command/src/ui/reduce.rs b/crates/improvise-command/src/ui/reduce.rs index a598f68b..5beb22ef 100644 --- a/crates/improvise-command/src/ui/reduce.rs +++ b/crates/improvise-command/src/ui/reduce.rs @@ -654,6 +654,8 @@ pub(crate) mod tests { /// resolves — otherwise every walk below fails for a reason not its own. fn model_app() -> App { let mut app = app(); + let workbook = &mut app.model_state.workbook; + workbook.add_item(MEASURE_CATEGORY, "Revenue").unwrap(); app.model_state .workbook .model @@ -1101,6 +1103,9 @@ pub(crate) mod tests { }, ] { let mut app = app(); + let workbook = &mut app.model_state.workbook; + workbook.add_item(MEASURE_CATEGORY, "Revenue").unwrap(); + app.rebuild_layout(); assert!(!app.model_state.dirty, "the fixture starts clean"); let wire = caught_up(&app, cmd.clone()); apply_checked(&mut app, &wire); From eb146bccabae7da60356abed9fb559641c34d9b5 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 11:29:13 -0700 Subject: [PATCH 19/48] Pin the browser's Tab-commit advance and the tile cursor's upper clamp improvise-rq3f: two resolver faults passed the whole workspace. Two unit tests in resolve.rs now fail under each, and only under its own fault. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/test-evidence/clients.md | 33 +++++++++++++++++ crates/improvise-protocol/src/resolve.rs | 45 ++++++++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/context/test-evidence/clients.md b/context/test-evidence/clients.md index 1b893acb..9d77250e 100644 --- a/context/test-evidence/clients.md +++ b/context/test-evidence/clients.md @@ -5117,3 +5117,36 @@ Rival injected: `claim_as_read` ignoring the captured generation and claiming the current one. That is the pre-fix behaviour, restated after the fix. Only this test failed in the whole workspace. The layout tier staying current is still pinned by `an_edit_writes_the_cell_the_user_clicked_after_the_rows_renumber`. + +### tab_in_an_editor_commits_and_lands_one_column_right + +improvise-rq3f, found in the adversarial review of PR #246. Changing `advanced`'s +`Advance::Right` arm to `Some((row, col))` passed the whole workspace (1945 +tests): nothing asserted where the browser's Tab-commit +(`commit-and-advance-right`) lands. The test opens the editor with `i` on a +centred cursor, (1, 1) in a three-by-three grid, so no edge clamps the move. +It then presses Tab and asserts three things: the composed commands contain +`SetSelected { row: 1, col: 2 }`, the folded cursor is at (1, 2), and the +editor is open again there. + +- **Rival `right_stays`** (the ticket's fault): fails only this test, across + the workspace. +- A unit test and not a lockstep case, because the fault is in the resolver's + own arithmetic. Lockstep compares the client with the host; this test + compares the client with the answer `CommitAndAdvance` gives, whose + `AdvanceDir::Right` arm is `(col + 1).min(col_max)`. + +### the_tile_cursor_stops_on_the_last_tile + +improvise-rq3f. Changing `tile_cursor`'s upper clamp from `(count - 1)` to +`count` passed the whole workspace: pressing right on the last tile selected an +index past the end, and nothing noticed. The fixture is `furnished(TileSelect)`, +with three tiles and the cursor on the middle one. The control moves right +once, unclamped, to the last tile. Then both `Right` and `l` must clamp there, +and each must still emit `SetTileCatIdx(last)`, as `MoveTileCursor` emits even +when the index does not move (gotcha 47). + +- **Rival `clamp_count`** (the ticket's fault): fails only this test, with + `SetTileCatIdx(3)` over three tiles. +- The control matters: without the unclamped move first, a resolver that never + moves at all would also pass the two clamped presses. diff --git a/crates/improvise-protocol/src/resolve.rs b/crates/improvise-protocol/src/resolve.rs index 981b5227..6322e9be 100644 --- a/crates/improvise-protocol/src/resolve.rs +++ b/crates/improvise-protocol/src/resolve.rs @@ -3454,6 +3454,51 @@ mod tests { commands } + /// Tab in an editor lands one column right, off a centred cursor that no + /// edge clamps (improvise-rq3f). Evidence: `context/test-evidence/clients.md`. + #[test] + fn tab_in_an_editor_commits_and_lands_one_column_right() { + let mut client = centred_on(record_cell()); + press_folded(&mut client, KeyCode::Char('i')); + assert!(client.view.mode.is_editing(), "setup: the editor opened"); + let commands = press_folded(&mut client, KeyCode::Tab); + assert!( + commands.contains(&Command::SetSelected { row: 1, col: 2 }), + "Tab must advance right from (1, 1): {commands:?}" + ); + assert_eq!(client.view.cursor().selected, (1, 2)); + assert!( + client.view.mode.is_editing(), + "and re-enter the editor there" + ); + } + + /// Right on the last tile stays on it, and still emits, as `MoveTileCursor` + /// does (improvise-rq3f). Evidence: `context/test-evidence/clients.md`. + #[test] + fn the_tile_cursor_stops_on_the_last_tile() { + let mut client = furnished(AppMode::TileSelect); + let last = client.tiles.tiles.len() - 1; + assert_eq!( + client.view.tile_cat_idx, + last - 1, + "setup: one short of the end" + ); + assert_eq!( + press_folded(&mut client, KeyCode::Right), + vec![Command::SetTileCatIdx(last)], + "the control: a move that is not clamped" + ); + for code in [KeyCode::Right, KeyCode::Char('l')] { + assert_eq!( + press_folded(&mut client, code), + vec![Command::SetTileCatIdx(last)], + "{code:?} past the last tile must clamp to it" + ); + } + assert_eq!(client.view.tile_cat_idx, last); + } + /// BUG (improvise-ixxx) — see `context/test-evidence/clients.md`. #[test] fn an_untouched_browser_commit_writes_nothing_and_still_advances() { From 2b1c5bf7adac74b44ddaf13dd6e7587e5390bba6 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 11:33:00 -0700 Subject: [PATCH 20/48] A minibuffer refusal stands while the user fixes it (improvise-su4u) #233 clears the status message at the start of every TUI keypress. In a text-entry mode the message sits beside the prompt, and a formula that does not parse keeps the editor open with the error. So the error vanished on the first correcting key. press now remembers the mode and the message. It clears the message as before and dispatches the key. It puts the stale message back only if the key left the user in the same text-entry mode and wrote no message of its own. Editing keys keep the refusal on screen, a key's own message still wins, and leaving the minibuffer (a successful commit or Esc) drops it. The mode check is observed, not predicted: which keys commit is the keymap's knowledge, and the TUI loop does not copy it. Rivals, each injected into press and restored: never restoring (#233), restoring over a key's own message, and restoring whatever the mode each fail a distinct step of the test. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/tui.md | 47 ++++++++++++++++++++++++ context/test-evidence/tui.md | 23 ++++++++++++ src/draw.rs | 69 ++++++++++++++++++++++++++++++++++-- 3 files changed, 136 insertions(+), 3 deletions(-) diff --git a/context/code/tui.md b/context/code/tui.md index 309ee160..e595cdc3 100644 --- a/context/code/tui.md +++ b/context/code/tui.md @@ -424,3 +424,50 @@ is always drawn. So a browser user sees a message briefly and never loses the hint. The TUI now does the same, on a keypress rather than a timer, because a terminal status row has one line to share and a keypress is the event the TUI already has. + +### Except while a minibuffer stays open under it (improvise-su4u) + +In a text-entry mode `minibuffer_line` draws the message to the right of the +prompt. Since improvise-v4hg, a formula that does not parse keeps the user in +`FormulaEdit` with the error. Under the plain rule, the error vanished on the +first correcting key: one Backspace and the user was fixing text they could no +longer see the complaint about. The same happened to any refusal that leaves +the user in a minibuffer. + +**The rule: a key that leaves the user in the same text-entry mode, and says +nothing of its own, puts the stale message back.** `press` remembers the mode +and the message, clears the message as before, dispatches the key, and then +restores the message only if all three of these hold: +- the mode before the key had a minibuffer; +- the mode after the key is the same mode; +- the key wrote no message of its own. + +So: + +- editing keys keep the refusal on screen until the user acts on it; +- a key's own message always wins, so a second refused `Enter` shows the new + error, not the old one; +- leaving the minibuffer, by a successful commit or by `Esc`, drops it, exactly + as the plain rule does. + +**Rejected alternatives.** + +- *Expire only on the commit key.* That needs to know which keys commit, which + is the keymap's knowledge (`TextEntry::commit`), and a second copy of it in + the TUI loop. "The mode did not change" is observed rather than predicted, + like gotcha 31's `ReturnToNormalUnlessMoved`. +- *Never clear in a text-entry mode.* The message would then outlive `Esc` and + stand in the mode the user returns to, which is the cq3d bug again. +- *Compare the message after the key with the stale one.* A key that wrote the + same text would be indistinguishable from a key that wrote none. Clearing + first and restoring only an empty slot has no such ambiguity. + +The mode test is equality of the whole `AppMode`. An edit that stayed in "a" +minibuffer but a different one does not count as staying. No shipped key does +that, but it would be leaving one prompt for another, and the message belonged +to the first. + +**The browser is not covered by this.** There, `status_msg` is a toast that +hides after `TOAST_MS` (six seconds), and the minibuffer element draws only the +prompt and the text. A refusal therefore disappears on a timer, whatever the +user is doing. That is a different mechanism and has not been changed here. diff --git a/context/test-evidence/tui.md b/context/test-evidence/tui.md index e19a8690..b8b5b422 100644 --- a/context/test-evidence/tui.md +++ b/context/test-evidence/tui.md @@ -946,3 +946,26 @@ than `App::handle_key`, because the expiry is the front end's rule. at step 1: `" TILES Saved to x.improv …"`. - With the expiry moved *after* dispatch, it fails at step 2: "the axis cycle must say what it did", since every message is wiped before it can be drawn. + +### a_minibuffer_refusal_stands_while_the_user_fixes_it + +improvise-su4u. It drives `draw.rs::press`, the function `run_tui` calls, +because the expiry is the TUI loop's rule. In the formula panel it presses `n`, +types `Total = (` and `Enter`. The formula does not parse, so the editor stays +open with `Formula error` on the bottom row. Then: + +1. `Backspace`: still editing, and the error must still be drawn. It failed here + at base 202e7eac: `"formula: Total = ▌"`, with the error gone. +2. `Enter` on the still-bad `Total = `: a new refusal, which must differ from + the first, and whose text must be on the row. A key's own message beats the + stale one. +3. `1` and `Enter`: the fixed formula commits and leaves the editor, and the + error must be gone. +4. A second run: `Esc` straight after the refusal must drop the error too. + +Rivals, each injected into `press` and restored: +- **Never restore** (#233's behaviour) fails step 1. +- **Restore even when the key wrote its own message** fails step 2. +- **Restore whatever the mode after the key** fails step 4, because the error + outlives `Esc`. It does not fail step 3, because a successful commit writes + its own message. That is why step 4 exists. diff --git a/src/draw.rs b/src/draw.rs index 11db0e70..05409e93 100644 --- a/src/draw.rs +++ b/src/draw.rs @@ -99,11 +99,18 @@ pub fn run_tui( Ok(()) } -/// One keypress in the TUI. A message lasts until the next key, so it is drawn -/// once and then gives way to the hint (improvise-cq3d, `context/code/tui.md`). +/// One keypress in the TUI. A message lasts until the next key (improvise-cq3d), +/// except while a minibuffer stays open under it (su4u). `context/code/tui.md`. fn press(app: &mut App, key: key::KeyEvent) -> Result<()> { + let mode = app.view_state.mode.clone(); + let stale = app.view_state.status_msg.clone(); app.apply_effects(vec![Box::new(crate::ui::effect::SetStatus(String::new()))]); - app.handle_key(key) + app.handle_key(key)?; + let still_editing = mode.minibuffer().is_some() && app.view_state.mode == mode; + if still_editing && app.view_state.status_msg.is_empty() && !stale.is_empty() { + app.apply_effects(vec![Box::new(crate::ui::effect::SetStatus(stale))]); + } + Ok(()) } // ── Key event conversion (TUI boundary) ────────────────────────────────────── @@ -635,6 +642,62 @@ mod bottom_bar_tests { ); } + /// 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] + fn a_minibuffer_refusal_stands_while_the_user_fixes_it() { + let key = |code| KeyEvent::new(code, KeyModifiers::NONE); + let refused = || { + let mut app = App::new(Workbook::new("T"), None); + app.view_state.formula_panel_open = true; + app.view_state.mode = AppMode::FormulaPanel; + press(&mut app, key(KeyCode::Char('n'))).unwrap(); + for c in "Total = (".chars() { + press(&mut app, key(KeyCode::Char(c))).unwrap(); + } + press(&mut app, key(KeyCode::Enter)).unwrap(); + assert!( + matches!(app.view_state.mode, AppMode::FormulaEdit { .. }), + "a bad formula keeps the editor open" + ); + assert!(bottom_row(&app, 240).contains("Formula error")); + app + }; + + let mut app = refused(); + press(&mut app, key(KeyCode::Backspace)).unwrap(); + let row = bottom_row(&app, 240); + assert!(row.contains("Total = "), "still editing: {row:?}"); + assert!( + row.contains("Formula error"), + "the refusal vanished on the first correcting key: {row:?}" + ); + let first = app.view_state.status_msg.clone(); + press(&mut app, key(KeyCode::Enter)).unwrap(); + let second = app.view_state.status_msg.clone(); + assert_ne!(second, first, "a new refusal names the new text"); + assert!( + bottom_row(&app, 240).contains(second.lines().next().unwrap_or_default()), + "a key's own message must beat the stale one" + ); + press(&mut app, key(KeyCode::Char('1'))).unwrap(); + press(&mut app, key(KeyCode::Enter)).unwrap(); + assert!(!matches!(app.view_state.mode, AppMode::FormulaEdit { .. })); + let row = bottom_row(&app, 240); + assert!( + !row.contains("Formula error"), + "the refusal outlived its fix: {row:?}" + ); + + let mut app = refused(); + press(&mut app, key(KeyCode::Esc)).unwrap(); + let row = bottom_row(&app, 240); + assert!( + !row.contains("Formula error"), + "the refusal outlived leaving the editor: {row:?}" + ); + } + /// The wizard overlay draws correct per-step hints; the status bar stacked a /// second static table under it (improvise-rr8n) — `context/test-evidence/tui.md`. #[test] From 8cdea3a430b4eab095582ae8a5e07f1ef014b063 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 16:58:59 -0700 Subject: [PATCH 21/48] improvise-24lz: the head checks precede the ended-PR exit; CLOSED fixture takes its live shape Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- .agents/skills/pr-watch/SKILL.md | 3 +- context/code/tooling.md | 21 +++++++++---- context/test-evidence/tooling.md | 51 +++++++++++++++++++++++++------- scripts/pr-watch | 11 +++---- scripts/pr-watch-test.sh | 36 +++++++++++++++------- 5 files changed, 89 insertions(+), 33 deletions(-) diff --git a/.agents/skills/pr-watch/SKILL.md b/.agents/skills/pr-watch/SKILL.md index 18ee787a..c39a5254 100644 --- a/.agents/skills/pr-watch/SKILL.md +++ b/.agents/skills/pr-watch/SKILL.md @@ -19,7 +19,8 @@ exit 2 the head moved during the wait, or is not --expect exit 3 timed out with checks still running, 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; it ends on the first poll +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 0 now means mergeable, not only green.** GitHub computes mergeability diff --git a/context/code/tooling.md b/context/code/tooling.md index 78275a1e..e6e17187 100644 --- a/context/code/tooling.md +++ b/context/code/tooling.md @@ -281,12 +281,21 @@ The rule now: `mergeable == CONFLICTING`. "Whatever mergeability says" includes `MERGEABLE/BLOCKED`, which is how a failed *required* check reports here, so it is the commonest red this script sees (improvise-9pfs). -- **A PR that is no longer open ends it on the first poll, with exit 5** - (improvise-1qaa). GitHub reports a MERGED or CLOSED PR as `UNKNOWN/UNKNOWN` - permanently. The UNKNOWN wait therefore slept the whole timeout there and - then blamed mergeability. The script now fetches `state`. Exit 5 is its own - code, not 1 or 0, because under merge-as-soon-as-green "someone else already - merged it" is a normal answer, and the caller should hear it as that. +- **A PR that is no longer open ends the wait on the first poll that sees it + closed, with exit 5** (improvise-1qaa). That poll is the first when the PR was + already merged, and can come mid-wait when someone merges it during the watch. + A merged PR reports `UNKNOWN/UNKNOWN` permanently (#189, read live), so the + UNKNOWN wait slept the whole timeout there and then blamed mergeability. A + closed one need not: #74 reads `CONFLICTING/DIRTY`, which already ended the + wait, but as a misleading exit 1. The script now fetches `state`. Exit 5 is + its own code, not 1 or 0, because under merge-as-soon-as-green "someone else + already merged it" is a normal answer, and the caller should hear it as that. +- **The head checks come first, and the ended check after them** + (improvise-24lz). A PR that merged at a head the watcher never saw is the + PR #67 hazard in a new form. It exits 2, whether the head moved during the + watch or `--expect` names another. Only then is a closed PR exit 5. On the + first poll the pin block only records the head, so an already-merged PR + still ends on its first read. - **The pass condition is an allowlist**: `mergeable == MERGEABLE` *and* `mergeStateStatus` in `CLEAN`, `UNSTABLE` or `HAS_HOOKS`. Anything else exits 1. That includes `null` and any state GitHub adds later, so the check fails diff --git a/context/test-evidence/tooling.md b/context/test-evidence/tooling.md index dd62dca5..f289a1f7 100644 --- a/context/test-evidence/tooling.md +++ b/context/test-evidence/tooling.md @@ -698,20 +698,49 @@ failed-check break gated on `state != BLOCKED`. That case alone fails, exiting 3 where 1 is wanted, and the other 45 cases pass. Nothing else in the suite could see a refactor that turned every required-check red into a timeout. -**1qaa: a merged PR slept the whole timeout.** Two case pairs, one for MERGED -and one for CLOSED, each with green checks and `UNKNOWN/UNKNOWN`. That is the -shape `gh pr view 189` returned live once #189 had merged. Each pair asserts -exit 5, and also that the fixture was read **once**, from the poll counter -`fetch` keeps on disk. At `202e7eac` all four failed: exit 3 after 108 and 109 -polls within a 5s timeout. Two rivals: +**1qaa: a merged PR slept the whole timeout.** Two case pairs. Each uses the +shape GitHub returned live, with green checks. MERGED is `UNKNOWN/UNKNOWN` +(#189 once merged). CLOSED is `CONFLICTING/DIRTY` (#74). The CLOSED fixture was +`UNKNOWN/UNKNOWN` at `b13355c7`, and review found that shape unreal. Each pair +asserts exit 5, and that the fixture was read **once**, using the poll counter +`fetch` keeps on disk. Against the script at `202e7eac`: -- **The state checked only when the timeout expires.** It exits 5 after the full - wait, so both exit-code cases pass and only the two poll-count cases fail, at - 52 and 25 polls. The poll count is what pins "does not wait". An exit code - alone cannot, which is why each case is a pair. -- **Exit 1 instead of 5.** The two exit-code cases fail with exit 1. That keeps +- MERGED: exit 3 after 102 polls within a 5s timeout. +- CLOSED: exit 1 on the first poll. `CONFLICTING` already ended the wait, but + as "not mergeable". + +Two rivals: + +- **The state checked only when the timeout expires.** The MERGED read-once case + fails (44 polls), and so does the CLOSED exit-code case, because `CONFLICTING` + breaks to exit 1 before the timeout branch is reached. The poll count is what + pins "does not wait" for MERGED, which is why each case is a pair. +- **Exit 1 instead of 5.** Both exit-code cases fail with exit 1. That keeps "already merged" apart from "not mergeable". +### The pr-watch ended-PR ordering cases — `scripts/pr-watch-test.sh` + +improvise-24lz, found reviewing #253 at `b13355c7`. That head checked `state` +after `--expect` but **before** pinning the head. So a watch with no `--expect` +that saw head a running, and then head b MERGED, exited 5 where it should have +exited 2. It accepted a merge at a head the watcher never saw, which is the +PR #67 hazard. Two cases: + +- `a head that moves and then merges is not reported as merged`: head a with a + running check, then head b MERGED. Expects 2. At `b13355c7` it exited 5. +- `a PR merged at a head other than --expect is a head mismatch`: head b MERGED, + `--expect aaaa`. Expects 2. It passed at `b13355c7`, where `--expect` already + came first. + +The fix moves the `state` check below the pin block. On the first poll that +block only records the head, so an already-merged PR still ends on its first +read. Both new cases pass at `202e7eac`, since it never reads `state`; they pin +the order of the new code. **Rivals:** + +- **The state checked before `--expect` and before the pin.** Both new cases fail + with exit 5, and the other 50 pass. +- **The `b13355c7` placement.** Only the moved-then-merged case fails. + ## Every JSON parser reads numbers exactly (improvise-dz8c) ### `every_crate_that_can_parse_json_enables_float_roundtrip` diff --git a/scripts/pr-watch b/scripts/pr-watch index c4496df6..67222362 100755 --- a/scripts/pr-watch +++ b/scripts/pr-watch @@ -75,17 +75,18 @@ while :; do echo "pr-watch: head is $head, expected $EXPECT*" >&2 exit 2 fi - # A merged or closed PR reports UNKNOWN/UNKNOWN for good (improvise-1qaa). - pr_state="$(printf '%s' "$json" | jqf '.state')" - case "$pr_state" in - MERGED|CLOSED) echo "pr-watch: PR $PR is $pr_state, nothing to wait for" >&2; exit 5 ;; - esac if [ -z "$pinned" ]; then pinned="$head" elif [ "$head" != "$pinned" ]; then echo "pr-watch: head moved $pinned -> $head during the wait" >&2 exit 2 fi + # A PR no longer open ends the wait (improvise-1qaa), but only after the head + # checks: a merge at a head never seen is PR #67 again (improvise-24lz). + pr_state="$(printf '%s' "$json" | jqf '.state')" + case "$pr_state" in + MERGED|CLOSED) echo "pr-watch: PR $PR is $pr_state, nothing to wait for" >&2; exit 5 ;; + esac # total>0 AND nothing still running. "0 pending" alone reads identically for # "all finished" and "none registered yet", which is how this exited instantly diff --git a/scripts/pr-watch-test.sh b/scripts/pr-watch-test.sh index cc2e9b25..2f6531ba 100755 --- a/scripts/pr-watch-test.sh +++ b/scripts/pr-watch-test.sh @@ -160,19 +160,35 @@ gap_state "$work/blocked-red" last.json MERGEABLE BLOCKED \ run_case "a failed required check under MERGEABLE/BLOCKED fails without waiting" 1 \ "$work/blocked-red" --timeout 0 -# improvise-1qaa. GitHub reports a merged or closed PR as UNKNOWN/UNKNOWN, so -# waiting on UNKNOWN slept the full timeout there. One poll, then exit 5. -for s in MERGED CLOSED; do - d="$work/ended-$s" - mkdir -p "$d" - printf '{"headRefOid":"aaaaaaaaaaaa","state":"%s","mergeable":"UNKNOWN","mergeStateStatus":"UNKNOWN","statusCheckRollup":%s}\n' \ - "$s" "$green_rollup" > "$d/last.json" - run_case "a $s PR ends the watch as no longer open" 5 "$d" --timeout 5 +# improvise-1qaa. A merged PR reports UNKNOWN/UNKNOWN, which was waited on for +# the full timeout. Each fixture is the shape GitHub returned live (#189, #74). +# $1 dir, $2 file, $3 head, $4 state, $5 mergeable, $6 mergeStateStatus +ended() { + mkdir -p "$1" + printf '{"headRefOid":"%s","state":"%s","mergeable":"%s","mergeStateStatus":"%s","statusCheckRollup":%s}\n' \ + "$3" "$4" "$5" "$6" "$green_rollup" > "$1/$2" +} +for shape in "MERGED UNKNOWN UNKNOWN" "CLOSED CONFLICTING DIRTY"; do + set -- $shape + d="$work/ended-$1" + ended "$d" last.json aaaaaaaaaaaa "$1" "$2" "$3" + run_case "a $1 PR ends the watch as no longer open" 5 "$d" --timeout 5 polls="$(cat "$d/.poll" 2>/dev/null || echo 0)" - [ "$polls" = 1 ] && ok "a $s PR is read once, not waited on" \ - || bad "a $s PR is read once, not waited on (polls)" "$polls" 1 + [ "$polls" = 1 ] && ok "a $1 PR is read once, not waited on" \ + || bad "a $1 PR is read once, not waited on (polls)" "$polls" 1 done +# improvise-24lz. A merge at a head the watcher never saw is the PR #67 hazard, +# so a moved head outranks a closed PR, with or without --expect. +state "$work/moved-merged" 1.json '"MERGEABLE"' '"UNSTABLE"' \ + '[{"name":"build","status":"IN_PROGRESS","conclusion":""}]' +ended "$work/moved-merged" last.json bbbbbbbbbbbb MERGED UNKNOWN UNKNOWN +run_case "a head that moves and then merges is not reported as merged" 2 \ + "$work/moved-merged" --timeout 60 +ended "$work/merged-elsewhere" last.json bbbbbbbbbbbb MERGED UNKNOWN UNKNOWN +run_case "a PR merged at a head other than --expect is a head mismatch" 2 \ + "$work/merged-elsewhere" --expect aaaa --timeout 5 + # improvise-h3ni. The head must stay pinned across the UNKNOWN wait, or a # verdict about b is reported as a verdict about a. state "$work/unk-moved" 1.json '"UNKNOWN"' '"UNKNOWN"' "$green_rollup" From 3fcd407b08381810a7cd4bd8005c75f302b3ec7f Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 16:58:39 -0700 Subject: [PATCH 22/48] A reopened editor awaits any unanswered write, settles only while editing, and states its awaiting flag (improvise-mdfc, improvise-4cb5, improvise-mgis) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit improvise-mdfc: an untouched commit seeded the reopened editor from the cache even while an earlier write from this client was unanswered, so Tab Tab at the rightmost column stored 505 where the TUI stores 5095. `advanced` now awaits when this frame writes or any in-flight frame is awaited. The lockstep test sends three Tabs before answering any. improvise-4cb5: `settle_awaited_seed` also requires an editing mode, so an Esc before the answer no longer leaves the seed in Normal's buffer. improvise-mgis: `OpenedEditor::awaiting` loses `serde(default)`; a frame missing it fails to parse as the missing field (protocol.md §3, 1wl0). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/protocol.md | 27 +++++- context/test-evidence/clients.md | 56 ++++++++++++ crates/improvise-protocol/src/client.rs | 34 +++++-- crates/improvise-protocol/src/resolve.rs | 41 +++++++-- .../improvise-wasm-client/tests/lockstep.rs | 88 +++++++++++++++++++ 5 files changed, 233 insertions(+), 13 deletions(-) diff --git a/context/code/protocol.md b/context/code/protocol.md index 0355e861..b0c34642 100644 --- a/context/code/protocol.md +++ b/context/code/protocol.md @@ -2033,8 +2033,27 @@ stated once, over the write, never over a list of cases: client holds, but only once no frame it sent is still unanswered. The answer folds the host's projection before `Accepted`, so the grid is the host's by then. `accepted()` therefore settles. -- **A commit that writes nothing** still seeds from the cache at once. Nothing - changed, so the cache is current. +- **A commit that writes nothing** awaits as well while any earlier frame's + write is unanswered. Otherwise it seeds from the cache at once. "Nothing + changed, so the cache is current" was the rule here, and it was false + (improvise-mdfc). *This* commit changed nothing, but an earlier one might + have, and the cache predates it until the answer arrives. Tab Tab pressed + quickly at the rightmost column opened the second editor on the value from + before the first Tab's write: 505 where the TUI stores 5095. So the test is + "does this frame write, or is a write still in flight", which is + `writes || in_flight.iter().any(|sent| sent.awaited)`. It checks *any* + in-flight write, not the newest frame: a frame that writes nothing stays in + the queue behind an unanswered write, so the newest frame can be one of + those. +- **The seed settles only into an open editor** (improvise-4cb5). An Esc typed + before the answer arrives leaves the awaiting `opened_editor` in place. The + late seed would then fill the edit buffer in `Normal` mode, where the TUI's is + empty. +- **`awaiting` has no `serde(default)`** (improvise-mgis), for the reason + `AxisFrame::first` has none (§3a, improvise-1wl0). `awaiting: false` is a + claim, "the seed is settled". A peer that dropped the field would hand over an + editor that stays empty forever, and whose Enter writes nothing. A missing + field is therefore a parse failure. Asking the host to send the seed was the alternative. It would have needed a new message, and the host already sends it: the landed cell's `edit_text` in @@ -2043,7 +2062,9 @@ the projection it owes this client anyway. Two things are accepted costs: well under a frame. - A key typed before the answer arrives marks the editor touched, and the late seed then does not overwrite it. What the user sees is then what gets - written, which is never another record's number. + written. That is never another record's number only because of the mdfc + rule above: before it, an untouched commit behind an unanswered write opened + on the stale cache, and typing then extended the wrong number. The TUI is unchanged: its `RebuildLayout` in front of `EnterEditAtCursor` already reads the renumbered grid (gotcha 23). This also took diff --git a/context/test-evidence/clients.md b/context/test-evidence/clients.md index a846ea1e..1390ab0b 100644 --- a/context/test-evidence/clients.md +++ b/context/test-evidence/clients.md @@ -5222,3 +5222,59 @@ The fold half of improvise-3uzg, without a host. The steps: tests. - `accepted()` not settling fails the same four. - Ignoring `touched` fails this test alone. It is the one guard on type-ahead. + +### `a_tab_sent_before_the_last_write_is_answered_reopens_on_that_write` + +**improvise-mdfc**, found by the #227 review at `baabe1a9`. Fixture: +`precise_host`, cursor (0,1) on North/Revenue = 2 (the rightmost column), window +2×2. `i 9` go through `sent_and_answered`. Then **three** Tabs are sent through +`sent`, and none is answered until all three are out. The first writes 29 and +the other two write nothing. After the answers are delivered in order, the +browser's buffers must equal the TUI host's. `5 Enter` then has to store 295 on +both ends. + +Every earlier lockstep test answered each key before the next was sent, so the +client's cache was always the host's by the time it resolved a key. That is why +none of them saw this. The hazard needs a key resolved while a write is still +unanswered. + +Red at `baabe1a9`: after the answers the browser buffer was `"2"` (the cache from +before the write) and the host's was `"29"`. In Chromium over the worker, with +`tall.improv` R01/Cost = 50 and `i 9 Tab Tab 5 Enter`, the browser stored 505 +where the TUI stores 5095. + +**Rivals:** +- The fix reverted (await only when this commit writes) fails this test alone. + The workspace was otherwise green at `baabe1a9`. +- Awaiting only when the **newest** in-flight frame is awaited + (`in_flight.back()`) fails this test, and the other 53 lockstep tests pass. That is why it sends three Tabs, + not two: with two, the newest in-flight frame *is* the write, and `back()` and + `any()` cannot be told apart. + +### `client::tests::an_awaited_seed_arriving_after_escape_leaves_the_buffer_empty` + +**improvise-4cb5**. A write frame with an awaiting editor is sent, and then Esc +(`SetBuffer edit ""`, `ChangeMode Normal`) is sent before the answer arrives. +The host's projection says `3`, and then `Accepted` arrives. The mode must be +`Normal` and the edit buffer must be empty. + +Red before the fix: the buffer was `"3"`. `settle_awaited_seed` asked only +whether the opened editor was awaiting and untouched, and Esc leaves +`opened_editor` in place. The seed `3` is distinct from the empty buffer Esc +leaves, so the assertion can only pass if nothing was written. + +**Rival:** the mode check removed (the code at `baabe1a9`) fails this test, and +the rest of `improvise-protocol`'s lib passes. + +### `resolve::opened_editor_wire_tests::a_frame_whose_editor_lost_its_awaiting_flag_does_not_parse` + +**improvise-mgis**. A `ClientFrame::Commands` carrying +`SetOpenedEditor(Some(OpenedEditor::awaiting()))` is serialized, and the +`awaiting` key is removed from the JSON. The test asserts the key was there, and +that `seed` is still there, so the mutated value is a well-formed editor minus +one field. It must then fail to parse **as the missing field**, not as broken +JSON. + +**Rival:** `#[serde(default)]` restored. The frame then parses, with +`awaiting: false`, and the test fails at `expect_err`. Before this test existed, +deleting the attribute left the whole workspace green, so nothing pinned it. diff --git a/crates/improvise-protocol/src/client.rs b/crates/improvise-protocol/src/client.rs index e580f7de..a1c724c7 100644 --- a/crates/improvise-protocol/src/client.rs +++ b/crates/improvise-protocol/src/client.rs @@ -183,11 +183,12 @@ impl ClientState { /// An editor opened after a write takes its seed from the grid the host /// answered with, never from the cache the write made stale (improvise-3uzg). fn settle_awaited_seed(&mut self) { - let waiting = self - .view - .opened_editor - .as_ref() - .is_some_and(|opened| opened.awaiting && !opened.touched); + let waiting = self.view.mode.is_editing() + && self + .view + .opened_editor + .as_ref() + .is_some_and(|opened| opened.awaiting && !opened.touched); let answered = !self.in_flight.iter().any(|sent| sent.awaited); if let (true, true, Some(seed)) = (waiting, answered, crate::resolve::edit_seed(self)) { self.view.buffers.insert("edit".to_string(), seed.clone()); @@ -489,6 +490,29 @@ mod tests { ); } + /// BUG (improvise-4cb5) — see `context/test-evidence/clients.md`. + #[test] + fn an_awaited_seed_arriving_after_escape_leaves_the_buffer_empty() { + let mut state = ClientState::default(); + answered_with(&mut state, "2"); + state.fold_outgoing(&write_then_awaiting_editor()); + state.fold_outgoing(&[ + Command::SetBuffer { + name: "edit".into(), + value: String::new(), + }, + Command::ChangeMode(crate::AppMode::Normal), + ]); + answered_with(&mut state, "3"); + state.accepted(); + assert_eq!(state.view.mode, crate::AppMode::Normal); + assert_eq!( + state.view.buffers.get("edit").map(String::as_str), + Some(""), + "the late seed was written into the buffer of an editor Esc had closed" + ); + } + /// A frame the host does not answer must not consume an answer, or the /// queue addresses the wrong frame. Evidence file. #[test] diff --git a/crates/improvise-protocol/src/resolve.rs b/crates/improvise-protocol/src/resolve.rs index cdfbe558..df46c7d8 100644 --- a/crates/improvise-protocol/src/resolve.rs +++ b/crates/improvise-protocol/src/resolve.rs @@ -1009,9 +1009,10 @@ fn advanced( return Some(Vec::new()); }; let mut commands = scrolled_to(state, nr, nc); - // After a write the cache may name other cells than the host's grid does, so - // the seed waits for the host's answer (improvise-3uzg, `protocol.md` §3). - commands.extend(match writes { + // After a write — this frame's or an unanswered earlier one's — the cache may + // be stale, so the seed waits for the host (improvise-3uzg/mdfc, `protocol.md` §3). + let stale = writes || state.in_flight.iter().any(|sent| sent.awaited); + commands.extend(match stale { true => awaiting_seed(edit_mode), false => opened_on(seed_at(state, nr, nc), edit_mode), }); @@ -1071,8 +1072,8 @@ pub struct OpenedEditor { pub seed: String, /// Whether the user has edited the buffer since it opened (improvise-2054). pub touched: bool, - /// The seed is still to come from the host's answer (improvise-3uzg). - #[serde(default)] + /// The seed is still to come from the host's answer (improvise-3uzg). Not + /// `serde(default)`: `false` is a claim — `protocol.md` §3, improvise-mgis. pub awaiting: bool, } @@ -4064,3 +4065,33 @@ mod tests { ); } } + +#[cfg(test)] +mod opened_editor_wire_tests { + use super::OpenedEditor; + use crate::{ClientFrame, Command}; + + /// improvise-mgis: `awaiting: false` is the claim that the seed is settled, + /// so a frame that dropped it is refused. `context/test-evidence/clients.md`. + #[test] + fn a_frame_whose_editor_lost_its_awaiting_flag_does_not_parse() { + let frame = ClientFrame::Commands(vec![Command::SetOpenedEditor(Some( + OpenedEditor::awaiting(), + ))]); + let mut json = serde_json::to_value(&frame).expect("a ClientFrame serializes"); + let opened = json + .pointer_mut("/Commands/0/SetOpenedEditor") + .and_then(serde_json::Value::as_object_mut) + .expect("the editor is where this test expects it"); + assert!( + opened.remove("awaiting").is_some() && opened.contains_key("seed"), + "the field must have been there, and the rest of the editor must remain" + ); + let err = serde_json::from_value::(json) + .expect_err("an editor that states no awaiting flag states no claim"); + assert!( + err.to_string().contains("missing field `awaiting`"), + "it must fail as the MISSING FIELD, not as broken JSON: {err}" + ); + } +} diff --git a/crates/improvise-wasm-client/tests/lockstep.rs b/crates/improvise-wasm-client/tests/lockstep.rs index 8950f2ca..5ca971a8 100644 --- a/crates/improvise-wasm-client/tests/lockstep.rs +++ b/crates/improvise-wasm-client/tests/lockstep.rs @@ -2549,6 +2549,94 @@ fn a_commit_that_prunes_its_row_reopens_on_the_row_that_moved_up() { ); } +/// What the host owes a frame it has applied: its projection, its basis, and +/// `Accepted` when the frame was awaited. Held back so the next key overtakes it. +struct Unanswered { + grid: improvise_protocol::GridCache, + basis: improvise_protocol::Basis, + awaited: bool, +} + +fn sent( + client: &mut ClientState, + host: &mut App, + event: KeyEvent, + window: (usize, usize), +) -> Unanswered { + let KeyOutcome::Local(cmds) = resolve(client, event) else { + panic!("{event:?}: the client must answer this key locally"); + }; + client.fold_outgoing(&cmds); + apply_on(host, &cmds); + let awaited = cmds + .iter() + .any(|c| matches!(c, improvise_protocol::Command::Model { .. })); + let (grid, basis) = (client_of(host, window).grid, published_basis(host, window)); + Unanswered { + grid, + basis, + awaited, + } +} + +fn answered(client: &mut ClientState, answer: Unanswered) { + client.grid = answer.grid; + improvise_protocol::WidgetCache::apply(&mut client.basis, &answer.basis); + if answer.awaited { + client.accepted(); + } +} + +/// BUG (improvise-mdfc) — see `context/test-evidence/clients.md`. +#[test] +fn a_tab_sent_before_the_last_write_is_answered_reopens_on_that_write() { + let window = (2, 2); + let mut client_host = precise_host(improvise_protocol::CellValue::Number(4.0)); + client_host.cursor_mut().selected = (0, 1); + let mut key_host = precise_host(improvise_protocol::CellValue::Number(4.0)); + key_host.cursor_mut().selected = (0, 1); + let mut client = client_ready_to_edit(&client_host, window); + let tab = BrowserKey::new("Tab").to_key_event().unwrap(); + for key in ["i", "9"] { + let event = BrowserKey::new(key).to_key_event().unwrap(); + sent_and_answered(&mut client, &mut client_host, event, window); + reduce_key(&mut key_host, event); + } + // Three Tabs, so a frame that writes nothing sits between the write and the last. + let answers: Vec = (0..3) + .map(|_| sent(&mut client, &mut client_host, tab, window)) + .collect(); + let awaited: Vec = answers.iter().map(|a| a.awaited).collect(); + assert_eq!( + awaited, + [true, false, false], + "setup: only the first Tab writes" + ); + for answer in answers { + reduce_key(&mut key_host, tab); + answered(&mut client, answer); + } + assert_eq!( + client.view.buffers, key_host.view_state.buffers, + "the second editor opened on the cache the first write had made stale" + ); + for key in ["5", "Enter"] { + let event = BrowserKey::new(key).to_key_event().unwrap(); + sent_and_answered(&mut client, &mut client_host, event, window); + reduce_key(&mut key_host, event); + } + for (end, app) in [("browser", &client_host), ("host", &key_host)] { + assert_eq!( + app.model_state + .workbook + .model + .get_cell(®ion_measure("North", "Revenue")), + Some(&improvise_protocol::CellValue::Number(295.0)), + "{end}: the browser overwrote the value Tab had just written" + ); + } +} + const FORMULAS: [&str; 3] = [ "Profit = Revenue * 2", "Tax = Revenue * 0.1", From 74920ade5db8a085105e610f1d7eb6a68a120356 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 16:59:40 -0700 Subject: [PATCH 23/48] improvise-b5dp: pin the pooled-ratio ruling for a formula column's total; bench mid-size cases Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- benches/grid_projection.rs | 8 +++- crates/improvise-command/src/ui/project.rs | 48 ++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/benches/grid_projection.rs b/benches/grid_projection.rs index 17699856..1f3fd08f 100644 --- a/benches/grid_projection.rs +++ b/benches/grid_projection.rs @@ -120,9 +120,12 @@ fn case(label: &str, regions: usize, products: usize, measures: usize, iters: u3 true => "UNSTABLE".to_string(), false => format!("{share:.0}%"), }; + // The TUI pays both on every keypress: widths in `cmd_context`, the grid in draw. + let keypress = full + widths; println!( "{label:<22} {rows:>5} x {cols:>3} = {cells:>7} cells whole-grid {full:>11.3?} \ -windowed {windowed:>11.3?} ratio {ratio:>4.1}x col_widths {widths:>11.3?} ({share} of the frame)" +windowed {windowed:>11.3?} ratio {ratio:>4.1}x col_widths {widths:>11.3?} ({share} of the frame) \ +keypress {keypress:>11.3?}" ); } @@ -130,6 +133,9 @@ fn main() { println!("project::grid_of, one call = one TUI frame's grid cache"); println!("a 60fps frame budget is 16.7ms; the TUI redraws on each keypress\n"); case("typical", 20, 5, 4, 200); + case("1.2k cells", 60, 5, 4, 100); + case("2.4k cells", 60, 10, 4, 100); + case("4.8k cells", 60, 20, 4, 50); case("large", 60, 20, 8, 50); case("18k cells (gotcha 41)", 150, 20, 6, 20); case("pathological", 300, 30, 10, 5); diff --git a/crates/improvise-command/src/ui/project.rs b/crates/improvise-command/src/ui/project.rs index 51a1a5ae..1acdac4b 100644 --- a/crates/improvise-command/src/ui/project.rs +++ b/crates/improvise-command/src/ui/project.rs @@ -6022,4 +6022,52 @@ mod column_totals { assert_eq!(total(book(false), "Revenue").text, "7.00"); assert_eq!(total(book(true), "Revenue").text, "7.00"); } + + /// The column's grid and its index, for a test that reads rows and total together. + fn column(wb: Workbook, measure: &str) -> (GridCache, usize) { + let mut app = App::new(wb, None); + app.rebuild_layout(); + let col = (0..app.layout.col_count()) + .find(|&c| app.layout.col_label(c) == measure) + .unwrap_or_else(|| panic!("no column {measure}")); + (grid_of(&app, WHOLE), col) + } + + /// North 25/30, South 27/30, East -4/0: the rows draw 0.83, 0.90, div/0. + fn margins_with_a_zero_revenue_row() -> Workbook { + money(&[ + ("North", 30.0, 5.0), + ("South", 30.0, 3.0), + ("East", 0.0, 4.0), + ]) + } + + /// improvise-b5dp, user ruling: a formula total is the pooled ratio, so a row's + /// div/0 does not poison it. context/test-evidence/command.md. + #[test] + fn a_formula_rows_error_does_not_poison_the_pooled_total() { + let mut wb = margins_with_a_zero_revenue_row(); + for f in ["Profit = Revenue - Cost", "Margin = Profit / Revenue"] { + let formula = crate::formula::parse_formula(f, "_Measure").unwrap(); + wb.model.add_formula(formula); + } + let (grid, col) = column(wb, "Margin"); + let rows: Vec = grid.cells.iter().map(|r| r[col].error).collect(); + assert_eq!(rows, vec![false, false, true], "East's row must draw div/0"); + let t = &grid.totals[col]; + assert_eq!((t.text.as_str(), t.error), ("0.80", false), "48 / 60, pooled"); + } + + /// improvise-b5dp: the same ruling for a formula whose own aggregate is zero + /// at one row. context/test-evidence/command.md. + #[test] + fn a_share_columns_total_pools_past_a_rows_div_zero() { + let mut wb = margins_with_a_zero_revenue_row(); + let share = crate::formula::parse_formula("Share = Revenue / SUM(Revenue)", "_Measure"); + wb.model.add_formula(share.unwrap()); + let (grid, col) = column(wb, "Share"); + assert!(grid.cells[2][col].error, "East's row must draw div/0"); + let t = &grid.totals[col]; + assert_eq!((t.text.as_str(), t.error), ("1.00", false), "60 / 60, pooled"); + } } From ec85401a44e8482fd43b4859b82301184b9e5fd3 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 17:03:05 -0700 Subject: [PATCH 24/48] improvise-yb4b: a stored fold scans the store once instead of looking up each member Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- crates/improvise-core/src/model/cell.rs | 34 +++++++ crates/improvise-core/src/model/types.rs | 109 +++++++++++++---------- 2 files changed, 94 insertions(+), 49 deletions(-) diff --git a/crates/improvise-core/src/model/cell.rs b/crates/improvise-core/src/model/cell.rs index 7a10edf1..031cc72d 100644 --- a/crates/improvise-core/src/model/cell.rs +++ b/crates/improvise-core/src/model/cell.rs @@ -395,6 +395,40 @@ impl DataStore { .collect() } + /// The union of `matching_values` (or, `exact`, of `get`) over every key `partial` + /// extends by one listed item per spanned category — scanned once (improvise-yb4b). + pub fn spanned_values( + &self, + partial: &[(String, String)], + spans: &[(&str, Vec<&str>)], + exact: bool, + ) -> Vec<&CellValue> { + let wanted: Option)>> = spans + .iter() + .map(|(cat, items)| { + let items = items.iter().filter_map(|i| self.symbols.get(i)).collect(); + Some((self.symbols.get(cat)?, items)) + }) + .collect(); + let Some(wanted) = wanted else { + return Vec::new(); + }; + let admits = |ikey: &InternedKey| { + (!exact || ikey.0.len() == partial.len() + wanted.len()) + && wanted.iter().all(|(cat, items)| { + ikey.0 + .iter() + .any(|(c, i)| c == cat && items.contains(i)) + }) + }; + self.matching_positions(partial) + .into_iter() + .filter_map(|n| self.cells.get_index(n)) + .filter(|(ikey, _)| admits(ikey)) + .map(|(_, value)| value) + .collect() + } + /// Every cell matching `partial`, in insertion order. Allocates a /// `CellKey` per match — prefer `matching_values` when values suffice. #[allow(dead_code)] diff --git a/crates/improvise-core/src/model/types.rs b/crates/improvise-core/src/model/types.rs index fc5e61b0..26677825 100644 --- a/crates/improvise-core/src/model/types.rs +++ b/crates/improvise-core/src/model/types.rs @@ -159,6 +159,27 @@ struct Span<'a> { over: &'a [String], } +/// A fold's reach as data: `base` is the key with each expanded category removed, +/// and `spans` the covered items of each, in expansion order (improvise-yb4b). +struct Expansion<'a> { + base: CellKey, + spans: Vec<(&'a str, Vec<&'a str>)>, +} + +impl Expansion<'_> { + /// One key per member, the cross product in expansion order. + fn members(&self) -> Vec { + let mut expanded = vec![self.base.clone()]; + for (cat_name, covered) in &self.spans { + expanded = expanded + .iter() + .flat_map(|k| covered.iter().map(|m| k.clone().with(*cat_name, *m))) + .collect(); + } + expanded + } +} + /// Where a formula's references are read: at one record, or across the members /// a collapsed group's fold covers, as a None axis reads them (core.md §14). #[derive(Clone, Copy)] @@ -645,13 +666,13 @@ impl Model { span: Span, memo: &FoldMemo, ) -> Option { - let members = self.group_expansion(key, span); - match (members.is_empty(), self.formula_for(key)) { + let expansion = self.group_expansion(key, span); + match (expansion.spans.is_empty(), self.formula_for(key)) { (true, _) => self.evaluate_unfolded(key, agg), (false, Some(formula)) => { - self.fold_formula_once(formula, key, &members, (agg, span, memo)) + self.fold_formula_once(formula, key, &expansion, (agg, span, memo)) } - (false, None) => self.fold_stored(key, &members, agg), + (false, None) => self.fold_stored(key, &expansion, agg), } } @@ -661,7 +682,7 @@ impl Model { &self, formula: &Formula, key: &CellKey, - members: &[CellKey], + expansion: &Expansion, (agg, span, memo): (Aggregation, Span, &FoldMemo), ) -> Option { let seen = memo.borrow().get(key).cloned(); @@ -673,7 +694,7 @@ impl Model { #[cfg(test)] FOLD_FORMULA_EVALUATIONS.with(|n| n.set(n.get() + 1)); let reach = Reach::Fold { agg, span, memo }; - let value = self.eval_formula_folded(formula, key, members, agg, reach); + let value = self.eval_formula_folded(formula, key, expansion, agg, reach); memo.borrow_mut().insert(key.clone(), Some(value.clone())); value } @@ -691,9 +712,9 @@ impl Model { aggregate_cell_values(&self.data.matching_values(&key.0), self.agg_for(key)) } - /// One key per covered member where `key` names a collapsed GROUP rather than - /// an item, or omits a spanned category — the cross product. Empty when neither. - fn group_expansion(&self, key: &CellKey, span: Span) -> Vec { + /// Each category `key` folds, with its covered members: a collapsed GROUP it + /// names, or a spanned category it omits. No spans when neither. + fn group_expansion<'a>(&'a self, key: &'a CellKey, span: Span<'a>) -> Expansion<'a> { let grouped = key.0.iter().map(|(cat_name, name)| { let members: Vec<&str> = self .category(cat_name) @@ -710,24 +731,21 @@ impl Model { let items = self.category(cat_name).map(|cat| cat.ordered_item_names()); (cat_name.as_str(), items.unwrap_or_default()) }); - let mut expanded = vec![key.clone()]; - for (cat_name, members) in grouped.chain(spanned) { - let covered: Vec<&str> = members - .into_iter() - .filter(|m| (span.covers)(cat_name, m)) - .collect(); - if covered.is_empty() { - continue; - } - expanded = expanded - .iter() - .flat_map(|k| covered.iter().map(|m| k.clone().with(cat_name, *m))) - .collect(); - } - match expanded.as_slice() { - [only] if only == key => Vec::new(), - _ => expanded, - } + let spans: Vec<(&str, Vec<&str>)> = grouped + .chain(spanned) + .map(|(cat_name, members)| { + let covered: Vec<&str> = members + .into_iter() + .filter(|m| (span.covers)(cat_name, m)) + .collect(); + (cat_name, covered) + }) + .filter(|(_, covered)| !covered.is_empty()) + .collect(); + let base = spans + .iter() + .fold(key.clone(), |k, (cat_name, _)| k.without(cat_name)); + Expansion { base, spans } } /// A coordinate as a formula reads it. At a fold a group's name is no item, so @@ -753,30 +771,23 @@ impl Model { /// The contexts an aggregate inside a formula pools over at `context`. fn contexts_of(&self, context: &CellKey, reach: Reach) -> Vec { - let members = match reach { - Reach::Record => Vec::new(), - Reach::Fold { span, .. } => self.group_expansion(context, span), + let expansion = match reach { + Reach::Record => None, + Reach::Fold { span, .. } => Some(self.group_expansion(context, span)), }; - match members.is_empty() { - true => vec![context.clone()], - false => members, + match expansion.filter(|e| !e.spans.is_empty()) { + None => vec![context.clone()], + Some(e) => e.members(), } } - /// A group's stored values fold ONCE over every record the group covers. - fn fold_stored( - &self, - key: &CellKey, - members: &[CellKey], - agg: Aggregation, - ) -> Option { - let values: Vec<&CellValue> = members - .iter() - .flat_map(|k| match agg { - Aggregation::Exact => self.data.get(k).into_iter().collect(), - Aggregation::OverHidden => self.data.matching_values(&k.0), - }) - .collect(); + /// A group's stored values fold ONCE over every record the group covers, in + /// the store's own order: one scan, not a lookup per member (improvise-yb4b). + fn fold_stored(&self, key: &CellKey, expansion: &Expansion, agg: Aggregation) -> Option { + let exact = agg == Aggregation::Exact; + let values = self + .data + .spanned_values(&expansion.base.0, &expansion.spans, exact); aggregate_cell_values(&values, self.agg_for(key)) } @@ -786,7 +797,7 @@ impl Model { &self, formula: &Formula, key: &CellKey, - members: &[CellKey], + expansion: &Expansion, agg: Aggregation, reach: Reach, ) -> Option { @@ -795,7 +806,7 @@ impl Model { .as_ref() .is_none_or(|f| self.read_coordinate(key, &f.category, reach) == Some(f.item.as_str())); if !applies { - return self.fold_stored(key, members, agg); + return self.fold_stored(key, expansion, agg); } let depth = Self::MAX_EVAL_DEPTH; match eval_expr( From de4a7d786279c58224b38284df521935cdc6b3eb Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 17:07:53 -0700 Subject: [PATCH 25/48] =?UTF-8?q?protocol.md=20=C2=A73:=20name=20the=20typ?= =?UTF-8?q?e-ahead=20divergence=20that=20remains=20after=20mdfc=20(improvi?= =?UTF-8?q?se-n4i9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured in Chromium over the worker: with keys pressed back to back, `i 9 Tab Tab 5 Enter` stores 5 or 5095 depending on whether `5` beats the answer; with a pause, 5095 in 5 of 5 runs. Never the stale 505. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/protocol.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/context/code/protocol.md b/context/code/protocol.md index b0c34642..0a9c6920 100644 --- a/context/code/protocol.md +++ b/context/code/protocol.md @@ -2064,7 +2064,11 @@ the projection it owes this client anyway. Two things are accepted costs: seed then does not overwrite it. What the user sees is then what gets written. That is never another record's number only because of the mdfc rule above: before it, an untouched commit behind an unanswered write opened - on the stale cache, and typing then extended the wrong number. + on the stale cache, and typing then extended the wrong number. What is left + is a divergence from the TUI rather than a wrong record. A key that beats the + answer replaces the value the TUI would have extended: back to back, + `i 9 Tab Tab 5 Enter` stores 5 or 5095 in Chromium over the worker + (improvise-n4i9). The TUI is unchanged: its `RebuildLayout` in front of `EnterEditAtCursor` already reads the renumbered grid (gotcha 23). This also took From ca34cdf811f5ccdded6788dbb53d25379ffd015e Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 17:11:23 -0700 Subject: [PATCH 26/48] improvise-29w2: a CLOSED PR the loop waits on (MERGEABLE/BLOCKED) pins the state check at the loop top Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/tooling.md | 9 +++++++-- context/test-evidence/tooling.md | 13 +++++++++++++ scripts/pr-watch-test.sh | 14 +++++++------- 3 files changed, 27 insertions(+), 9 deletions(-) diff --git a/context/code/tooling.md b/context/code/tooling.md index e6e17187..1cd51ca6 100644 --- a/context/code/tooling.md +++ b/context/code/tooling.md @@ -286,8 +286,13 @@ The rule now: already merged, and can come mid-wait when someone merges it during the watch. A merged PR reports `UNKNOWN/UNKNOWN` permanently (#189, read live), so the UNKNOWN wait slept the whole timeout there and then blamed mergeability. A - closed one need not: #74 reads `CONFLICTING/DIRTY`, which already ended the - wait, but as a misleading exit 1. The script now fetches `state`. Exit 5 is + closed PR's mergeability is whatever it was when it closed, so it can land on + either side of the wait. #74 reads `CONFLICTING/DIRTY`, which ends the loop at + once, but as a misleading exit 1. #46 has green checks and reads + `MERGEABLE/BLOCKED`, which the loop waits on, so it slept the full timeout + too (improvise-29w2). The suite carries all three shapes, and only the + waited-on ones can tell whether the state is checked at the top of the loop. + The script now fetches `state`. Exit 5 is its own code, not 1 or 0, because under merge-as-soon-as-green "someone else already merged it" is a normal answer, and the caller should hear it as that. - **The head checks come first, and the ended check after them** diff --git a/context/test-evidence/tooling.md b/context/test-evidence/tooling.md index f289a1f7..0a27ae5f 100644 --- a/context/test-evidence/tooling.md +++ b/context/test-evidence/tooling.md @@ -718,6 +718,19 @@ Two rivals: - **Exit 1 instead of 5.** Both exit-code cases fail with exit 1. That keeps "already merged" apart from "not mergeable". +**29w2: the CLOSED cases could not discriminate.** Found reviewing #253 at +`8cdea3a4`. The only CLOSED shape was #74's `CONFLICTING/DIRTY`, and that ends +the loop on the first poll whether or not the state is checked there. So +**rival R8** passed 52 of 52. R8 exits 5 for MERGED at the loop top, but for +CLOSED only after the loop breaks. Run live against #46 with `--timeout 15`, R8 +exited 3 and `8cdea3a4` exited 5. A third shape is added, #46's: CLOSED, green +checks, `MERGEABLE/BLOCKED`, which the loop waits on. #74's shape stays. + +- **R8:** both new cases fail, the exit-code case with exit 3 and the read-once + case after 65 polls. The other 52 pass. +- **The state checked only at timeout:** the new read-once case fails (77 polls), + alongside the MERGED read-once case and the `CONFLICTING/DIRTY` exit-code case. + ### The pr-watch ended-PR ordering cases — `scripts/pr-watch-test.sh` improvise-24lz, found reviewing #253 at `b13355c7`. That head checked `state` diff --git a/scripts/pr-watch-test.sh b/scripts/pr-watch-test.sh index 2f6531ba..59dd6531 100755 --- a/scripts/pr-watch-test.sh +++ b/scripts/pr-watch-test.sh @@ -160,22 +160,22 @@ gap_state "$work/blocked-red" last.json MERGEABLE BLOCKED \ run_case "a failed required check under MERGEABLE/BLOCKED fails without waiting" 1 \ "$work/blocked-red" --timeout 0 -# improvise-1qaa. A merged PR reports UNKNOWN/UNKNOWN, which was waited on for -# the full timeout. Each fixture is the shape GitHub returned live (#189, #74). +# improvise-1qaa/29w2. Each ended fixture is a shape GitHub returned live (#189, +# #74, #46); the last is the one a waiting loop would sit on. # $1 dir, $2 file, $3 head, $4 state, $5 mergeable, $6 mergeStateStatus ended() { mkdir -p "$1" printf '{"headRefOid":"%s","state":"%s","mergeable":"%s","mergeStateStatus":"%s","statusCheckRollup":%s}\n' \ "$3" "$4" "$5" "$6" "$green_rollup" > "$1/$2" } -for shape in "MERGED UNKNOWN UNKNOWN" "CLOSED CONFLICTING DIRTY"; do +for shape in "MERGED UNKNOWN UNKNOWN" "CLOSED CONFLICTING DIRTY" "CLOSED MERGEABLE BLOCKED"; do set -- $shape - d="$work/ended-$1" + d="$work/ended-$1-$3" ended "$d" last.json aaaaaaaaaaaa "$1" "$2" "$3" - run_case "a $1 PR ends the watch as no longer open" 5 "$d" --timeout 5 + run_case "a $1 PR at $2/$3 ends the watch as no longer open" 5 "$d" --timeout 5 polls="$(cat "$d/.poll" 2>/dev/null || echo 0)" - [ "$polls" = 1 ] && ok "a $1 PR is read once, not waited on" \ - || bad "a $1 PR is read once, not waited on (polls)" "$polls" 1 + [ "$polls" = 1 ] && ok "a $1 PR at $2/$3 is read once, not waited on" \ + || bad "a $1 PR at $2/$3 is read once, not waited on (polls)" "$polls" 1 done # improvise-24lz. A merge at a head the watcher never saw is the PR #67 hazard, From 09db8eb0cde5f75fd185cf4730c1218a89fa8056 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 17:18:44 -0700 Subject: [PATCH 27/48] The same mode is not the same editor: a message does not follow a commit (improvise-sx7h) #257 restored a stale message whenever the mode after a key equalled the mode before it. Editing and RecordsEditing commit with CommitAndAdvance, which re-opens an equal AppMode on the next cell. So the message followed the editor from cell to cell until Esc. The reviewer's repro was R, o, 5, Tab: "Added new record row", or o's "That record row is already here" refusal, survived the Tab. press now compares an EditorSession, made of the things an opening sets: - the whole AppMode, not its variant; - the cursor cell, which is a cell editor's target; - the editor's seed, which EnterEditAtCursor re-seeds from the cell, so a commit that re-opens on the same cell (Tab in the last column) is new. A generation counter was rejected: editors are opened by more than one effect, and a counter in each is a hand-kept list. The view was left out: no minibuffer key can switch it and stay. Tests: - four press scenarios: the two records repros, Enter in a plain grid, and Tab in the last column; - a direct check that ItemAdd on another category is another editor. Rivals: mode-only (#257 as pushed), no cell, no seed and discriminant each fail a distinct step. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/tui.md | 58 ++++++++++--- context/test-evidence/tui.md | 34 ++++++++ src/draw.rs | 159 ++++++++++++++++++++++++++++++++++- 3 files changed, 237 insertions(+), 14 deletions(-) diff --git a/context/code/tui.md b/context/code/tui.md index e595cdc3..a3589e3e 100644 --- a/context/code/tui.md +++ b/context/code/tui.md @@ -434,12 +434,12 @@ first correcting key: one Backspace and the user was fixing text they could no longer see the complaint about. The same happened to any refusal that leaves the user in a minibuffer. -**The rule: a key that leaves the user in the same text-entry mode, and says -nothing of its own, puts the stale message back.** `press` remembers the mode -and the message, clears the message as before, dispatches the key, and then -restores the message only if all three of these hold: -- the mode before the key had a minibuffer; -- the mode after the key is the same mode; +**The rule: a key that leaves the user in the same editor, and says nothing of +its own, puts the stale message back.** `press` remembers the editor session and +the message, clears the message as before, dispatches the key, and then restores +the message only if all three of these hold: +- a text-entry mode was open before the key; +- the same editor session is open after it (below); - the key wrote no message of its own. So: @@ -462,10 +462,48 @@ So: same text would be indistinguishable from a key that wrote none. Clearing first and restoring only an empty slot has no such ambiguity. -The mode test is equality of the whole `AppMode`. An edit that stayed in "a" -minibuffer but a different one does not count as staying. No shipped key does -that, but it would be leaving one prompt for another, and the message belonged -to the first. +### What "the same editor" means (improvise-sx7h) + +The first version of this rule compared modes: an equal `AppMode` before and +after the key meant the same editor. That is false for the two cell editors. +`Editing` and `RecordsEditing` commit with `CommitAndAdvance`, which drops to +Normal, writes the cell, moves the cursor and re-opens the editor through +`EnterEditAtCursor`, which sets an **equal** `AppMode`. So after an `Enter` or +`Tab` that committed and moved on, the mode looked unchanged, and the stale +message was restored beside a different cell's editor. It came back again on +every later commit until `Esc`. The reviewer's repro was `R`, `o`, `5`, `Tab`, +`7`: "Added new record row" survived the `Tab`, and so did `o`'s refusal +"That record row is already here…" after the `Tab` that filled the row in. +`FormulaEdit` could not show it, because its successful commit leaves the mode +and writes a message of its own. + +An editor session is one *opening* of a minibuffer. The TUI loop cannot see +openings, so `EditorSession` compares what an opening sets: +- **the whole `AppMode`**, not only its variant: an `ItemAdd` for `Month` is + not the `ItemAdd` for `Region` it replaced; +- **the cursor cell**, which is a cell editor's target, so a commit that + advances is a new editor. The prompt modes do not move the cursor while the + user types, so for them this is inert; +- **the editor's seed** (`opened_editor.seed`). `EnterEditAtCursor` re-seeds + from the cell. So a commit that re-opens on the **same** cell, such as `Tab` + in the last column, is a new editor too, because the seed is now the + committed text. + +What it cannot see: a commit that re-opens on the same cell with the same seed. +That means the user committed the text the cell already held, which writes +nothing. Restoring a message across a commit that changed nothing is harmless. + +**Rejected: an editor generation counter.** It would be exact, but an editor is +opened by more than one effect (`EnterEditAtCursor`, and `ChangeMode` from +`enter-mode`). A counter bumped in each is a hand-kept list of the places a mode +is written, which is the shape gotcha 24 retired. The three fields are set by +the opening itself, so they need no list. + +**Not included: the active view.** The cursor is per view, so in principle the +view belongs with the cell. But no key a minibuffer binds can switch the view +and stay in the minibuffer: a `:` line that switches view settles back to +Normal. So nothing could test the field, and an untestable field is a claim no +test can check. **The browser is not covered by this.** There, `status_msg` is a toast that hides after `TOAST_MS` (six seconds), and the minibuffer element draws only the diff --git a/context/test-evidence/tui.md b/context/test-evidence/tui.md index b8b5b422..016a651c 100644 --- a/context/test-evidence/tui.md +++ b/context/test-evidence/tui.md @@ -969,3 +969,37 @@ Rivals, each injected into `press` and restored: - **Restore whatever the mode after the key** fails step 4, because the error outlives `Esc`. It does not fail step 3, because a successful commit writes its own message. That is why step 4 exists. + +### a_message_does_not_follow_the_editor_onto_the_next_cell + +improvise-sx7h, a regression #257 introduced against #233. Equal modes stood for +"the same editor", but `Editing`/`RecordsEditing` commit by re-opening an equal +mode on the next cell. The test drives `press` through four scenarios: +1. **Records.** `R`, then `o` ("Added new record row", in `RecordsEditing`), + then `5`. The message stands while typing in the same editor. Then `Tab`: the + editor re-opens on the next cell, and the message must be gone. +2. **Records.** `R`, `o`, `Esc`, then `o` again, which is refused with "That + record row is already here…". Then `5`, with the refusal still drawn. Then + `Tab`, which fills the row in, and the refusal must be gone. +3. **A plain grid.** `i` on `(0, 0)`, with a note set while editing. Then `7`, + with the note kept. Then `Enter`: the editor re-opens a row down, and the + note must be gone. +4. **The last column.** `i`, a note, `7`, `Tab`. The cursor cannot advance, so + the editor re-opens on the **same** cell, and the note must be gone. + +Scenarios 1 and 2 are the reviewer's repro. + +Rivals, each injected into `EditorSession::of` and restored: +- **mode only**, which is #257's rule as pushed at 2b1c5bf7, fails at + scenario 1; +- **no cursor cell** fails at scenario 1, because the new record's cells both + seed `""`, so the seed alone cannot tell them apart; +- **no seed** fails at scenario 4, the only place the cell does not change. + +### an_editor_on_another_category_is_another_editor + +improvise-sx7h's pinning test, asked for by the review. The mode is compared +whole, not by variant. Two `ItemAdd` sessions for different categories must be +different editors, and the same category must be the same. No key reaches that +transition, so it is asserted on `EditorSession::of` directly. The rival that +compares `std::mem::discriminant` fails it, and passes every other test. diff --git a/src/draw.rs b/src/draw.rs index 05409e93..05708b4a 100644 --- a/src/draw.rs +++ b/src/draw.rs @@ -100,19 +100,43 @@ pub fn run_tui( } /// One keypress in the TUI. A message lasts until the next key (improvise-cq3d), -/// except while a minibuffer stays open under it (su4u). `context/code/tui.md`. +/// except while one editor stays open under it (su4u, sx7h). `context/code/tui.md`. fn press(app: &mut App, key: key::KeyEvent) -> Result<()> { - let mode = app.view_state.mode.clone(); + let before = EditorSession::of(app); let stale = app.view_state.status_msg.clone(); app.apply_effects(vec![Box::new(crate::ui::effect::SetStatus(String::new()))]); app.handle_key(key)?; - let still_editing = mode.minibuffer().is_some() && app.view_state.mode == mode; + let still_editing = before.is_some() && before == EditorSession::of(app); if still_editing && app.view_state.status_msg.is_empty() && !stale.is_empty() { app.apply_effects(vec![Box::new(crate::ui::effect::SetStatus(stale))]); } Ok(()) } +/// What one opening of a minibuffer sets, which is all the TUI loop can see of +/// it: equal before and after a key means the same editor (improvise-sx7h). +#[derive(Debug, PartialEq)] +struct EditorSession { + mode: AppMode, + cell: (usize, usize), + seed: Option, +} + +impl EditorSession { + fn of(app: &App) -> Option { + app.view_state.mode.minibuffer()?; + Some(Self { + mode: app.view_state.mode.clone(), + cell: app.cursor().selected, + seed: app + .view_state + .opened_editor + .as_ref() + .map(|o| o.seed.clone()), + }) + } +} + // ── Key event conversion (TUI boundary) ────────────────────────────────────── // The only place crossterm's key types and `command::key` may both appear. @@ -580,7 +604,7 @@ fn draw_welcome(f: &mut Frame, area: Rect) { #[cfg(test)] mod bottom_bar_tests { - use super::{draw_bottom_bar, minibuffer_line, press}; + use super::{EditorSession, draw_bottom_bar, minibuffer_line, press}; use crate::command::key::{KeyCode, KeyEvent, KeyModifiers}; use crate::ui::app::{App, AppMode}; use crate::workbook::Workbook; @@ -698,6 +722,133 @@ mod bottom_bar_tests { ); } + /// Region{North, South}, two measures, one cell: the reviewer's records fixture + /// for improvise-sx7h, plus a second row for the plain grid's Enter. + fn one_record() -> App { + use crate::model::cell::{CellKey, CellValue}; + let mut wb = Workbook::new("T"); + wb.add_category("Region").unwrap(); + wb.add_item("Region", "North").unwrap(); + wb.add_item("Region", "South").unwrap(); + wb.add_item("_Measure", "Cost").unwrap(); + wb.add_item("_Measure", "Revenue").unwrap(); + wb.model.set_cell( + CellKey::new(vec![ + ("Region".into(), "North".into()), + ("_Measure".into(), "Revenue".into()), + ]), + CellValue::Number(1.0), + ); + App::new(wb, None) + } + + /// A commit that re-opens the editor on another cell starts another editor, so + /// a message from the last one must not follow it (improvise-sx7h). Evidence file. + #[test] + fn a_message_does_not_follow_the_editor_onto_the_next_cell() { + let key = |code| KeyEvent::new(code, KeyModifiers::NONE); + let ch = |app: &mut App, c: char| press(app, key(KeyCode::Char(c))).unwrap(); + + // Records: `o` says it added a row and opens an editor on it. + let mut app = one_record(); + ch(&mut app, 'R'); + ch(&mut app, 'o'); + let added = app.view_state.status_msg.clone(); + assert!(!added.is_empty(), "o must say what it did"); + assert!(matches!( + app.view_state.mode, + AppMode::RecordsEditing { .. } + )); + ch(&mut app, '5'); + assert!( + bottom_row(&app, 240).contains(&added), + "typing in the same editor keeps the message" + ); + let cell = app.cursor().selected; + press(&mut app, key(KeyCode::Tab)).unwrap(); + assert!(matches!( + app.view_state.mode, + AppMode::RecordsEditing { .. } + )); + assert_ne!(app.cursor().selected, cell, "Tab moved the editor"); + let row = bottom_row(&app, 240); + assert!( + !row.contains(&added), + "the message followed the editor to the next cell: {row:?}" + ); + + // Records: a second `o` on the blank row is refused, and filling it in ends it. + let mut app = one_record(); + ch(&mut app, 'R'); + ch(&mut app, 'o'); + press(&mut app, key(KeyCode::Esc)).unwrap(); + ch(&mut app, 'o'); + let refusal = app.view_state.status_msg.clone(); + assert!(refusal.contains("already here"), "{refusal:?}"); + ch(&mut app, '5'); + assert!(bottom_row(&app, 240).contains("already here")); + press(&mut app, key(KeyCode::Tab)).unwrap(); + let row = bottom_row(&app, 240); + assert!( + !row.contains("already here"), + "the refusal outlived the Tab that answered it: {row:?}" + ); + + // A plain grid: Enter commits and re-opens the editor one row down. + let mut app = one_record(); + app.cursor_mut().selected = (0, 0); + ch(&mut app, 'i'); + assert!(matches!(app.view_state.mode, AppMode::Editing { .. })); + app.view_state.status_msg = "a note about this cell".into(); + ch(&mut app, '7'); + assert!(bottom_row(&app, 240).contains("a note about this cell")); + let cell = app.cursor().selected; + press(&mut app, key(KeyCode::Enter)).unwrap(); + assert!(matches!(app.view_state.mode, AppMode::Editing { .. })); + assert_ne!(app.cursor().selected, cell, "Enter moved the editor"); + let row = bottom_row(&app, 240); + assert!( + !row.contains("a note about this cell"), + "the note followed the editor down a row: {row:?}" + ); + + // The last column: Tab commits and re-opens the editor on the SAME cell, so + // only the new seed says it is another editor. + let mut app = one_record(); + let last = app.layout.col_count() - 1; + app.cursor_mut().selected = (0, last); + ch(&mut app, 'i'); + app.view_state.status_msg = "a note about this cell".into(); + ch(&mut app, '7'); + press(&mut app, key(KeyCode::Tab)).unwrap(); + assert!(matches!(app.view_state.mode, AppMode::Editing { .. })); + assert_eq!(app.cursor().selected, (0, last), "Tab could not advance"); + let row = bottom_row(&app, 240); + assert!( + !row.contains("a note about this cell"), + "the note outlived the commit on the same cell: {row:?}" + ); + } + + /// Equal modes are the test, not equal variants: an editor adding to another + /// category is another editor (improvise-sx7h). Evidence file. + #[test] + fn an_editor_on_another_category_is_another_editor() { + let mut app = App::new(Workbook::new("T"), None); + let item_add = |category: &str| AppMode::item_add(category.into()); + app.view_state.mode = item_add("Region"); + let region = EditorSession::of(&app); + assert!(region.is_some(), "ItemAdd is a text-entry mode"); + app.view_state.mode = item_add("Month"); + assert_ne!(region, EditorSession::of(&app)); + app.view_state.mode = item_add("Region"); + assert_eq!( + region, + EditorSession::of(&app), + "and the same one is the same" + ); + } + /// The wizard overlay draws correct per-step hints; the status bar stacked a /// second static table under it (improvise-rr8n) — `context/test-evidence/tui.md`. #[test] From 076ead7c7c6620467bb35da5520f6a1e099f4601 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 17:18:55 -0700 Subject: [PATCH 28/48] improvise-yb4b/b5dp: pin the scan against the per-member fold; record the ruling, the bench and the evidence Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/command.md | 68 ++++++++++++++++++++++--- context/test-evidence/command.md | 23 +++++++++ context/test-evidence/core.md | 26 ++++++++++ crates/improvise-core/src/model/cell.rs | 32 ++++++++++++ 4 files changed, 141 insertions(+), 8 deletions(-) diff --git a/context/code/command.md b/context/code/command.md index f058e052..fd4043c8 100644 --- a/context/code/command.md +++ b/context/code/command.md @@ -1766,9 +1766,12 @@ its records under its own aggregator. The user ruled on three cases: - **Text** follows the unanimity rule (gotcha 7): the text if every row agrees, otherwise the `mixed` error. - **An error** in any row makes the total an error, which is what that rule - answers for an error among numbers. A formula column is evaluated over the - pooled inputs, so a row's `div/0` is not an input and does not propagate. - Only the fold's own error does. + answers for an error among numbers. That holds for a **stored** error. A + formula column is evaluated over the pooled inputs, so a row's `div/0` is not + an input and does not propagate. Only the fold's own error does. Rulings (1) + and (2) disagree here: the column shows an error in one row and a clean + number under it. The user chose the **pooled ratio** (improvise-b5dp). Margin + over rows 25/30, 27/30 and -4/0 totals 48/60 = 0.80, not `div/0`. - **With `_Measure` on the row axis** the strip is empty, because a column adds different measures together. An empty strip already means "no totals" (gotcha 33). @@ -1789,11 +1792,60 @@ before and after: | pathological (90,000) | 1.54 s | 3.49 s | 1.07 s | 1.96 s | `col_widths` is the keypress path, and it pays too, because a total's text sizes -its column. The fold rebuilds a key per drawn row and reads it again rather than -reusing the walk's value, since a pooled Avg or a formula needs records, not the -rows' answers. A grid-wide fold memo (improvise-h6z1) is the lever. The walk -no longer carries a sum, so the window-into-totals fault below cannot happen any -more. The test still guards the widths half. +its column. The event loop draws once per key, so every key pays the fold twice: +once in `App::cmd_context` for `visible_cols`, and once in the draw's `grid_of`. +At 4.8k cells that took a held arrow key from about 31 ms to about 52 ms, past +the ~33 ms key-repeat interval (improvise-yb4b). The walk no longer carries a +sum, so the window-into-totals fault below cannot happen any more. The test +still guards the widths half. + +**The fold scans instead of enumerating (improvise-yb4b).** The cost was not the +fold's arithmetic. It was *how the fold found its records*: `fold_stored` built +one `CellKey` per member, the cross product of every spanned category's covered +items (3,000 keys for an 18k column), and looked each one up. Now +`group_expansion` returns the expansion as data: a `base` key and the covered +items of each expanded category. `fold_stored` hands that to +`DataStore::spanned_values`, which narrows by `base` through the index once and +filters each candidate by membership. In `Exact` mode a candidate must also +carry no coordinate beyond those, which is what `get(member)` answered. Only a +formula still enumerates members (`contexts_of`), since its references are +evaluated per context. + +Two levers were **not** taken. Combining the walk's row values for decomposable +aggregators was rejected under §5 as a second derivation of the total. It is +also wrong wherever a formula owns a row-axis item: the row reads the formula, +while the spanning fold reads the store. Sharing one total between +`cmd_context` and the draw was also rejected: a key's effects run between the +two, so sharing needs an invalidation story. Dropping the totals from the widths +would make the two width computations disagree about `visible_cols`. + +**What it changes besides speed:** a stored fold's values now arrive in the +store's insertion order, not member order. That is the order gotcha 18 names as +the store's answer. Min, Max, Count and text unanimity are order-free. A Sum or +Avg over non-integral values may differ in the last bits. + +Measured by alternating three builds of `cargo bench --bench grid_projection` on +an Apple M4 Max (16 cores, 64 GB) under heavy shared load (load average 30–90). +This is min-of-N, three rounds, with the order reversed in the third. The +keypress figure is `whole-grid + col_widths`. The millisecond columns are +ranges over the rounds. The ratio columns are each round's own pair, in round +order: + +| cells | before #239 (`5932ad74`) | #239 (`9948aaf5`) | yb4b | #239 ÷ before | yb4b ÷ before | +|---|---|---|---|---|---| +| 400 | 1.36–1.77 ms | 2.36–2.76 ms | 1.69–1.82 ms | 2.03, 1.34, 1.78 | 1.24, 0.98, 1.25 | +| 1.2k | 4.1–5.2 ms | 7.2–8.2 ms | 5.0–5.3 ms | 2.00, 1.39, 1.85 | 1.23, 0.96, 1.25 | +| 2.4k | 10.8–13.6 ms | 18.4–21.5 ms | 12.0–13.9 ms | 1.98, 1.36, 1.91 | 1.18, 0.88, 1.29 | +| 4.8k | 32.8–41.8 ms | 55.4–64.1 ms | 34.5–42.0 ms | 1.77, 1.32, 1.92 | 1.15, 0.82, 1.26 | +| 9.6k | 102.8–121.2 ms | 190.7–209.6 ms | 107.2–140.0 ms | 2.04, 1.57, 1.92 | 1.36, 0.88, 1.20 | +| 18k | 192.9–208.3 ms | 338.6–448.0 ms | 230.4–273.3 ms | 2.32, 1.63, 1.72 | 1.42, 1.11, 1.23 | +| 90k | 2.68–3.26 s | 4.92–6.49 s | 3.18–4.00 s | 1.99, 1.84, 1.79 | 1.23, 1.47, 1.14 | + +Read the ratio columns, not the milliseconds. Round 2's `before` ran at load +86 against about 31 for its `yb4b`, which is why its ratios fall below 1. The +two clean rounds put #239 at about 1.8–2.3× the pre-#239 keypress, and put +yb4b at about 1.15–1.4×. At 4.8k a held key is back under the ~33 ms repeat +interval in the quietest round (34.5 ms against 32.8 ms before). 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 diff --git a/context/test-evidence/command.md b/context/test-evidence/command.md index b81f783d..c88c0f4a 100644 --- a/context/test-evidence/command.md +++ b/context/test-evidence/command.md @@ -6118,3 +6118,26 @@ four sites. It reverts each `effect::refuse(` to `effect::set_status(` alone and runs `cargo test --workspace --no-fail-fast`. Four of four fail this test, and nothing else fails. None of the four needs an abort: each is the only effect its command emits, and `p`/`i` are single bindings, not sequences. + +### a_formula_rows_error_does_not_poison_the_pooled_total + +improvise-b5dp, the user's ruling on the conflict between wm20's rulings (1) and +(2): a formula column's total is the **pooled ratio**. A row's formula error +does not poison it, and a stored error still does +(`an_error_in_any_row_makes_the_total_an_error`). Rows North 25/30, South 27/30 +and East -4/0 draw 0.83, 0.90 and `div/0`. The total must read **0.80** +(48/60), not an error. The test asserts that East's row really draws the error, +so a fixture where no row errors cannot pass it. + +It was **green at `9948aaf5`**, because #239 already implemented the choice the +user then ratified. It is a pin, not a reproduction. Rival: in `grid_body`'s +walk, a drawn cell's `Error` overwrote its column's total. That is ruling (2) +applied to every row. This test and its Share sibling fail on the total +assertion. The other seven `column_totals` tests pass. + +### a_share_columns_total_pools_past_a_rows_div_zero + +improvise-b5dp, the second case the bead named. `Share = Revenue / +SUM(Revenue)`: East's own `SUM` is 0, so its row draws `div/0`. The pooled +total is 60/60 = **1.00**. It was green at `9948aaf5` and fails under the same +rival as the test above. diff --git a/context/test-evidence/core.md b/context/test-evidence/core.md index 26c16963..2a30c19a 100644 --- a/context/test-evidence/core.md +++ b/context/test-evidence/core.md @@ -1511,3 +1511,29 @@ are each compared with the same document with Month on `Axis::None`. At `Flag` at `Some(0.0)`, false rather than absent, so a fix that dropped the cell cannot pass. Rival: `read_coordinate` ignoring the reach (`if false && …`). This test fails. + +### a_spanned_scan_finds_what_a_lookup_per_member_finds + +improvise-yb4b. `fold_stored` used to enumerate one `CellKey` per member of a +fold and look each one up. Since #239 a column total spans every row +category, which is 3,000 lookups for one 18k column, paid twice per keypress. +`DataStore::spanned_values` now answers with one index-narrowed scan. This test +compares the scan with the per-member definition it replaces, in both modes, on +a store built to separate them. It has a record with an **extra coordinate**: +`get` misses it and `matching_values` finds it. It has an **uncovered item**, a +record **lacking** the spanned category, **another measure**, and another +value of an unspanned coordinate. It also has a covered item that was never +interned. The values are distinct powers of two, so a sum identifies the +admitted set. The order differs on purpose: the store's order, not member +order, as gotcha 18 names it. + +Three faults were injected, each run against the full `improvise-core` and +`improvise-command` suites: + +- Exact mode loses its length check (`==` → `>=`). **Only this test fails.** + Nothing else in either suite reaches an `Exact` fold over a record with an + extra coordinate. +- The span ignores coverage (`|| true`). This test fails, and so do five others + (collapsed-group, hidden-row and CSV-agreement tests). +- A category the store never interned is skipped instead of matching nothing. + **Only this test fails.** diff --git a/crates/improvise-core/src/model/cell.rs b/crates/improvise-core/src/model/cell.rs index 031cc72d..1984869e 100644 --- a/crates/improvise-core/src/model/cell.rs +++ b/crates/improvise-core/src/model/cell.rs @@ -772,6 +772,38 @@ mod data_store { "the fast path and the general path must agree" ); } + + /// improvise-yb4b: one scan answers what a lookup per member answered, in both + /// modes. context/test-evidence/core.md. + #[test] + fn a_spanned_scan_finds_what_a_lookup_per_member_finds() { + let mut store = DataStore::new(); + let cells: [(&[(&str, &str)], f64); 7] = [ + (&[("M", "Rev"), ("R", "North"), ("P", "A")], 1.0), + (&[("M", "Rev"), ("R", "South"), ("P", "A")], 2.0), + (&[("M", "Rev"), ("R", "East"), ("P", "A")], 4.0), + (&[("M", "Rev"), ("R", "North"), ("P", "A"), ("X", "x")], 8.0), + (&[("M", "Rev"), ("P", "A")], 16.0), + (&[("M", "Cost"), ("R", "North"), ("P", "A")], 32.0), + (&[("M", "Rev"), ("R", "North"), ("P", "B")], 64.0), + ]; + for (coords, v) in cells { + store.set(key(coords), CellValue::Number(v)); + } + let base = key(&[("M", "Rev"), ("P", "A")]); + let covered = vec!["North", "South", "West"]; + let members: Vec = covered.iter().map(|r| base.clone().with("R", *r)).collect(); + let sum = |values: Vec<&CellValue>| values.iter().filter_map(|v| v.as_f64()).sum::(); + let per_member_exact = members.iter().filter_map(|k| store.get(k)).collect(); + let per_member_over = members.iter().flat_map(|k| store.matching_values(&k.0)).collect(); + let spans = [("R", covered.clone())]; + let scan_exact = store.spanned_values(&base.0, &spans, true); + let scan_over = store.spanned_values(&base.0, &spans, false); + assert_eq!((sum(scan_exact), sum(per_member_exact)), (3.0, 3.0)); + assert_eq!((sum(scan_over), sum(per_member_over)), (11.0, 11.0)); + let unknown = [("Nowhere", covered)]; + assert!(store.spanned_values(&base.0, &unknown, false).is_empty()); + } } #[cfg(test)] From cf5c6fc7119ae04ab37c2fec54b1a20603e70198 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 17:22:48 -0700 Subject: [PATCH 29/48] Mark the help rows a browser cannot run, and guard it from one list improvise-uln5. The browser painted every help-overlay row plainly, including six whose keys go upstream to a host that answers nothing: /, n, H, z, Enter, and search mode's Enter/Esc. Every ex-command row was painted plainly too, although `:` opens a command line the browser cannot run. A dead key is worse than a gap: the keys typed after it land on live bindings (improvise-koh6). - resolve::NEEDS_THE_HOST is now the one list of names this client has no arm for. KeyRow keeps the command it was built for. - The browser's RenderHelpRow draws a listed row, and every ex row while the command line is stranded, as `dim` with "(not in the browser yet)". It marks rather than omits: both front ends keep drawing the same rows, the feature stays findable, and web/ needs no change. - the_command_names_that_still_need_the_host_are_these now drives that list rather than a table of its own. - every_help_row_the_browser_cannot_run_is_one_it_marks holds it from the other side: silent help rows == listed rows, and no hint line names one. - every_advertised_command_answers_in_its_own_mode no longer restates the black holes. Red: a_help_row_the_browser_cannot_run_is_drawn_marked at 202e7eac. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/clients.md | 38 ++++- context/test-evidence/clients.md | 44 +++++- crates/improvise-protocol/src/help.rs | 15 +- crates/improvise-protocol/src/resolve.rs | 157 +++++++++++++++------ crates/improvise-wasm-client/src/render.rs | 85 +++++++++-- 5 files changed, 280 insertions(+), 59 deletions(-) diff --git a/context/code/clients.md b/context/code/clients.md index 73c7cbde..146d0138 100644 --- a/context/code/clients.md +++ b/context/code/clients.md @@ -578,11 +578,43 @@ 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 +`resolve::NEEDS_THE_HOST` names them, and +`the_command_names_that_still_need_the_host_are_these` drives each through the +key that reaches it. `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)`). +**The help overlay marks them, and it marks rather than omits** (improvise-uln5). +The overlay is shared content, and before this the browser painted every row +plainly, including six that do nothing there. That is worse than a gap: +improvise-koh6 showed that the keys typed after a dead one land on live Normal +bindings, and `i` opens an editor. The browser's `RenderHelpRow` now draws a row +whose command is on the list as `dim`, and appends "(not in the browser yet)" +to its description. It does the same for every ex-command row while `:` opens a +command line this client cannot run (`resolve::runs_the_command_line`, which is +`stranding`'s answer, not a second one). + +Marking was chosen over omitting for three reasons: +- **Both front ends draw the same rows.** A page with rows missing is a second + layout of the same content. +- **The feature stays findable.** The row still says it exists in the terminal. +- **It needs no change in `web/`.** `dim` is a kind the page already paints. + +The TUI is unaffected, because the marking lives in the browser's renderer. + +**One list, held from both sides.** `NEEDS_THE_HOST` is production data because +the renderer needs it. Two tests hold it to what `resolve` actually does: +- `the_command_names_that_still_need_the_host_are_these` fails if a name on it + resolves locally. +- `every_help_row_the_browser_cannot_run_is_one_it_marks` fails if a help row + is silent in the browser without being on the list, or on the list without + being silent. It also fails if a *hint line* ever names a listed command, + because the status bar is never marked. + +`every_advertised_command_answers_in_its_own_mode` therefore no longer restates +the black holes. It skips a silent row whose command is on the list, because +the other test owns it. + **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 diff --git a/context/test-evidence/clients.md b/context/test-evidence/clients.md index 1b893acb..8092cd6e 100644 --- a/context/test-evidence/clients.md +++ b/context/test-evidence/clients.md @@ -213,6 +213,45 @@ when Ctrl-S became a `Request`; putting the entry back fails with *"save stops being a gap"*. So the mechanism gotcha 47 asks for was watched failing rather than assumed. +**Since improvise-uln5 it iterates `resolve::NEEDS_THE_HOST`** rather than a +table of its own. It derives each name's keys from the Normal and search-mode +keymaps, and arms a prefix before pressing its second key. So the list the +browser's help marks and the list this test drives are the same list. +`exit-search-mode` joined it, because search mode's `Enter`/`Esc` are silent +too. Rival: add `move-selection`, which the browser runs, to the list. This test +fails, and so does `every_help_row_the_browser_cannot_run_is_one_it_marks`. + +### every_help_row_the_browser_cannot_run_is_one_it_marks + +improvise-uln5. Every help row is pressed, in its own mode, on the fixture +`furnished` builds. The commands whose keys are silent must be exactly the +commands on `NEEDS_THE_HOST`, which are the rows the browser draws marked. The +check runs in both directions, because each direction catches its own mistake: +- a silent row that is not on the list is an unmarked black hole; +- a listed row that acts is a false warning. + +It also asserts that no status-bar hint names a listed command, since +`hint_line` is never marked. + +Rivals, each run against the fixed tree: +- Drop `search` from the list: red. This test fails, and so does + `every_advertised_command_answers_in_its_own_mode`, which no longer skips `/`. +- Add `move-selection` to the list: red. This test fails, and so does the + list's own test. + +### a_help_row_the_browser_cannot_run_is_drawn_marked + +improvise-uln5, the display half, and the red test. On the Navigation page the +row "Start search — …" (key `/`) must render as `dim`, with "not in the browser" +in its description. On the Commands page every `:` row must say the same. A +row the browser runs ("Jump to last row") must stay a plain `keys` row with its +description unchanged, so a renderer that marks everything cannot pass. + +Red at `202e7eac`: the search row rendered as `keys`. Rival: the renderer's +`needs_the_host` check replaced by `true`. It is red here, and only here, which +is why the display has a test of its own and does not lean on the resolve-side +guard. + ### a_prompt_this_client_cannot_commit_is_never_entered The refusal list above is only half the property. `Upstream` says the key @@ -2105,7 +2144,10 @@ two cannot drift — including prefix sequences (`gg`, `yy`, `ZZ`), which it presses by arming the prefix first. That is what added the five host-only black holes (`/`, `n`, `H`, `z`, Normal's `Enter`) to the list: each was already in `the_command_names_that_still_need_the_host_are_these`, and the help page -advertised it anyway. +advertised it anyway. Since the rest of improvise-uln5 they are no longer listed +here: the browser marks those rows, and this test skips a silent row whose +command is on `resolve::NEEDS_THE_HOST`, which +`every_help_row_the_browser_cannot_run_is_one_it_marks` owns. *Which cells.* `furnished` stood on a record cell only, so `i` on an aggregate (s8h7's whole subject) was outside its reach. Normal mode is now pressed on a diff --git a/crates/improvise-protocol/src/help.rs b/crates/improvise-protocol/src/help.rs index 19cc648b..5d0c7a52 100644 --- a/crates/improvise-protocol/src/help.rs +++ b/crates/improvise-protocol/src/help.rs @@ -335,6 +335,7 @@ mod key_row { #[derive(Debug, Clone, PartialEq, Eq)] pub struct KeyRow { keys: Vec, + command: &'static str, description: &'static str, } @@ -343,6 +344,12 @@ mod key_row { &self.keys } + /// The command the keys run, kept so a front end can ask whether it + /// can run it (improvise-uln5). + pub fn command(&self) -> &'static str { + self.command + } + pub fn description(&self) -> &'static str { self.description } @@ -351,7 +358,7 @@ mod key_row { /// advertises a key that does not exist. fn looked_up( mode: &ModeKey, - command: &str, + command: &'static str, args: Option<&[String]>, description: &'static str, ) -> Option { @@ -360,7 +367,11 @@ mod key_row { .map(|(c, m)| key_label(c, *m)) .collect(); keys.extend(prefixed_labels(mode, command, args)); - (!keys.is_empty()).then_some(Self { keys, description }) + (!keys.is_empty()).then_some(Self { + keys, + command, + description, + }) } } diff --git a/crates/improvise-protocol/src/resolve.rs b/crates/improvise-protocol/src/resolve.rs index 981b5227..4e115009 100644 --- a/crates/improvise-protocol/src/resolve.rs +++ b/crates/improvise-protocol/src/resolve.rs @@ -359,6 +359,34 @@ fn not_yet(what: &str) -> String { format!("{what} is not available in the browser yet") } +/// The command names this client has no arm for, so their keys go upstream to +/// a host that answers nothing (gotcha 47). The one list: see clients.md. +pub const NEEDS_THE_HOST: &[&str] = &[ + "enter-advance", + "exit-search-mode", + "force-quit", + "hide-selected-row-item", + "search", + "search-navigate", + "toggle-group-under-cursor", +]; + +/// Whether a key running `name` does nothing in this client. +pub fn needs_the_host(name: &str) -> bool { + NEEDS_THE_HOST.contains(&name) +} + +/// Whether `:` opens a command line this client can run; it cannot, so an +/// ex-command it advertises is unreachable (improvise-uln5). +pub fn runs_the_command_line() -> bool { + AppMode::from_name("command").is_some_and(|mode| stranding(&mode).is_none()) +} + +/// A help description, marked as naming something this client cannot do. +pub fn not_here(description: &str) -> String { + format!("{description} (not in the browser yet)") +} + /// The subset of command names this client can turn into `Command`s; `None` /// means "the host's". Each arm mirrors its `Cmd` line for line — clients.md. fn composed( @@ -2402,7 +2430,12 @@ mod tests { } } } - for (where_, got) in report.into_iter().filter(|(_, got)| *got != Heard::Acts) { + // A black hole the browser marks is the next test's, by NEEDS_THE_HOST. + let marked = |got: &Heard| *got == Heard::Silent && needs_the_host(command); + for (where_, got) in report + .into_iter() + .filter(|(_, got)| *got != Heard::Acts && !marked(got)) + { short .entry((where_, command, got)) .or_default() @@ -2426,14 +2459,6 @@ mod tests { "COMMAND: Enter execute-command Silent", "EXPORT: Enter commit-export Silent", "SAVE AS: Enter commit-save Silent", - "NORMAL SEARCH: Enter/Esc exit-search-mode Silent", - // Commands the browser has no arm for: upstream, so a black hole - // (improvise-uln5). Each is also in the host-only list below. - "NORMAL: Enter enter-advance Silent", - "NORMAL: H hide-selected-row-item Silent", - "NORMAL: / search Silent", - "NORMAL: n search-navigate Silent", - "NORMAL: z toggle-group-under-cursor Silent", // Honestly unavailable, and saying so in `declined`'s words. "FORMULAS: Del/d delete-formula-at-cursor Declines", "NORMAL: > drill-into-cell Declines", @@ -2667,41 +2692,93 @@ mod tests { /// closing one of these gaps has to come here and say so. Evidence file. #[test] fn the_command_names_that_still_need_the_host_are_these() { - let mut state = in_mode(AppMode::Normal); - state.grid.extent = GridExtent { rows: 4, cols: 4 }; - state.window = (4, 4); - for (key, name) in [ - // The three prefixes left with improvise-hs0u: arming is a - // `Command`, and the second key resolves in the sub-keymap. - (ev(KeyCode::Char('H')), "hide-selected-row-item"), - (ev(KeyCode::Char('z')), "toggle-group-under-cursor"), - // `>`, `R`, `P`, Ctrl-W and Ctrl-E are NOT here any more: each - // answers with a status of its own (improvise-gd28, improvise-rmvx). - - // `<` left too, and further: since improvise-cfly it composes a - // real `Command::ViewBack` rather than a refusal. - (ev(KeyCode::Char('/')), "search"), - (ev(KeyCode::Char('n')), "search-navigate"), - // `N` left this list with improvise-koh6: both its arms are - // answered here, one of them out loud. See clients.md. - (ev(KeyCode::Enter), "enter-advance"), - // Ctrl-S left this list with improvise-jz6w: it is a `Request`, - // not a `Command`, and the host has answered it since 368h. - ( - KeyEvent::new(KeyCode::Char('q'), KeyModifiers::CONTROL), - "force-quit", - ), - ] { - assert_eq!( - resolve(&state, key), - KeyOutcome::Upstream(key), - "{name} ({:?}) was resolved locally; if that is deliberate, \ - this list is where it stops being a gap", - key.code + // `N` left with improvise-koh6, Ctrl-S with jz6w, and `>`, `R`, `P`, + // Ctrl-W, Ctrl-E and `<` with gd28, rmvx and cfly: see clients.md. + for name in NEEDS_THE_HOST { + let mut sequences = Vec::new(); + for (mode, search) in [(AppMode::Normal, false), (AppMode::Normal, true)] { + let key = ModeKey::from_app_mode(&mode, search).expect("a keyed mode"); + let keymap = Keymaps::shared().get(&key).expect("a keymap"); + for sequence in sequences_running(keymap, name, None) { + sequences.push((mode.clone(), search, sequence)); + } + } + assert!( + !sequences.is_empty(), + "{name} is bound nowhere this test looks" ); + for (mode, search, sequence) in sequences { + let mut state = in_mode(mode); + state.view.search_mode = search; + state.grid.extent = GridExtent { rows: 4, cols: 4 }; + state.window = (4, 4); + let (key, prefix) = sequence.split_last().expect("a key"); + for armed in prefix { + if let KeyOutcome::Local(commands) = resolve(&state, *armed) { + commands.iter().for_each(|c| state.fold(c)); + } + } + let key = *key; + assert_eq!( + resolve(&state, key), + KeyOutcome::Upstream(key), + "{name} ({:?}) was resolved locally; if that is deliberate, it \ + leaves NEEDS_THE_HOST, and the browser stops marking it", + key.code + ); + } } } + /// improvise-uln5: the help rows a browser cannot run are exactly the ones + /// it marks, and no hint names one. See `context/test-evidence/clients.md`. + #[test] + fn every_help_row_the_browser_cannot_run_is_one_it_marks() { + for mode in AppMode::all() { + for (command, _, _) in crate::help::advertised(&mode) { + assert!( + !needs_the_host(command), + "the {mode:?} hint line advertises {command}, which a browser \ + cannot run and which the hint line never marks" + ); + } + } + let mut silent = Vec::new(); + let mut marked = Vec::new(); + for (key, command, args, _) in crate::help::help_bindings() { + let (mode, search) = match &key { + ModeKey::SearchMode => (AppMode::Normal, true), + key => ( + AppMode::all() + .into_iter() + .find(|m| ModeKey::from_app_mode(m, false).as_ref() == Some(key)) + .expect("a help row's mode is reachable"), + false, + ), + }; + let keymap = Keymaps::shared().get(&key).expect("a keymap"); + for sequence in sequences_running(keymap, command, args.as_deref()) { + let mut state = furnished(mode.clone()); + state.view.search_mode = search; + if press(state, &sequence) == Heard::Silent { + silent.push(command); + } + } + if needs_the_host(command) { + marked.push(command); + } + } + for list in [&mut silent, &mut marked] { + list.sort(); + list.dedup(); + } + assert_eq!( + silent, marked, + "left: help rows whose keys do nothing in a browser; right: the rows \ + it marks as needing the terminal. They must be the same rows." + ); + } + /// improvise-jz6w: the outcome a resolver had no way to name. See /// `context/test-evidence/clients.md`. #[test] diff --git a/crates/improvise-wasm-client/src/render.rs b/crates/improvise-wasm-client/src/render.rs index 8535f78b..6e916ec4 100644 --- a/crates/improvise-wasm-client/src/render.rs +++ b/crates/improvise-wasm-client/src/render.rs @@ -2,7 +2,7 @@ //! already made. The four silent ones are in `context/code/clients.md`. use improvise_protocol::viewmodel::{CatTreeEntry, flatten_cat_tree}; -use improvise_protocol::{AxisFrame, CellDisplay, CellId, FrameEntry}; +use improvise_protocol::{AxisFrame, CellDisplay, CellId, FrameEntry, resolve}; use serde::Serialize; use crate::{Client, ClientState}; @@ -423,22 +423,37 @@ impl RenderHelpRow { L::Heading(t) => prose("heading", t), L::Text(t) => prose("text", t), L::Accent(t) => prose("accent", t), - L::Keys(row) => Self { - keys: row.keys().join(" "), - description: row.description().to_string(), - kind: "keys", - }, + L::Keys(row) => Self::row( + row.keys().join(" "), + row.description(), + !resolve::needs_the_host(row.command()), + false, + ), L::Literal { keys, description, dim, - } => Self { - keys: (*keys).to_string(), - description: description.to_string(), - kind: match dim { - true => "dim", - false => "keys", - }, + } => Self::row( + (*keys).to_string(), + description, + resolve::runs_the_command_line(), + *dim, + ), + } + } + + /// A keys row. One this client cannot run is drawn dim and says so, since a + /// dead key followed by live ones edits the grid (improvise-uln5, koh6). + fn row(keys: String, description: &str, runs: bool, dim: bool) -> Self { + Self { + keys, + description: match runs { + true => description.to_string(), + false => resolve::not_here(description), + }, + kind: match dim || !runs { + true => "dim", + false => "keys", }, } } @@ -579,6 +594,50 @@ mod tests { FrameEntry::Item(vec![label.into()]) } + fn help_page(page: usize) -> Vec { + let view = ViewState { + mode: improvise_protocol::AppMode::Help, + help_page: page, + ..ViewState::default() + }; + help_rows(&view) + } + + /// improvise-uln5: a row whose key this client sends to a host that answers + /// nothing is drawn marked. Evidence: `context/test-evidence/clients.md`. + #[test] + fn a_help_row_the_browser_cannot_run_is_drawn_marked() { + let rows = help_page(1); + let row = |prefix: &str| { + let found = rows.iter().find(|r| r.description.starts_with(prefix)); + found.unwrap_or_else(|| panic!("no Navigation row reads {prefix:?}")) + }; + let search = row("Start search"); + assert_eq!(search.kind, "dim", "{search:?}"); + assert!( + search.description.contains("not in the browser"), + "{search:?}" + ); + let control = row("Jump to last row"); + assert_eq!( + control.kind, "keys", + "a row the browser runs is drawn plainly" + ); + assert_eq!(control.description, "Jump to last row"); + let commands = help_page(4); + let ex: Vec<_> = commands + .iter() + .filter(|r| r.keys.trim_start().starts_with(':')) + .collect(); + assert!( + !ex.is_empty(), + "precondition: the Commands page lists ex-commands" + ); + for row in ex { + assert!(row.description.contains("not in the browser"), "{row:?}"); + } + } + /// A run is opened by a HEADER, not by a name — improvise-h0iv, and both /// ends now ask the one rule. See the evidence file. #[test] From f668ddc94e120345019d4b6e908e9bd91cb1e4c6 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 17:24:40 -0700 Subject: [PATCH 30/48] rustfmt the yb4b/b5dp changes Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- crates/improvise-command/src/ui/project.rs | 12 ++++++++++-- crates/improvise-core/src/model/cell.rs | 13 +++++++------ crates/improvise-core/src/model/types.rs | 7 ++++++- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/crates/improvise-command/src/ui/project.rs b/crates/improvise-command/src/ui/project.rs index d94351c3..2369a7be 100644 --- a/crates/improvise-command/src/ui/project.rs +++ b/crates/improvise-command/src/ui/project.rs @@ -6066,7 +6066,11 @@ mod column_totals { let rows: Vec = grid.cells.iter().map(|r| r[col].error).collect(); assert_eq!(rows, vec![false, false, true], "East's row must draw div/0"); let t = &grid.totals[col]; - assert_eq!((t.text.as_str(), t.error), ("0.80", false), "48 / 60, pooled"); + assert_eq!( + (t.text.as_str(), t.error), + ("0.80", false), + "48 / 60, pooled" + ); } /// improvise-b5dp: the same ruling for a formula whose own aggregate is zero @@ -6079,6 +6083,10 @@ mod column_totals { let (grid, col) = column(wb, "Share"); assert!(grid.cells[2][col].error, "East's row must draw div/0"); let t = &grid.totals[col]; - assert_eq!((t.text.as_str(), t.error), ("1.00", false), "60 / 60, pooled"); + assert_eq!( + (t.text.as_str(), t.error), + ("1.00", false), + "60 / 60, pooled" + ); } } diff --git a/crates/improvise-core/src/model/cell.rs b/crates/improvise-core/src/model/cell.rs index 2dab13fd..38121786 100644 --- a/crates/improvise-core/src/model/cell.rs +++ b/crates/improvise-core/src/model/cell.rs @@ -446,11 +446,9 @@ impl DataStore { }; let admits = |ikey: &InternedKey| { (!exact || ikey.0.len() == partial.len() + wanted.len()) - && wanted.iter().all(|(cat, items)| { - ikey.0 - .iter() - .any(|(c, i)| c == cat && items.contains(i)) - }) + && wanted + .iter() + .all(|(cat, items)| ikey.0.iter().any(|(c, i)| c == cat && items.contains(i))) }; self.matching_positions(partial) .into_iter() @@ -826,7 +824,10 @@ mod data_store { let members: Vec = covered.iter().map(|r| base.clone().with("R", *r)).collect(); let sum = |values: Vec<&CellValue>| values.iter().filter_map(|v| v.as_f64()).sum::(); let per_member_exact = members.iter().filter_map(|k| store.get(k)).collect(); - let per_member_over = members.iter().flat_map(|k| store.matching_values(&k.0)).collect(); + let per_member_over = members + .iter() + .flat_map(|k| store.matching_values(&k.0)) + .collect(); let spans = [("R", covered.clone())]; let scan_exact = store.spanned_values(&base.0, &spans, true); let scan_over = store.spanned_values(&base.0, &spans, false); diff --git a/crates/improvise-core/src/model/types.rs b/crates/improvise-core/src/model/types.rs index a6a84802..324e15fb 100644 --- a/crates/improvise-core/src/model/types.rs +++ b/crates/improvise-core/src/model/types.rs @@ -824,7 +824,12 @@ impl Model { /// A group's stored values fold ONCE over every record the group covers, in /// the store's own order: one scan, not a lookup per member (improvise-yb4b). - fn fold_stored(&self, key: &CellKey, expansion: &Expansion, agg: Aggregation) -> Option { + fn fold_stored( + &self, + key: &CellKey, + expansion: &Expansion, + agg: Aggregation, + ) -> Option { let exact = agg == Aggregation::Exact; let values = self .data From 9872cc936f7f1ba19f5e9654a591f88f4a6f4eea Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 17:33:33 -0700 Subject: [PATCH 31/48] Every cell-editor opener awaits its seed while a write is unanswered (improvise-p6v6) mdfc put the "any unanswered write" check inside `advanced` alone, so an editor opened with i/a (`edit_or_drill`) still seeded from the cache the write had made stale: `i 9 Tab Esc i 5 Enter`, typed faster than the answer, stored 15 in the browser where the TUI stores 195. The in-flight half now lives in `opened_on`, so no opener can forget it. `advanced` keeps the "this frame writes" half, since the commit being resolved is not in flight yet; dropping it fails the 3tt4/3uzg tests. Stacked on #227 (fix/fy9o-reopened-editor-reseeded, de4a7d78). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/protocol.md | 14 ++++++ context/test-evidence/clients.md | 26 ++++++++++ crates/improvise-protocol/src/resolve.rs | 30 ++++++++---- .../improvise-wasm-client/tests/lockstep.rs | 49 +++++++++++++++++++ 4 files changed, 110 insertions(+), 9 deletions(-) diff --git a/context/code/protocol.md b/context/code/protocol.md index 0a9c6920..268422a9 100644 --- a/context/code/protocol.md +++ b/context/code/protocol.md @@ -2045,6 +2045,20 @@ stated once, over the write, never over a list of cases: in-flight write, not the newest frame: a frame that writes nothing stays in the queue behind an unanswered write, so the newest frame can be one of those. +- **Every opener asks the in-flight half, because it lives in `opened_on`** + (improvise-p6v6). mdfc first put the whole test inside `advanced`, which left + `i`/`a` (`edit_or_drill`) seeding from the cache with no in-flight check. + `i 9 Tab Esc i 5 Enter`, typed faster than the answer, stored 15 in the + browser where the TUI stores 195. The two halves now live in different + places, for a reason. "A write is still in flight" is true of the *client*, + so `opened_on` asks it, and no opener can forget. "This frame writes" is + true only of the commit being resolved, which is not in `in_flight` yet, so + `advanced` still asks it itself. Folding both into `opened_on` would lose the + second: the 3tt4 and 3uzg tests fail when `advanced` defers entirely. + The click-edit path (`socket.rs::edit_target_at`, improvise-ugj4) also seeds + from the cache, but it claims the record at the version it *read*. Behind an + unanswered write the host therefore refuses its commit as `RecordMoved`: a + loud failure, not a silent overwrite. - **The seed settles only into an open editor** (improvise-4cb5). An Esc typed before the answer arrives leaves the awaiting `opened_editor` in place. The late seed would then fill the edit buffer in `Normal` mode, where the TUI's is diff --git a/context/test-evidence/clients.md b/context/test-evidence/clients.md index 1390ab0b..80cbc20b 100644 --- a/context/test-evidence/clients.md +++ b/context/test-evidence/clients.md @@ -5278,3 +5278,29 @@ JSON. **Rival:** `#[serde(default)]` restored. The frame then parses, with `awaiting: false`, and the test fails at `expect_err`. Before this test existed, deleting the attribute left the whole workspace green, so nothing pinned it. + +### `an_editor_opened_with_i_before_the_last_write_is_answered_opens_on_that_write` + +**improvise-p6v6**, found by the #227 review at `de4a7d78`. It predates #227: +the reviewer reproduced it against gh/main `202e7eac`. Fixture: `precise_host`, +cursor (0,1) on North/Revenue = 2 (the rightmost column), window 2×2. + +1. `i 9` are answered one at a time. +2. `Tab` (writes 29), `Escape` and `i` go out through `sent`, and none is + answered until all three are out. +3. The browser's buffers must then equal the TUI host's. +4. `5 Enter` must store 295 on both ends. + +Red at `de4a7d78`: the browser buffer was `"2"` where the TUI's was `"29"`. `i` +reaches `edit_or_drill`, which called `opened_on(cell.edit_text)` with no +in-flight check. mdfc had put that check inside `advanced` alone. + +**Rivals:** +- **The fix reverted** (the code at `de4a7d78`): fails this test only. +- **`advanced` defers wholly to `opened_on`** (its own `writes` half dropped, + on the reasoning that `opened_on` now checks): fails + `a_commit_that_prunes_its_row_reopens_on_the_row_that_moved_up`, both + `tab_at_the_rightmost_column_*` tests, and + `resolve::tests::enter_commits_the_cell_advances_and_re_enters_editing`. The + commit being resolved is not in `in_flight` yet, so `opened_on` cannot see its + write. diff --git a/crates/improvise-protocol/src/resolve.rs b/crates/improvise-protocol/src/resolve.rs index df46c7d8..eaf2fc7e 100644 --- a/crates/improvise-protocol/src/resolve.rs +++ b/crates/improvise-protocol/src/resolve.rs @@ -781,7 +781,11 @@ fn edit_or_drill(state: &ClientState, mode: AppMode) -> Option> { match cell.id.as_ref()? { // `edit_text`, never `text`: the drawn text has been through // `format_f64`. `tests/lockstep.rs` found exactly that. - CellId::Record(_) => Some(opened_on(cell.edit_text.clone().unwrap_or_default(), mode)), + CellId::Record(_) => Some(opened_on( + state, + cell.edit_text.clone().unwrap_or_default(), + mode, + )), CellId::Aggregate(_) => Some(vec![Command::SetStatus(format!( "an aggregate is edited by drilling in — {}", not_yet("drill") @@ -796,9 +800,18 @@ fn edit_or_drill(state: &ClientState, mode: AppMode) -> Option> { } } -/// Open the cell editor on `seed`: the buffer, what it captured, then the mode. -/// `EditOrDrill`'s and `EnterEditAtCursor`'s shared shape (improvise-ixxx). -fn opened_on(seed: String, mode: AppMode) -> Vec { +/// Open the cell editor on `seed`, or await it while any write is unanswered: the +/// cache predates that write (improvise-mdfc/p6v6, `protocol.md` §3). +fn opened_on(state: &ClientState, seed: String, mode: AppMode) -> Vec { + match state.in_flight.iter().any(|sent| sent.awaited) { + true => awaiting_seed(mode), + false => seeded(seed, mode), + } +} + +/// The buffer, what the editor captured, then the mode: `EditOrDrill`'s and +/// `EnterEditAtCursor`'s shared shape (improvise-ixxx). +fn seeded(seed: String, mode: AppMode) -> Vec { vec![ Command::SetBuffer { name: "edit".to_string(), @@ -1009,12 +1022,11 @@ fn advanced( return Some(Vec::new()); }; let mut commands = scrolled_to(state, nr, nc); - // After a write — this frame's or an unanswered earlier one's — the cache may - // be stale, so the seed waits for the host (improvise-3uzg/mdfc, `protocol.md` §3). - let stale = writes || state.in_flight.iter().any(|sent| sent.awaited); - commands.extend(match stale { + // This frame's own write makes the cache stale too; `opened_on` answers for + // earlier ones (improvise-3uzg/mdfc/p6v6, `protocol.md` §3). + commands.extend(match writes { true => awaiting_seed(edit_mode), - false => opened_on(seed_at(state, nr, nc), edit_mode), + false => opened_on(state, seed_at(state, nr, nc), edit_mode), }); Some(commands) } diff --git a/crates/improvise-wasm-client/tests/lockstep.rs b/crates/improvise-wasm-client/tests/lockstep.rs index 5ca971a8..c298ffc9 100644 --- a/crates/improvise-wasm-client/tests/lockstep.rs +++ b/crates/improvise-wasm-client/tests/lockstep.rs @@ -2637,6 +2637,55 @@ fn a_tab_sent_before_the_last_write_is_answered_reopens_on_that_write() { } } +/// BUG (improvise-p6v6) — see `context/test-evidence/clients.md`. +#[test] +fn an_editor_opened_with_i_before_the_last_write_is_answered_opens_on_that_write() { + let window = (2, 2); + let mut client_host = precise_host(improvise_protocol::CellValue::Number(4.0)); + client_host.cursor_mut().selected = (0, 1); + let mut key_host = precise_host(improvise_protocol::CellValue::Number(4.0)); + key_host.cursor_mut().selected = (0, 1); + let mut client = client_ready_to_edit(&client_host, window); + for key in ["i", "9"] { + let event = BrowserKey::new(key).to_key_event().unwrap(); + sent_and_answered(&mut client, &mut client_host, event, window); + reduce_key(&mut key_host, event); + } + // Tab writes 29; Esc and i reopen before its answer comes back. + let answers: Vec = ["Tab", "Escape", "i"] + .into_iter() + .map(|key| { + let event = BrowserKey::new(key).to_key_event().unwrap(); + reduce_key(&mut key_host, event); + sent(&mut client, &mut client_host, event, window) + }) + .collect(); + let awaited: Vec = answers.iter().map(|a| a.awaited).collect(); + assert_eq!(awaited, [true, false, false], "setup: only the Tab writes"); + answers + .into_iter() + .for_each(|answer| answered(&mut client, answer)); + assert_eq!( + client.view.buffers, key_host.view_state.buffers, + "i opened on the cache the unanswered write had made stale" + ); + for key in ["5", "Enter"] { + let event = BrowserKey::new(key).to_key_event().unwrap(); + sent_and_answered(&mut client, &mut client_host, event, window); + reduce_key(&mut key_host, event); + } + for (end, app) in [("browser", &client_host), ("host", &key_host)] { + assert_eq!( + app.model_state + .workbook + .model + .get_cell(®ion_measure("North", "Revenue")), + Some(&improvise_protocol::CellValue::Number(295.0)), + "{end}: the browser overwrote the value Tab had just written" + ); + } +} + const FORMULAS: [&str; 3] = [ "Profit = Revenue * 2", "Tax = Revenue * 0.1", From 69764bd52b5ddb2fde2cf17bd2e6b1af0535a908 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 17:41:36 -0700 Subject: [PATCH 32/48] improvise-enz6: red tests, the re-opened editor lands on a fold or a formula's cell Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- .../improvise-command/src/command/cmd/cell.rs | 31 +++++++++++++++++++ .../improvise-command/src/command/cmd/grid.rs | 30 ++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/crates/improvise-command/src/command/cmd/cell.rs b/crates/improvise-command/src/command/cmd/cell.rs index 01a2d5b9..449162f1 100644 --- a/crates/improvise-command/src/command/cmd/cell.rs +++ b/crates/improvise-command/src/command/cmd/cell.rs @@ -525,6 +525,37 @@ mod tests { ); } + /// improvise-enz6's formula twin: Tab does not re-open the editor on a cell `i` + /// would decline. See context/test-evidence/command.md. + #[test] + fn tab_advancing_onto_a_formula_cell_does_not_reopen_the_editor_there() { + let tab = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE); + let mut app = formula_app(); + app.cursor_mut().selected = (1, 0); + press(&mut app, 'i'); + app.handle_key(tab).unwrap(); + assert_eq!(app.cursor().selected, (1, 1), "setup: Tab moved to Cost"); + assert!( + app.view_state.mode.is_editing(), + "the control: Cost is stored, so the editor re-opens there" + ); + app.handle_key(tab).unwrap(); + assert_eq!(app.cursor().selected, (1, 2), "Tab still moves onto Profit"); + assert!( + !app.view_state.mode.is_editing(), + "an editor was opened on the formula: {:?}", + app.view_state.mode + ); + assert_eq!(app.view_state.opened_editor, None, "no editor was captured"); + assert!( + app.view_state + .status_msg + .contains("Profit is computed by a formula"), + "the decline is out loud, got {:?}", + app.view_state.status_msg + ); + } + /// The second door onto a formula cell: Tab advances the editor onto it, where /// `i` would have declined. See context/test-evidence/command.md. #[test] diff --git a/crates/improvise-command/src/command/cmd/grid.rs b/crates/improvise-command/src/command/cmd/grid.rs index 24a2fb65..4f24bf8e 100644 --- a/crates/improvise-command/src/command/cmd/grid.rs +++ b/crates/improvise-command/src/command/cmd/grid.rs @@ -66,6 +66,36 @@ mod tests { assert_eq!(non_item_coordinates(&back), Vec::<(String, String)>::new()); } + /// improvise-enz6, the entry half: the editor that Enter re-opens is not opened + /// on a fold. See context/test-evidence/command.md. + #[test] + fn enter_advancing_onto_a_fold_does_not_reopen_the_editor_there() { + use crate::command::key::{KeyCode, KeyEvent, KeyModifiers}; + let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE); + let mut app = app_on_a_fold(); + app.cursor_mut().selected = (0, 1); + press(&mut app, 'i'); + app.handle_key(enter).unwrap(); + assert_eq!(app.cursor().selected, (1, 1), "setup: Enter moved to Feb"); + assert!( + app.view_state.mode.is_editing(), + "the control: Feb is an item, so the editor re-opens there" + ); + app.handle_key(enter).unwrap(); + assert_eq!(app.cursor().selected, (2, 1), "Enter still moves onto the fold"); + assert!( + !app.view_state.mode.is_editing(), + "an editor was opened on the fold: {:?}", + app.view_state.mode + ); + assert_eq!(app.view_state.opened_editor, None, "no editor was captured"); + assert!( + app.view_state.status_msg.contains("ollapsed group"), + "the decline is out loud, got: {:?}", + app.view_state.status_msg + ); + } + /// The control for the refusal above: the same key one row up drills. #[test] fn drilling_into_the_row_above_a_fold_still_opens_a_drill() { From f4c0feec2ce27b811b5e7897b6dfe1000e368e1e Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 17:49:44 -0700 Subject: [PATCH 33/48] Close the entry half of improvise-enz6: no editor re-opens on a cell i declines EnterEditAtCursor asks core::uneditable, the commit door's own question, now shared with committable_cell_key. The browser's advance asks the projected id at the landing cell; lockstep pins both ends on a fold and on a formula's cell. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- .../improvise-command/src/command/cmd/cell.rs | 1 - .../improvise-command/src/command/cmd/core.rs | 26 +++-- .../improvise-command/src/command/cmd/grid.rs | 7 +- crates/improvise-command/src/ui/effect.rs | 17 +++- crates/improvise-protocol/src/resolve.rs | 49 ++++++--- .../improvise-wasm-client/tests/lockstep.rs | 99 ++++++++++++++----- 6 files changed, 145 insertions(+), 54 deletions(-) diff --git a/crates/improvise-command/src/command/cmd/cell.rs b/crates/improvise-command/src/command/cmd/cell.rs index 449162f1..c1a6c23d 100644 --- a/crates/improvise-command/src/command/cmd/cell.rs +++ b/crates/improvise-command/src/command/cmd/cell.rs @@ -546,7 +546,6 @@ mod tests { "an editor was opened on the formula: {:?}", app.view_state.mode ); - assert_eq!(app.view_state.opened_editor, None, "no editor was captured"); assert!( app.view_state .status_msg diff --git a/crates/improvise-command/src/command/cmd/core.rs b/crates/improvise-command/src/command/cmd/core.rs index e3f9e876..a30cdedf 100644 --- a/crates/improvise-command/src/command/cmd/core.rs +++ b/crates/improvise-command/src/command/cmd/core.rs @@ -27,6 +27,22 @@ pub fn derived_refusal(target: &str) -> String { format!("{target} is computed by a formula — edit the formula, not the cell") } +/// Why no editor may open on or commit to `(row, col)`: an aggregate (a fold +/// named as one, §14) or a formula's result. `i` declines the same two arms. +pub fn uneditable(model: &Model, layout: &GridLayout, row: usize, col: usize) -> Option { + match crate::ui::project::cell_id(model, layout, row, col) { + Some(CellId::Aggregate(_)) => Some( + match layout.folds_a_group(row, col) { + true => FOLD_REFUSAL, + false => AGGREGATE_REFUSAL, + } + .to_string(), + ), + Some(CellId::Derived { target }) => Some(derived_refusal(&target)), + Some(CellId::Record(_) | CellId::Create(_) | CellId::Coordinate) | None => None, + } +} + /// Read-only context available to commands for decision-making: a `&Model`, the /// active `&View`, and the `&Workbook` for the rare command that enumerates views. pub struct CmdContext<'a> { @@ -99,12 +115,10 @@ impl<'a> CmdContext<'a> { /// The cursor's key for a commit, which resolves a records position itself /// (gotcha 19). An aggregate or a formula's result is refused, as `i` refuses it. pub fn committable_cell_key(&self) -> Result { - match self.cursor_cell_id() { - Some(CellId::Aggregate(_)) => Err(self.aggregate_refusal()), - Some(CellId::Derived { target }) => Err(derived_refusal(&target)), - Some(CellId::Record(_) | CellId::Create(_) | CellId::Coordinate) | None => { - self.cell_key().ok_or_else(|| NO_CELL.to_string()) - } + let (row, col) = self.selected; + match uneditable(self.model, self.layout, row, col) { + Some(refusal) => Err(refusal), + None => self.cell_key().ok_or_else(|| NO_CELL.to_string()), } } diff --git a/crates/improvise-command/src/command/cmd/grid.rs b/crates/improvise-command/src/command/cmd/grid.rs index 4f24bf8e..54a67061 100644 --- a/crates/improvise-command/src/command/cmd/grid.rs +++ b/crates/improvise-command/src/command/cmd/grid.rs @@ -82,13 +82,16 @@ mod tests { "the control: Feb is an item, so the editor re-opens there" ); app.handle_key(enter).unwrap(); - assert_eq!(app.cursor().selected, (2, 1), "Enter still moves onto the fold"); + assert_eq!( + app.cursor().selected, + (2, 1), + "Enter still moves onto the fold" + ); assert!( !app.view_state.mode.is_editing(), "an editor was opened on the fold: {:?}", app.view_state.mode ); - assert_eq!(app.view_state.opened_editor, None, "no editor was captured"); assert!( app.view_state.status_msg.contains("ollapsed group"), "the decline is out loud, got: {:?}", diff --git a/crates/improvise-command/src/ui/effect.rs b/crates/improvise-command/src/ui/effect.rs index e8fe85d3..3a1b796b 100644 --- a/crates/improvise-command/src/ui/effect.rs +++ b/crates/improvise-command/src/ui/effect.rs @@ -324,6 +324,20 @@ pub struct EnterEditAtCursor { } impl Effect for EnterEditAtCursor { fn apply(&self, app: &mut App) { + let (row, col) = app.cursor().selected; + let model = &app.model_state.workbook.model; + match crate::command::cmd::core::uneditable(model, &app.layout, row, col) { + Some(refusal) => app.view_state.status_msg = refusal, + None => self.open(app), + } + } + fn kind(&self) -> EffectKind { + EffectKind::ViewOnly + } +} +impl EnterEditAtCursor { + /// Open the editor on the cursor's cell, seeded with its unformatted value. + fn open(&self, app: &mut App) { let value = app.display_value_at_cursor(); app.view_state .buffers @@ -331,9 +345,6 @@ impl Effect for EnterEditAtCursor { app.view_state.opened_editor = Some(improvise_protocol::OpenedEditor::on(value)); app.view_state.mode = self.target_mode.clone(); } - fn kind(&self) -> EffectKind { - EffectKind::ViewOnly - } } #[derive(Debug)] diff --git a/crates/improvise-protocol/src/resolve.rs b/crates/improvise-protocol/src/resolve.rs index 981b5227..c34200f2 100644 --- a/crates/improvise-protocol/src/resolve.rs +++ b/crates/improvise-protocol/src/resolve.rs @@ -786,9 +786,7 @@ fn edit_or_drill(state: &ClientState, mode: AppMode) -> Option> { "an aggregate is edited by drilling in — {}", not_yet("drill") ))]), - CellId::Derived { target } => Some(vec![Command::SetStatus(format!( - "{target} is computed by a formula — edit the formula, not the cell" - ))]), + CellId::Derived { target } => Some(vec![Command::SetStatus(derived_decline(target))]), // Both creating columns are records-only, so the guard above takes // them; declining rather than `None` keeps that a door, not a hole. CellId::Create(_) => Some(vec![Command::SetStatus(not_yet("creating a column"))]), @@ -993,9 +991,8 @@ fn advanced(state: &ClientState, edit_mode: AppMode, advance: Advance) -> Option return Some(Vec::new()); }; let mut commands = scrolled_to(state, nr, nc); - // `EnterEditAtCursor`: seed from where we landed, then change mode. The - // new cell is a different cell, so a stale cache is correct here. - commands.extend(opened_on(seed_at(state, nr, nc), edit_mode)); + // The new cell is a different cell, so a stale cache is correct here. + commands.extend(reopened_at(state, nr, nc, edit_mode)); Some(commands) } @@ -1018,20 +1015,40 @@ pub(crate) fn edit_seed(state: &ClientState) -> Option { /// The display text at an absolute grid position, as the cache holds it. fn seed_at(state: &ClientState, row: usize, col: usize) -> String { - let cursor = state.view.cursor(); - let Some(r) = row.checked_sub(cursor.row_offset) else { - return String::new(); - }; - let Some(c) = col.checked_sub(cursor.col_offset) else { - return String::new(); - }; - state - .grid - .cell(r, c) + cell_at(state, row, col) .and_then(|cell| cell.edit_text.clone()) .unwrap_or_default() } +/// The projected cell at an absolute grid position, `None` outside the window. +fn cell_at(state: &ClientState, row: usize, col: usize) -> Option<&crate::CellDisplay> { + let cursor = state.view.cursor(); + let r = row.checked_sub(cursor.row_offset)?; + let c = col.checked_sub(cursor.col_offset)?; + state.grid.cell(r, c) +} + +/// `EnterEditAtCursor`: re-open the editor where the advance landed, unless `i` +/// would decline that cell. The host asks `uneditable`; this asks the same id. +fn reopened_at(state: &ClientState, row: usize, col: usize, mode: AppMode) -> Vec { + match cell_at(state, row, col).and_then(|cell| cell.id.as_ref()) { + Some(CellId::Aggregate(_)) => vec![Command::SetStatus(AGGREGATE_DECLINE.to_string())], + Some(CellId::Derived { target }) => vec![Command::SetStatus(derived_decline(target))], + Some(CellId::Record(_) | CellId::Create(_) | CellId::Coordinate) | None => { + opened_on(seed_at(state, row, col), mode) + } + } +} + +/// What the host says of an aggregate, the fold of a collapsed group included. +const AGGREGATE_DECLINE: &str = + "Cannot write to an aggregate — it combines several records, so there is no one cell to write"; + +/// What both ends say of a formula's result: the host's `derived_refusal`. +fn derived_decline(target: &str) -> String { + format!("{target} is computed by a formula — edit the formula, not the cell") +} + /// What a cell editor captured as it **opened**, so its commit is judged /// against that and never a seed read again at commit (improvise-ixxx). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/improvise-wasm-client/tests/lockstep.rs b/crates/improvise-wasm-client/tests/lockstep.rs index be6784aa..462e9ab1 100644 --- a/crates/improvise-wasm-client/tests/lockstep.rs +++ b/crates/improvise-wasm-client/tests/lockstep.rs @@ -2091,36 +2091,83 @@ fn p_onto_a_formula_owned_cell_declines_on_both_ends() { /// improvise-gw18/l4mp: `p` and `x` on a collapsed group's fold, off the origin. #[test] fn a_write_onto_a_fold_declines_on_both_ends() { - let fold_host = || { - let mut wb = Workbook::new("Test"); - wb.add_category("Month").unwrap(); - for (month, quarter) in [("Jan", "Q1"), ("Feb", "Q1"), ("Apr", "Q2"), ("May", "Q2")] { - wb.add_item_in_group("Month", month, quarter).unwrap(); - } - wb.add_item("_Measure", "Revenue").unwrap(); - wb.add_item("_Measure", "Cost").unwrap(); - for month in ["Jan", "Feb", "Apr", "May"] { - for measure in ["Revenue", "Cost"] { - let key = improvise_command::model::cell::CellKey::new(vec![ - ("Month".to_string(), month.to_string()), - ("_Measure".to_string(), measure.to_string()), - ]); - wb.model - .set_cell(key, improvise_protocol::CellValue::Number(3.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("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"); - app - }; declines_on_both_ends(fold_host(), (2, 1), 'p'); declines_on_both_ends(fold_host(), (2, 1), 'x'); } +/// Months grouped into quarters on Row, two measures on Column, Q2 collapsed: +/// rows `[Jan, Feb, Q2]`, so the fold is row 2 and off the origin. +fn fold_host() -> App { + let mut wb = Workbook::new("Test"); + wb.add_category("Month").unwrap(); + for (month, quarter) in [("Jan", "Q1"), ("Feb", "Q1"), ("Apr", "Q2"), ("May", "Q2")] { + wb.add_item_in_group("Month", month, quarter).unwrap(); + } + wb.add_item("_Measure", "Revenue").unwrap(); + wb.add_item("_Measure", "Cost").unwrap(); + for month in ["Jan", "Feb", "Apr", "May"] { + for measure in ["Revenue", "Cost"] { + let key = improvise_command::model::cell::CellKey::new(vec![ + ("Month".to_string(), month.to_string()), + ("_Measure".to_string(), measure.to_string()), + ]); + wb.model + .set_cell(key, improvise_protocol::CellValue::Number(3.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("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"); + app +} + +/// improvise-enz6: a commit that advances onto a cell `i` declines re-opens no +/// editor there, on either end. See `context/test-evidence/clients.md`. +#[test] +fn advancing_the_editor_onto_a_cell_i_declines_leaves_both_ends_out_of_it() { + let cases = [ + (fold_host(), (0, 1), "Enter", (2, 1)), + (yank_host(), (1, 1), "Tab", (1, 3)), + ]; + for (mut app, at, key, lands) in cases { + app.cursor_mut().selected = at; + let mut client = client_ready_to_edit(&app, (6, 6)); + for (step, key) in ["i", key, key].into_iter().enumerate() { + let event = BrowserKey::new(key).to_key_event().unwrap(); + let KeyOutcome::Local(cmds) = resolve(&client, event) else { + panic!("{key}: the client must answer this key locally"); + }; + for cmd in &cmds { + client.fold(cmd); + } + reduce_key(&mut app, event); + assert_eq!( + format!("{:?}", client.view.mode), + format!("{:?}", app.view_state.mode), + "step {step} ({key}): client and host disagree about the mode" + ); + assert_eq!(client.view.cursor(), app.view_state.cursor(), "step {step}"); + assert_eq!(client.view.buffers, app.view_state.buffers, "step {step}"); + } + assert_eq!( + app.view_state.cursor().selected, + lands, + "setup: {key} landed" + ); + assert!( + !app.view_state.mode.is_editing() && !client.view.mode.is_editing(), + "{key}: an editor is open on a cell i declines" + ); + assert!( + !client.view.status_msg.is_empty(), + "{key}: the browser declined silently" + ); + } +} + /// Two regions by two measures, `value` at (South, Revenue): row 1, column 1, /// so "the cell under the cursor" is not "the first cell". fn precise_host(value: improvise_protocol::CellValue) -> App { From aab62a362af37b05b4f34e5327e6f823b21e8c9c Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 17:56:41 -0700 Subject: [PATCH 34/48] improvise-enz6: pin the commit door directly, and write up the evidence The advance no longer reaches the commit door on a fold or a formula's cell, so the two older advance tests stopped seeing it (measured: the cell_key rival passes them). Two tests seat the editor there directly. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/clients.md | 15 ++++ context/code/command.md | 22 ++++-- context/test-evidence/clients.md | 31 ++++++++ context/test-evidence/command.md | 71 +++++++++++++++++-- .../improvise-command/src/command/cmd/cell.rs | 18 +++++ .../improvise-command/src/command/cmd/grid.rs | 24 +++++++ 6 files changed, 171 insertions(+), 10 deletions(-) diff --git a/context/code/clients.md b/context/code/clients.md index 73c7cbde..9cf5a5ba 100644 --- a/context/code/clients.md +++ b/context/code/clients.md @@ -516,6 +516,21 @@ 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. +The re-entry is `reopened_at`, and it declines where the host's +`EnterEditAtCursor` declines (improvise-enz6). The host asks +`core::uneditable` of its layout. The browser asks the landing cell's projected +`CellId`, which is the same answer, because `cell_id` is what fills it. Two +limits follow from asking the cache: +- **The wording differs on a fold.** The host can tell a fold from any other + aggregate and names the fold. The browser sees only `CellId::Aggregate`, so it + gives the host's generic aggregate text. `lockstep.rs` compares the mode, the + cursor and the buffers here, never the status text. +- **A landing past the window opens anyway.** The cache holds only the + announced window, so a `Down` from the window's last row lands on a cell with + no projected id. The browser then opens the editor, where the host does not. + No write can follow it: once the host has projected that cell, + `record_at_cursor` finds no record there and the commit composes nothing. + `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 diff --git a/context/code/command.md b/context/code/command.md index 2122603e..a47cef07 100644 --- a/context/code/command.md +++ b/context/code/command.md @@ -678,6 +678,15 @@ 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. +It opens nothing on a cell `i` declines, and says why (improvise-enz6). The +question is `cmd::core::uneditable`, the one `committable_cell_key` asks. A +fold or another aggregate, or a formula's result, gets its refusal text in +the status line. The mode stays where the batch put it. `CommitAndAdvance` +already dropped to normal at its head (gotcha 21), so the user lands on the +fold in normal mode. It is 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. + ### 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 @@ -3417,12 +3426,17 @@ the one writer that must see the synthetic key: renaming a coordinate column, creating a column and staging a drill edit all resolve the records position inside `commit_cell_value`. Its door refuses the two arms `i` refuses, an aggregate (a fold included) and a formula's result. It needed one because -`Enter` and `Tab` advance the editor, and `EnterEditAtCursor` re-opens it at the +`Enter` and `Tab` advance the editor, and `EnterEditAtCursor` re-opened it at the new cursor with no guard. `i` declines to edit `Profit`, yet `Tab` from `Cost` landed the editor on it, and the next commit stored a value under the formula. -That is a fourth door nobody had listed. **The entry half is still open** -(improvise-enz6): the editor still opens on a fold or a formula's cell. Only the -write is refused, out loud, at commit. Closing the entry belongs in `ui/effect.rs`. +That is a fourth door nobody had listed. **The entry half is closed too** +(improvise-enz6). The door's two arms are now one function, +`core::uneditable`, and `EnterEditAtCursor` asks it before opening. So the +re-opened editor and the commit that would follow it cannot disagree about which +cells they decline. The commit door stays: a `:commit-cell-edit` line runs it at +the cursor with no editor opened there. Since the advance no longer reaches it, +the door has tests of its own that seat the editor directly +(`an_editor_already_open_on_a_fold_commits_nothing_there` and its formula twin). **Drill keeps the narrowest door** because drilling into an aggregate is its purpose. Only a fold is refused, since no record carries a group's name. The diff --git a/context/test-evidence/clients.md b/context/test-evidence/clients.md index 1b893acb..94abf6a8 100644 --- a/context/test-evidence/clients.md +++ b/context/test-evidence/clients.md @@ -4578,6 +4578,37 @@ a client that never writes cannot pass. The fold case, at row 2. Rival for all three: the host's paste/clear door restored to `ctx.cell_key()`, which was `gh/main`'s code. All three fail. +### advancing_the_editor_onto_a_cell_i_declines_leaves_both_ends_out_of_it + +improvise-enz6 (and improvise-jmti), both ends. Two cases, each off the +origin, each with a control step: +- `fold_host`: `i` on Jan, `Enter` to Feb, `Enter` onto the Q2 fold at row 2. +- `yank_host`: `i` on North/Revenue, `Tab` to Cost, `Tab` onto Profit at + column 3. + +After every key the client's mode, cursor and buffers must equal the host's. +At the end, neither end may be editing, and the browser must have said +something. + +It was red with the host fixed and the browser not. At step 2 the client was +`Editing` and the host `Normal`. So this is the test that keeps the two ends' +re-entry in step. Before the fix they agreed, both opening the editor, and +nothing compared them on a cell `i` declines. + +The status text is not compared. On a fold the host names the collapsed group, +and the browser, which sees only `CellId::Aggregate`, gives the generic +aggregate text (`context/code/clients.md`). + +Rivals: +- The browser's `reopened_at` opening everywhere: fails at the fold's step 2. +- `reopened_at` opening on an aggregate only: fails on the fold. +- The host opening everywhere: fails, the client now declining where the host + opens. + +One limit is not covered, because the window is 6×6 and every landing is +inside it. A landing past the announced window has no projected id, so the +browser still opens the editor there (`clients.md`). + ### the worker is not called up until its first frame has arrived improvise-i0v9. The worker transport wrote `in-page worker · ` when it diff --git a/context/test-evidence/command.md b/context/test-evidence/command.md index 5960c542..16e78555 100644 --- a/context/test-evidence/command.md +++ b/context/test-evidence/command.md @@ -5344,11 +5344,67 @@ deliberate over-refusal, taken to keep one predicate. ### enter_advancing_onto_a_fold_commits_nothing_there The write half of improvise-enz6, closed by the commit door. `i` on Feb, then -`Enter`, which commits and advances onto the Q2 fold and re-opens the editor -there. That entry is still enz6's open half. Then `7` and `Enter`. At the -previous commit door (`cell_key`) the second commit stored `Month=Q2`. Rival: -`commit_at_cursor` on `cell_key` again. This test and -`tab_advancing_onto_a_formula_cell_commits_nothing_under_it` fail. +`Enter`, which commits and advances onto the Q2 fold. When this test was +written, the advance also re-opened the editor there. That was enz6's open +half, and `enter_advancing_onto_a_fold_does_not_reopen_the_editor_there` now +closes it. Then `7` and `Enter`. At the previous commit door (`cell_key`) the +second commit stored `Month=Q2`. Rival: `commit_at_cursor` on `cell_key` again. +This test and `tab_advancing_onto_a_formula_cell_commits_nothing_under_it` fail. + +**That rival no longer fails either test**, measured after enz6's entry half +closed. The advance now leaves the user in normal mode on the fold, so `7` and +`Enter` never reach the commit door. Both tests still pin the end-to-end +property that the key path writes nothing there. The commit door itself is now +pinned by `an_editor_already_open_on_a_fold_commits_nothing_there` and its +formula twin, which that rival fails. The door is still reachable: a +`:commit-cell-edit` line runs it at the cursor, and a browser editor can open +past its window (`context/code/clients.md`). + +### an_editor_already_open_on_a_fold_commits_nothing_there + +The commit door alone, once improvise-enz6 closed the route the tests above +reached it by. The test seats the editor on the fold directly: mode `Editing`, +buffer `7`, cursor on the fold at (2, 1). Then `Enter`. Nothing may reach `## Data` +with `Q2`, and the status names the collapsed group. Rival: `commit_at_cursor` +back on `cell_key`. This test and the formula twin fail, and the four +advance-path tests pass. + +### an_editor_already_open_on_a_formula_cell_commits_nothing_under_it + +The same door on `formula_app`'s Profit (row 1, column 2): `7` then `Enter` +stores nothing under the formula. The rival is the one above. + +### enter_advancing_onto_a_fold_does_not_reopen_the_editor_there + +The entry half of improvise-enz6. `CommitAndAdvance` ends in `EnterEditAtCursor`, +which opened the editor on whatever cell the advance landed on. `i` declines a +fold, and the re-entry did not. The fixture is `app_on_a_fold`, whose fold sits +at row 2, off row 0. `i` on Jan (row 0, column 1), then `Enter` to Feb. That +first step is the control: Feb is an item, and the editor must re-open there, +so a re-entry that never opens cannot pass. The second `Enter` lands on the +fold, and the test asserts the mode is not editing and that the status names +the collapsed group. + +It was red at `202e7eac` (gh/main) on the mode assertion: `Editing`. A first +version also asserted that `opened_editor` was `None`. It failed after the fix, +because Feb's `OpenedEditor` outlives the commit. Nothing clears it in normal +mode, and every opener overwrites it, so that is not this bug. The assertion +was dropped rather than widening the fix. + +Rivals: +- `EnterEditAtCursor` opening everywhere (the `uneditable` check replaced by + `None`): this test, its formula twin and the lockstep test fail. +- `uneditable` answering `None` for a formula's result: the formula twin, + `tab_advancing_onto_a_formula_cell_commits_nothing_under_it` and the lockstep + test fail. That confirms the entry and the commit door ask one function. + +### tab_advancing_onto_a_formula_cell_does_not_reopen_the_editor_there + +enz6's entry half on the second arm `i` declines, a formula's result. This is the +case improvise-jmti filed separately. `formula_app`, `i` on West/Revenue (row 1, +column 0). `Tab` to Cost is the control, and the editor re-opens there. The +second `Tab` lands on Profit, and the test asserts no editor and the formula's +refusal text. It was red at `202e7eac` on the mode assertion. Rivals: as above. ### x_in_records_mode_clears_the_real_record_under_the_cursor @@ -5380,7 +5436,10 @@ A door the tickets did not list, found by working the enumeration. `i` declines to edit a formula's cell, but `Tab` from West/Cost commits and advances onto Profit, `EnterEditAtCursor` re-opens the editor there, and `Enter` stored `7` under the formula. Found by this test failing at `90b93d4`, before -`committable_cell_key` existed. The rival is the one above. +`committable_cell_key` existed. The rival is the one above. Since +improvise-enz6 the advance no longer opens that editor, so this test is +end-to-end only and the rival passes it. The door is +`an_editor_already_open_on_a_formula_cell_commits_nothing_under_it`. ### ui::host::tests::every_failing_model_command_refuses_its_whole_frame diff --git a/crates/improvise-command/src/command/cmd/cell.rs b/crates/improvise-command/src/command/cmd/cell.rs index c1a6c23d..7bcb3112 100644 --- a/crates/improvise-command/src/command/cmd/cell.rs +++ b/crates/improvise-command/src/command/cmd/cell.rs @@ -525,6 +525,24 @@ mod tests { ); } + /// The commit door alone on a formula's cell, with no advance to reach it by. + /// See context/test-evidence/command.md. + #[test] + fn an_editor_already_open_on_a_formula_cell_commits_nothing_under_it() { + let mut app = formula_app(); + app.cursor_mut().selected = (1, 2); + app.view_state.mode = crate::ui::app::AppMode::editing(); + app.view_state.buffers.insert("edit".into(), "7".into()); + app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) + .unwrap(); + let (text, back) = saved_and_reloaded(&app.model_state.workbook); + assert!( + !text.contains("_Measure=Profit"), + "saved under the formula:\n{text}" + ); + assert_eq!(back.model.get_cell(&record("West", "Profit")), None); + } + /// improvise-enz6's formula twin: Tab does not re-open the editor on a cell `i` /// would decline. See context/test-evidence/command.md. #[test] diff --git a/crates/improvise-command/src/command/cmd/grid.rs b/crates/improvise-command/src/command/cmd/grid.rs index 54a67061..083a5c6a 100644 --- a/crates/improvise-command/src/command/cmd/grid.rs +++ b/crates/improvise-command/src/command/cmd/grid.rs @@ -99,6 +99,30 @@ mod tests { ); } + /// The commit door alone, now the advance no longer opens an editor on a fold. + /// See context/test-evidence/command.md. + #[test] + fn an_editor_already_open_on_a_fold_commits_nothing_there() { + use crate::command::key::{KeyCode, KeyEvent, KeyModifiers}; + let mut app = app_on_a_fold(); + app.view_state.mode = AppMode::editing(); + app.view_state.buffers.insert("edit".into(), "7".into()); + app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) + .unwrap(); + let (text, back) = saved_and_reloaded(&app.model_state.workbook); + let data = text.split("## Data").nth(1).expect("a data section"); + assert!( + !data.contains("Q2"), + "a group coordinate was saved:\n{text}" + ); + assert_eq!(non_item_coordinates(&back), Vec::<(String, String)>::new()); + assert!( + app.view_state.status_msg.contains("ollapsed group"), + "the refusal is out loud, got: {:?}", + app.view_state.status_msg + ); + } + /// The control for the refusal above: the same key one row up drills. #[test] fn drilling_into_the_row_above_a_fold_still_opens_a_drill() { From 08f84c25e2e2c76004c276540a102062f7dcd056 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 18:16:29 -0700 Subject: [PATCH 35/48] improvise-jq0m: a small span narrows per covered item, not by the whole category; grouped bench cases Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- benches/grid_projection.rs | 53 ++++++++++- crates/improvise-core/src/model/cell.rs | 113 ++++++++++++++++++++++-- 2 files changed, 157 insertions(+), 9 deletions(-) diff --git a/benches/grid_projection.rs b/benches/grid_projection.rs index 1f3fd08f..946610e4 100644 --- a/benches/grid_projection.rs +++ b/benches/grid_projection.rs @@ -54,6 +54,50 @@ fn dense_app(regions: usize, products: usize, measures: usize) -> App { app } +/// Days in collapsed groups of `per_group` on Row, with Product: every row is a +/// fold over a few days, never the whole category (improvise-jq0m). +fn grouped_app(days: usize, per_group: usize, products: usize, measures: usize) -> App { + let mut wb = Workbook::new("Grouped"); + wb.add_category("Day").unwrap(); + wb.add_category("Product").unwrap(); + for d in 0..days { + let group = format!("G{}", d / per_group); + wb.add_item_in_group("Day", format!("Day{d}"), group).unwrap(); + } + for p in 0..products { + wb.add_item("Product", format!("Product{p}")).unwrap(); + } + for m in 0..measures { + wb.add_item("_Measure", format!("Measure{m}")).unwrap(); + } + { + let v = wb.active_view_mut(); + 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 { + for p in 0..products { + for m in 0..measures { + n += 1.0; + let key = CellKey::new(vec![ + ("Day".to_string(), format!("Day{d}")), + ("Product".to_string(), format!("Product{p}")), + ("_Measure".to_string(), format!("Measure{m}")), + ]); + wb.model.set_cell(key, CellValue::Number(n)); + } + } + } + let mut app = App::new(wb, None); + app.rebuild_layout(); + app +} + fn whole_grid(app: &App) -> Viewport { Viewport { row_offset: 0, @@ -105,7 +149,10 @@ fn time_widths(iters: u32, app: &App) -> Duration { } fn case(label: &str, regions: usize, products: usize, measures: usize, iters: u32) { - let app = dense_app(regions, products, measures); + report(label, dense_app(regions, products, measures), iters); +} + +fn report(label: &str, app: App, iters: u32) { let rows = app.layout.row_count(); let cols = app.layout.col_count(); let cells = rows * cols; @@ -139,4 +186,8 @@ fn main() { case("large", 60, 20, 8, 50); case("18k cells (gotcha 41)", 150, 20, 6, 20); case("pathological", 300, 30, 10, 5); + println!("\ncollapsed small groups on the row axis (improvise-jq0m)\n"); + report("364 days in weeks", grouped_app(364, 7, 20, 6), 5); + report("364 days in pairs", grouped_app(364, 2, 20, 6), 5); + report("1000 days in pairs", grouped_app(1000, 2, 10, 4), 3); } diff --git a/crates/improvise-core/src/model/cell.rs b/crates/improvise-core/src/model/cell.rs index 38121786..19254613 100644 --- a/crates/improvise-core/src/model/cell.rs +++ b/crates/improvise-core/src/model/cell.rs @@ -378,16 +378,30 @@ 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 { - // Intern the partial key (lookup only, no new symbols) - let interned_partial: Vec<(Symbol, Symbol)> = partial + self.intern_partial(partial) + .map(|pairs| self.positions_of(&pairs)) + .unwrap_or_default() + } + + /// `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> { + partial .iter() - .filter_map(|(c, i)| Some((self.symbols.get(c)?, self.symbols.get(i)?))) - .collect(); + .map(|(c, i)| Some((self.symbols.get(c)?, self.symbols.get(i)?))) + .collect() + } - if interned_partial.len() < partial.len() { - return vec![]; - } + /// How many candidates narrowing by `pairs` would visit: its smallest index set. + fn narrowing_cost(&self, pairs: &[(Symbol, Symbol)]) -> usize { + pairs + .iter() + .map(|pair| self.index.get(pair).map_or(0, HashSet::len)) + .min() + .unwrap_or(self.cells.len()) + } + fn positions_of(&self, interned_partial: &[(Symbol, Symbol)]) -> Vec { let mut sets: Vec<&HashSet> = interned_partial .iter() .filter_map(|pair| self.index.get(pair)) @@ -450,7 +464,7 @@ impl DataStore { .iter() .all(|(cat, items)| ikey.0.iter().any(|(c, i)| c == cat && items.contains(i))) }; - self.matching_positions(partial) + self.spanned_positions(partial, &wanted) .into_iter() .filter_map(|n| self.cells.get_index(n)) .filter(|(ikey, _)| admits(ikey)) @@ -458,6 +472,35 @@ impl DataStore { .collect() } + /// Candidates for a span: narrowed once by `partial`, or once per covered item + /// of its smallest span, whichever visits fewer. Sorted (improvise-jq0m). + fn spanned_positions( + &self, + partial: &[(String, String)], + wanted: &[(Symbol, HashSet)], + ) -> Vec { + self.intern_partial(partial) + .map(|base| self.narrowed(&base, wanted)) + .unwrap_or_default() + } + + fn narrowed(&self, base: &[(Symbol, Symbol)], wanted: &[(Symbol, HashSet)]) -> Vec { + let with = |cat: Symbol, item: Symbol| [base, &[(cat, item)]].concat(); + let per_item: Vec> = wanted + .iter() + .min_by_key(|(_, items)| items.len()) + .map(|(cat, items)| items.iter().map(|i| with(*cat, *i)).collect()) + .unwrap_or_default(); + let item_cost: usize = per_item.iter().map(|p| self.narrowing_cost(p)).sum(); + let narrow_per_item = !per_item.is_empty() && item_cost < self.narrowing_cost(base); + let mut positions: Vec = match narrow_per_item { + true => per_item.iter().flat_map(|p| self.positions_of(p)).collect(), + false => self.positions_of(base), + }; + positions.sort_unstable(); + positions + } + /// Every cell matching `partial`, in insertion order. Allocates a /// `CellKey` per match — prefer `matching_values` when values suffice. #[allow(dead_code)] @@ -1008,6 +1051,60 @@ mod prop_tests { "returned key {result_key} does not match partial {partial:?}"); } } + + /// improvise-jq0m: whichever narrowing it picks, a span returns what a lookup + /// per member returns, in store order. context/test-evidence/core.md. + #[test] + fn a_span_finds_what_its_members_find_in_store_order( + cells in prop::collection::vec((0u8..6, 0u8..3, 0u8..2, any::()), 0..40), + covered_r in prop::collection::vec(any::(), 7), + covered_p in prop::option::of(prop::collection::vec(any::(), 3)), + base_m in 0u8..2, + exact in any::(), + ) { + let mut store = DataStore::new(); + for (n, (r, p, m, extra)) in cells.iter().enumerate() { + let mut key = CellKey::new(vec![ + ("P".into(), format!("p{p}")), + ("M".into(), format!("m{m}")), + ]); + // r = 5 leaves the record without the spanned category at all. + key = match r { 5 => key, r => key.with("R", format!("r{r}")) }; + key = match extra { true => key.with("X", "x"), false => key }; + store.set(key, CellValue::Number(n as f64)); + } + // r6 is never stored, so it is a covered item the store never interned. + let pick = |prefix: &str, flags: &[bool]| -> Vec { + flags.iter().enumerate().filter(|(_, f)| **f) + .map(|(i, _)| format!("{prefix}{i}")).collect() + }; + let rs = pick("r", &covered_r); + let ps = covered_p.as_deref().map(|f| pick("p", f)); + let base = CellKey::new(vec![("M".into(), format!("m{base_m}"))]); + let mut spans: Vec<(&str, Vec<&str>)> = vec![("R", rs.iter().map(String::as_str).collect())]; + if let Some(ps) = &ps { + spans.push(("P", ps.iter().map(String::as_str).collect())); + } + let mut members = vec![base.clone()]; + for (cat, items) in &spans { + members = members.iter() + .flat_map(|k| items.iter().map(|i| k.clone().with(*cat, *i))) + .collect(); + } + let matched: std::collections::HashSet = members.iter() + .flat_map(|k| match exact { + true => store.get(k).map(|_| k.clone()).into_iter().collect::>(), + false => store.matching_cells(&k.0).into_iter().map(|(k, _)| k).collect(), + }) + .collect(); + let expected: Vec = store.iter_cells() + .filter(|(k, _)| matched.contains(k)) + .map(|(_, v)| v.clone()) + .collect(); + let got: Vec = store.spanned_values(&base.0, &spans, exact) + .into_iter().cloned().collect(); + prop_assert_eq!(got, expected); + } } } From 3f9f74a7dab599e1b66605988e209220e12a7593 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 18:21:52 -0700 Subject: [PATCH 36/48] improvise-i5z6: red test, formula.pest is re-parsed per generated case Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- crates/improvise-formula/src/parser.rs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/crates/improvise-formula/src/parser.rs b/crates/improvise-formula/src/parser.rs index 38498fbf..e2f7d207 100644 --- a/crates/improvise-formula/src/parser.rs +++ b/crates/improvise-formula/src/parser.rs @@ -1167,9 +1167,15 @@ mod generator { use pest_meta::parser; use proptest::prelude::*; use std::collections::HashMap; + use std::sync::atomic::{AtomicUsize, Ordering}; + + /// Counts how often formula.pest is actually parsed; the guard below keeps it + /// from becoming a per-case parse again. + static GRAMMAR_PARSES: AtomicUsize = AtomicUsize::new(0); /// Parse formula.pest and return rules keyed by name. fn load_grammar() -> HashMap { + GRAMMAR_PARSES.fetch_add(1, Ordering::Relaxed); let grammar = include_str!("formula.pest"); let pairs = parser::parse(parser::Rule::grammar_rules, grammar) .unwrap_or_else(|e| panic!("Bad grammar: {e}")); @@ -1314,6 +1320,25 @@ mod generator { Gen::new(&rules, choices).generate("expr_eoi") }) } + + /// improvise-i5z6. Evidence: `context/test-evidence/formula-and-io.md`. + #[test] + fn the_grammar_is_parsed_once_however_many_formulas_the_strategies_yield() { + use proptest::strategy::ValueTree; + use proptest::test_runner::TestRunner; + + let mut runner = TestRunner::deterministic(); + let (formulas, exprs) = (formula_string(), expr_string()); + for _ in 0..32 { + let _ = formulas.new_tree(&mut runner).unwrap().current(); + let _ = exprs.new_tree(&mut runner).unwrap().current(); + } + assert_eq!( + GRAMMAR_PARSES.load(Ordering::Relaxed), + 1, + "formula.pest was re-parsed per generated case" + ); + } } #[cfg(test)] From b1715f49b4b4c70f9617e3503b08921f9d8f3d8b Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 18:23:45 -0700 Subject: [PATCH 37/48] improvise-i5z6: parse formula.pest once per test process The two generator strategies re-parsed the grammar inside their prop_map, once per generated case: the same defect improvise-8shu fixed in improvise-io. A OnceLock takes the lib test binary from ~0.9s to ~0.06s on the same machine and load. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/test-evidence/formula-and-io.md | 35 +++++++++++++++++++++++++ crates/improvise-formula/src/parser.rs | 20 ++++++++------ 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/context/test-evidence/formula-and-io.md b/context/test-evidence/formula-and-io.md index 536effb1..3f39c31a 100644 --- a/context/test-evidence/formula-and-io.md +++ b/context/test-evidence/formula-and-io.md @@ -1197,6 +1197,41 @@ 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. +### parser::generator::the_grammar_is_parsed_once_however_many_formulas_the_strategies_yield + +improvise-i5z6 is improvise-8shu's twin, in `improvise-formula`. Both +generator strategies, `formula_string()` and `expr_string()`, called +`load_grammar()` inside their `prop_map`. So `pest_meta` re-parsed +`formula.pest` for every generated case: three 256-case properties plus +shrinks. The `improvise-io` fix never reached this crate, which has its own +copy of the generator. + +All figures were measured on an Apple M4 Max (16 cores) under heavy parallel +load (load average 15–33), because an unloaded window was not available. The +ratios are more trustworthy than the seconds. + +- **Before:** the lib binary was the **longest binary in all three workspace + runs**, at 0.91s, 1.02s and 1.95s. The runner-up was 0.50–0.92s, and a fourth + run gave 3.13s. The median binary reports `0.00s` at two decimals, so the + ratio to the median is not defined. By any finite reading it is two orders + of magnitude above the median. Alone, the binary took 0.84–0.98s over five + runs. The three generator properties alone took 0.94–1.03s, and the other 82 + tests together took 0.01s. +- **After:** the binary alone takes 0.06–0.07s over five runs, and + 0.06–0.09s inside the workspace run. `headless_cli`, which spawns processes, + is now the longest binary at 0.49–0.75s. + +The guard copies 8shu's shape. It counts **parses**, not calls, so it does not +depend on test order. It drives both strategies through a `TestRunner` rather +than re-implementing them. Red before the fix with `left: 64, right: 1`. + +Rivals, one per strategy: restoring `let rules = load_grammar();` in either +`formula_string` or `expr_string` alone fails the test with `left: 33`. So the +two strategies are guarded independently. + +The case count stays 256. It is already the default that +`context/design-principles.md` §7 prescribes, and it was never the cost. + ### export_csv_omits_the_rows_the_view_prunes improvise-iot6. `render_csv` built its layout with `GridLayout::new`, which does diff --git a/crates/improvise-formula/src/parser.rs b/crates/improvise-formula/src/parser.rs index e2f7d207..c582483c 100644 --- a/crates/improvise-formula/src/parser.rs +++ b/crates/improvise-formula/src/parser.rs @@ -1167,12 +1167,20 @@ mod generator { use pest_meta::parser; use proptest::prelude::*; use std::collections::HashMap; + use std::sync::OnceLock; use std::sync::atomic::{AtomicUsize, Ordering}; /// Counts how often formula.pest is actually parsed; the guard below keeps it /// from becoming a per-case parse again. static GRAMMAR_PARSES: AtomicUsize = AtomicUsize::new(0); + /// The parsed grammar, read once for the process. Evidence: + /// `context/test-evidence/formula-and-io.md`. + fn grammar() -> &'static HashMap { + static RULES: OnceLock> = OnceLock::new(); + RULES.get_or_init(load_grammar) + } + /// Parse formula.pest and return rules keyed by name. fn load_grammar() -> HashMap { GRAMMAR_PARSES.fetch_add(1, Ordering::Relaxed); @@ -1307,18 +1315,14 @@ mod generator { /// Proptest strategy: generate a random valid formula by walking the /// grammar's `formula` rule. pub fn formula_string() -> impl Strategy { - prop::collection::vec(any::(), 32..=128).prop_map(|choices| { - let rules = load_grammar(); - Gen::new(&rules, choices).generate("formula") - }) + prop::collection::vec(any::(), 32..=128) + .prop_map(|choices| Gen::new(grammar(), choices).generate("formula")) } /// Proptest strategy: generate a random valid standalone expression. pub fn expr_string() -> impl Strategy { - prop::collection::vec(any::(), 32..=128).prop_map(|choices| { - let rules = load_grammar(); - Gen::new(&rules, choices).generate("expr_eoi") - }) + prop::collection::vec(any::(), 32..=128) + .prop_map(|choices| Gen::new(grammar(), choices).generate("expr_eoi")) } /// improvise-i5z6. Evidence: `context/test-evidence/formula-and-io.md`. From 500d46092b1b72f992bd88563eedba446cd2e62a Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 18:32:33 -0700 Subject: [PATCH 38/48] improvise-jq0m: pin both narrowings against the per-member definition; record the bench and the evidence Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- benches/grid_projection.rs | 3 +- context/code/command.md | 43 ++++++++++++++++++++++--- context/test-evidence/core.md | 27 ++++++++++++++++ crates/improvise-core/src/model/cell.rs | 6 +++- 4 files changed, 72 insertions(+), 7 deletions(-) diff --git a/benches/grid_projection.rs b/benches/grid_projection.rs index 946610e4..83e73069 100644 --- a/benches/grid_projection.rs +++ b/benches/grid_projection.rs @@ -62,7 +62,8 @@ fn grouped_app(days: usize, per_group: usize, products: usize, measures: usize) wb.add_category("Product").unwrap(); for d in 0..days { let group = format!("G{}", d / per_group); - wb.add_item_in_group("Day", format!("Day{d}"), group).unwrap(); + wb.add_item_in_group("Day", format!("Day{d}"), group) + .unwrap(); } for p in 0..products { wb.add_item("Product", format!("Product{p}")).unwrap(); diff --git a/context/code/command.md b/context/code/command.md index 6ec985ca..8b05d17a 100644 --- a/context/code/command.md +++ b/context/code/command.md @@ -1805,11 +1805,44 @@ one `CellKey` per member, the cross product of every spanned category's covered items (3,000 keys for an 18k column), and looked each one up. Now `group_expansion` returns the expansion as data: a `base` key and the covered items of each expanded category. `fold_stored` hands that to -`DataStore::spanned_values`, which narrows by `base` through the index once and -filters each candidate by membership. In `Exact` mode a candidate must also -carry no coordinate beyond those, which is what `get(member)` answered. Only a -formula still enumerates members (`contexts_of`), since its references are -evaluated per context. +`DataStore::spanned_values`, which narrows through the index and filters each +candidate by membership. In `Exact` mode a candidate must also carry no +coordinate beyond those, which is what `get(member)` answered. Only a formula +still enumerates members (`contexts_of`), since its references are evaluated +per context. + +**How it narrows is a choice, and the first version got it wrong +(improvise-jq0m).** Narrowing by `base` alone is right for a column total, which +spans *every* item of each row category. It is wrong for a collapsed group, +which spans two or seven items of a category with hundreds: `base` drops the +grouped category entirely, so every fold cell scanned every record of the +column, and a grid of small groups went quadratic. The review measured 1000 +days in pairs at 11–15.7 s a keypress, against 0.44–0.51 s before #239. So +`spanned_positions` now prices both narrowings off the index's own set sizes +and takes the cheaper: once by `base`, or once per covered item of the span +with the fewest items, each intersected with `base`. The per-item positions are +disjoint (a record has one item per category) and are sorted afterwards, so +both paths return the store's order. A `base` naming a name the store never +interned matches nothing on both paths. + +The bench now carries the grouped cases, which it could not see before: Day in +collapsed groups on Row, with Product, and `_Measure` on Column. The machine is +an Apple M4 Max (16 cores, 64 GB) at load average 15–63. The figures are +keypress (`whole-grid + col_widths`), min-of-N, from two alternating rounds, +the second reversed. Each cell gives both rounds: + +| case | before #239 (`5932ad74`) | `f668ddc9` | jq0m | jq0m ÷ before | +|---|---|---|---|---| +| 4.8k dense | 33.9 / 39.1 ms | 40.7 / 40.2 ms | 36.3 / 33.3 ms | 1.07 / 0.85 | +| 18k dense | 237 / 239 ms | 238 / 232 ms | 196 / 211 ms | 0.83 / 0.88 | +| 90k dense | 3.04 / 3.46 s | 3.24 / 2.70 s | 2.68 / 2.84 s | 0.88 / 0.82 | +| 364 days in weeks | 483 / 415 ms | 1.42 / 1.18 s | 356 / 399 ms | 0.74 / 0.96 | +| 364 days in pairs | 715 / 539 ms | 5.02 / 4.62 s | 471 / 518 ms | 0.66 / 0.96 | +| 1000 days in pairs | 406 / 399 ms | 10.8 / 20.2 s | 352 / 364 ms | 0.87 / 0.91 | + +The grouped cases now come in **below** the pre-#239 figures. The old +per-member path looked up each member through a full `CellKey`, and the +per-item narrowing intersects index sets directly. Two levers were **not** taken. Combining the walk's row values for decomposable aggregators was rejected under §5 as a second derivation of the total. It is diff --git a/context/test-evidence/core.md b/context/test-evidence/core.md index 2a30c19a..1df4babf 100644 --- a/context/test-evidence/core.md +++ b/context/test-evidence/core.md @@ -1537,3 +1537,30 @@ Three faults were injected, each run against the full `improvise-core` and (collapsed-group, hidden-row and CSV-agreement tests). - A category the store never interned is skipped instead of matching nothing. **Only this test fails.** + +### a_span_finds_what_its_members_find_in_store_order + +improvise-jq0m. `spanned_values` now chooses between two narrowings, and a +fixed example can only ever take one of them. This property builds random +stores over `R`, `P` and `M`. Some records lack `R`, some carry an extra `X`, +and `r6` is a covered item the store never interned. It spans `R` and sometimes +`P`, in both modes, and compares the result with the per-member definition, +**in store order**: the values the members' own lookups find, read back in +`iter_cells` order. Store and span sizes vary across cases, so both narrowings +are taken. + +Three faults were injected, each run against the full `improvise-core` and +`improvise-command` suites: + +- The per-item narrowing drops the base, so it keeps each item's pair alone. + This test and `a_spanned_scan_finds_what_a_lookup_per_member_finds` fail. + **Nothing in either crate's grid or command tests does.** Those fixtures never + hold a second value of the base's coordinate within a small span. +- The base narrowing drops the base. This test fails, along with eight + fold and totals tests. +- The per-item positions are not sorted after they are merged. **Only this + test fails.** Values stay correct and only their order moves, which changes + a Sum or Avg in the last bits. + +The regression itself is a cost, which no test here can see. The evidence for +it is the `grid_projection` bench's grouped cases, added for this ticket. diff --git a/crates/improvise-core/src/model/cell.rs b/crates/improvise-core/src/model/cell.rs index 19254613..dbae8faa 100644 --- a/crates/improvise-core/src/model/cell.rs +++ b/crates/improvise-core/src/model/cell.rs @@ -484,7 +484,11 @@ impl DataStore { .unwrap_or_default() } - fn narrowed(&self, base: &[(Symbol, Symbol)], wanted: &[(Symbol, HashSet)]) -> Vec { + fn narrowed( + &self, + base: &[(Symbol, Symbol)], + wanted: &[(Symbol, HashSet)], + ) -> Vec { let with = |cat: Symbol, item: Symbol| [base, &[(cat, item)]].concat(); let per_item: Vec> = wanted .iter() From c5e8d7d92222bc322e182faa7269dd6af2afbe12 Mon Sep 17 00:00:00 2001 From: Ed L Date: Wed, 23 Sep 2026 20:05:56 -0700 Subject: [PATCH 39/48] chore: update Cargo.nix --- Cargo.nix | 188 ++++++++++++++++++++++++++++-------------------------- 1 file changed, 97 insertions(+), 91 deletions(-) diff --git a/Cargo.nix b/Cargo.nix index daf3b0be..4478da93 100644 --- a/Cargo.nix +++ b/Cargo.nix @@ -2640,7 +2640,6 @@ rec { { name = "indexmap"; packageId = "indexmap"; - features = [ "serde" ]; } { name = "ratatui"; @@ -2654,6 +2653,7 @@ rec { { name = "serde_json"; packageId = "serde_json"; + features = [ "float_roundtrip" ]; } { name = "thiserror"; @@ -2673,6 +2673,10 @@ rec { name = "improvise-wasm-client"; packageId = "improvise-wasm-client"; } + { + name = "libc"; + packageId = "libc"; + } { name = "pest"; packageId = "pest"; @@ -2735,6 +2739,7 @@ rec { { name = "serde_json"; packageId = "serde_json"; + features = [ "float_roundtrip" ]; } { name = "unicode-width"; @@ -2776,7 +2781,6 @@ rec { { name = "indexmap"; packageId = "indexmap"; - features = [ "serde" ]; } { name = "serde"; @@ -2898,7 +2902,6 @@ rec { { name = "indexmap"; packageId = "indexmap"; - features = [ "serde" ]; } { name = "js-sys"; @@ -2921,6 +2924,7 @@ rec { { name = "serde_json"; packageId = "serde_json"; + features = [ "float_roundtrip" ]; } { name = "wasm-bindgen"; @@ -2975,6 +2979,7 @@ rec { name = "serde_json"; packageId = "serde_json"; optional = true; + features = [ "float_roundtrip" ]; } { name = "unicode-width"; @@ -3011,6 +3016,7 @@ rec { { name = "serde_json"; packageId = "serde_json"; + features = [ "float_roundtrip" ]; } { name = "wasm-bindgen"; @@ -3068,6 +3074,7 @@ rec { { name = "serde_json"; packageId = "serde_json"; + features = [ "float_roundtrip" ]; } { name = "wasm-bindgen"; @@ -3135,6 +3142,7 @@ rec { { name = "serde_json"; packageId = "serde_json"; + features = [ "float_roundtrip" ]; } { name = "tokio"; @@ -3171,27 +3179,6 @@ rec { packageId = "hashbrown 0.17.1"; usesDefaultFeatures = false; } - { - name = "serde"; - packageId = "serde"; - optional = true; - usesDefaultFeatures = false; - target = { target, features }: false; - } - { - name = "serde_core"; - packageId = "serde_core"; - optional = true; - usesDefaultFeatures = false; - } - ]; - devDependencies = [ - { - name = "serde"; - packageId = "serde"; - usesDefaultFeatures = false; - features = [ "derive" ]; - } ]; features = { "arbitrary" = [ "dep:arbitrary" ]; @@ -3202,7 +3189,7 @@ rec { "serde" = [ "dep:serde_core" "dep:serde" ]; "sval" = [ "dep:sval" ]; }; - resolvedDefaultFeatures = [ "default" "serde" "std" ]; + resolvedDefaultFeatures = [ "default" "std" ]; }; "indoc" = rec { crateName = "indoc"; @@ -5655,7 +5642,7 @@ rec { "preserve_order" = [ "indexmap" "std" ]; "std" = [ "memchr/std" "serde_core/std" ]; }; - resolvedDefaultFeatures = [ "default" "std" ]; + resolvedDefaultFeatures = [ "default" "float_roundtrip" "std" ]; }; "sha1" = rec { crateName = "sha1"; @@ -8871,7 +8858,15 @@ rec { ) crateConfigs; target = makeTarget pkgs.stdenv.hostPlatform; - build = mkBuiltByPackageIdByPkgs pkgs.buildPackages; + # Build-time dependency graph (for proc-macros and build + # dependencies). When not cross-compiling it equals the host + # graph, so reuse `self`; otherwise build it for + # `pkgs.buildPackages`. + build = + if pkgs.stdenv.buildPlatform.config == pkgs.stdenv.hostPlatform.config then + self + else + mkBuiltByPackageIdByPkgs pkgs.buildPackages; }; in self; @@ -8887,38 +8882,35 @@ rec { devDependencies = lib.optionals (runTests && packageId == rootPackageId) ( crateConfig'.devDependencies or [ ] ); - dependencies = dependencyDerivations { + # Enabled (platform- and feature-filtered) dependency lists, reused + # for both derivation wiring and the crate renames below. + enabledDependencies = filterEnabledDependencies { inherit features; inherit (self) target; - buildByPackageId = - depPackageId: - # proc_macro crates must be compiled for the build architecture - if crateConfigs.${depPackageId}.procMacro or false then - self.build.crates.${depPackageId} - else - self.crates.${depPackageId}; dependencies = (crateConfig.dependencies or [ ]) ++ devDependencies; }; - buildDependencies = dependencyDerivations { + enabledBuildDependencies = filterEnabledDependencies { inherit features; inherit (self.build) target; - buildByPackageId = depPackageId: self.build.crates.${depPackageId}; dependencies = crateConfig.buildDependencies or [ ]; }; + dependencies = map + ( + dependency: + # proc_macro crates must be compiled for the build architecture + if crateConfigs.${dependency.packageId}.procMacro or false then + self.build.crates.${dependency.packageId} + else + self.crates.${dependency.packageId} + ) + enabledDependencies; + buildDependencies = map + (dependency: self.build.crates.${dependency.packageId}) + enabledBuildDependencies; + # Order (build dependencies, then normal dependencies) feeds the + # crateRenames grouping below. dependenciesWithRenames = - let - buildDeps = filterEnabledDependencies { - inherit features; - inherit (self) target; - dependencies = crateConfig.dependencies or [ ] ++ devDependencies; - }; - hostDeps = filterEnabledDependencies { - inherit features; - inherit (self.build) target; - dependencies = crateConfig.buildDependencies or [ ]; - }; - in - lib.filter (d: d ? "rename") (hostDeps ++ buildDeps); + lib.filter (d: d ? "rename") (enabledBuildDependencies ++ enabledDependencies); # Crate renames have the form: # # { @@ -9090,6 +9082,17 @@ rec { corresponding feature sets are merged. Features in rust are additive. */ mergePackageFeatures = + args: builtins.mapAttrs (_packageId: builtins.attrNames) (mergePackageFeaturesImpl args); + + /* + Core of the feature-resolution fixpoint. The cache (`featuresByPackageId`) + maps each packageId to a feature *set* (an attrset `feature -> 1`) rather + than a sorted list, so the fold merges with `//` and detects convergence + with attrset equality instead of re-concatenating and re-sorting the + accumulated feature list on every step. `mergePackageFeatures` projects the + result back to canonical sorted lists. + */ + mergePackageFeaturesImpl = { crateConfigs ? crates , packageId , rootPackageId ? packageId @@ -9138,14 +9141,15 @@ rec { cache: { packageId, features }: let - cacheFeatures = cache.${packageId} or [ ]; - combinedFeatures = sortedUnique (cacheFeatures ++ features); + cacheFeatures = cache.${packageId} or { }; + # `features` is the (small) incoming list; merge it into the set. + combinedFeatures = cacheFeatures // listToSet features; in - if cache ? ${packageId} && cache.${packageId} == combinedFeatures then + if cache ? ${packageId} && cacheFeatures == combinedFeatures then cache else - mergePackageFeatures { - features = combinedFeatures; + mergePackageFeaturesImpl { + features = builtins.attrNames combinedFeatures; featuresByPackageId = cache; inherit crateConfigs @@ -9158,8 +9162,8 @@ rec { ); cacheWithSelf = let - cacheFeatures = featuresByPackageId.${packageId} or [ ]; - combinedFeatures = sortedUnique (cacheFeatures ++ enabledFeatures); + cacheFeatures = featuresByPackageId.${packageId} or { }; + combinedFeatures = cacheFeatures // listToSet enabledFeatures; in featuresByPackageId // { @@ -9186,27 +9190,31 @@ rec { assert (builtins.isList features); assert (builtins.isAttrs target); + let + # Identical for every dep in this call; build the predicate arg once. + targetArgs = { inherit features target; }; + in lib.filter ( dep: - let - targetFunc = dep.target or (features: true); - in - targetFunc { inherit features target; } + (dep.target or (features: true)) targetArgs && (!(dep.optional or false) || builtins.any (doesFeatureEnableDependency dep) features) ) dependencies; # Returns whether the given feature should enable the given dependency. doesFeatureEnableDependency = - dependency: feature: + dependency: + # Callers partially apply this once per dependency, then test every feature, + # so hoist the dep-invariant strings out of the per-feature comparison. let name = dependency.rename or dependency.name; + depName = "dep:" + name; prefix = "${name}/"; len = builtins.stringLength prefix; - startsWithPrefix = builtins.substring 0 len feature == prefix; in - feature == name || feature == "dep:" + name || startsWithPrefix; + feature: + feature == name || feature == depName || builtins.substring 0 len feature == prefix; /* Returns the expanded features for the given inputFeatures by applying the @@ -9220,29 +9228,24 @@ rec { assert (builtins.isAttrs featureMap); assert (builtins.isList inputFeatures); let + # Transitive closure of `inputFeatures` under `featureMap`. `seen` + # tracks already-expanded features so each is visited at most once; + # this also breaks feature cycles (issue #209). expandFeaturesNoCycle = - oldSeen: inputFeatures: - if inputFeatures != [ ] then - let - # The feature we're currently expanding. - feature = builtins.head inputFeatures; - # All the features we've seen/expanded so far, including the one - # we're currently processing. - seen = oldSeen // { - ${feature} = 1; - }; - # Expand the feature but be careful to not re-introduce a feature - # that we've already seen: this can easily cause a cycle, see issue - # #209. - enables = builtins.filter (f: !(seen ? "${f}")) (featureMap."${feature}" or [ ]); - in - [ feature ] ++ (expandFeaturesNoCycle seen (builtins.tail inputFeatures ++ enables)) - # No more features left, nothing to expand to. - else - [ ]; - outFeatures = expandFeaturesNoCycle { } inputFeatures; + seen: features: + builtins.foldl' + ( + acc: feature: + if acc ? ${feature} then + acc + else + expandFeaturesNoCycle (acc // { ${feature} = 1; }) (featureMap.${feature} or [ ]) + ) + seen + features; + seen = expandFeaturesNoCycle { } inputFeatures; in - sortedUnique outFeatures; + sortedUnique (builtins.attrNames seen); /* This function adds optional dependencies as features if they are enabled @@ -9301,16 +9304,19 @@ rec { in defaultOrNil ++ explicitFeatures ++ additionalDependencyFeatures; + # Builds a feature set (attrset `feature -> 1`) from a list of feature names. + listToSet = + features: + builtins.listToAttrs (map (feature: { name = feature; value = 1; }) features); + # Sorts and removes duplicates from a list of strings. sortedUnique = features: assert (builtins.isList features); - assert (builtins.all builtins.isString features); - let - outFeaturesSet = lib.foldl (set: feature: set // { "${feature}" = 1; }) { } features; - outFeaturesUnique = builtins.attrNames outFeaturesSet; - in - builtins.sort (a: b: a < b) outFeaturesUnique; + # attrNames returns keys in ascending string order, so the result is + # sorted and deduplicated. The feature-merge fixpoint relies on this + # canonical order to detect convergence. + builtins.attrNames (listToSet features); deprecationWarning = message: value: From af44653f928fc53b21019abe348b803c2f9f5fe4 Mon Sep 17 00:00:00 2001 From: Ed L Date: Wed, 23 Sep 2026 20:27:52 -0700 Subject: [PATCH 40/48] chore: setup nixos runners --- .github/actionlint.yaml | 6 ++ .github/workflows/ci.yml | 132 ++++++++++++++++---------------------- flake.nix | 58 +++++++++++------ harness/package-lock.json | 48 +++++--------- harness/package.json | 2 +- scripts/checkers-test.sh | 12 +++- 6 files changed, 126 insertions(+), 132 deletions(-) create mode 100644 .github/actionlint.yaml diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 00000000..bce99740 --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,6 @@ +# Labels the self-hosted NixOS microVM runners register with. +self-hosted-runner: + labels: + - nixos + - microvm + - audited diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4646ac2d..9b1cd222 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,15 @@ on: env: CARGO_TERM_COLOR: always +# Every job runs on the self-hosted NixOS microVM runners, and every `run:` +# step inside the flake's `ci` dev shell. Generic-Linux downloads (rustup, +# setup-node's node, Playwright's browsers) cannot run on NixOS, so the +# toolchain comes from flake.nix, the same one `nix develop` gives locally. +# Each step re-enters the shell; after the first, the eval cache makes it cheap. +defaults: + run: + shell: nix develop .#ci --command bash --noprofile --norc -eo pipefail {0} + jobs: # --------------------------------------------------------------------------- # The one place wasm-pack runs (improvise CI dedup). @@ -35,7 +44,7 @@ jobs: # would serialise a 109s job behind a 126s one for no artifact at all. build: name: Build all bundles - runs-on: ubuntu-latest + runs-on: [self-hosted, nixos, microvm] steps: - uses: actions/checkout@v7 # First, because it needs nothing and takes a second: the gate that stops @@ -44,9 +53,10 @@ jobs: # below, so it fails under this job's name rather than another's. - name: The stamp gate refuses what it claims to run: ./scripts/checkers.sh assemble-stamp - - uses: dtolnay/rust-toolchain@stable - with: - targets: wasm32-unknown-unknown + # rust-cache is a JS action: it runs outside the dev shell, and needs + # cargo and rustc on the job's PATH to compute its key. + - name: Put the dev shell's toolchain on PATH for actions + run: dirname "$(command -v cargo)" >> "$GITHUB_PATH" # Two workspaces, because `harness/opfs-contract` carries an empty # `[workspace]` table and so has its own target dir, which the default # single-workspace configuration never cached (it recompiled its whole @@ -56,34 +66,21 @@ jobs: workspaces: | . -> target harness/opfs-contract -> target - # One home for the two pins, so the release capture - # (scripts/build-web-release.sh, improvise-oujb) provisions the same - # versions rather than a second copy free to go stale. The grep keeps a - # comment or a blank line out of $GITHUB_ENV, which takes NAME=VALUE only. - - name: Pinned web toolchain versions - run: grep -E '^[A-Z_]+=' scripts/web-toolchain-versions.env >> "$GITHUB_ENV" - # Pinned release binary rather than `cargo install` (a three-minute - # source build) or a third-party action. wasm-pack runs its own bundled - # `wasm-opt`, which is why improvise-wasm-client's Cargo.toml names - # `--enable-bulk-memory`: without it the optimizer rejects the - # `memory.copy` LLVM emits regardless. - # v0.15.0 is what `nix develop` supplies, and the bundle was built and - # measured against it locally (346 KB, 120 KB gzipped). Pinning the same - # version is what makes that local run evidence about this one. - - name: Install wasm-pack + # scripts/web-toolchain-versions.env stays the one home for the pins, so + # the release capture (scripts/build-web-release.sh, improvise-oujb) + # provisions the same versions. CI no longer installs them — the dev + # shell supplies wasm-pack and node — so this checks the shell against + # the pins instead: a nixpkgs bump that moves either fails here, by name. + # wasm-pack runs its own bundled `wasm-opt`, which is why + # improvise-wasm-client's Cargo.toml names `--enable-bulk-memory`. + - name: The dev shell supplies the pinned web toolchain run: | - set -eu - url="https://github.com/rustwasm/wasm-pack/releases/download/${WASM_PACK_VERSION}/wasm-pack-${WASM_PACK_VERSION}-x86_64-unknown-linux-musl.tar.gz" - curl -sSfL "$url" | tar -xz --strip-components=1 -C /usr/local/bin \ - "wasm-pack-${WASM_PACK_VERSION}-x86_64-unknown-linux-musl/wasm-pack" + . scripts/web-toolchain-versions.env + test "$(wasm-pack --version)" = "wasm-pack ${WASM_PACK_VERSION#v}" + test "$(node -p 'process.versions.node.split(".")[0]')" = "$NODE_VERSION" wasm-pack --version - - # Both assemblies below generate the prose site with node. The runner - # image ships one, but pinning it is what makes a local run evidence. - - uses: actions/setup-node@v4 - with: - node-version: ${{ env.NODE_VERSION }} + node --version # The same script a developer runs locally, so there is one answer to # "how is the bundle built" rather than two that can drift. It builds all @@ -133,12 +130,11 @@ jobs: test: name: Test and lint (host) - runs-on: ubuntu-latest + runs-on: [self-hosted, nixos, microvm] steps: - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@stable - with: - components: clippy, rustfmt + - name: Put the dev shell's toolchain on PATH for actions + run: dirname "$(command -v cargo)" >> "$GITHUB_PATH" - uses: Swatinem/rust-cache@v2 # NOT `cargo fmt --all`, which reaches only ONE of the two workspace # roots. Exit 2 here is a usage error, never a verdict (improvise-b4ks). @@ -187,12 +183,11 @@ jobs: wasm: name: wasm32 check (browser bundle crates) - runs-on: ubuntu-latest + runs-on: [self-hosted, nixos, microvm] steps: - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@stable - with: - targets: wasm32-unknown-unknown + - name: Put the dev shell's toolchain on PATH for actions + run: dirname "$(command -v cargo)" >> "$GITHUB_PATH" - uses: Swatinem/rust-cache@v2 # Only the crates that go into the browser bundle. The root `improvise` # crate is the terminal binary and depends on crossterm, which does not @@ -229,15 +224,12 @@ jobs: # here is a decision `web/shell.js` or `web/serial.js` got wrong, and # reporting that as "Browser harness" would put it behind an 80 MB browser # download and two wasm-pack builds. It needs neither — `node --test` is in - # the runtime, and this job installs nothing at all. + # the runtime, which the dev shell supplies; this job installs nothing. js: name: Browser JS unit tests (node --test) - runs-on: ubuntu-latest + runs-on: [self-hosted, nixos, microvm] steps: - uses: actions/checkout@v7 - - uses: actions/setup-node@v4 - with: - node-version: 22 # The glob is the SCRIPT's, not the shell's and not node's. Under bash an # unmatched glob survives as a literal, node globs it, matches nothing and # exits 0 having run no test -- so this step gated nothing the moment @@ -252,23 +244,23 @@ jobs: kb: name: Knowledge base consistency - runs-on: ubuntu-latest + runs-on: [self-hosted, nixos, microvm] steps: - uses: actions/checkout@v7 - - uses: cachix/install-nix-action@v31 # kb.pl is executable knowledge, so it gets checked like code: rules # must reject what they should, domains must close, and functional keys # must not carry conflicting values. See kb_report/0 in the file. + # trealla comes from the dev shell, as it does locally. - name: kb_consistent run: | - nix shell nixpkgs#trealla --command tpl prolog-sessions/kb.pl \ + tpl prolog-sessions/kb.pl \ -g "(kb_consistent -> halt(0) ; (kb_report, halt(1)))" # scripts/kb is the only thing that writes kb.pl, so its refusals are as # load-bearing as kb_consistent itself. Without this the replacement # writer could regress green -- the review that found the directive # bypass also found that nothing ran this suite. - name: The kb tool refuses what it claims to - run: nix shell nixpkgs#trealla --command ./scripts/checkers.sh kb + run: ./scripts/checkers.sh kb # Every suite goes through scripts/checkers.sh, which pins a bash >= 5 # and scrubs the environment, so the shell and the variables a suite # passes under here are the ones it passes under locally (improvise-f9qf). @@ -301,13 +293,11 @@ jobs: # failed the *Browser harness* job. `-p opfs-contract` scopes it to 1 file. harness-lint: name: Lint the harness crate - runs-on: ubuntu-latest + runs-on: [self-hosted, nixos, microvm] steps: - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@stable - with: - targets: wasm32-unknown-unknown - components: clippy, rustfmt + - name: Put the dev shell's toolchain on PATH for actions + run: dirname "$(command -v cargo)" >> "$GITHUB_PATH" - uses: Swatinem/rust-cache@v2 with: workspaces: harness/opfs-contract -> target @@ -335,37 +325,25 @@ jobs: browser: name: Browser harness (OPFS contract + shell) needs: build - runs-on: ubuntu-latest + runs-on: [self-hosted, nixos, microvm] steps: - uses: actions/checkout@v7 - - uses: actions/setup-node@v4 - with: - node-version: 22 - name: Install the harness working-directory: harness run: npm ci - # Keyed on the resolved @playwright/test version, so a version bump is a - # miss and anything else is a hit. The download was 50s on run - # 35535225637 and is pure I/O — nothing about it depends on this commit. - - name: Playwright version - id: pw + # No browser install step: the dev shell points PLAYWRIGHT_BROWSERS_PATH + # at nixpkgs' Chromium, Firefox and WebKit, since Playwright's own + # downloads cannot run on NixOS. All three, because improvise-xf1's + # acceptance criterion names Chrome, Firefox and Safari and + # `createSyncAccessHandle` is implemented separately by each engine. + # WebKit's OPFS is absent today; the spec skips on a runtime probe. + # @playwright/test must equal nixpkgs' playwright-driver version, or it + # looks for browser builds that are not there; this says which. + - name: Playwright matches the dev shell's browsers working-directory: harness - run: echo "v=$(node -p "require('@playwright/test/package.json').version")" >> "$GITHUB_OUTPUT" - - uses: actions/cache@v4 - id: pw-cache - with: - path: ~/.cache/ms-playwright - key: playwright-${{ runner.os }}-${{ steps.pw.outputs.v }} - # All three, because improvise-xf1's acceptance criterion names Chrome, - # Firefox and Safari and `createSyncAccessHandle` is implemented - # separately by each engine. WebKit is installed even though its OPFS is - # absent today: the spec skips on a runtime probe, so the day that build - # gains OPFS the check starts running without a CI change. - # `--with-deps` installs apt packages OUTSIDE the cached directory, so it - # runs on a cache hit too; only the browser download is skipped. - - name: Install browsers - working-directory: harness - run: npx playwright install --with-deps chromium firefox webkit + run: | + npx playwright --version + ls "$PLAYWRIGHT_BROWSERS_PATH" # The tree `build` assembled, not one this job rebuilds. Same bytes as # the Pages artifact, modulo the cache-bust token `web/assemble.sh` # stamps — the harness tree is stamped `harness` and the site the commit @@ -434,7 +412,7 @@ jobs: name: Deploy to GitHub Pages needs: [test, wasm, js, browser, harness-lint, kb, build] if: github.event_name == 'push' && github.ref == 'refs/heads/main' - runs-on: ubuntu-latest + runs-on: [self-hosted, nixos, microvm] permissions: contents: read pages: write diff --git a/flake.nix b/flake.nix index 0db23e81..5f323551 100644 --- a/flake.nix +++ b/flake.nix @@ -36,12 +36,47 @@ cargoNix = import ./Cargo.nix { inherit nixpkgs pkgs; }; + # Everything CI runs, and nothing else: `devShells.ci` is exactly this, + # and the default shell is this plus editor and release tooling, so a + # local `nix develop` runs the toolchain the checks run. + ciInputs = [ + rustToolchain + pkgs.pkg-config + + # `scripts/kb` and the CI knowledge-base check both run trealla. It + # was reached through `nix shell nixpkgs#trealla` per invocation, + # which cost more than the work: the tool's first real use took past + # two minutes for a write that takes half a second on PATH. + pkgs.trealla + + # `web/build.sh` — and so the Pages job's local dry run — needs this. + # The `wasm` CI job only runs `cargo check`, which does not, so the + # shell was missing the one tool that turns a passing check into a + # loadable page (improvise-d31). Must match WASM_PACK_VERSION in + # scripts/web-toolchain-versions.env; CI checks that it does. + pkgs.wasm-pack + + # NODE_VERSION's major, same check. The js and browser jobs ran 22 + # while `build` ran the pinned 24; one shell means one node. + pkgs.nodejs_24 + pkgs.python3 + pkgs.jq + ]; + + # 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. + playwrightEnv = { + PLAYWRIGHT_BROWSERS_PATH = "${pkgs.playwright-driver.browsers}"; + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "1"; + PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS = "true"; + RUST_BACKTRACE = "1"; + }; in { inherit cargoNix; - devShells.default = pkgs.mkShell { - nativeBuildInputs = [ - rustToolchain - pkgs.pkg-config + devShells.ci = pkgs.mkShell (playwrightEnv // {nativeBuildInputs = ciInputs;}); + devShells.default = pkgs.mkShell (playwrightEnv // { + nativeBuildInputs = ciInputs ++ [ pkgs.rust-analyzer crate2nix.packages.${system}.default pkgs.cargo-expand @@ -50,18 +85,6 @@ pkgs.universal-ctags beads.packages.${system}.default - # `scripts/kb` and the CI knowledge-base check both run trealla. It - # was reached through `nix shell nixpkgs#trealla` per invocation, - # which cost more than the work: the tool's first real use took past - # two minutes for a write that takes half a second on PATH. - pkgs.trealla - - # `web/build.sh` — and so the Pages job's local dry run — needs this. - # The `wasm` CI job only runs `cargo check`, which does not, so the - # shell was missing the one tool that turns a passing check into a - # loadable page (improvise-d31). - pkgs.wasm-pack - # Demo recording and release tooling pkgs.asciinema pkgs.vhs @@ -69,8 +92,7 @@ # nixpkgs cargo-dist installs as "dist"; alias so `cargo dist` works (pkgs.writeShellScriptBin "cargo-dist" ''exec ${pkgs.cargo-dist}/bin/dist "$@"'') ]; - RUST_BACKTRACE = "1"; - }; + }); packages = { improvise = cargoNix.workspaceMembers.improvise.build; diff --git a/harness/package-lock.json b/harness/package-lock.json index 2904809a..2862707d 100644 --- a/harness/package-lock.json +++ b/harness/package-lock.json @@ -6,70 +6,52 @@ "": { "name": "improvise-harness", "devDependencies": { - "@playwright/test": "1.56.1" + "@playwright/test": "1.63.0" } }, "node_modules/@playwright/test": { - "version": "1.56.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz", - "integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.56.1" + "playwright": "1.63.0" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=18" - } - }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node": ">=20" } }, "node_modules/playwright": { - "version": "1.56.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz", - "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.56.1" + "playwright-core": "1.63.0" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "fsevents": "2.3.2" + "node": ">=20" } }, "node_modules/playwright-core": { - "version": "1.56.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz", - "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", "dev": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=20" } } } diff --git a/harness/package.json b/harness/package.json index 2923ee0f..bfc4fc72 100644 --- a/harness/package.json +++ b/harness/package.json @@ -8,6 +8,6 @@ "test": "playwright test" }, "devDependencies": { - "@playwright/test": "1.56.1" + "@playwright/test": "1.63.0" } } diff --git a/scripts/checkers-test.sh b/scripts/checkers-test.sh index 01995e07..ba7ab935 100755 --- a/scripts/checkers-test.sh +++ b/scripts/checkers-test.sh @@ -133,9 +133,15 @@ EOF chmod +x "$work/fakebin/bash" # The runner is invoked through a REAL bash, so the fake one below is only # what its own `command -v bash` probe finds -- not its interpreter. -# /usr/bin:/bin carries the utilities the runner itself needs and carries no -# nix, so the fake bash is the only candidate and the nix fallback is absent. -env PATH="$work/fakebin:/usr/bin:/bin" HOME="$HOME" \ +# utilbin carries only the utilities the runner itself needs, and no nix, so +# the fake bash is the only candidate and the nix fallback is absent. Linked +# one by one rather than taking /usr/bin:/bin, which on NixOS hold only `env` +# and `sh` -- the runner died on `dirname` there, exit 1 instead of 3. +mkdir -p "$work/utilbin" +for tool in dirname; do + ln -sf "$(command -v "$tool")" "$work/utilbin/$tool" +done +env PATH="$work/fakebin:$work/utilbin" HOME="$HOME" \ "${BASH:-bash}" "$d/checkers.sh" any > "$work/out" 2>&1 got=$? [ "$got" = 3 ] && ok "an old bash with no nix is exit 3" \ From 171f982de3ab84cde43accae6924ce54c4089164 Mon Sep 17 00:00:00 2001 From: Ed L Date: Wed, 23 Sep 2026 21:25:11 -0700 Subject: [PATCH 41/48] chore: fix wasm builds --- .github/workflows/ci.yml | 48 ++++++++++++++++++++-------------------- flake.nix | 21 ++++++++++++++++++ 2 files changed, 45 insertions(+), 24 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9b1cd222..7b2d49df 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,6 +81,14 @@ jobs: test "$(node -p 'process.versions.node.split(".")[0]')" = "$NODE_VERSION" wasm-pack --version node --version + # wasm-pack uses the shell's wasm-bindgen only at Cargo.lock's exact + # version; otherwise it cargo-installs one (71s) — so a lock bump + # must move flake.nix's wasmBindgenCli too, and this says so. + lock="$(awk '/^name = "wasm-bindgen"$/ {getline; gsub(/[^0-9.]/, ""); print}' Cargo.lock)" + test "$(wasm-bindgen --version)" = "wasm-bindgen $lock" || { + echo "flake.nix wasmBindgenCli is $(wasm-bindgen --version); Cargo.lock wants $lock" >&2 + exit 1 + } # The same script a developer runs locally, so there is one answer to # "how is the bundle built" rather than two that can drift. It builds all @@ -157,29 +165,21 @@ jobs: - name: Every check job gates the deploy run: python3 scripts/check-ci-graph.py - # Too slow on GitHub Actions - # - # nix-test: - # name: Nix Test - # runs-on: ubuntu-latest - # steps: - # - name: Free up disk space - # run: | - # sudo rm -rf /usr/share/dotnet \ - # /usr/local/lib/android - # sudo df -h - - # - uses: actions/checkout@v7 - # - name: Install Nix - # uses: DeterminateSystems/nix-installer-action@v14 - - # - name: Magic Nix Cache - # uses: DeterminateSystems/magic-nix-cache-action@v8 - - # - name: Check Nix health - # run: nix doctor - - # - run: nix build + # --------------------------------------------------------------------------- + # The crate2nix build (Cargo.nix), which `cargo` never exercises. It was off + # because GitHub-hosted runners start with an empty store and rebuilt every + # 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. + nix-build: + name: Nix build (crate2nix) + runs-on: [self-hosted, nixos, microvm] + steps: + - uses: actions/checkout@v7 + # Plain bash, not the dev shell: this step needs nix and nothing else. + - name: nix build .#improvise + shell: bash + run: nix build --print-build-logs .#improvise wasm: name: wasm32 check (browser bundle crates) @@ -410,7 +410,7 @@ jobs: # exists to stop. pages-deploy: name: Deploy to GitHub Pages - needs: [test, wasm, js, browser, harness-lint, kb, build] + needs: [test, wasm, js, browser, harness-lint, kb, build, nix-build] if: github.event_name == 'push' && github.ref == 'refs/heads/main' runs-on: [self-hosted, nixos, microvm] permissions: diff --git a/flake.nix b/flake.nix index 5f323551..8e686b9b 100644 --- a/flake.nix +++ b/flake.nix @@ -36,6 +36,25 @@ cargoNix = import ./Cargo.nix { inherit nixpkgs pkgs; }; + # wasm-pack downloads wasm-bindgen and binaryen from GitHub releases + # unless they are on PATH, and its downloader ignores SSL_CERT_FILE, so + # behind the runners' TLS-auditing proxy those downloads fail (binaryen + # with no fallback). On PATH, it downloads nothing. wasm-pack only takes + # a PATH wasm-bindgen whose version equals Cargo.lock's `wasm-bindgen`, + # so this pin moves with the lock; CI checks that it does. + wasmBindgenCli = pkgs.buildWasmBindgenCli rec { + src = pkgs.fetchCrate { + pname = "wasm-bindgen-cli"; + version = "0.2.128"; + hash = "sha256-a7lcXJnnZkYReja+iUO7NqqrWyv3toxnUgQb8s4IS5s="; + }; + cargoDeps = pkgs.rustPlatform.fetchCargoVendor { + inherit src; + inherit (src) pname version; + hash = "sha256-R1Tas33Ursy8kqsxguAkG0ZhNed2n5uFTAhw1l2qlLY="; + }; + }; + # Everything CI runs, and nothing else: `devShells.ci` is exactly this, # and the default shell is this plus editor and release tooling, so a # local `nix develop` runs the toolchain the checks run. @@ -55,6 +74,8 @@ # loadable page (improvise-d31). Must match WASM_PACK_VERSION in # scripts/web-toolchain-versions.env; CI checks that it does. pkgs.wasm-pack + wasmBindgenCli + pkgs.binaryen # NODE_VERSION's major, same check. The js and browser jobs ran 22 # while `build` ran the pinned 24; one shell means one node. From 91fee245b8e55dd2e4d72c160be62bdeca3d52b7 Mon Sep 17 00:00:00 2001 From: Ed L Date: Wed, 23 Sep 2026 21:44:36 -0700 Subject: [PATCH 42/48] ci: build with Nix (crane) instead of cargo in the dev shell Every CI build is now a derivation in nix/ci.nix, so the self-hosted runners substitute it from titan's harmonia cache instead of rebuilding: - checks.clippy, checks.test (with the --check-evidence gate, which needs the compiled tests), checks.wasm-check (per crate, no feature unification), checks.harness-lint (opfs-contract fmt + clippy) - packages.web: harness/build.sh unchanged -> the three wasm bundles and harness/serve; packages.site re-assembles with the commit as the cache-bust token - packages.ci-deps: the crane dependency builds, for titan's cache timer to keep rooted Dependencies are separate buildDepsOnly derivations keyed on the lock, so a PR that leaves Cargo.lock alone rebuilds only the workspace. The script-only checks (node, kb, checkers, comment/CI-graph gates, fmt) stay dev-shell steps: they compile nothing, and their enforced `#!/usr/bin/env bash` cannot run in the build sandbox. ci.yml's build, test, wasm and harness-lint jobs now run `nix build` on these; rust-cache and the PATH workaround for it are gone. Job names and the deploy graph are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ci.yml | 122 ++++++++++---------------- flake.lock | 16 ++++ flake.nix | 28 +++++- nix/ci.nix | 184 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 270 insertions(+), 80 deletions(-) create mode 100644 nix/ci.nix diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b2d49df..f8f7f9ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,20 +53,6 @@ jobs: # below, so it fails under this job's name rather than another's. - name: The stamp gate refuses what it claims to run: ./scripts/checkers.sh assemble-stamp - # rust-cache is a JS action: it runs outside the dev shell, and needs - # cargo and rustc on the job's PATH to compute its key. - - name: Put the dev shell's toolchain on PATH for actions - run: dirname "$(command -v cargo)" >> "$GITHUB_PATH" - # Two workspaces, because `harness/opfs-contract` carries an empty - # `[workspace]` table and so has its own target dir, which the default - # single-workspace configuration never cached (it recompiled its whole - # dependency tree every run). - - uses: Swatinem/rust-cache@v2 - with: - workspaces: | - . -> target - harness/opfs-contract -> target - # scripts/web-toolchain-versions.env stays the one home for the pins, so # the release capture (scripts/build-web-release.sh, improvise-oujb) # provisions the same versions. CI no longer installs them — the dev @@ -90,20 +76,29 @@ jobs: exit 1 } - # The same script a developer runs locally, so there is one answer to - # "how is the bundle built" rather than two that can drift. It builds all - # three wasm bundles (client, worker, opfs-contract) and assembles - # `harness/serve` — the deployed arrangement plus /opfs/. - - name: Build the bundles and the harness tree - run: ./harness/build.sh - - # Re-assembles from the pkg dirs the step above already produced — no - # wasm-pack runs here. A second assembly rather than a copy of - # `harness/serve` because the cache-bust token differs: the harness tree - # is stamped `harness` and the published site the commit SHA, which is - # what makes a returning visitor pick up a fresh bundle (improvise-9x6). - - name: Assemble the site - run: ./web/assemble.sh site "$GITHUB_SHA" + # Built by Nix (packages.web and packages.site in nix/ci.nix), which runs + # the same harness/build.sh and web/assemble.sh a developer runs, so there + # is still one answer to "how is the bundle built". `web` is the three + # wasm bundles plus `harness/serve`; `site` re-assembles from web's pkg + # dirs with the commit SHA as the cache-bust token (improvise-9x6), where + # the harness tree is stamped `harness`. Dependencies are their own + # derivations, substituted from titan's cache when Cargo.lock is unchanged. + - name: Build the bundles, the harness tree and the site + shell: bash + run: | + nix build --print-build-logs --out-link result-web .#web + nix build --print-build-logs --out-link result-site .#site + # The upload steps below read these paths. Copied rather than linked: + # an artifact of a symlink into /nix/store is a dangling link elsewhere. + - name: Lay the outputs out where the uploads read them + shell: bash + run: | + rm -rf harness/serve web/pkg web/worker-pkg site + cp -rL result-web/harness-serve harness/serve + cp -rL result-web/pkg web/pkg + cp -rL result-web/worker-pkg web/worker-pkg + cp -rL result-site site + chmod -R u+w harness/serve web/pkg web/worker-pkg site - run: find site -type f | sort - run: du -sh site @@ -141,23 +136,26 @@ jobs: runs-on: [self-hosted, nixos, microvm] steps: - uses: actions/checkout@v7 - - name: Put the dev shell's toolchain on PATH for actions - run: dirname "$(command -v cargo)" >> "$GITHUB_PATH" - - uses: Swatinem/rust-cache@v2 # NOT `cargo fmt --all`, which reaches only ONE of the two workspace # roots. Exit 2 here is a usage error, never a verdict (improvise-b4ks). - name: Every workspace root is formatted run: ./scripts/fmt-workspaces.sh - - run: cargo clippy --workspace --all-targets -- -D warnings - - run: cargo test --workspace + # checks.clippy and checks.test in nix/ci.nix: the same cargo commands, + # on the dev/test profiles, with dependencies as a cached derivation. + - name: cargo clippy --workspace --all-targets -- -D warnings + shell: bash + run: nix build --print-build-logs .#checks.x86_64-linux.clippy + # Also runs the test-evidence gate (check-comment-runs.py + # --check-evidence), which lists the tests this derivation compiled. + - name: cargo test --workspace, and test-evidence headings name real tests + shell: bash + run: nix build --print-build-logs .#checks.x86_64-linux.test # The comment cap and the test-evidence keys (improvise-9c8c); without # this step the rule is advisory. See context/design-principles.md §6. - name: The comment checker catches what it has been fixed for run: python3 scripts/check-comment-runs.py --self-test - name: Comment runs capped at two lines run: python3 scripts/check-comment-runs.py - - name: Test-evidence headings name real tests - run: python3 scripts/check-comment-runs.py --check-evidence # `pages-deploy` needs every other job, and saying so was not enough: a # job added during review of PR #83 was ungated and nothing noticed. - name: The deploy gate refuses what it claims to @@ -186,38 +184,14 @@ jobs: runs-on: [self-hosted, nixos, microvm] steps: - uses: actions/checkout@v7 - - name: Put the dev shell's toolchain on PATH for actions - run: dirname "$(command -v cargo)" >> "$GITHUB_PATH" - - uses: Swatinem/rust-cache@v2 - # Only the crates that go into the browser bundle. The root `improvise` - # crate is the terminal binary and depends on crossterm, which does not - # build for wasm — that is correct, not a gap (improvise-ywd). - - name: improvise-core - run: cargo check -p improvise-core --target wasm32-unknown-unknown - - name: improvise-formula - run: cargo check -p improvise-formula --target wasm32-unknown-unknown - - name: improvise-protocol - run: cargo check -p improvise-protocol --target wasm32-unknown-unknown - - name: improvise-io - run: cargo check -p improvise-io --target wasm32-unknown-unknown - - name: improvise-command - run: cargo check -p improvise-command --target wasm32-unknown-unknown - # The browser client itself. This is the one crate for which a wasm - # build is the *point* rather than a property worth keeping, so a - # failure here is not a portability regression but a broken deliverable - # (improvise-gsw). - - name: improvise-wasm-client - run: cargo check -p improvise-wasm-client --target wasm32-unknown-unknown - # The worker half of the standalone deployment (improvise-9x6). Same - # terms as the client: a wasm build is the point, so a failure here is a - # broken deliverable rather than a portability regression. - - name: improvise-worker-server - run: cargo check -p improvise-worker-server --target wasm32-unknown-unknown - # Not in the browser bundle yet — nothing depends on improvise-gp. Gated - # anyway because it is a candidate (fitting a tree in the browser) and - # staying wasm-clean is free now, expensive to retrofit (improvise-z5eg). - - name: improvise-gp - run: cargo check -p improvise-gp --target wasm32-unknown-unknown + # checks.wasm-check in nix/ci.nix: `cargo check --target wasm32` for each + # crate in the browser bundle, one at a time, so features are not unified + # across them. The root `improvise` crate is the terminal binary and + # depends on crossterm, which does not build for wasm — that is correct, + # not a gap (improvise-ywd). The log names each crate as it is checked. + - name: cargo check --target wasm32-unknown-unknown, per bundle crate + shell: bash + run: nix build --print-build-logs .#checks.x86_64-linux.wasm-check # --------------------------------------------------------------------------- # The browser JavaScript's unit tests. Its own job, deliberately: a failure @@ -296,15 +270,11 @@ jobs: runs-on: [self-hosted, nixos, microvm] steps: - uses: actions/checkout@v7 - - name: Put the dev shell's toolchain on PATH for actions - run: dirname "$(command -v cargo)" >> "$GITHUB_PATH" - - uses: Swatinem/rust-cache@v2 - with: - workspaces: harness/opfs-contract -> target - - working-directory: harness/opfs-contract - run: | - cargo fmt -p opfs-contract -- --check - cargo clippy --target wasm32-unknown-unknown --all-targets -- -D warnings + # checks.harness-lint in nix/ci.nix, run from inside harness/opfs-contract: + # `cargo fmt -p opfs-contract -- --check` and clippy for wasm32. + - name: cargo fmt -p opfs-contract and clippy --target wasm32 + shell: bash + run: nix build --print-build-logs .#checks.x86_64-linux.harness-lint # --------------------------------------------------------------------------- # The browser harness (improvise-vvl3, absorbing improvise-xf1). diff --git a/flake.lock b/flake.lock index 5d755b13..32d9649a 100644 --- a/flake.lock +++ b/flake.lock @@ -45,6 +45,21 @@ "type": "github" } }, + "crane": { + "locked": { + "lastModified": 1789760033, + "narHash": "sha256-jtT4yxZpR8seYnlCMMWSSPlFN92zO6ICuZ8pJrmi86k=", + "owner": "ipetkov", + "repo": "crane", + "rev": "73b980519cefc727a5f6cc8e5c0947a2f9be6edd", + "type": "github" + }, + "original": { + "owner": "ipetkov", + "repo": "crane", + "type": "github" + } + }, "crate2nix": { "inputs": { "cachix": "cachix", @@ -341,6 +356,7 @@ "root": { "inputs": { "beads": "beads", + "crane": "crane", "crate2nix": "crate2nix", "flake-utils": "flake-utils", "nixpkgs": "nixpkgs_4", diff --git a/flake.nix b/flake.nix index 8e686b9b..7f06ffe0 100644 --- a/flake.nix +++ b/flake.nix @@ -9,6 +9,7 @@ }; flake-utils.url = "github:numtide/flake-utils"; crate2nix.url = "github:nix-community/crate2nix"; + crane.url = "github:ipetkov/crane"; }; outputs = { @@ -18,6 +19,7 @@ rust-overlay, flake-utils, crate2nix, + crane, }: flake-utils.lib.eachDefaultSystem (system: let pkgs = import nixpkgs { @@ -87,6 +89,14 @@ # 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. + craneLib = (crane.mkLib pkgs).overrideToolchain (_: rustToolchain); + ci = import ./nix/ci.nix { + inherit pkgs craneLib wasmBindgenCli; + src = self; + # What the site's cache-bust token names; CI checks out a clean tree. + rev = self.rev or "dirty"; + }; + playwrightEnv = { PLAYWRIGHT_BROWSERS_PATH = "${pkgs.playwright-driver.browsers}"; PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "1"; @@ -115,9 +125,19 @@ ]; }); - packages = { - improvise = cargoNix.workspaceMembers.improvise.build; - default = self.packages.${system}.improvise; - }; + packages = + ci.packages + // { + improvise = cargoNix.workspaceMembers.improvise.build; + default = self.packages.${system}.improvise; + }; + + # `nix flake check` builds every CI build: crane's checks plus the + # crate2nix package and the bundles. + checks = + ci.checks + // { + inherit (self.packages.${system}) improvise web; + }; }); } diff --git a/nix/ci.nix b/nix/ci.nix new file mode 100644 index 00000000..ceb4f384 --- /dev/null +++ b/nix/ci.nix @@ -0,0 +1,184 @@ +# Everything CI *builds*, as Nix derivations (crane). The script-only checks +# (node tests, kb, the checkers, the comment and CI-graph gates, fmt) stay dev +# shell steps in ci.yml: they compile nothing, and their `#!/usr/bin/env bash` +# shebangs -- which scripts/checkers.sh itself enforces -- cannot run in the +# build sandbox, which has no /usr/bin/env. +# +# Why Nix: the self-hosted runners substitute from titan's harmonia first, and +# titan builds gh/main's outputs hourly. Dependencies are separate derivations +# (buildDepsOnly, keyed on the Cargo.lock), so a PR that leaves the lock alone +# rebuilds only the workspace. +{ + pkgs, + craneLib, + wasmBindgenCli, + src, + rev, +}: let + inherit (pkgs) lib; + + # Two lockfiles: harness/opfs-contract is its own workspace root. + cargoVendorDir = craneLib.vendorMultipleCargoDeps { + cargoLockList = [../Cargo.lock ../harness/opfs-contract/Cargo.lock]; + }; + + common = { + inherit src cargoVendorDir; + pname = "improvise"; + version = "0"; + strictDeps = true; + # The `test` job ran the dev/test profiles, not crane's default release: + # debug assertions and overflow checks are part of what it tested. + CARGO_PROFILE = ""; + }; + + wasmTarget = "wasm32-unknown-unknown"; + + # The crates in the browser bundle, checked for wasm32 one at a time: a + # single `cargo check -p a -p b` unifies features across them and can hide + # a crate that only builds with a sibling's features (improvise-ywd). + wasmCrates = [ + "improvise-core" + "improvise-formula" + "improvise-protocol" + "improvise-io" + "improvise-command" + "improvise-wasm-client" + "improvise-worker-server" + "improvise-gp" + ]; + pFlags = crates: lib.concatMapStringsSep " " (c: "-p ${c}") crates; + + nativeDeps = craneLib.buildDepsOnly (common + // { + pname = "improvise-deps"; + cargoExtraArgs = "--locked --workspace"; + }); + + wasmCheckDeps = craneLib.buildDepsOnly (common + // { + pname = "improvise-wasm-check-deps"; + cargoExtraArgs = "--locked --target ${wasmTarget} ${pFlags wasmCrates}"; + cargoCheckExtraArgs = ""; + doCheck = false; + }); + + # What wasm-pack's `cargo build --release --target wasm32` needs. + bundleDeps = craneLib.buildDepsOnly (common + // { + pname = "improvise-bundle-deps"; + CARGO_PROFILE = "release"; + cargoExtraArgs = "--locked --target ${wasmTarget} ${pFlags ["improvise-wasm-client" "improvise-worker-server"]}"; + cargoCheckCommand = "true"; + doCheck = false; + }); + + # A derivation that runs cargo commands and only has to succeed. + cargoCheck = name: args: + craneLib.mkCargoDerivation (common + // { + pname = "improvise-${name}"; + doInstallCargoArtifacts = false; + installPhaseCommand = "mkdir -p $out"; + } + // args); +in rec { + checks = { + clippy = craneLib.cargoClippy (common + // { + pname = "improvise-clippy"; + cargoArtifacts = nativeDeps; + cargoClippyExtraArgs = "--workspace --all-targets -- -D warnings"; + }); + + # The evidence gate runs `cargo test --workspace -- --list`, so it runs + # here, where the tests are already compiled, not as a separate step. + test = craneLib.cargoTest (common + // { + pname = "improvise-test"; + cargoArtifacts = nativeDeps; + cargoTestExtraArgs = "--workspace"; + nativeBuildInputs = [pkgs.python3 pkgs.git]; + # tests/shipped_documents_load.rs walks the git index (`git ls-files`) + # and makes scratch repos. The flake source is exactly the tracked + # files, so force-adding all of it (but crane's target/) rebuilds the + # checkout's index, ignored-but-tracked documents included. + preBuild = '' + export HOME=$TMPDIR + export GIT_AUTHOR_NAME=nix GIT_AUTHOR_EMAIL=nix@localhost + export GIT_COMMITTER_NAME=nix GIT_COMMITTER_EMAIL=nix@localhost + git init -q . + git add --all --force -- . ':!target' + ''; + postBuild = '' + python3 scripts/check-comment-runs.py --check-evidence + ''; + }); + + wasm-check = cargoCheck "wasm-check" { + cargoArtifacts = wasmCheckDeps; + buildPhaseCargoCommand = lib.concatMapStringsSep "\n" (c: '' + echo "== ${c}" + cargo check --locked -p ${c} --target ${wasmTarget} + '') + wasmCrates; + }; + + # harness/opfs-contract is its own workspace, so the root clippy never + # reaches it; `-p` keeps cargo fmt to this one crate (see ci.yml). + harness-lint = cargoCheck "harness-lint" { + cargoArtifacts = null; + buildPhaseCargoCommand = '' + cd harness/opfs-contract + cargo fmt -p opfs-contract -- --check + cargo clippy --locked --target ${wasmTarget} --all-targets -- -D warnings + ''; + }; + }; + + packages = { + # The dependency builds on their own, for titan's cache timer to keep as a + # GC root: the checks' outputs do not reference them, so rooting only the + # checks would let the part a PR actually reuses be collected. + ci-deps = pkgs.linkFarmFromDrvs "improvise-ci-deps" [nativeDeps wasmCheckDeps bundleDeps]; + + # harness/build.sh, unchanged: the three wasm bundles and harness/serve. + # No network: wasm-bindgen and wasm-opt come from PATH, so wasm-pack + # downloads nothing (see wasmBindgenCli in flake.nix). + web = craneLib.mkCargoDerivation (common + // { + pname = "improvise-web"; + cargoArtifacts = bundleDeps; + doInstallCargoArtifacts = false; + nativeBuildInputs = [pkgs.wasm-pack wasmBindgenCli pkgs.binaryen pkgs.nodejs_24]; + # web/assemble.sh calls scripts/assemble-stamp.sh, whose + # `#!/usr/bin/env bash` has no interpreter in the sandbox. Patching + # this private copy is harmless: no checker runs here. + postPatch = "patchShebangs scripts web harness"; + buildPhaseCargoCommand = '' + export HOME=$TMPDIR + ./harness/build.sh + ''; + installPhaseCommand = '' + mkdir -p $out + cp -r harness/serve $out/harness-serve + cp -r web/pkg web/worker-pkg $out/ + ''; + }); + + # The Pages tree, stamped with the commit it was built from. Separate from + # `web` so the stamp changing every commit does not rebuild the bundles. + site = + pkgs.runCommand "improvise-site" { + nativeBuildInputs = [pkgs.nodejs_24]; + } '' + cp -r ${src} tree + chmod -R u+w tree + patchShebangs tree/scripts tree/web + cp -r ${packages.web}/pkg ${packages.web}/worker-pkg tree/web/ + chmod -R u+w tree/web + cd tree + ./web/assemble.sh $out ${rev} + ''; + }; +} From 1790764f766959a437e9c5e3ace4f5c9970f113b Mon Sep 17 00:00:00 2001 From: Ed L Date: Wed, 23 Sep 2026 21:58:51 -0700 Subject: [PATCH 43/48] ci: run the browser harness on Chromium and Firefox only nixpkgs' WebKit (WPEWebProcess) aborts on its first page inside the self-hosted runner VMs, though not on the host with the same sandbox; its OPFS specs skip on a runtime probe anyway. Re-add --project=webkit once the abort is understood. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ci.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f8f7f9ea..2fdaca1d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -337,9 +337,13 @@ jobs: # renamed export is cheaper to read here than as a blank page. - name: The page's calls match the bundle it calls run: ./scripts/node-test.sh 'web/tests/bundle/*.test.js' + # Chromium and Firefox only, for now. nixpkgs' WebKit (WPEWebProcess) + # aborts on its first page inside the runner VMs, though not on the + # host; its four specs skip on the OPFS probe anyway, so nothing gated + # is lost. Put `--project=webkit` back once that abort is understood. - name: Run the harness working-directory: harness - run: npx playwright test + run: npx playwright test --project=chromium --project=firefox - if: failure() uses: actions/upload-artifact@v7 with: From f2093dc0c1e1260c65abc75b8bbe7dc27989bdf3 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 22:31:05 -0700 Subject: [PATCH 44/48] bd update --- .beads/issues.jsonl | 333 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 301 insertions(+), 32 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index d852d360..8b1317db 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,15 +1,30 @@ -{"_type":"issue","id":"improvise-ktlk","title":"Editing overlay with an absent buffer now SHOWS the cell but Enter still CLEARS it (xg1z fixed the paint, not the commit)","description":"Found by post-merge adversarial review of PR #162 (improvise-xg1z), head b6805232.\n\nThe xg1z fix changed what the overlay DRAWS for an Editing mode that arrived without its `edit` buffer, but not what Enter COMMITS. `render.rs` now shows `edit_seed(state)` (the cell's value, e.g. \"1200\"), but `resolve.rs::commit` still reads `read_buffer(\u0026state.view, \"edit\")`, which is `unwrap_or_default()` -\u003e \"\" -\u003e `typed_cell_command` -\u003e `ClearCell`. Typing one character likewise appends to the absent buffer, so the overlay jumps from \"1200\" to \"5\", not \"12005\".\n\nSo the data loss the ticket describes (\"the next Enter commits the empty string and the value is gone\") still happens. The only change is that the screen now promises the value survives: before #162 a blank overlay at least showed what Enter would commit, and now it doesn't. The display and the commit are two derivations of \"what is in the editor\", and they now disagree.\n\nREPRODUCED at 40b8913 (gh/main), temporary test in resolve.rs tests: `client_on(record_cell())`, fold `ChangeMode(AppMode::editing())` with no SetBuffer, assert `edit_seed == \"1200\"` (passes, which is what render.rs paints), then `resolve(Enter)` -\u003e commands[0] = `Command::Model { cmd: ClearCell(..) }`. Test reverted.\n\nWhy #162's tests could not see it: both tests assert `render_model().editing` only. Neither presses a key. A test of the claim \"the overlay shows the cell rather than a blank\" should also assert that Enter on that overlay commits what it shows.\n\nAlso a fixture-right-by-accident note: `CellDisplay::number(\"1200\")` sets `text == edit_text`, so a fallback that seeded from the formatted `text` (gotcha 8: never feed a rounded value back) would pass `an_editing_mode_with_no_edit_buffer_shows_the_cell_rather_than_a_blank` too. A fixture with text \"1,200.00\" and edit_text \"1200\" separates them.\n\nLikely fix direction: make the buffer the one source, e.g. seed the absent buffer on ChangeMode(Editing) in the fold, or have commit/append read the same seeded value. The fix is not decided here.\n","status":"open","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T03:46:25Z","created_by":"agent-review-wire","updated_at":"2026-09-23T03:46:25Z","labels":["adversarial-review","browser","data-loss","pr-162"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-2bac","title":"commit-cell-edit from a script or : line ignores its value and coords and commits the empty edit buffer to the cursor cell (fourth two-arm demotion, post-6uva)","description":"Found in a post-merge adversarial review of PR #151 (improvise-6uva), head b855faf3. This is a fourth instance of that PR's own lesson (\"a registry with two construction arms demotes the weaker caller\"). The Codex round closed the first three.\n\n`commit-cell-edit` (CommitAndAdvance, AdvanceDir::Down) is registered with `register`, and its two arms mean different things:\n- the pure arm reads `\u003cmode\u003e \u003cvalue\u003e \u003ccoords...\u003e`: value = args[1], key = parse_cell_key_from_args(\u0026args[2..]);\n- the interactive arm reads only args[0] (the mode). It DISCARDS the value and coords, takes the value from the \"edit\" buffer and the key from ctx.cell_key().\n\nSince #151, `improvise cmd`/`improvise script` and the TUI `:` line build every segment through the interactive arm. The whole-line `parse_line` syntax gate still runs through the pure arm, and it accepts the explicit form. So the gate passes and the other arm runs.\n\nMeasured in the headless CLI on a document with Region = North, South, East, where North=1 and East=3:\n\n improvise cmd 'commit-cell-edit editing 5 Region/East' -f a.improv\n\n- pre-#151 (f7573bff): East = 5, North = 1. That is the intended result.\n- gh/main (40b8913): **North is DELETED** and East is still 3. Exit code 0 and nothing on stderr. The empty edit buffer was committed to the cursor cell, which is the origin.\n\nSo a script line that names a cell and a value silently destroys a different cell instead. The failure is partial and silent, and it loses data.\n\nRepair shape: the same one #151 used for clear-cell/yank/paste/drill-into-cell. When args carry a value and coords, the interactive arm must honour them. The regression test should write to a cell the cursor is NOT on and assert that the cursor cell survives.\n\nA sibling of the same shape, not yet run (PLAUSIBLE): `toggle-panel-and-focus \u003cpanel\u003e [open] [focused]`. The pure arm honours explicit open/focused. The interactive arm ignores them and toggles based on current state, so `:toggle-panel-and-focus formula false false` now opens the panel when it is closed. It is view-only and not destructive, but it is the same demotion.\n\nA durable guard worth considering: for every `register` entry, whenever the pure arm accepts an argument vector, the interactive arm must build an equal command from it against a context whose cursor is NOT on the named cell. Today nothing type-checks which arm a caller deserves.\n","status":"in_progress","priority":1,"issue_type":"bug","assignee":"agent-2bac","owner":"el-github@elangley.org","created_at":"2026-09-23T03:36:16Z","created_by":"agent-review-cmd","updated_at":"2026-09-23T03:43:22Z","started_at":"2026-09-23T03:43:22Z","lease_expires_at":"2026-09-23T03:48:22Z","heartbeat_at":"2026-09-23T03:43:22Z","comments":[{"id":"9dcd479b-8d4b-531d-b3d4-4276559dd846","issue_id":"improvise-2bac","author":"agent-review-cmd","text":"Second symptom of the same root, CONFIRMED at gh/main 40b8913. context/code/cli.md section 7 claims \"Line atomicity is preserved by keeping the existing whole-line parse_line as a syntax gate\". That is false wherever the interactive arm refuses something the pure arm accepted:\n\n improvise cmd 'add-category Foo . commit-cell-edit editing 5 Foo/x' -f e.improv (a new, empty document)\n\nThe output is `Parse error: commit-cell-edit: no cell at cursor`, with exit 1. Category Foo IS written to the file. The gate passed, segment 1 applied, and segment 2 was refused at construction. So half the line landed. Before #151 the same line ran whole. commit-cell-edit is the only entry I found whose pure arm accepts an argument vector its interactive arm can refuse, so fixing the arm disagreement closes this too. The doc claim should be corrected either way.\n","created_at":"2026-09-23T03:39:18Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} -{"_type":"issue","id":"improvise-usus","title":"scripts/kb writes onto a truncated kb.pl and reports success: consistency is not completeness","description":"scripts/kb add wrote 4 facts onto a kb.pl that had ALREADY LOST 276 of its 801 facts, and reported success. kb_consistent passed, because it checks that the facts are CONSISTENT, never that they are all still there. The loss was only caught because the orchestrator noticed kb-merge print \"529 before\" where it had last printed 805, and compared the count against HEAD.\n\nLIKELY CAUSE: a background `scripts/kb replace` was killed mid-write when the previous Claude Code process exited -- it completed with exit code -1. So a truncated file was left on disk, and every later `kb add` built on it. Nothing committed was lost (HEAD, origin/main and gh/main all held 801); the working tree was restored from HEAD and the six new facts re-added, giving a diff of 6 insertions and 0 deletions.\n\nTWO FIXES, both cheap, both in scripts/kb:\n1. WRITE ATOMICALLY: write to a temp file in the same directory and rename(2) over kb.pl, so a killed write leaves the old file intact rather than a truncated one.\n2. REFUSE A SHRINK on `add`: an `add` must leave count(after) == count(before) + len(delta). kb-merge ALREADY PRINTS both numbers (\"529 before, 4 in delta, 533 after\") -- the check is to compare `before` against the count in git HEAD and refuse if the working tree has fewer facts than HEAD with no `rm` recorded. The tool reported the symptom in its own output and nothing read it.\n\nCheck_the_checker: truncate kb.pl to half, run `kb add`, and assert it REFUSES. Then kill a `kb replace` mid-write (or simulate by truncating between the two phases) and assert the old file survives.\n\nThis is doctrine(a_prose_count_is_checked_by_no_one) in executable form: a number the tool prints every run and no gate compares.","notes":"CAUSE CORRECTED: the user reports having moved git underneath the session at around this time, which is at least as likely as the killed 'kb replace' this ticket first named -- treat the cause as UNKNOWN between the two. That changes the priority of the two fixes: an atomic write only covers the killed-write cause, while REFUSE-A-SHRINK (compare the working-tree fact count against HEAD before any add) catches BOTH, since either leaves a file with fewer facts than HEAD and no rm recorded. Do refuse-a-shrink first.","status":"in_progress","priority":1,"issue_type":"bug","assignee":"agent-kbguard","owner":"el-github@elangley.org","created_at":"2026-09-23T03:20:57Z","created_by":"Edward Langley","updated_at":"2026-09-23T03:31:13Z","started_at":"2026-09-23T03:31:13Z","lease_expires_at":"2026-09-23T03:36:13Z","heartbeat_at":"2026-09-23T03:31:13Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-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-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-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} +{"_type":"issue","id":"improvise-umi4","title":"TUI edit seeds its buffer from CellValue Display, so i then Enter on an unchanged 1.23456789 commits 1.2346","description":"Found reviewing PR #184 (improvise-6p8q) at 0ed2e41. Pre-existing, not introduced by #184. App::display_value_in (improvise-command/src/ui/app.rs) seeds the edit buffer with CellValue::to_string (records: layout.resolve_display -\u003e records_display; pivot: get_cell().to_string()), and CellValue's Display writes a non-integer as {n:.4}. Probe (scratch test, both arms): set Region=West,_Measure=Revenue = 1.23456789; cursor on it; press i -\u003e buffer is \"1.2346\"; press Enter with no change -\u003e the model holds Number(1.2346). Reproduced in records mode (RecordsEditing) and in pivot mode. So opening and closing an editor silently destroys precision, and the saved file carries the rounded value. The projection's CellDisplay::edit_text comes from the same call, so the browser seeds the same rounded text. Same root as improvise-dsxq (browser yank), which was fixed by carrying the value instead of text; #184's own drill-paste decline rests on this same Display rounding. Fix direction: seed the editor from a lossless spelling of the value (shortest round-trip f64), not Display. Test must use a value where display and value disagree, cursor off (0,0).","notes":"Raised to P1 by the orchestrator: this is silent data loss on a keystroke that should change nothing. Pressing i then Enter on 1.23456789 stores 1.2346, and the user sees the same displayed value before and after, so nothing tells them. Same root cause as improvise-dsxq (the browser yank): a value re-derived from DISPLAY text, which rounds to 4 decimals. Likely the browser editor too, since #169/#178 note edit_text is deliberately 'the TUI buffer's string'. Check both front ends.","status":"closed","priority":1,"issue_type":"bug","assignee":"agent-umi4","owner":"el-github@elangley.org","created_at":"2026-09-23T04:38:55Z","created_by":"agent-review-184","updated_at":"2026-09-23T05:06:55Z","started_at":"2026-09-23T04:40:48Z","closed_at":"2026-09-23T05:06:55Z","close_reason":"Fixed in PR #194 (CellValue::edit_text), merged after an adversarial review at 0680f65. Follow-ups: improvise-ixxx (P1), improvise-msg2.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-cz8d","title":"#181's refused() aborts and #185's App::refuse does not: reconcile to one writer, with the abort as a separate choice","description":"Found by the adversarial review of PR #181 (improvise-bok4), which was asked to weigh it against PR #185 (improvise-c5ip/udt2).\n\nThe two PRs answer \"does a refusal stop the batch?\" in opposite ways, in the same file:\n\n- #181 adds `effect::refused(app, why)`, which sets `status_msg` **and** `abort_effects`. `Host::mutating_frame` rejects a frame on `last_batch_aborted()`. That is what turns a failed client AddItem, LoadModel and similar into `Rejected(MutationRefused)` with rollback.\n- #185 adds `App::refuse(msg)`, which sets `status_msg` **and** a per-batch `batch_refused`, with **no** abort. The headless exit code reads only `last_batch_refused()`. `context/code/cli.md` §10 states that \"AddItem refuses without stopping anything\".\n\nMerged as they stand, each loses the other's guarantee:\n- With #181's helper at a site, `batch_refused` is never set, so a headless run whose `LoadModel` fails exits 0 (udt2's bug, back).\n- With #185's helper at a site, the frame is not aborted, so the wire reports `Accepted` (bok4's bug, back).\n\nBoth PRs rewrite the same lines, so git will conflict, and a resolver must pick one side. Eight of #181's sites are unwitnessed by any test (improvise-9fpl), so choosing #185's side there fails nothing.\n\nRecommended combination: **refused and aborted are different facts**. Refused means the request did not happen. Aborted means the chain stopped. Keep one writer, `App::refuse`, which sets the status and `batch_refused`. Have `Host::mutating_frame` reject on `last_batch_refused() || last_batch_aborted()`: clause 4 is about the request, and on the wire every ModelCommand is a one-effect batch, so an abort there skips nothing that a refusal did not already name. Whether a refusal also aborts then becomes an explicit per-site choice, e.g. `app.refuse(msg); app.abort_effects = true;` where later effects depend on it (AddCategory, LoadModel for `:e … . q`, file writes). The frame no longer needs the abort to be rejected. #181's `refused` helper then folds into `App::refuse` plus an explicit abort, and the two PRs' tests both hold.\n","status":"closed","priority":1,"issue_type":"task","assignee":"agent-wire2","owner":"el-github@elangley.org","created_at":"2026-09-23T04:37:44Z","created_by":"agent-review-181","updated_at":"2026-09-23T05:14:41Z","started_at":"2026-09-23T04:48:43Z","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","pr-185"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-6p8q","title":"TUI yy/p in records mode use the synthetic (_Index,_Dim) key: yank gets None, paste writes a junk _Dim/_Index cell into the document","description":"Found while fixing improvise-dsxq (agent-dsxq), by a throwaway probe in lockstep.rs over records_pair() at 40b8913 + the dsxq fix.\n\nIn records mode the TUI's yank and paste resolve the cursor through ctx.cell_key(), which is the SYNTHETIC key {_Dim=\u003ccol index\u003e, _Index=\u003crow index\u003e} (gotcha 1). Neither YankCell nor PasteCell (command/cmd/cell.rs, key_from_args_or_cursor in registry.rs) translates it to the record's real key.\n\n- yy over a stored records cell (Revenue = 1200) yanks None: evaluate_aggregated on the synthetic key finds nothing. Every cell in the records grid yanks None.\n- p with Number(5.5) yanked, over that cell, writes a NEW cell keyed `_Dim=|3|, _Index=|0|` and leaves the real 1200 untouched. It persists: format_md writes `_Dim=|3|, _Index=|0| = 5.5` into ## Data. That is document corruption from one keystroke.\n\nThe browser does not share this since dsxq: its yank copies the projected value (records_value) and its paste composes SetCell against record_cell_at_cursor, the real record. So records mode is a known TUI/browser divergence where the BROWSER is right; tests/lockstep.rs's yank test deliberately stays on a pivot grid for that reason.\n\nFix direction (not decided): resolve the synthetic key via layout.record_cell_key_of (as CmdContext::owning_formula already does) in the yank/paste constructors, or route records-mode p through the records commit path (gotcha 19). Then add the records case to yy_then_p_copies_the_value_the_host_copies.\n","status":"closed","priority":1,"issue_type":"bug","assignee":"agent-6p8q","owner":"el-github@elangley.org","created_at":"2026-09-23T04:06:34Z","created_by":"agent-dsxq","updated_at":"2026-09-23T04:51:01Z","started_at":"2026-09-23T04:14:56Z","closed_at":"2026-09-23T04:51:01Z","close_reason":"PR #184 MERGED (confirmed at ead7968), adversarially reviewed (merge) and re-reviewed after the conflict resolution plus the cheap half of 48jd (merge). TUI records-mode yank/paste resolve the synthetic (_Index,_Dim) key through layout.records_value / record_cell_key, so yy no longer yanks None and p no longer persists a synthetic-keyed line into ## Data. Paste declines on coordinate, +cat and + columns and inside a drill, and declines a key that omits a regular None-axis category. A save-and-reload test asserts ## Data carries no _Index/_Dim line. Follow-ups: improvise-lx7w (x in records mode is a no-op), improvise-54g1 (paste onto a formula cell), improvise-48jd (real drill paste), improvise-umi4 (P1 editor seed rounding), improvise-eucp, improvise-s1lx.","labels":["data-loss","parity","records"],"comments":[{"id":"71ddf18d-2bb2-5c8f-918f-58624ce2d16c","issue_id":"improvise-6p8q","author":"agent-review-178","text":"From review of PR #178 (agent-review-178): the claim 'the BROWSER is right in records mode' rests on the records arm of project.rs::cell_display setting value: value.clone(), and NO test guards that line. Injected value: None in that arm and ran the whole workspace: 1756 passed, 0 failed. When 6p8q fixes the TUI, add the records-mode lockstep yy/p case (source off index 0), which then guards both ends.","created_at":"2026-09-23T04:22:26Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} +{"_type":"issue","id":"improvise-dsxq","title":"Browser yy yanks the rounded display text re-parsed, not the value: 1.23456789 pastes as 1.2346, Text 007 as Number 7","description":"Found by post-merge adversarial review of PR #161 (improvise-lut7), head 594734ad.\n\nThe browser's new `\"yank\"` arm in `resolve.rs::composed` yanks `typed(cell.edit_text)`. Its comment says \"`edit_text` is what the host evaluated, which is what the TUI yanks\". That is false on two counts:\n- `edit_text` comes from `App::display_value_in` -\u003e `CellValue::to_string()`, which writes a non-integral Number as `{n:.4}`, so the value is ROUNDED. It is also `get_cell` (raw) only, never `evaluate_aggregated`.\n- `typed()` re-parses the text, so a Text cell whose text parses as a number becomes a Number.\n\nThe TUI's `YankCell::execute` yanks `evaluate_aggregated(key)`, the value itself.\n\nREPRODUCED at 40b8913 with a temporary lockstep.rs test that presses `y`,`y` on a real App (reduce_key) and on a ClientState built by `grid_of` (resolve + fold), then compares `yanked`:\n- Number(1.23456789): host Some(Number(1.23456789)), client Some(Number(1.2346)) -- precision lost, gotcha 8\n- Text(\"007\"): host Some(Text(\"007\")), client Some(Number(7.0)) -- a zip code / id loses its leading zeros and its type\n- Number(1200.0): agree (the only case #161's test uses)\nTest reverted.\n\n`yy` then `p` in the browser therefore WRITES a different value than the TUI does: silent data corruption on copy/paste. By reading, and not run: on an aggregate or formula cell `get_cell` is None, so edit_text is \"\" and the browser yanks None (\"Yanked\", then paste has nothing) where the TUI yanks the computed value.\n\nWhy #161's tests could not see it: `yy_yanks_the_cell_under_the_cursor_and_p_pastes_what_it_yanked` uses 1200, an integral number whose text round-trips exactly. It is the one value on which the two derivations agree (fixture right by accident). `the_g_chord_returns_to_the_first_row_on_both_ends` is the only chord lockstep.rs drives, and `yy` never goes through lockstep.\n\nFix direction (not decided): project the yankable value itself (a CellValue, not text), or at minimum a full-precision, type-preserving edit value, and add a lockstep case over a non-integral number and a numeric-looking text cell.\n","status":"closed","priority":1,"issue_type":"bug","assignee":"agent-dsxq","owner":"el-github@elangley.org","created_at":"2026-09-23T03:46:39Z","created_by":"agent-review-wire","updated_at":"2026-09-23T04:24:42Z","started_at":"2026-09-23T03:49:35Z","closed_at":"2026-09-23T04:24:42Z","close_reason":"PR #178 MERGED (confirmed), adversarially reviewed at 5d1bf75 (merge); CI green per pr-watch --expect. CellDisplay carries the evaluated value (the same evaluation text is formatted from), and the browser's yank copies it rather than re-parsing rounded edit text. 1.23456789 no longer yanks as 1.2346, Text(007) as Number(7), or an aggregate as nothing. Follow-ups: improvise-6oy4 (P2: serde_json's default float parser does not round-trip, so about 1 number in 7 comes back 1 ULP off over the wire; in-process lockstep tests can't see it), improvise-4w09 (P3: dropping Eq means a NaN cell is re-sent every frame and breaks the seed round-trip), and a note on improvise-6p8q that nothing guards the records-mode value line.","labels":["adversarial-review","browser","data-loss","parity","pr-161"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-ktlk","title":"Editing overlay with an absent buffer now SHOWS the cell but Enter still CLEARS it (xg1z fixed the paint, not the commit)","description":"Found by post-merge adversarial review of PR #162 (improvise-xg1z), head b6805232.\n\nThe xg1z fix changed what the overlay DRAWS for an Editing mode that arrived without its `edit` buffer, but not what Enter COMMITS. `render.rs` now shows `edit_seed(state)` (the cell's value, e.g. \"1200\"), but `resolve.rs::commit` still reads `read_buffer(\u0026state.view, \"edit\")`, which is `unwrap_or_default()` -\u003e \"\" -\u003e `typed_cell_command` -\u003e `ClearCell`. Typing one character likewise appends to the absent buffer, so the overlay jumps from \"1200\" to \"5\", not \"12005\".\n\nSo the data loss the ticket describes (\"the next Enter commits the empty string and the value is gone\") still happens. The only change is that the screen now promises the value survives: before #162 a blank overlay at least showed what Enter would commit, and now it doesn't. The display and the commit are two derivations of \"what is in the editor\", and they now disagree.\n\nREPRODUCED at 40b8913 (gh/main), temporary test in resolve.rs tests: `client_on(record_cell())`, fold `ChangeMode(AppMode::editing())` with no SetBuffer, assert `edit_seed == \"1200\"` (passes, which is what render.rs paints), then `resolve(Enter)` -\u003e commands[0] = `Command::Model { cmd: ClearCell(..) }`. Test reverted.\n\nWhy #162's tests could not see it: both tests assert `render_model().editing` only. Neither presses a key. A test of the claim \"the overlay shows the cell rather than a blank\" should also assert that Enter on that overlay commits what it shows.\n\nAlso a fixture-right-by-accident note: `CellDisplay::number(\"1200\")` sets `text == edit_text`, so a fallback that seeded from the formatted `text` (gotcha 8: never feed a rounded value back) would pass `an_editing_mode_with_no_edit_buffer_shows_the_cell_rather_than_a_blank` too. A fixture with text \"1,200.00\" and edit_text \"1200\" separates them.\n\nLikely fix direction: make the buffer the one source, e.g. seed the absent buffer on ChangeMode(Editing) in the fold, or have commit/append read the same seeded value. The fix is not decided here.\n","status":"closed","priority":1,"issue_type":"bug","assignee":"agent-dataloss","owner":"el-github@elangley.org","created_at":"2026-09-23T03:46:25Z","created_by":"agent-review-wire","updated_at":"2026-09-23T04:09:59Z","started_at":"2026-09-23T03:49:35Z","closed_at":"2026-09-23T04:09:59Z","close_reason":"PR #169, adversarially reviewed at bdf9bff (merge). The editor's fallback now has ONE home, ClientState::settle_editor, run after every fold, bootstrap and refusal. The overlay, the Enter path and typed characters all read the buffer through read_buffer, so what is drawn and what is committed can no longer come from separate derivations. The reviewer injected the original bug, a display-text seed (the fixture separates '1,200.50' from '1200.5') and treating empty as absent, and all three fail. With real keys, Backspace to empty then Enter commits ClearCell. Follow-up: improvise-ulhd (P3): every absent-buffer test sits at cursor (0,0) with zero scroll, so a seed that ignores the offsets passes the whole workspace. The shipped code is correct, but the guard is missing.","labels":["adversarial-review","browser","data-loss","pr-162"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-2bac","title":"commit-cell-edit from a script or : line ignores its value and coords and commits the empty edit buffer to the cursor cell (fourth two-arm demotion, post-6uva)","description":"Found in a post-merge adversarial review of PR #151 (improvise-6uva), head b855faf3. This is a fourth instance of that PR's own lesson (\"a registry with two construction arms demotes the weaker caller\"). The Codex round closed the first three.\n\n`commit-cell-edit` (CommitAndAdvance, AdvanceDir::Down) is registered with `register`, and its two arms mean different things:\n- the pure arm reads `\u003cmode\u003e \u003cvalue\u003e \u003ccoords...\u003e`: value = args[1], key = parse_cell_key_from_args(\u0026args[2..]);\n- the interactive arm reads only args[0] (the mode). It DISCARDS the value and coords, takes the value from the \"edit\" buffer and the key from ctx.cell_key().\n\nSince #151, `improvise cmd`/`improvise script` and the TUI `:` line build every segment through the interactive arm. The whole-line `parse_line` syntax gate still runs through the pure arm, and it accepts the explicit form. So the gate passes and the other arm runs.\n\nMeasured in the headless CLI on a document with Region = North, South, East, where North=1 and East=3:\n\n improvise cmd 'commit-cell-edit editing 5 Region/East' -f a.improv\n\n- pre-#151 (f7573bff): East = 5, North = 1. That is the intended result.\n- gh/main (40b8913): **North is DELETED** and East is still 3. Exit code 0 and nothing on stderr. The empty edit buffer was committed to the cursor cell, which is the origin.\n\nSo a script line that names a cell and a value silently destroys a different cell instead. The failure is partial and silent, and it loses data.\n\nRepair shape: the same one #151 used for clear-cell/yank/paste/drill-into-cell. When args carry a value and coords, the interactive arm must honour them. The regression test should write to a cell the cursor is NOT on and assert that the cursor cell survives.\n\nA sibling of the same shape, not yet run (PLAUSIBLE): `toggle-panel-and-focus \u003cpanel\u003e [open] [focused]`. The pure arm honours explicit open/focused. The interactive arm ignores them and toggles based on current state, so `:toggle-panel-and-focus formula false false` now opens the panel when it is closed. It is view-only and not destructive, but it is the same demotion.\n\nA durable guard worth considering: for every `register` entry, whenever the pure arm accepts an argument vector, the interactive arm must build an equal command from it against a context whose cursor is NOT on the named cell. Today nothing type-checks which arm a caller deserves.\n","status":"closed","priority":1,"issue_type":"bug","assignee":"agent-2bac","owner":"el-github@elangley.org","created_at":"2026-09-23T03:36:16Z","created_by":"agent-review-cmd","updated_at":"2026-09-23T04:12:04Z","started_at":"2026-09-23T03:43:22Z","closed_at":"2026-09-23T04:12:04Z","close_reason":"PR #171 MERGED (confirmed), adversarially reviewed at d9b1090 (merge). A structural fix for the two-arm demotion class: CmdRegistry::register now takes parse(args) -\u003e C, then contextualize(C, ctx) -\u003e C (given the built command, never the args), then an optional fallback that runs only when parse refused. So the interactive arm can't discard explicit args, and whatever the script gate accepts, the run accepts too, with no hand-kept list. key_from_args_or_cursor is deleted. The sweep found a fifth instance, toggle-panel-and-focus, whose explicit false false was ignored. The reviewer drove i/Enter/Tab/yy/p/x/F through App::handle_key at nonzero cursors. Follow-ups: improvise-yf7p (P2: a contextualize that OVERWRITES a parsed field is guarded for commit-cell-edit only; five injected overwrites left the workspace green), improvise-c5ip (P2: a scripted commit whose advance aborts at bottom-right saves the write and exits 1; older than #151).","comments":[{"id":"9dcd479b-8d4b-531d-b3d4-4276559dd846","issue_id":"improvise-2bac","author":"agent-review-cmd","text":"Second symptom of the same root, CONFIRMED at gh/main 40b8913. context/code/cli.md section 7 claims \"Line atomicity is preserved by keeping the existing whole-line parse_line as a syntax gate\". That is false wherever the interactive arm refuses something the pure arm accepted:\n\n improvise cmd 'add-category Foo . commit-cell-edit editing 5 Foo/x' -f e.improv (a new, empty document)\n\nThe output is `Parse error: commit-cell-edit: no cell at cursor`, with exit 1. Category Foo IS written to the file. The gate passed, segment 1 applied, and segment 2 was refused at construction. So half the line landed. Before #151 the same line ran whole. commit-cell-edit is the only entry I found whose pure arm accepts an argument vector its interactive arm can refuse, so fixing the arm disagreement closes this too. The doc claim should be corrected either way.\n","created_at":"2026-09-23T03:39:18Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} +{"_type":"issue","id":"improvise-usus","title":"scripts/kb writes onto a truncated kb.pl and reports success: consistency is not completeness","description":"scripts/kb add wrote 4 facts onto a kb.pl that had ALREADY LOST 276 of its 801 facts, and reported success. kb_consistent passed, because it checks that the facts are CONSISTENT, never that they are all still there. The loss was only caught because the orchestrator noticed kb-merge print \"529 before\" where it had last printed 805, and compared the count against HEAD.\n\nLIKELY CAUSE: a background `scripts/kb replace` was killed mid-write when the previous Claude Code process exited -- it completed with exit code -1. So a truncated file was left on disk, and every later `kb add` built on it. Nothing committed was lost (HEAD, origin/main and gh/main all held 801); the working tree was restored from HEAD and the six new facts re-added, giving a diff of 6 insertions and 0 deletions.\n\nTWO FIXES, both cheap, both in scripts/kb:\n1. WRITE ATOMICALLY: write to a temp file in the same directory and rename(2) over kb.pl, so a killed write leaves the old file intact rather than a truncated one.\n2. REFUSE A SHRINK on `add`: an `add` must leave count(after) == count(before) + len(delta). kb-merge ALREADY PRINTS both numbers (\"529 before, 4 in delta, 533 after\") -- the check is to compare `before` against the count in git HEAD and refuse if the working tree has fewer facts than HEAD with no `rm` recorded. The tool reported the symptom in its own output and nothing read it.\n\nCheck_the_checker: truncate kb.pl to half, run `kb add`, and assert it REFUSES. Then kill a `kb replace` mid-write (or simulate by truncating between the two phases) and assert the old file survives.\n\nThis is doctrine(a_prose_count_is_checked_by_no_one) in executable form: a number the tool prints every run and no gate compares.","notes":"CAUSE CORRECTED: the user reports having moved git underneath the session at around this time, which is at least as likely as the killed 'kb replace' this ticket first named -- treat the cause as UNKNOWN between the two. That changes the priority of the two fixes: an atomic write only covers the killed-write cause, while REFUSE-A-SHRINK (compare the working-tree fact count against HEAD before any add) catches BOTH, since either leaves a file with fewer facts than HEAD and no rm recorded. Do refuse-a-shrink first.","status":"closed","priority":1,"issue_type":"bug","assignee":"agent-kbguard","owner":"el-github@elangley.org","created_at":"2026-09-23T03:20:57Z","created_by":"Edward Langley","updated_at":"2026-09-23T04:00:27Z","started_at":"2026-09-23T03:31:13Z","closed_at":"2026-09-23T04:00:27Z","close_reason":"PR #166, adversarially reviewed at a55b3c7 (merge). scripts/kb refuses a write whose candidate lacks any clause HEAD has, compared BY KEY rather than by count (a count lets adds in one predicate hide a loss in another), unless kb rm recorded the removal in a per-worktree ledger. The candidate is built beside kb.pl so publishing it is a rename(2): the old tool built it in $TMPDIR, so its mv could cross devices. Follow-ups filed by the review: improvise-ih3k (keys compared as a set, so one of two clauses sharing a key can vanish unseen on the non-functional predicates), improvise-yea8 (a stale ledger entry excuses a later loss), improvise-za97 (doc defects, including the refusal message offering 'commit the removal' as a way out).","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-6uva","title":"improvise script cannot move the cursor: every Move resolves through the pure-parse arm","description":"`improvise script` and `improvise cmd` resolve every command through CmdRegistry::parse -- the PURE arm, which builds CursorState::default() (row 0, col 0, row_count 0, col_count 0). The interactive arm, which builds CursorState::from_ctx(ctx), is never reached from a script. See command/cmd/core.rs:188 for the two-arm register.\n\nConsequence: every Move command (move-selection, scroll-rows, page-scroll, all four jump-*) and enter-advance snapshots a ZEROED cursor at PARSE time, clamps to (0,0), and emits SetSelected(0,0). It is worse than a no-op -- it RESETS the cursor. There is no way to position the cursor from a script, so every cursor-reading command is permanently pinned to the origin: clear-cell, yank, paste, cycle-axis-at-cursor, hide-selected-row-item, edit-or-drill, drill-into-cell, toggle-group-under-cursor, delete-*-at-cursor, commit-and-advance.\n\nDEMONSTRATED AT HEAD (a9cad15), web/demo.improv, Product on row:\n script A: \"move-selection 2 0\" then \"hide-selected-row-item\"\n script B: \"hide-selected-row-item\"\nBoth documents came out IDENTICAL, both carrying `hidden: Product/Espresso` -- row 0. diff reports IDENTICAL.\n\nBOTH DIRECTIONS DEMONSTRATED. Built the parent of the breaking commit (649d80c) in a worktree and probed:\n \"move-selection 2 0 . clear-selected-cell\" -\u003e cleared Product=Cold (row 2)\n \"clear-selected-cell\" -\u003e cleared Product=Espresso (row 0)\nThe cursor moved there. At HEAD it does not. So neither result is vacuous.\n\nBROKEN BY 35946af (2026-04-04), \"refactor(command): pre-resolve cell key and grid dimensions in CmdContext\", which introduced CursorState, InteractiveFn and the register/register_pure/register_nullary split. Verified ancestry: 35946af is an ancestor of HEAD and of 30c4bb6 (2026-09-19, the first wasm-client commit), so THE REGRESSION PREDATES THE BROWSER WORK BY FIVE MONTHS. The commit message says nothing about scripts.\n\nTHE SHAPE TO REMEMBER: a registry with two construction arms silently demotes every caller of the weaker arm, and nothing type-checks which arm a caller deserves.\n\nREPAIR (not written): run_headless_commands already builds a CmdContext for execute -- it just does not use one for CONSTRUCTION. There is no context-aware script parser today (parse_line_with also calls registry.parse), so closing this needs a parse_line_interactive(registry, line, ctx) calling registry.interactive, plus a PER-COMMAND re-parse inside the loop, since command n must see command n-1's cursor.\n\nA regression test must assert the cursor MOVED, not that the script ran: three identical replays of a fixture whose cursor moves are silent no-ops prove nothing, which is how this survived.\n\nFound by the improvise-ilvl replay audit.","status":"closed","priority":1,"issue_type":"bug","assignee":"agent-cmd","owner":"el-github@elangley.org","created_at":"2026-09-23T01:24:42Z","created_by":"Edward Langley","updated_at":"2026-09-23T02:17:29Z","started_at":"2026-09-23T01:45:53Z","closed_at":"2026-09-23T02:17:29Z","close_reason":"PR #151. improvise cmd/script now build each command PER SEGMENT against a live CmdContext (parse::segments + parse::parse_segment_interactive); construction cannot be hoisted, because command n must see n-1's cursor. Line atomicity preserved by keeping the whole-line parse_line as a syntax gate, so the only behavioural change is which cursor the commands see. VERIFIED BY THE ORCHESTRATOR with a real binary on main: 'move-selection 2 0 . hide-selected-row-item' now writes hidden: Region/East while a bare hide writes Region/North. THREE INSTANCES OF THE TICKET'S OWN LESSON, all found during the fix, none predicted: (1) the TUI's ':' line had the IDENTICAL defect -- ExecuteCommand also used parse_line_with, so :move-selection 2 0 reset the cursor; (2) register_pure's two arms DISAGREED, its interactive arm refusing empty args while its pure arm accepted them, so 'w' parsed one way and refused the other; (3) found by ChatGPT Codex -- clear-cell, yank, paste and drill-into-cell had interactive arms taking |_args, ctx| that DISCARDED explicit coordinates, harmless only while scripts reached the pure arm, so after the fix 'clear-cell Region/East' cleared the origin. Here the WEAKER arm was the one that HAD the context. Resolved by key_from_args_or_cursor: explicit coordinates win, cursor is the fallback.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-xgra","title":"pr-reviews is blind to inline review comments","description":"scripts/pr-reviews printed review BODIES only. On PR #122 the user reviewed with the body 'Generally good, added comments inline.' and five substantive rulings as inline comments; the script printed the one-line body and nothing else, and an agent reported the PR as awaiting review. Extend it to all three surfaces (reviews, pulls/{n}/comments, issues/{n}/comments) interleaved in time order, and give it a fixture-driven check_the_checker.","notes":"FIXED in PR #133 (merge 22ab58f). pr-reviews now prints all three surfaces --\nreview bodies, INLINE comments, issue comments -- interleaved in time order, with\na fixture-driven check_the_checker (22 cases) in CI's kb job.\n\nTHE DAMAGE IT REPAIRS, for the record: on PR #122 the user reviewed with the body\n\"Generally good, added comments inline.\" and five substantive rulings underneath.\npr-reviews printed the one-liner, and the orchestrator reported the PR as awaiting\nreview while the answers had been sitting there. The user had to say so.\n\nTHE FINDING THAT IS SHARPER THAN THE BUG -- VERIFIED BY THE ORCHESTRATOR AGAINST\nTHE LIVE API: commit_id is PER-COMMENT, not per-review, and they differ INSIDE ONE\nSUBMISSION. On #122, one review submitted 19:48-19:53 carries five inline\ncomments: three naming 246e8f4a and two naming 6a4c7dcd, while its body names\n246e8f4a. GitHub pins each comment to the file version it was written against, so\na comment on an outdated diff position keeps the older commit while its siblings\nmove on.\n\nSo the old head check was answering about the WRONG OBJECT, not merely reading too\nfew of them. A head comparison against the review BODY is not even a proxy for its\nown comments.\n\n--wait: AN INLINE COMMENT AT THE HEAD SATISFIES IT, argued rather than assumed --\na pulls/{n}/comments entry carries a commit_id, so it is a claim about which tree\nwas read. The clincher is that GitHub's \"Add single comment\" CREATES A REVIEW\nWHOSE BODY IS EMPTY: under the old rule that reviewer's exit code was right while\ntheir words were invisible, so the surfaces and the wait had to move together or\nthe tool would be honest and useless. AN ISSUE-LEVEL COMMENT DOES NOT satisfy it\n-- no commit, so no claim about a tree; \"I am rebasing shortly\" satisfying a wait\nfor a review is the widening that looks like the fix, and there is a fixture and\nan injected fault for exactly it.\n\nSIX FAULTS INJECTED, each caught by the case that names it. The two found by Codex\non the PR are the interesting ones and are bash-5-only:\n - `local n=\"$1\" name=\"$2\" f=\"...$n...\"` is NOT left-to-right on bash 5: every\n name is created before the assignments run, so $n is unbound under set -u.\n macOS ships bash 3.2, where it works. THE SUITE PASSED 19/19 LOCALLY AND 2/19\n IN CI ON THE SAME COMMIT -- which is the argument for wiring a shell checker\n into CI in the SAME change that adds it.\n - gh api --paginate emits one array PER PAGE, so a long thread is several\n concatenated documents.\n\nAND THE DIAGNOSTIC WORTH CARRYING: both failed as EXIT 2 WITH NO OUTPUT AT ALL,\nbecause a dead command substitution hands jq --argjson an empty string and jq\nanswers an invalid --argjson with a USAGE error, which is 2. A shell fault at the\ntop of a script arrives wearing jq's exit code at the bottom.\n\nNOT VERIFIED: the LIVE --paginate path. No PR in this repo has enough inline\ncomments to reach a second page (#122 has five), so pagination is exercised by\nfixture only -- and the fixture runs through the SAME pages_to_array, so it can\nhold the concatenated shape the live fetch actually produces.\n\nTHE AGENT ALSO WALKED INTO THIS PR'S OWN TRAP WHILE BUILDING IT: it ran\n`./scripts/pr-watch 133 \u003e f 2\u003e\u00261; echo \"EXIT=$?\"` and its tooling reported exit 0\nover a FAILING kb job, because the echo took the status. pr-watch had correctly\nexited 1. That is the strongest argument for the widened header wording being in\nthe file rather than in anyone's memory.","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T20:44:08Z","created_by":"Edward Langley","updated_at":"2026-09-22T21:04:54Z","closed_at":"2026-09-22T21:04:54Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-e0ex","title":"A ratio renders as an integer: number_format is per view, which is too coarse for one grid","description":"USER 2026-09-22: \"Right now, a Formula like `Margin = Profit / Revenue` displays\nuseless numbers. Probably, in most cases 2 decimal places displayed is sufficient\"\n\nTHE IMMEDIATE CAUSE IS THE FORMAT, NOT THE ARITHMETIC, and that is worth stating\nplainly because the user's own diagnosis pointed at decimal math (filed separately\nas the numeric-tower ticket, which is legitimate on its own merits but is NOT what\nproduces this symptom).\n\nView::default() sets number_format to \",.0\" (improvise-core/src/view/types.rs:56),\nand web/demo.improv sets \",.0\" explicitly on both views. `.0` is ZERO DECIMAL\nPLACES. So Margin = Profit / Revenue, a ratio around 0.66, is rounded\nhalf-away-from-zero (gotcha 8) and drawn as \"1\". Exact decimal arithmetic would\nnot change that by one character: 0.66 formatted with \",.0\" is still \"1\".\n\nTHE REAL DEFECT IS THAT number_format IS PER VIEW, WHICH IS TOO COARSE. One\nformat cannot serve Revenue (18,400 -- wants \",.0\") and Margin (0.66 -- wants\n\".2\" or a percent) in the same grid. Blanket-changing the default to 2 decimals\njust moves the damage: every currency measure becomes \"18,400.00\". So the fix is\nNOT a new default; it is a format that can vary per measure.\n\nWHAT TO DECIDE, in the PR body, rather than assume:\n - PER WHAT? Per _Measure item is the obvious unit and matches how a user\n thinks (\"Margin is a percentage\"). Per formula is another candidate and is\n narrower -- note a formula TARGET is a _Measure item (gotcha 7), so the two\n are not independent and picking both would be two answers to one question.\n - WHERE DOES IT LIVE? A per-measure format is document data, so it persists --\n which means the .improv grammar gains something and persistence/improv.pest\n is the single source of truth (design-principles section 3). Grammar first,\n then the tree-walker.\n - WHAT IS THE FALLBACK? The view's number_format must remain the answer for a\n measure with no format of its own, or every existing document changes\n appearance on load.\n - IS A SENSIBLE DEFAULT INFERRABLE? A formula whose expression is a division\n is almost always a ratio. Inferring is tempting and is the kind of rule that\n is wrong 10% of the time and unexplainable when it is -- if you propose it,\n propose it as a DEFAULT THE USER CAN OVERRIDE and say how they override it.\n\nROUND-TRIP IS THE TEST THAT MATTERS: persistence/roundtrip_tests.rs and the\ngrammar-walking generator. A new format field that does not survive\nformat_md -\u003e parse_md is a silent data loss, and the generator is what will find\nthe spellings you did not think of.\n\nNOTE FOR SCOPE: this is display only. format_f64 is called in rendering and\nnowhere else; formula eval uses full f64 and must keep doing so (gotcha 8 -- never\nfeed a rounded value back into eval). Nothing here touches the evaluator.","notes":"USER RULING 2026-09-22, which NARROWS this ticket from what it was filed as:\n\"We need to default to showing decimals: the expectation is that 2/3 shows\nsomething like .66... rather than 1 for this sort of application, unless the\nformat is explicit in the formula\"\n\nTWO CLAUSES, and the second changes the design:\n\n1. THE DEFAULT SHOWS DECIMALS. View::default()'s \",.0\" becomes decimal-showing.\n Combined with the user's earlier \"in most cases 2 decimal places displayed is\n sufficient\", that reads as \",.2\": Revenue draws 18,400.00 and Margin draws\n 0.66. Accounting applications do exactly this, so the noise on whole-number\n measures is the expected cost rather than a regression.\n\n2. THE OVERRIDE IS SPELLED IN THE FORMULA. This SUPERSEDES the \"per-_Measure\n item\" framing this ticket was filed with. Per-formula is narrower and is what\n the user asked for -- and the two nearly coincide, because a formula TARGET is\n a _Measure item (gotcha 7). So: a formula may carry its own format, which wins\n for its target; a RAW measure keeps the view's format. Clean split, and the\n grammar change lands in the `## Formulas` line rather than in a new section.\n\nTHE MIGRATION IS CLEANER THAN EXPECTED -- verified, not assumed. format_md writes\n`format: \u003cfmt\u003e` whenever number_format is non-empty (persistence/format.rs:121),\nand View::default() is non-empty, so EVERY DOCUMENT IMPROVISE HAS EVER WRITTEN\ncarries an explicit format line. format_line is optional in the grammar\n(improv.pest:118/126), so changing the default affects only (a) newly created\nviews and (b) hand-written documents that omit the line. No migration is needed\nfor round-tripped files.\n\nBUT web/demo.improv SETS \",.0\" EXPLICITLY ON BOTH VIEWS (lines 11 and 19), so the\ndefault change alone leaves the front page showing the bug the user reported.\nChange the demo in the same commit, and note tests/web_demo_model.rs asserts\nproperties of that document.\n\nGRAMMAR NOTES for the formula-side format:\n - The `## Formulas` line already carries a bracket suffix for the target\n category: `- Tax = Revenue * 0.1 [CustomCat]`. A format needs a spelling that\n cannot be confused with a category name. Decide it in improv.pest FIRST --\n the grammar is the single source of truth and the tree-walker follows\n (design-principles section 3).\n - The grammar-walking generator reads improv.pest via pest_meta and will\n exercise whatever you write, including spellings you did not intend. Note it\n SKIPS PREDICATES (gotcha 34), so a rule guarded only by a lookahead is a rule\n the generator can violate -- use a real token, as WHERE's `sp+` does.\n - Formula::raw is what the writer emits, not the reconstructed text, so a\n format that round-trips through `raw` may appear to work while being dropped\n from the parsed structure. gotcha 34 records exactly that failure for the\n formula TARGET: the round-trip tests never saw it because raw was unaffected.\n Assert the PARSED format, not the re-emitted line.\n\nSTILL DISPLAY ONLY. format_f64 is called in rendering and nowhere else; eval uses\nfull f64 and must keep doing so (gotcha 8). ADR 005 (improvise-l2b8) is the\nseparate question of what the arithmetic itself is, and nothing here waits on it.\nUSER CONSTRAINT 2026-09-22: \"improvise-e0ex should not affect persistence at all,\nexcept to store format metadata.\"\n\nALREADY TRUE BY CONSTRUCTION -- verified before recording, so the job is to NOT\nBREAK IT rather than to build it:\n\n - DISPLAY formatting is format_f64 in improvise-core/src/format.rs. Its only\n callers are rendering paths.\n - PERSISTENCE formatting is a DIFFERENT function in a DIFFERENT crate:\n persistence/format.rs:38 `fn format_number(n: f64)`. It writes `{n}` --\n Rust's shortest round-trip representation -- for non-integers, `{n as i64}`\n for integers below 1e15, and named forms for inf/-inf/nan.\n - `grep format_f64` over crates/improvise-io, crates/improvise-core/src/*.rs\n and src/*.rs returns ONLY format.rs's own definition and its tests.\n PERSISTENCE NEVER CALLS THE DISPLAY FORMATTER.\n\nSo stored values keep full round-trip precision regardless of what any view's\nnumber_format says, and a ,.2 default cannot round anything on disk.\n\nTHE REGRESSION TO WATCH FOR, stated because it is the one way this constraint\ngets violated by someone trying to be helpful: routing a save through the display\nformatter \"so the file matches what the user sees\". That would make the on-disk\nvalue depend on a VIEW's setting -- and a model has many views (gotcha 29), so\nthere is no single right answer and the last save would win. gotcha 8 already\nforbids feeding a rounded value back into eval; this is the persistence twin of\nit and is now written down.\n\nWHAT IS IN SCOPE ON THE PERSISTENCE SIDE, and it is only metadata:\n 1. The per-formula format spelling in the `## Formulas` line -- grammar first\n in improv.pest, then the tree-walker.\n 2. The `format:` line's VALUE for newly created views changes with the default\n (\",.0\" -\u003e the decimal default). That is metadata, not data, and existing\n documents are unaffected because format_md has always written the line\n explicitly (format.rs:121) whenever number_format is non-empty.\n\nNOTHING ELSE IN .improv CHANGES. No data line is rewritten, no number is\nre-rendered, no migration is needed.\nFIXED in PR #125 (merge 5fdce21). 1654 tests, all gates green.\n\nMEASURED, not asserted -- web/demo.improv through project::grid_of, the path both\nfront ends use:\n Revenue Cost Profit Margin\n before 18,400 6,200 12,200 1\n after 18,400.00 6,200.00 12,200.00 0.66\n with FORMAT .4 on a ,.0 view 0.6630\n\nONE CORRECTION TO THIS TICKET AND TO THE BRIEF I WROTE FROM IT: the demo has NO\nMargin formula -- only `Profit = Revenue - Cost`. Verified on main. So the front\npage was never literally showing the reported symptom; it was showing ,.0 on\nevery measure. My brief asserted the demo would \"still show the bug\" after a\ndefault change, which was wrong because I assumed a ratio that is not there.\nThe agent declined to ADD a Margin formula: that is demo content nobody asked for\nand it moves tests/web_demo_model.rs's asserted column list plus harness\nassertions. It is a two-line change if the user wants the ratio on the front page.\n\nTHE GRAMMAR DESIGN IS BETTER THAN THE BRIEF ASKED FOR. improv.pest is UNCHANGED\n-- verified. formula_raw already takes the `## Formulas` line verbatim up to the\noptional [Category] suffix, so the format rides in the FORMULA TEXT:\n format_clause = ${ ^\"FORMAT\" ~ sp+ ~ format_spec }\nin formula.pest, after the optional WHERE, with sp+ rather than a lookahead for\nWHERE's reason (the grammar-walking generator skips predicates). Consequences:\nthe generator has no new spelling to exercise, and the text a user types into the\nTUI minibuffer is the text the file carries -- one namespace, one spelling rule.\nRejected: a `{.2}` line suffix, which puts the format somewhere a user cannot\ntype it.\n\nTHE raw-VS-PARSED TRAP WAS ANSWERED WITH A MEASUREMENT. Injecting a walker that\nDISCARDS the format clause fails exactly ONE test and passes 242 others in\nimprovise-io, including the whole format_md/parse_md characterization suite and\nthe grammar-walking properties. No text-comparing test can see the field go\nmissing -- which is the gotcha-34 shape this ticket warned about, now quantified.\n\nBOTH CODEX FINDINGS WERE REAL, and both are the kind that only review catches:\n 1. FormatPlan used a HashMap keyed by target. A target category need not be\n _Measure, so a key can be claimed twice and HASH ORDER DECIDED THE SPELLING\n while model order decided the value -- gotcha 18's shape in a new place.\n Now: every formula's (cat, target, Option\u003cformat\u003e) in MODEL order, FIRST\n match wins, and a match naming no format falls through to the view.\n 2. A column total took its format from ROW 0. The agent's own context/ prose\n had argued this was safe \"for the case it cannot arise in\" -- and it arises,\n because a formula's target category is any category and can own a ROW-AXIS\n item. New GridLayout::column_key(col) gives the coordinates surviving the\n row aggregation.\n\nTWO FIXTURES PINNED TO ,.0 RATHER THAN RE-BASELINED, and the first is the\nimportant one: improvise_gp::dataset::a_cell_reaches_the_table_unrounded asserted\nits fixture's format WAS ,.0 as a precondition, then checked a stored 5.25\nreaches a fit unrounded. UNDER A ,.2 DEFAULT THAT TEST WOULD HAVE GONE VACUOUS\nWHILE PASSING. The fixture now sets ,.0 explicitly.\n\nNO .improv DATA LINE CHANGED. The user's constraint held.","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T06:11:33Z","created_by":"Edward Langley","updated_at":"2026-09-22T07:16:26Z","closed_at":"2026-09-22T07:16:26Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-y11g","title":"The : command line must be present in the browser (core feature): a request frame carrying the line","description":"USER RULING 2026-09-22: \"The : command line needs to be present in the browser,\nit's another core feature.\"\n\nThis REVERSES the decline. resolve.rs::stranding() currently refuses\n`enter-mode command-mode` (with SavePrompt and ExportPrompt), and the\nimprovise-h3y7 audit classified it \"host-only unless the ruling is otherwise\n-- shipping it to a browser means shipping the registry\". The ruling is\notherwise, AND the audit's framing of the cost was wrong.\n\nTHE COST IS NOT WHAT IT LOOKED LIKE. The registry does not have to reach the\nclient, because IN BOTH DEPLOYMENTS THE HOST ALREADY HAS ONE:\n\n - socket (?ws=): the host is native improvise-ws-server. Obviously has it.\n - worker (no ?ws=): web/build.sh builds TWO wasm bundles -- web/pkg from\n improvise-wasm-client (thin) and web/worker-pkg from improvise-worker-server,\n which depends on improvise-command and therefore ALREADY CONTAINS THE FULL\n CmdRegistry, in the browser, today.\n\nSo a `:` line is a REQUEST FRAME CARRYING A STRING, executed by the host against\nits own App and CmdContext. Zero new bundle weight on the main thread, and no\nsecond implementation of the command language.\n\nTHIS MATTERS BECAUSE THE ALTERNATIVE IS EXPLICITLY FORBIDDEN. improvise-wasm-client's\nCargo.toml carries a written argument for why improvise-command is deliberately NOT\na dependency: a binding names an intent resolved against a CmdContext -- a Model, a\nGridLayout and an App the client does not have -- so depending on it would drag the\nwhole host into the bundle to reach a lookup that could not be used. Do not do that.\n\nSHAPE, and note it is the SAME shape as the export/download ticket (improvise-2mqz),\nwhich is a reason to land them together or at least to design them once:\n ClientFrame -\u003e the line, as a string\n ServerFrame -\u003e Rejected on a parse or execution failure, else the ordinary\n projection stream carries whatever the command changed\n\nWHY A FRAME AND NOT A Command, for improvise-368h's reason: ExecuteCommand runs an\narbitrary script line and is runtime-fallible, so it must not sit inside a\nClientFrame::Commands batch that promises all-or-nothing.\n\nBLOCKED ON improvise-jz6w: a resolver cannot emit a frame today.\n\nWHAT MUST LAND WITH IT, not after:\n - the minibuffer must actually RENDER. The user reported 2026-09-21 that the\n minibuffer for typing a category name \"doesn't actually show up anywhere so\n you're typing blind\". A `:` line you cannot see is not the feature.\n - remove the CommandMode arm from stranding() and say so in the named list.\n - help page 5 already advertises the whole `:` surface to browser users\n (improvise-uln5). Shipping this closes the advertised-but-unreachable gap for\n `:` specifically; check what uln5 has left afterwards rather than closing it.\n\nSECURITY NOTE, because `:` is a wider door than any other frame: a line can reach\n`:w`, `:e` and the import wizard, i.e. the host's Storage. In the worker deployment\nthat is the user's own OPFS and is fine. In the socket deployment it is SOMEONE\nELSE'S FILESYSTEM, and improvise-kc7n's Origin check answers what page is driving a\nclient, never WHO is connecting -- improvise-kpnr (authentication) is still open.\nDecide explicitly whether the socket deployment accepts `:` lines at all, or whether\nthe frame is refused there until kpnr lands. Do not let this arrive by accident.","status":"open","priority":1,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T05:13:57Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:13:57Z","labels":["browser","protocol"],"dependencies":[{"issue_id":"improvise-y11g","depends_on_id":"improvise-jz6w","type":"blocks","created_at":"2026-09-22T05:14:02Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-y11g","depends_on_id":"improvise-owep","type":"blocks","created_at":"2026-09-22T05:24:34Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-fdk1","title":"A records coordinate rename has no wire form, and the surrogate is why","description":"Found by the improvise-h3y7 completeness audit, WHICH CORRECTED THE TICKET'S OWN\nDESCRIPTION OF THIS GAP -- and the correction changes the fix.\n\nimprovise-h3y7 said \"grepping reduce.rs for Rename returns zero\". True, and\nMISLEADING. Two paths do this:\n\n - effect::apply_coordinate_rename (ui/effect.rs:655), inside ApplyAndClearDrill,\n which is deliberately off the wire (gotcha 38).\n - commit.rs::rename_record_coordinate (cmd/commit.rs:366), the plain records path,\n which ALREADY decomposes into [AddItem, ClearCell(old), SetCell(new)] -- three\n commands that all have ModelCommand forms.\n\nSo it reads as though the plain path were already wire-expressible. IT IS NOT, for a\nreason the ticket does not state: ModelCommand::SetCell takes a RecordCell, whose\nRecordHash is an OPAQUE SURROGATE the server resolves against cells that ALREADY\nEXIST (App::resolve_record, ui/app.rs:516). The renamed record does not exist yet, so\na client cannot name it.\n\nTwo things that look like they solve this and do not. `will_be_created_by_the_frame`\n(ui/reduce.rs:284) exempts such a record from the BASIS CHECK -- it says nothing about\nhow to NAME one, and the two are easy to confuse. And RecordHash::of being pub is not\na licence: a client computing surrogates is precisely the invention ADR 002 exists to\nprevent.\n\nWHAT TO ADD: one position-free variant naming the existing record by surrogate and the\nnew coordinate by value --\n\n ModelCommand::RenameRecordCoordinate { record: RecordHash, category: String, item: String }\n\n-- mapped to a SINGLE effect factored out of the two copies above. Position-free, so\nimprovise-hkbc's rule still holds. Add it to records_named's match: it names one\nrecord, and creating the ITEM is internal to the effect, as both existing copies\nalready do.\n\nCONSOLIDATING THE TWO COPIES IS WORTH DOING REGARDLESS. They differ today only in how\nthey report the empty-value refusal, and gotcha 19 already warns that the synthetic-\nrecords branch and the plain branch drift.\n\nREMEMBER gotcha 1: renaming a category coordinate in wide records mode moves EVERY\nmeasure cell in the row, not just the one under the cursor. That is also why the basis\nunit is the record (gotcha 40) -- a cell-level unit is violated by this one legal edit.\n\nRELATED: improvise-o9uq is about the same code. Read it in full and say whether this\nsubsumes it before starting.","notes":"agent-wire2 2026-09-22, NOT BUILT, and here is the argument. There is no producer for the variant. A browser cannot enter records editing: edit_or_drill declines when state.grid.records is set (improvise-bd9o), toggle-records-mode and drill-into-cell are both in declined(), and a records coordinate cell projects id: None (improvise-exdg), so a client has no surrogate for it to echo. RenameRecordCoordinate would be a ModelCommand that nothing composes, which is the promise-not-kept shape vocabulary_is_composed.rs refuses for Command. Composing it needs the browser half of commit_plain_records_edit, and that needs RecordCol, which is session-local by gotcha 1. bd9o records that as a design question. So this ticket is BLOCKED on that design, not on vocabulary. What stands regardless: consolidating effect::apply_coordinate_rename with commit.rs::rename_record_coordinate (gotcha 19 drift). That work is in the command layer, outside the wire lane.","status":"open","priority":1,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T04:47:16Z","created_by":"Edward Langley","updated_at":"2026-09-23T03:36:47Z","labels":["protocol"],"dependency_count":0,"dependent_count":1,"comment_count":0} -{"_type":"issue","id":"improvise-2mqz","title":"SaveAs and ExportCsv have no wire form, which is why two prompts are stranded","description":"Found by the improvise-h3y7 completeness audit (direction A: effects -\u003e vocabulary).\nOf 32 ViewOnly effects, these are the only two that write storage and have NO wire\nform of any kind -- exactly the hole `Save` was in before improvise-368h.\n\neffect::SaveAs(path) and effect::ExportCsv(path) are unreachable from any client.\nresolve.rs::stranding() refuses SavePrompt and ExportPrompt for precisely this\nreason, so THE DECLINE IS A SYMPTOM: closing it means closing this, not editing the\ndecline.\n\nSHAPE: request FRAMES, not Commands -- improvise-368h's stated reason applies\nunchanged. A runtime-fallible write inside a ClientFrame::Commands batch breaks the\nall-or-nothing promise; [Save, SetCell] reported a failed write and then applied the\nedit. And neither mutates shared state or any client's slice, so reduce_view has\nnothing to fold and Checked has no basis to answer for.\n\n ClientFrame::SaveAs(PathBuf) / ClientFrame::ExportCsv(PathBuf)\n\nanswered ServerFrame::Rejected(..) on failure, to the asker alone, and SILENTLY on\nsuccess -- nothing moved, so no widget is dirty.\n\nBLOCKED ON the KeyOutcome shape gap (a resolver cannot emit a frame today). Land that\nfirst.\n\nTHE OPEN QUESTION THIS RAISES AND DOES NOT ANSWER -- flag it in the PR and get a\nruling before shipping: a browser client naming a path in the HOST's Storage is a\ndifferent act from a TUI user doing it (gotcha 2 -- the host picks the backend). The\nalternative is that a browser gets a download and never names a host path. Say which\nin context/code/protocol.md; different protocols follow from each.","notes":"=== USER RULING 2026-09-22 -- THE SHAPE ABOVE IS SUPERSEDED. READ THIS FIRST. ===\n\n'Save can save to OPFS, but exports should be downloads. There should also be a\nway to download the .improv file.'\n\nSo the description's proposed ClientFrame::SaveAs(PathBuf) / ExportCsv(PathBuf) is\nWRONG and must not be built. A browser client never names a path in the host's\nStorage. That also ANSWERS the open question the description flagged, in the safe\ndirection -- do not re-open it.\n\nTHE CORRECT SHAPE: a request frame that asks the host for BYTES, and a page that\nturns bytes into a download. Uniform across both deployments, names no path, and\ncarries no filesystem semantics across the wire at all.\n\n ClientFrame -\u003e a request for the rendered document or export\n ServerFrame -\u003e the bytes (or Rejected)\n web/app.js -\u003e Blob + an \u003ca download\u003e, which is the ONLY new browser-side code\n\nTWO ARTEFACTS ARE WANTED, not one: the CSV export, and THE .improv FILE ITSELF.\nThe second is new scope the original ticket did not have and is arguably the more\nimportant of the two -- it is how a browser user gets their document out of OPFS\nand into a file they own. persistence::format_md already renders it; nothing new\nis needed server-side beyond the frame.\n\nWHY BYTES AND NOT CLIENT-SIDE RENDERING, which is the tempting shortcut:\nimprovise-io IS in the browser bundle, so the worker deployment could render CSV\nlocally. The socket deployment could not -- a thin client holds no Model. Rendering\nlocally where possible and asking otherwise would be TWO derivations of one answer,\nwhich is the disease design-principles §5 names and improvise-edp is the worked\nexample of. Ask the host in both.\n\nNOTE render_csv must build its layout with with_frozen_records, NOT GridLayout::new\n(gotcha 1, improvise-iot6) -- export.rs already does; do not regress it.\n\nSAVE ITSELF NEEDS NOTHING NEW: the worker already persists to OPFS on the dirty\nflag (gotcha 45), and ClientFrame::Save exists for the socket deployment. What is\nmissing there is only the KEY that reaches it -- improvise-jz6w.\n\nSTILL BLOCKED ON improvise-jz6w for the same reason as before: a resolver cannot\nemit a frame today.","status":"open","priority":1,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T04:47:04Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:13:41Z","labels":["protocol"],"dependencies":[{"issue_id":"improvise-2mqz","depends_on_id":"improvise-jz6w","type":"blocks","created_at":"2026-09-22T04:48:02Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-2mqz","depends_on_id":"improvise-owep","type":"blocks","created_at":"2026-09-22T05:24:35Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-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-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":"in_progress","priority":1,"issue_type":"task","assignee":"agent-wire2","owner":"el-github@elangley.org","created_at":"2026-09-22T02:51:02Z","created_by":"Edward Langley","updated_at":"2026-09-23T03:33:55Z","started_at":"2026-09-23T03:33:55Z","lease_expires_at":"2026-09-23T03:38:55Z","heartbeat_at":"2026-09-23T03:33:55Z","labels":["architecture","protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-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-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} @@ -17,9 +32,9 @@ {"_type":"issue","id":"improvise-nxah","title":"Optimistic fold is not rolled back on server rejection","description":"## Summary\n\nWasm client folds commands optimistically in `Client::key` before sending. On server rejection (`Rejection::LayoutMoved`, etc.), `socket.rs::fold_frame` sets `last_rejection` / `status_msg` but does **not** roll back already-folded local state. Client `page_coords` (and other folded fields) can diverge from the server.\n\n## Expected / Actual\n\n- **Expected:** Rejected optimistic folds are undone (or client resyncs to server projection) so local state matches the accepted basis.\n- **Actual:** Rejection is reported; folded mutations remain.\n\n## Evidence / verify\n\nSend a command the server will reject (e.g. page-next after layout moved). Observe client state advanced while server did not.\n\nDistinct from improvise-yqpd (server-side all-or-nothing transactional rollback of effect batches / ADR 001 clause 4). This is client optimistic-fold rollback on rejection.\n\n## Location\n\n`crates/improvise-wasm-client/src/lib.rs` — `Client::key` ~L182-193; rejection handling in socket fold path\n\n## PR / Attribution\n\nPR #100 F02. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","notes":"WAVE 2026-09-21: NOT DONE, and the reason is a finding rather than a shortfall. improvise-nxah and improvise-3al1 are IN DIRECT CONTRADICTION and neither bead mentions the other. They are ONE design and should be scheduled as one.\n\nTHE CONTRADICTION, measured: nxah's naive fix is to snapshot ViewState before the optimistic fold and restore it on ServerFrame::Rejected. That WIPES ViewState::buffers -- which is precisely the user's typing that 3al1 requires be kept staged so a force can re-compose it. resolve.rs folds 9 Command::SetBuffer sites alongside 19 Command::Model sites, so a whole-slice rollback is wrong and WHICH FIELDS roll back is a design decision, not a mechanical fix.\n\nTHE SECOND BLOCKER, and it is a genuine protocol gap: there is NO POSITIVE ACKNOWLEDGEMENT. The server answers nothing on success, so a client cannot know when to DISCARD an undo snapshot. With two frames in flight a single pending slot rolls back the wrong one. This is the same shape as the publication gap ADR 001 originally omitted -- it said a batch carries the basis it was composed against and never said how a client learns one, which Projection::Basis had to close.\n\nSo the work is: decide what a rejected frame rolls back on the client given that buffers must survive, and decide how success is acknowledged. Then both beads fall out. Do not start either alone.","status":"closed","priority":1,"issue_type":"bug","owner":"box@local","created_at":"2026-09-21T20:48:18Z","created_by":"external-grok-bot","updated_at":"2026-09-22T23:00:24Z","closed_at":"2026-09-22T23:00:24Z","close_reason":"Closed","external_ref":"gh-pr-100-F02","labels":["adversarial-review","pr-100"],"dependencies":[{"issue_id":"improvise-nxah","depends_on_id":"improvise-yqpd","type":"relates-to","created_at":"2026-09-21T20:49:19Z","created_by":"external-grok-bot","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-zre1","title":"Auto-persist failure in worker not communicated to client","description":"## Summary\n\nWhen worker-server auto-persist (`effect::Save`) fails (OPFS quota, closed handle, etc.), the dirty flag stays `true` but the failure is only written to `status_msg` that the worker-server has no UI to show. The client sees projected `dirty: true` and cannot tell \"save failed\" from \"save pending\". Risk of data loss on page close if the user trusts auto-save.\n\n## Expected / Actual\n\n- **Expected:** Save failures are projected to the client as an explicit failure/status (not just dirty).\n- **Actual:** Dirty stays true; failure stays local to worker status_msg.\n\n## Evidence / verify\n\nInject a storage backend that fails writes after N successes; edit past the failure; observe dirty true with no save-failure indication on the client.\n\n## Location\n\n`crates/improvise-worker-server/src/worker.rs` — `on_frame` ~L52-59\n\n## PR / Attribution\n\nPR #100 F04. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","status":"closed","priority":1,"issue_type":"bug","owner":"box@local","created_at":"2026-09-21T20:48:18Z","created_by":"external-grok-bot","updated_at":"2026-09-21T21:58:57Z","closed_at":"2026-09-21T21:58:57Z","close_reason":"Fixed in the 2026-09-21 wave, integrated into main at 1574 passed / 0 failed / 7 ignored. Playwright 42 passed / 0 failed / 4 skipped (WebKit, no OPFS) -- up from 37, so the count MOVED. All gates green.\n\nPersist failure raises a notice. The trigger is THE DIRTY FLAG SURVIVING persist() -- observed, not predicted from a table of commands, which is gotcha 45's shape. Fault injected with a real refusing backend; a companion test proves a success raises nothing.","external_ref":"gh-pr-100-F04","labels":["adversarial-review","pr-100"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-eiv2","title":"CSV import silently converts NaN/Infinity fields to zero","description":"## Summary\n\nCSV import silently converts NaN/Infinity field values to 0. `serde_json::Number::from_f64` returns `None` for NaN and Infinity; `parse_csv_field` uses `.unwrap_or(serde_json::Number::from(0))`, so `\"NaN\"`, `\"Infinity\"`, `\"-Infinity\"`, or `\"inf\"` import as numeric 0 with no warning — data is silently corrupted.\n\n## Expected / Actual\n\n- **Expected:** Refuse non-finite numbers, keep as text, or emit a loud import warning; never invent 0.\n- **Actual:** Non-finite parses become `Value::Number(0)` with no status/warning.\n\n## Evidence / verify\n\nImport a CSV with a field value `nan` or `inf` and observe it imports as 0 rather than text or a warning.\n\nRelated closed bead improvise-4yc fixed malformed numbers → 0.0 in `.improv` persistence; this is the CSV import path (`csv_parser.rs`), still open.\n\n## Location\n\n`crates/improvise-io/src/import/csv_parser.rs` — `parse_csv_field` ~L128-131\n\n## PR / Attribution\n\nPR #100 F01. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","status":"closed","priority":1,"issue_type":"bug","owner":"box@local","created_at":"2026-09-21T20:48:17Z","created_by":"external-grok-bot","updated_at":"2026-09-21T21:26:04Z","closed_at":"2026-09-21T21:26:04Z","close_reason":"Duplicate of improvise-jc95, which is the same defect found independently the same day: parse_csv_field's .unwrap_or(serde_json::Number::from(0)) in crates/improvise-io/src/import/csv_parser.rs:133-136, so NaN/Infinity/overflowing tokens import as 0 with no warning.\n\nKeeping jc95 rather than this one because it names the exact lines and because PR #101's reproduction (csv_nonfinite_is_not_zero, covering NaN, inf, -inf and 1e999) already cites jc95. This bead's P1 rating was the better one and has been carried over: jc95 is now P1. Nothing from this report is lost -- the serde_json::Number::from_f64 mechanism is recorded on jc95.","external_ref":"gh-pr-100-F01","labels":["adversarial-review","pr-100"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-s8h7","title":"[bug] i on an aggregate cell drills in the TUI and refuses in the browser, while help advertises it as edit","description":"DEMONSTRATED at 1f28868 by driving a real App through reduce_key and a real\nClientState through resolve on the same key and the same fixture\n(furnished_host() with Product moved to Axis::None, so cell(0,0).id is\nSome(Aggregate(..))):\n\n PROBE i: browser = Local[SetStatus(\"drilling into an aggregate needs the host\n -- not in the browser yet\")]\n | tui = mode RecordsNormal, status \"Drilled into cell: 1 rows\"\n\nEditOrDrill::execute (cmd/mode.rs) branches on regular_none and, when the cell\nis aggregated, DELEGATES TO DrillIntoCell -- a mode change, a new _Drill view, a\nwhole workflow. resolve.rs::edit_or_drill's CellId::Aggregate arm returns a\nstatus line instead.\n\nWhy this matters more than the \u003e decline already filed: i is the ADVERTISED EDIT\nKEY. help.rs:514 is\n keys(n, \"edit-or-drill\", Some(\u0026[\"editing\"]), \"Edit current cell\")\nand it PASSES every_advertised_command_answers_in_its_own_mode, because a\nSetStatus counts as \"answered\". So the guard is satisfied by a refusal, and the\nsecond, undocumented route into drill is invisible to it.\n\nOn web/demo.improv nothing regular sits on None, so it does not bite today. It\nbites the moment a user tiles a category to none, which is five keys away.\n\nA defensible interim is one line: make the Aggregate arm reuse declined()'s\nwording so the two doors at least say the same thing -- today a user cannot tell\nthat i and \u003e are the same missing feature. The real fix is addressed drill,\nwhich is ADR 001 layer 2 territory.\n","status":"in_progress","priority":1,"issue_type":"bug","assignee":"agent-web3","owner":"el-github@elangley.org","created_at":"2026-09-21T20:39:17Z","created_by":"Edward Langley","updated_at":"2026-09-23T03:43:55Z","started_at":"2026-09-23T03:31:32Z","labels":["browser","bug","parity"],"dependencies":[{"issue_id":"improvise-s8h7","depends_on_id":"improvise-gd28","type":"blocks","created_at":"2026-09-22T00:17:43Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-s8h7","title":"[bug] i on an aggregate cell drills in the TUI and refuses in the browser, while help advertises it as edit","description":"DEMONSTRATED at 1f28868 by driving a real App through reduce_key and a real\nClientState through resolve on the same key and the same fixture\n(furnished_host() with Product moved to Axis::None, so cell(0,0).id is\nSome(Aggregate(..))):\n\n PROBE i: browser = Local[SetStatus(\"drilling into an aggregate needs the host\n -- not in the browser yet\")]\n | tui = mode RecordsNormal, status \"Drilled into cell: 1 rows\"\n\nEditOrDrill::execute (cmd/mode.rs) branches on regular_none and, when the cell\nis aggregated, DELEGATES TO DrillIntoCell -- a mode change, a new _Drill view, a\nwhole workflow. resolve.rs::edit_or_drill's CellId::Aggregate arm returns a\nstatus line instead.\n\nWhy this matters more than the \u003e decline already filed: i is the ADVERTISED EDIT\nKEY. help.rs:514 is\n keys(n, \"edit-or-drill\", Some(\u0026[\"editing\"]), \"Edit current cell\")\nand it PASSES every_advertised_command_answers_in_its_own_mode, because a\nSetStatus counts as \"answered\". So the guard is satisfied by a refusal, and the\nsecond, undocumented route into drill is invisible to it.\n\nOn web/demo.improv nothing regular sits on None, so it does not bite today. It\nbites the moment a user tiles a category to none, which is five keys away.\n\nA defensible interim is one line: make the Aggregate arm reuse declined()'s\nwording so the two doors at least say the same thing -- today a user cannot tell\nthat i and \u003e are the same missing feature. The real fix is addressed drill,\nwhich is ADR 001 layer 2 territory.\n","status":"closed","priority":1,"issue_type":"bug","assignee":"agent-web3","owner":"el-github@elangley.org","created_at":"2026-09-21T20:39:17Z","created_by":"Edward Langley","updated_at":"2026-09-23T04:05:07Z","started_at":"2026-09-23T03:31:32Z","closed_at":"2026-09-23T04:05:07Z","close_reason":"PR #168, adversarially reviewed at 334e218 (merge). i on an aggregate now names the same not_yet(drill) sentence as \u003e in the browser; help captions edit-or-drill as drilling into an aggregate; Normal's Enter re-captioned as move-down, since enter-advance only moves. This REVERSES the earlier 'i and \u003e refuse with different sentences on purpose' rationale: in the TUI an aggregate IS written by drilling. The reviewer confirmed the new caption is true for every cell kind (aggregate, data, formula-owned, records, no-cell) and the Enter caption in every mode that binds it. Follow-ups: improvise-3t5x (a refusal counts as answering an advertised key), improvise-j4i9 (the help overlay has no scrolling, so every page is truncated below about 50 rows), improvise-jhdf (the Enter-caption test matches one exact phrase).","labels":["browser","bug","parity"],"dependencies":[{"issue_id":"improvise-s8h7","depends_on_id":"improvise-gd28","type":"blocks","created_at":"2026-09-22T00:17:43Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-uej8","title":"[bug] the category panel cursor does not follow a shrinking list, so d deletes the next category","description":"DEMONSTRATED in a real browser at 1f28868, found while probing improvise-g3no.\n\nAfter deleting the last item of an expanded category, cat_panel_cursor stays at\nits index and the row now under it is the NEXT CATEGORY'S HEADER. Observed live:\n\n GRP d on Valley -\u003e rows: .. \"item|West\", \"category selected|\u003e Store (2) R\", ..\n\nSo a user holding d to clear out a category deletes the next CATEGORY on the\nfollowing press. RemoveCategory is LayoutGeometry: Workbook::on_category_removed\nrewrites EVERY view's axis map, and Model::remove_category also drops every\nformula targeting it (gotcha 35). High blast radius, one keypress away, no\nconfirmation, no undo.\n\nSame class from the other side, also observed: with the cursor at its default 0,\nd deleted the virtual category _Dim outright. Deleting _Index/_Dim/_Measure may\nitself deserve a guard -- they are the categories that AppMode-agnostic code\nassumes always exist (gotcha 7, and GridLayout's records-mode auto-detection\nkeys off _Index on Row and _Dim on Column).\n\nThe cursor-follows-a-shrinking-list problem is general: any panel whose list can\nshrink under the cursor has it. Check the formula and view panels for the same\nshape rather than fixing the category panel alone.\n","notes":"ALREADY FIXED by 7a44616 (PR #114) -- verified an ancestor of ee0f57d by the orchestrator with git merge-base --is-ancestor, and verified IN BOTH DIRECTIONS by the agent dispatched to fix it: the named guards pass at HEAD on BOTH front ends (command::cmd::panel::tests::deleting_a_categorys_last_item_leaves_the_cursor_inside_that_category plus two siblings, mirrored in resolve::tests::), and deleting the retreat_from_deleted_item(ctx) call reproduces this ticket's exact symptom, with the guard naming it: 'the cursor stayed over a category header, so one more d removes a whole category'.\n\nStale-open: the work landed and nobody closed the ticket. It cost an agent dispatch. See doctrine(a_ready_ticket_may_already_be_fixed).","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:39:05Z","created_by":"Edward Langley","updated_at":"2026-09-22T06:39:20Z","closed_at":"2026-09-22T06:39:20Z","close_reason":"Closed","labels":["browser","bug","tui"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-i0v9","title":"[bug] a second browser tab on the same document is dead and silent, and blames the browser","description":"DEMONSTRATED, real Chromium, two tabs in one context, at 1f28868.\n\n tab2 console: error: improvise worker: failed to start\n Error: createSyncAccessHandle(two-tab.improv):\n NoModificationAllowedError: Access Handles cannot be created if there is\n another open Access Handle or Writable stream associated with the same file.\n\nbindings.rs::boot awaits storage.attach(\u0026path) before anything else; attach\ncalls create_sync_access_handle, which is exclusive per file ACROSS CONTEXTS.\nThe failure is at attach, NOT at open: OpfsFs::open() (getDirectory) and\nrefresh() both succeed in the second tab. Only handle acquisition fails. This\nconfirms for the OPEN the half of improvise-vvl3 that was anticipated for the\nINDEX.\n\nWHAT THE SECOND TAB SHOWS, MEASURED:\n\n t=13ms #status \"in-page worker - two-tab.improv\" page blank, 0 grid tables\n t=8016ms #status \"the in-page worker did not answer -- this browser may not\n allow it (see console)\" still blank\n\n8.4s wall from navigation to the message. Two things wrong beyond its lateness:\n\n - It BLAMES THE BROWSER for a condition the code knows precisely. It holds the\n exact NoModificationAllowedError naming the exact document.\n - The first 8 seconds ACTIVELY REASSURE. \"in-page worker - \u003cdoc\u003e\" is written at\n 13ms and reads as success. The user watches a blank page under a green-looking\n status for eight seconds and is then told it is probably their browser's fault.\n\nDiagnosis chain, exactly as gotcha 46 predicts: start() rejects -\u003e worker.js's\nstarted.catch logs to console and nothing else -\u003e a worker that rejects in\nstart() fires NO error on the main thread -\u003e onFrame's `if (!server) return;`\nsilently drops every frame -\u003e only the generic first-frame watchdog is left.\n\nIT IS NOT READ-ONLY, IT IS DEAD, AND IT IS SILENT. Measured in the failed tab:\n\n SECOND after C: mode=CATEGORIES #categories present status=(watchdog message)\n\nThe client is a fully live ClientState. View-only keys still work: C changes the\nmode and paints an empty category panel. So the tab does not present as\nread-only -- it presents as WORKING and then swallows every write into a socket\nwith no peer.\n\nDOES IT RECOVER? The handle IS released on tab close (the browser tears the\nworker context down; no detach needed) and a RELOAD of the second tab then\nsucceeds. But the page never retries: no reconnect, no watchdog rearm. So\n\"read-only until the other tab closes\" is in practice \"dead until you reload\".\n\nNote OpfsFs::detach (opfs.rs:106) has NO CALLER anywhere in the workspace, and\nweb/ has no beforeunload/pagehide/visibilitychange handler. Neither turns out to\nmatter for this case -- recorded so nobody \"fixes\" this by adding one.\n","notes":"SECOND TRIGGER, from improvise-16af (closed as duplicate; filed independently by an external Grok Bot browser smoke the same day): the bug also reproduces WITHOUT a second tab -- reload the page after a prior session left a handle open. The investigation behind this bead only drove the two-tab case, so the reload path is unverified here and should be checked when fixing; it may or may not be the same window between context teardown and handle release.\n\nThat report also observed the status flipping between 'in-page worker - demo.improv' and the watchdog message, i.e. intermittency the two-tab case does not show.\nWAVE 2026-09-21: PARTIALLY FIXED, deliberately left open.\n\nCLOSED by improvise-1wft's diagnosis channel and VERIFIED END TO END in both engines: the two-tab case. The second tab now opens READ-ONLY over vfs::ReadOnly with a true message, instead of painting a reassuring status for 8 seconds and then blaming the browser.\n\nMEASURED, which the read-only design rested on and nobody had probed: getFile() + arrayBuffer() DOES work on a file another context holds a sync access handle to -- 32 correct bytes on both Chromium and Firefox. The probe FIRST asserts that a second createSyncAccessHandle is refused, so it cannot pass vacuously in an engine that does not enforce exclusivity. Those two refusal strings are now the fixtures for boot::is_locked, so the DOMException name match is evidence-backed rather than guessed.\n\nTWO GAPS REMAIN and they need their own work, not more channel:\n 1. The RELOAD-AFTER-PRIOR-SESSION trigger (carried over from improvise-16af). It is a teardown/release race and is not deterministically drivable in the harness.\n 2. NO UPGRADE PATH. Nothing retries when the other tab closes, so it is still 'read-only until you reload' -- now with a true message instead of a false one. improvise-12ha's option C needs this same retry, so the work is not wasted either way.","status":"open","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:39:04Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:59:08Z","labels":["browser","bug","opfs"],"dependencies":[{"issue_id":"improvise-i0v9","depends_on_id":"improvise-1wft","type":"blocks","created_at":"2026-09-21T21:31:50Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-i0v9","title":"[bug] a second browser tab on the same document is dead and silent, and blames the browser","description":"DEMONSTRATED, real Chromium, two tabs in one context, at 1f28868.\n\n tab2 console: error: improvise worker: failed to start\n Error: createSyncAccessHandle(two-tab.improv):\n NoModificationAllowedError: Access Handles cannot be created if there is\n another open Access Handle or Writable stream associated with the same file.\n\nbindings.rs::boot awaits storage.attach(\u0026path) before anything else; attach\ncalls create_sync_access_handle, which is exclusive per file ACROSS CONTEXTS.\nThe failure is at attach, NOT at open: OpfsFs::open() (getDirectory) and\nrefresh() both succeed in the second tab. Only handle acquisition fails. This\nconfirms for the OPEN the half of improvise-vvl3 that was anticipated for the\nINDEX.\n\nWHAT THE SECOND TAB SHOWS, MEASURED:\n\n t=13ms #status \"in-page worker - two-tab.improv\" page blank, 0 grid tables\n t=8016ms #status \"the in-page worker did not answer -- this browser may not\n allow it (see console)\" still blank\n\n8.4s wall from navigation to the message. Two things wrong beyond its lateness:\n\n - It BLAMES THE BROWSER for a condition the code knows precisely. It holds the\n exact NoModificationAllowedError naming the exact document.\n - The first 8 seconds ACTIVELY REASSURE. \"in-page worker - \u003cdoc\u003e\" is written at\n 13ms and reads as success. The user watches a blank page under a green-looking\n status for eight seconds and is then told it is probably their browser's fault.\n\nDiagnosis chain, exactly as gotcha 46 predicts: start() rejects -\u003e worker.js's\nstarted.catch logs to console and nothing else -\u003e a worker that rejects in\nstart() fires NO error on the main thread -\u003e onFrame's `if (!server) return;`\nsilently drops every frame -\u003e only the generic first-frame watchdog is left.\n\nIT IS NOT READ-ONLY, IT IS DEAD, AND IT IS SILENT. Measured in the failed tab:\n\n SECOND after C: mode=CATEGORIES #categories present status=(watchdog message)\n\nThe client is a fully live ClientState. View-only keys still work: C changes the\nmode and paints an empty category panel. So the tab does not present as\nread-only -- it presents as WORKING and then swallows every write into a socket\nwith no peer.\n\nDOES IT RECOVER? The handle IS released on tab close (the browser tears the\nworker context down; no detach needed) and a RELOAD of the second tab then\nsucceeds. But the page never retries: no reconnect, no watchdog rearm. So\n\"read-only until the other tab closes\" is in practice \"dead until you reload\".\n\nNote OpfsFs::detach (opfs.rs:106) has NO CALLER anywhere in the workspace, and\nweb/ has no beforeunload/pagehide/visibilitychange handler. Neither turns out to\nmatter for this case -- recorded so nobody \"fixes\" this by adding one.\n","notes":"SECOND TRIGGER, from improvise-16af (closed as duplicate; filed independently by an external Grok Bot browser smoke the same day): the bug also reproduces WITHOUT a second tab -- reload the page after a prior session left a handle open. The investigation behind this bead only drove the two-tab case, so the reload path is unverified here and should be checked when fixing; it may or may not be the same window between context teardown and handle release.\n\nThat report also observed the status flipping between 'in-page worker - demo.improv' and the watchdog message, i.e. intermittency the two-tab case does not show.\nWAVE 2026-09-21: PARTIALLY FIXED, deliberately left open.\n\nCLOSED by improvise-1wft's diagnosis channel and VERIFIED END TO END in both engines: the two-tab case. The second tab now opens READ-ONLY over vfs::ReadOnly with a true message, instead of painting a reassuring status for 8 seconds and then blaming the browser.\n\nMEASURED, which the read-only design rested on and nobody had probed: getFile() + arrayBuffer() DOES work on a file another context holds a sync access handle to -- 32 correct bytes on both Chromium and Firefox. The probe FIRST asserts that a second createSyncAccessHandle is refused, so it cannot pass vacuously in an engine that does not enforce exclusivity. Those two refusal strings are now the fixtures for boot::is_locked, so the DOMException name match is evidence-backed rather than guessed.\n\nTWO GAPS REMAIN and they need their own work, not more channel:\n 1. The RELOAD-AFTER-PRIOR-SESSION trigger (carried over from improvise-16af). It is a teardown/release race and is not deterministically drivable in the harness.\n 2. NO UPGRADE PATH. Nothing retries when the other tab closes, so it is still 'read-only until you reload' -- now with a true message instead of a false one. improvise-12ha's option C needs this same retry, so the work is not wasted either way.\nagent-webpage 2026-09-22 at 942211b, RELEASED unfixed. (1) Two-tab diagnosis re-verified: harness 'a second tab on the same document opens read-only and says why' is present and green; the silence/blame-the-browser defect the brief targeted is already closed by 1wft. (2) RELOAD trigger NOT REPRODUCED: 60 reloads of one document (30 waitUntil load/commit alternating, half of them with an edit in flight whose persist had not settled) in Chromium and 20+20 in Firefox all booted writable ('in-page worker · \u003cdoc\u003e'), zero read-only. The 16af observation may be a real teardown race on slower machines or a genuinely second tab; not drivable here. (3) Upgrade path still absent, measured: after the holding tab closes, the read-only tab still refuses to persist. That is a feature (retry attach + rebase to a fresh document), shared with 12ha option C, not a diagnosis fix. (4) New finding filed as improvise-j4qt: the read-only tab applies and paints edits it can never save.","status":"closed","priority":1,"issue_type":"bug","assignee":"agent-i0v9","owner":"el-github@elangley.org","created_at":"2026-09-21T20:39:04Z","created_by":"Edward Langley","updated_at":"2026-09-23T05:28:24Z","started_at":"2026-09-23T03:48:41Z","closed_at":"2026-09-23T05:28:24Z","close_reason":"Fixed in PR #199 (merged as b59af2d) after adversarial review at d5854bc; the rest of the ticket was already closed by improvise-1wft. Follow-ups: improvise-a3dj, improvise-80vh.","labels":["browser","bug","opfs"],"dependencies":[{"issue_id":"improvise-i0v9","depends_on_id":"improvise-1wft","type":"blocks","created_at":"2026-09-21T21:31:50Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-g3no","title":"[bug] d on a _Measure formula target deletes nothing and reports success","description":"DEMONSTRATED, twice, at 1f28868.\n\n_Measure's items are \"explicit items UNION formula targets\" (gotcha 7). Profit\nin web/demo.improv exists ONLY because Formula { target: \"Profit\" } exists; it\nis not in the category's own item list. Model::remove_item calls\ncat.remove_item(name) -- which no-ops, there being no such item -- and then\ndeletes cells whose key names it, of which there are none because the value is\nderived. effective_item_names(\"_Measure\") then re-synthesises Profit from the\nsurviving formula, so the row comes straight back.\n\nThe separate and worse half is that both front ends LIE about it. Each composes\nan unconditional optimistic status beside the mutation:\n\n browser: resolve.rs::delete_at_cat_cursor\n [Command::Model(RemoveItem..), Command::SetStatus(\"Deleted item ..\")]\n TUI: cmd/panel.rs::DeleteCategoryAtCursor\n [effect::RemoveItem.., effect::set_status(\"Deleted item ..\")]\n\nSetStatus is ViewOnly, folded locally, never contradicted by the host. Note the\nordering is the OPPOSITE of gotcha 20's rule, where CommitBuffer emits the\nfallible effect LAST so its failure message wins. Here the fallible effect is\nfirst and the optimistic status wins.\n\nMODEL-LEVEL EVIDENCE (no browser):\n\n let mut wb = Workbook::new(\"T\");\n wb.add_item(\"_Measure\", \"Revenue\").unwrap();\n wb.add_item(\"_Measure\", \"Cost\").unwrap();\n wb.model.add_formula(parse_formula(\"Profit = Revenue - Cost\", \"_Measure\").unwrap());\n wb.remove_item(\"_Measure\", \"Profit\");\n // FAILS: reported success, row still there: [\"Revenue\", \"Cost\", \"Profit\"]\n\nBROWSER EVIDENCE (real Chromium, real worker, real OPFS, real keystrokes via\nharness/, driving C -\u003e j*n -\u003e Enter -\u003e j -\u003e d, no state hand-placed):\n\n MEASURE on: \"item selected|Profit\"\n MEASURE after d: _Measure (2) -- Cost, Profit \u003c- UNCHANGED\n MEASURE status: \"Deleted item 'Profit' from '_Measure'\" \u003c- false\n\nEverything else about d works and was checked: regular category on Row; on Page\nwith that item as the page selection; reached by mouse click rather than j;\ngrouped items and group members; ordinary _Measure items (Revenue really is\ndeleted). The ONLY failing input is a formula target.\n\nNOT BROWSER-SPECIFIC. The TUI runs the same Model::remove_item and composes the\nsame optimistic status. Confirm in the TUI before any fix says \"browser\".\n\nFiles: crates/improvise-core/src/model/types.rs::remove_item,\ncrates/improvise-core/src/workbook.rs::remove_item,\ncrates/improvise-protocol/src/resolve.rs::delete_at_cat_cursor,\ncrates/improvise-command/src/command/cmd/panel.rs::DeleteCategoryAtCursor\n","acceptance_criteria":"Two separable changes. Truthfulness first, it is the reportable half:\n\n1. TRUTHFULNESS. Model::remove_item returns (). Give it a Removed answer, or\n give effect::RemoveItem one, and let the failure message win -- the shape\n gotcha 20 already uses for CommitBuffer, whose rule this violates. Cheapest\n correct version: have the resolver and the Cmd refuse BEFORE composing\n anything, since both ends can already see the tree.\n\n2. CORRECTNESS. Deleting the row a user sees under _Measure should delete the\n FORMULA. The browser cannot: delete-formula-at-cursor is in declined()\n (resolve.rs:232). So the honest browser answer today is declined()-style\n wording -- \"Profit is a formula target; delete the formula instead\" -- and\n deleting formulas from the browser is a separate ticket.\n\nAcceptance: a failing test at the model level first (the snippet in the\ndescription), then the front ends. Both surfaces, one change.","notes":"ALREADY FIXED by 7a44616 (\"fix(panel): five category-panel and axis defects, four\nof them P1\"), an ancestor of 7470b61. Stale-open -- the fourth from that one\ncommit, after improvise-uej8 and improvise-3b2b.\n\nVERIFIED BY EXECUTION, not by reading. Model::remove_item now ends with\nself.formulas.retain(|f| !(f.target_category == cat_name \u0026\u0026 f.target ==\nitem_name)) -- the item-level twin of the sweep remove_category always had. A\nthrowaway probe at HEAD took effective_item_names(\"_Measure\") from\n[\"Revenue\",\"Cost\",\"Profit\"] to [\"Revenue\",\"Cost\"] across\nwb.remove_item(\"_Measure\",\"Profit\"). Probe deleted.\n\nAND THE TICKET'S SECOND HALF NEEDS NOTHING: because the removal now really\nhappens, the optimistic status both front ends compose is TRUE, so no ordering\nchange is owed for the \"reports success\" part.","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:38:52Z","created_by":"Edward Langley","updated_at":"2026-09-22T07:40:27Z","closed_at":"2026-09-22T07:40:27Z","close_reason":"Closed","labels":["browser","bug","core","tui"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-pnf5","title":"Saving while an unfinished records row exists writes an unreadable .improv file","description":"Report-only hunt at 70025e2. No source changes.\n\nLocation: crates/improvise-command/src/command/cmd/grid.rs:518-546; crates/improvise-io/src/persistence/format.rs:202-211; improv.pest:97-101\n\nToggleRecordsMode then AddRecordRow on a populated workbook produces a cell with no coordinates. format_md emits a data line \" = 0\"; parse_md rejects it because coord_list requires at least one coord. CleanEmptyRecords runs on leaving records mode, not before Save. Saving in records mode can therefore overwrite a good document with one the application cannot reopen. Actual command/effect reproduction below. Related improvise-o9uq concerns the cursor editing an unrelated row; fixing that does not make the empty key serializable. Older improvise-s0h mentions cleanup on leaving records but no saving contract. Define persistence for placeholder rows, and prove save/load while one exists. Do not discard legitimate data blindly.\n\n## Steps to Reproduce\nStandalone Rust 2024 package with improvise and improvise-io path dependencies:\n```rust\nuse improvise_io::{workbook::Workbook,persistence::{format_md,parse_md}};\nfn revenue_book()-\u003eWorkbook {\n use improvise_io::model::cell::{CellKey,CellValue};\n let mut w=Workbook::new(\"T\");w.add_category(\"Region\").unwrap();w.add_item(\"Region\",\"East\").unwrap();w.add_item(\"_Measure\",\"Revenue\").unwrap();\n w.model.set_cell(CellKey::new(vec![(\"Region\".into(),\"East\".into()),(\"_Measure\".into(),\"Revenue\".into())]),CellValue::Number(10.0));w\n}\n#[test] fn save_with_unfinished_record_is_readable() {\n use improvise::{ui::app::App,command::{cmd::{Cmd,grid::{ToggleRecordsMode,AddRecordRow}},key::{KeyCode,KeyModifiers}}};\n let mut app=App::new(revenue_book(),None);\n let fx=ToggleRecordsMode.execute(\u0026app.cmd_context(KeyCode::Char('r'),KeyModifiers::NONE));app.apply_effects(fx);\n assert!(app.layout.is_records_mode());\n let fx=AddRecordRow.execute(\u0026app.cmd_context(KeyCode::Char('o'),KeyModifiers::NONE));app.apply_effects(fx);\n let text=format_md(\u0026app.model_state.workbook);\n assert!(parse_md(\u0026text).is_ok(),\"saved unparseable file: {text}\");\n}\n```\nRun cargo test save_with_unfinished_record_is_readable -- --nocapture. Reproduced failing against unchanged code.\n\n## Acceptance Criteria\nCorrect the described observable behavior; preserve ordinary cases with focused regressions and explicit edge-case policy.\n","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:35:47Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:58:13Z","closed_at":"2026-09-21T21:58:13Z","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\nGrammar first: data_coords = _{ (coord_list ~ \" \")? }, so a coordinate-free cell is spelled '= 42'. Unambiguous under PEG because bare_name cannot start with '='. The scalar cell was given a SPELLING rather than discarded, per the ticket's 'do not discard legitimate data' -- it is what AddRecordRow leaves mid-row, and before this saving in records mode could overwrite a good document with an unreadable one.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-16af","title":"[bug] In-page worker intermittent blank grid: OPFS SyncAccessHandle / NotAllowedError","description":"## Summary\nDefault `/improvise/` (in-page worker) intermittently shows a blank grid. Status flips between `in-page worker · demo.improv` and `the in-page worker did not answer`. Console reports OPFS `createSyncAccessHandle` NotAllowedError / AccessHandle conflict.\n\n## Repro\n1. Open https://fiddlerwoaroof.github.io/improvise/ (or local web/) in Chromium.\n2. Optionally open a second tab on the same origin/doc, or reload after a prior session left a handle open.\n3. Observe blank grid and/or watchdog message; DevTools console shows SyncAccessHandle / NotAllowedError.\n\n## Expected\nWorker starts, handshake completes, demo grid paints. Concurrent tabs either share safely or fail with a loud, specific \"document open elsewhere\" status.\n\n## Actual\nIntermittent blank UI; generic watchdog text; OPFS exclusive-access conflict in console.\n\n## Evidence\n- Browser smoke (external Grok Bot agent for Ed Langley), 2026-09-21 PT.\n- `crates/improvise-worker-server/src/bindings.rs`: `storage.attach(\u0026path).await?` / OPFS path.\n- `crates/improvise-io/src/opfs.rs`: `attach` acquires sync access handle.\n- `web/app.js` / `web/shell.js`: handshake watchdog arms when peer never answers.\n\n## Notes\nFound by an external Grok Bot agent smoke/code hunt for Ed Langley (not Claude). Do not claim this is a black-hole key issue.\n","status":"closed","priority":1,"issue_type":"bug","created_at":"2026-09-21T20:32:37Z","created_by":"box","updated_at":"2026-09-21T21:26:25Z","closed_at":"2026-09-21T21:26:25Z","close_reason":"Duplicate of improvise-i0v9, same root cause (OPFS createSyncAccessHandle is exclusive per file across contexts, and bindings.rs::boot awaits storage.attach before anything else).\n\ni0v9 is kept because it carries the measurement: the failure is at attach, NOT open -- OpfsFs::open (getDirectory) and refresh() both SUCCEED in the second tab, only handle acquisition fails -- plus the timings (a reassuring 'in-page worker - \u003cdoc\u003e' at 13ms, the blame-the-browser message at 8016ms) and the finding that the tab is not read-only but DEAD, since view-only keys still work while all 13 composable ModelCommand variants vanish silently.\n\nTHE ONE THING THIS REPORT ADDS, carried over to i0v9: the single-tab trigger. 'Reload after a prior session left a handle open' reproduces it without a second tab, which i0v9's investigation did not test.","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -74,15 +89,112 @@ {"_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-lrrx","title":"View history survives a document swap: install_workbook clears cursors but not the back/forward stacks, so \u003c walks the old document's views in the new one","description":"Found in a post-merge adversarial review of PR #154 (improvise-swzk), head 5de61e48. improvise-8nd9 says ViewBack/ViewForward are \"correct today\" because `ViewState::forget_view` purges both stacks when a view is deleted, and says the class returns \"the moment a second path can put a dead name on a stack\". That second path already exists.\n\n`App::install_workbook` (reached from LoadModel, AcceptWizardModel, and the third WholeWorkbook effect) resets `view_state.active_view` and clears `view_state.cursors`, because \"the incoming workbook's view names have nothing to do with the outgoing one's\" (gotcha 24). It does NOT clear `view_back_stack` or `view_forward_stack`. So the history of the old document survives into the new one, and `\u003c`/`,` walk frames that name the OUTGOING document's views.\n\nCONFIRMED with the headless CLI at gh/main 40b8913:\n- B.improv has views Default and Y, with `Initial View: Y`, and Region North/South/East on row. A.improv has views Default and X.\n- Control: `switch-view X . load B.improv . hide-selected-row-item . w ctl.improv` -f A.improv writes `hidden: Region/North` under `## View: Y`, the view B opened on.\n- Subject: `switch-view X . load B.improv . view-back . hide-selected-row-item . w out.improv` -f A.improv writes `hidden: Region/North` under `## View: Default`. `\u003c` walked A's history into B's Default view, which this client never visited in B. Exit 0 and no status.\n\nWhen the old frame names a view the new document lacks, ViewBack pushes the current view onto the forward stack and then `switch_to_view` refuses. That is 8nd9's phantom frame, and a `\u003c` that visibly does nothing. This follows from the same code (PLAUSIBLE, not run separately).\n\nRepair shape: clear both stacks in `install_workbook` beside `cursors.clear()`, for the same reason. Or give ViewBack/ViewForward swzk's observe-after shape, which is 8nd9. Either way, the regression test needs a fixture where the frame's name EXISTS in the incoming document but is not where it opened (as above). Otherwise a refused switch looks the same as \"nothing happened\".\n\nConnected-client mirrors are not affected: `Sessions::rebase` sends a fresh Snapshot, which replaces ViewState whole. This is the host/TUI's own slice.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T03:38:04Z","created_by":"agent-review-cmd","updated_at":"2026-09-23T03:38:04Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-6qet","title":"[bug] z collapses the neighbouring group and can never re-expand one: a GroupHeader is drawn but has no data row to address it from","description":"Found while replacing improvise-ub4o's vacuous involution test, which is why\nnothing said so: the old test compared execute() against an UNCHANGED context,\nso no assertion in the suite had ever run the command twice over applied state.\n\nMEASURED. Fixture: Month with Q1={Jan,Feb}, Q2={Apr,May} on Row, Type on Column,\ncursor at data row 0. Pressing `z` (toggle-group-under-cursor) three times:\n\n 1. names Q1, collapses it\n 2. names Q2, collapses it \u003c- NOT the group just collapsed\n 3. returns no effects at all \u003c- row_count() is 0\n\nMechanism. expand_category skips every item of a collapsed group, so the group\ncontributes zero AxisEntry::DataItem. ToggleGroupAtCursor resolves through\nGridLayout::row_group_for(ctx.selected.0), which is indexed by DATA row and walks\nback to the nearest preceding GroupHeader. A collapsed group therefore has no data\nrow from which its own header is the nearest preceding one -- so the cursor can\nname it only while it is expanded.\n\nTwo consequences, and the second is the one a user hits:\n\n - Pressing `z` twice collapses two groups rather than restoring one. The cursor\n did not move; the grid moved under it.\n - A collapsed group can be re-expanded only by `:toggle-group`, which names the\n group in text. From the grid there is no way back.\n\nThis is the third bead in a family about exactly that way back -- improvise-lhi4\n(prune_empty dropped the header) and improvise-0o6x (a leading header was never\ndrawn) both fixed the header's VISIBILITY. AxisEntry's doc comment says a\nGroupHeader is always visible \"so its label and collapse state show\". Both of\nthose are now true and neither makes the header ADDRESSABLE, which is the half\nleft over.\n\nNot fixed under ub4o, which is a test bead: the three tests landed there pin the\nbehaviour above as it stands (a_second_toggle_at_the_same_cursor_names_the_next_\ngroup_not_the_collapsed_one, a_fully_collapsed_row_axis_leaves_the_command_\nnothing_to_address), so whichever answer is chosen here must edit them, which is\nthe point -- they are where the decision will be visible.\n\nOPTIONS, none obviously right:\n\n (a) Let the cursor address a header. That means a cursor position that is not a\n data row, which row_group_for's whole signature (data_row -\u003e visual) is built\n to avoid, and which every other cursor-addressed command would then have to\n answer for. Expensive and wide.\n (b) Make ToggleGroupAtCursor remember: if the view has a collapsed group whose\n header is the nearest preceding one in the VISUAL entry stream at the\n cursor's visual position, expand that instead. Keeps the data-row cursor and\n reads the entry stream, which is how lhi4 was resolved -- from the entries,\n with no \u0026View.\n (c) A separate expand-all / cycle-collapse binding, leaving `z` alone.\n (d) Decide it is acceptable because `:toggle-group` exists, and record that here\n plus in the gotcha, so the next person stops re-finding it. The family of\n three beads is evidence somebody keeps re-finding it.\n\nWhichever is chosen, the guard must inject a rival that collapses a neighbour:\n\"some group is named\" is satisfied by the current behaviour.","notes":"USER RULING 2026-09-23, and it reframes the bug. \"When collapsing a group, I'd generally expect the cursor to move to whichever place the group collapsed into. In some cases, the right thing to do might be to attach an argument to the command that is used to invalidate later commands.\"\n\nSo this is NOT \"the header is not addressable\". It is \"THE CURSOR DID NOT FOLLOW THE CONTENT IT WAS ON\". The four options above are all answers to the wrong question, and the chosen shape is a FIFTH that none of them listed.\n\nDECIDED (user, from a presented fork): A COLLAPSED GROUP CONTRIBUTES ONE DATA ROW, WHOSE CELL IS AN AGGREGATE OVER THE GROUP.\n\nThe change is one statement. layout.rs:755 today reads:\n\n // Skip the data item if its group is collapsed.\n if item_group.is_some_and(|g| view.is_group_collapsed(cat_name, g)) { continue; }\n\nA collapsed group emits its GroupHeader and then skips EVERY item, contributing zero AxisEntry::DataItem -- that `continue` is the whole mechanism of this bug. Instead it should emit ONE DataItem for the group.\n\nWHY THIS SHAPE RATHER THAN THE OTHERS. It dissolves the problem instead of working around it:\n\n- The cursor lands on the collapsed group BY ARITHMETIC, with no special case. Fixture Q1={Jan,Feb}, Q2={Apr,May}, cursor at data row 0 (Jan). Collapse Q1: data rows become [Q1, Apr, May], so data row 0 IS Q1 -- exactly \"the place the group collapsed into\". Pressing z again re-expands it, so z is an involution again without anything being taught about involution.\n- NOTHING about ViewCursor, row_group_for or any cursor-addressed command changes. The alternative that literally delivered the ruling (a cursor that names a visual entry) would have changed row_group_for's signature and forced every cursor-addressed command to define what it does on a header row.\n- It is what a pivot table MEANS by a collapsed group: the group's totals in one row. Collapsing currently hides the rows and shows nothing in their place, which is the part that reads as broken.\n- Settability is already answered. ADR 002 puts CellId on the projected cell and grid_of decides it, so the row comes back as CellId::Aggregate and is non-editable in BOTH front ends with no second derivation. Do NOT re-derive editability from \"is this a group row\".\n\nTHE ORDERING CONSTRAINT THAT WILL DECIDE THE IMPLEMENTATION, and it is easy to miss. ToggleGroup is EffectKind::Model(LayoutGeometry) (verified, effect.rs:444). Gotcha 24: since improvise-ni6k the cursor is invalidated by OBSERVING the grid's frame, and apply_effects diffs the frame after every model effect INSIDE the loop. So a collapse RESETS the cursor unless the batch places it afterwards. The placement must therefore come AFTER the ToggleGroup effect in the emitted vec -- the same shape as improvise-o9uq, where \"the order is the entire fix\" and the injected rival was the SWAP rather than the omission. A test that only checks the cursor ends up right must be shown to fail with the two effects reversed.\n\nPART 2 OF THE RULING, FOLDED IN HERE rather than filed as a general mechanism (user's choice): ToggleGroup may carry the group it acted on, so a later command in the same batch can be invalidated by it. State honestly what that does and does not buy. A Binding::Sequence flattens into ONE effect vec (gotcha 21), so `z z` bound as a sequence composes both toggles against the SAME pre-batch context -- that case the argument would fix. TWO SEPARATE KEYPRESSES are a different batch each and were never affected. AND UNDER THE CHOSEN DESIGN THE SAME-BATCH CASE MAY ALSO STOP MATTERING, because the second toggle resolves against a grid where the collapsed group is an addressable row. So: implement the aggregate row FIRST, then check whether the invalidation argument still buys anything. If it does not, say so here and do not add it -- a mechanism with no reachable case is the guard-one-level-away shape this repo keeps paying for.\n\nWHAT ELSE MOVES, since row_count() grows by one per collapsed group:\n- CSV export. persistence/export.rs::render_csv builds with with_frozen_records, NOT new (improvise-iot6, gotcha 1) -- a collapsed group will now appear as a row in exports. Decide whether that is wanted and say so; it is arguably correct and is certainly a behaviour change with round-trip tests to update.\n- Column totals, the browser grid and the viewport arithmetic all read the same GridLayout, so they follow for free -- that is the argument for making the change THERE rather than in a renderer.\n- The three tests PR #156 landed pin the CURRENT behaviour (a_second_toggle_at_the_same_cursor_names_the_next_group_not_the_collapsed_one, a_fully_collapsed_row_axis_leaves_the_command_nothing_to_address). They MUST be edited -- that is deliberate, they are where this decision becomes visible.\n- collapsed_group_has_header_but_no_data_items in layout.rs asserts the premise being changed and must be inverted, not deleted.\n\nGUARD. The rival to inject is a collapse that names a NEIGHBOUR: \"some group is named\" is satisfied by the current broken behaviour. Assert the cursor's position after a collapse of a group that is NOT first on the axis, because a group at data row 0 is right by accident under both the old and new behaviour -- the same trap as a fixture whose expected count is 1.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-collapse3","owner":"el-github@elangley.org","created_at":"2026-09-23T02:23:31Z","created_by":"agent-cmd2","updated_at":"2026-09-23T03:43:52Z","started_at":"2026-09-23T03:11:52Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-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-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-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-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} +{"_type":"issue","id":"improvise-kb1d","title":"Renaming a non-_Measure formula leaves the old target as a ghost item and appends the new one at the end (km71)","description":"Found reviewing PR #214 (improvise-km71), head 77f059c8.\n\nA rename through `Model::replace_formula` of a formula whose target category is NOT `_Measure` leaves the old target behind as a plain item and appends the new target at the END of the category. The formula therefore does not keep its place in the grid, and a ghost row or column appears where it was.\n\nCONFIRMED with a scratch test in `crates/improvise-core/src/model/types.rs` (not committed). Region has items North, South and West, plus `Total = North + South [Region]`. Replacing it with `Sum = North + South`:\n- Region items before: [North, South, West, Total]\n- Region items after: [North, South, West, Total, Sum]\n- (Revenue, Region=Total) now evaluates to None. It is an empty ghost item with no formula.\n- (Revenue, Region=Sum) = 3. The formula works, but it sits in the last position rather than Total's.\n\nCause: `replace_formula` copies `add_formula`'s \"a non-_Measure target becomes a real item\" (`cat.add_item(\u0026formula.target)`) and never removes the old target's item. A `_Measure` target is implied by `measure_item_names()`, so it follows the formula's slot. That is why km71's in-place tests, which are all `_Measure`, cannot see this.\n\nRelated, and NOT new to km71 (the same holds for `remove_formula` / `d`):\n- `Model::remove_formula` also leaves a non-`_Measure` target's item behind.\n- A `_Measure` rename or delete leaves stored cells under `_Measure=\u003cold\u003e` in the DataStore. They are invisible, because no item names them any more, and they persist in `## Data`.\n- It also leaves a stale `measure_agg` entry and stale hidden/collapsed/page-selection entries.\nThe formula shadowed those cells, so nothing the user could see is lost. `context/code/core.md` §18 names measure_agg and hidden. It does not name the stored cells.\n\nOptions: (a) the rename moves the old target's item: rename it in place when nothing else stores data under it, otherwise refuse or keep it; (b) refuse a rename for non-`_Measure` targets until improvise-sboz gives formulas stable identities; (c) document it. The acceptance test should use a Region-targeted formula at a NONZERO position of at least three items, and assert both the item order and that the old item is gone.\n","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T14:52:53Z","created_by":"agent-review-214","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).","labels":["adversarial-review","core","pr-214"],"comments":[{"id":"c529a124-1c19-5a01-a46b-629649838262","issue_id":"improvise-kb1d","author":"agent-review-214","text":"ChatGPT Codex independently found this on PR #214 at head 77f059c8 and rated it P1. Raised from P3 to P2. It damages no data (the formula shadowed whatever sat under the old item), but the PR claims an in-place rename and reports 'Renamed X to Y' while it silently leaves a ghost item and moves the formula to the end. Cheapest fix inside #214: refuse a target change for a non-_Measure formula until improvise-sboz lands.","created_at":"2026-09-23T14:53:31Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} +{"_type":"issue","id":"improvise-52eq","title":"Dead-ID audit recipe reads a bd that never ran as all-resolve; its environment note inverts which forms the guard refuses","description":"Found reviewing PR #211 at 6ccad4b8.\n\nAGENTS.md, \"Cite a ticket ID you have read back\", ends the audit recipe with\n\n bd show $(cat /tmp/bd-audit-ids.txt) 2\u003e\u00261 \\\n | grep -E '^(Issue [^ ]+ not found|[Nn]o such issue)' || echo \"all resolve\"\n\nIt checks for the ABSENCE of a negative line. So any bd run that prints nothing matching reports \"all resolve\": a missing binary, the version mismatch an un-allowed .envrc gives, or a step that never ran.\n\nDemonstrated in a worktree-isolated agent:\n- `xargs bd-missing-r211 show \u003c ids 2\u003e\u00261 | grep -E '...' || echo \"all resolve\"` prints `all resolve`.\n- The control: when bd does run, a real dead id IS caught. The injected id improvise-zzzzq prints `Issue improvise-zzzzq not found`.\n\nSecond defect, in the same section. The \"One environment note\" paragraph says the recipe hands bd the whole list in one `$(cat ...)` call *because* the worktree shell guard refuses loops. At 6ccad4b8 the guard refuses the recipe's own `bd show $(cat ...)` form, and also `sh -c '...'`. Both of these RUN: `xargs bd show \u003c file` and `xargs -n1 -I{} bd show {} \u003c file`. So the note gets backwards which forms work.\n\nFix shape:\n- Compare positively. For example, take the known ids from `bd list --all --json` and `comm -23` the cited set against them. Fail when the known set is empty.\n- Check the checker. Inject (a) a dead id and (b) a bd that does not run, and watch both fail.\n- Rewrite the environment note to match.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-mergetrain","owner":"el-github@elangley.org","created_at":"2026-09-23T14:47:33Z","created_by":"agent-review-211","updated_at":"2026-09-23T15:47:43Z","started_at":"2026-09-23T14:53:20Z","closed_at":"2026-09-23T15:47:43Z","close_reason":"Fixed in PR #226 (scripts/bd-audit-ids), merged as ca2d1b1 after adversarial review at 168a28d. Follow-up: improvise-lqs0.","labels":["adversarial-review","docs","pr-211","tooling"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_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-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-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-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-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-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} +{"_type":"issue","id":"improvise-6562","title":"3t5x's Acts classifier counts a no-op as acting, and the fixture's edges make ~20 advertised keys no-ops: $ sent to column 0 passes the whole workspace","description":"Found in adversarial review of PR #180 at head 949810cd.\n\n`every_advertised_command_answers_in_its_own_mode` sorts each advertised key into Acts / Declines / Silent. `heard()`'s doc says it is \"judged by what moved, never by the variant\", but it judges by command TYPE: any `Local` command other than `SetStatus` or `SetTransientPrefix(None)` counts as Acts, whether or not folding it changes anything.\n\nThe fixture makes that matter. `client_on` is a 2x1 grid with the cursor at (0,0). `furnished_on` has one tile at index 0, and help page 0. Folding each advertised key's commands onto the fixture and diffing the state (ignoring `revision`, and counting `Command::Model`/`Request` as acts) finds these keys classified Acts that change nothing:\n\n- NORMAL and RECORDS: h k l Left Up Right, and in Normal also 0 Home $ End u PgUp.\n- TILES: h l Left Right.\n- HELP: S-Tab h p Left.\n\nIn every case they emit `SetSelected{0,0}` / `SetTileCatIdx(0)` / `HelpPage(Prev)` onto the value already there.\n\nInjected, run:\n- `jump-last-col` =\u003e `GridMove::ToStart(false)`, so `$`/End go to the FIRST column. **The whole workspace stays green** (`cargo test --workspace --no-fail-fast`, 0 failures). Nothing else in the tree pins `$`, either.\n- `move-selection` =\u003e `Relative(0,0)`, so every hjkl/arrow key is dead. This guard stays green; only `a_move_that_goes_nowhere_follows_the_command_it_mirrors` fails.\n\nFix, both halves needed:\n1. Judge Acts by the fold: diff `ClientState` before and after (ignoring `revision`), with `Command::Model` and `Request` counted as acts.\n2. Seat the fixture off every edge: at least a 3x3 grid with the cursor at (1,1), three tiles with the cursor at 1, and help page 1. This is agent-rules' \"cursor at index 0\" vacuity shape.\n\nThen re-inject `$` =\u003e ToStart and confirm the guard names it.\n","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T04:35:04Z","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} +{"_type":"issue","id":"improvise-lx7w","title":"clear-cell (x) in TUI records mode is a silent no-op; the browser clears the real record","description":"Found by the improvise-6p8q fixer (PR #184) and not fixed there. It has the same synthetic-key shape as 6p8q: in records mode the cursor's key is the synthetic (_Index, _Dim) pair, and ClearCell resolves that rather than the real record. The TUI result is a silent no-op, since nothing is stored at a synthetic key, while the browser clears the real record. That makes it a parity divergence between front ends; it is not corruption like 6p8q. The fix mirrors #184: resolve through layout.record_cell_key(row, col), and decline on coordinate, +cat and + columns and inside a drill, as paste now does. Test through App::handle_key with the cursor off row 0 and column 0, plus a lockstep case asserting the TUI and browser agree. Check PR #184's review too: its reviewer was asked to list EVERY command that reads the cursor's records key, so more may surface there.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-collapse-fu","owner":"el-github@elangley.org","created_at":"2026-09-23T04:31:46Z","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-edpl","title":"pr-watch exits 0 when mergeability is UNKNOWN","description":"scripts/pr-watch documents 'exit 1: a check failed, or the PR is not mergeable', but on PR #172 it exited 0 while reporting 'UNKNOWN/UNKNOWN 13 checks, 0 failed'. GitHub computes mergeability lazily and reports UNKNOWN for a while after the base moves, so a PR that has just become CONFLICTING can pass the watch. The orchestrator then checked mergeable directly (MERGEABLE/CLEAN) before merging, but the watch is what the merge procedure is meant to rely on. Fix: keep polling while mergeable is UNKNOWN, and exit non-zero on timeout, rather than treating UNKNOWN as mergeable. Test: a stubbed gh reporting UNKNOWN then CONFLICTING must exit 1, and UNKNOWN forever must exit 3 (timeout), never 0. This is the same shape as improvise-b4ks: a checker whose uncertain state reads as success.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-tooling3","owner":"el-github@elangley.org","created_at":"2026-09-23T04:28:21Z","created_by":"Edward Langley","updated_at":"2026-09-23T05:05:45Z","started_at":"2026-09-23T04:30:39Z","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.","comments":[{"id":"0f6d9f92-59f3-50a7-9f32-300039089d02","issue_id":"improvise-edpl","author":"Edward Langley","text":"Second instance, PR #179: pr-watch exited 0 at 'UNKNOWN/UNKNOWN 14 checks, 0 failed'. A direct follow-up check resolved it to MERGEABLE/CLEAN. The orchestrator now resolves mergeability directly after every watch, until this is fixed.","created_at":"2026-09-23T04:29:57Z"},{"id":"1bfc0a7d-70d7-5189-9fcf-9de1e57fde61","issue_id":"improvise-edpl","author":"agent-tooling3","text":"PR #186 at 128a2a0 (branch fix/edpl-pr-watch-unknown)","created_at":"2026-09-23T04:41:55Z"}],"dependency_count":0,"dependent_count":0,"comment_count":2} +{"_type":"issue","id":"improvise-6oy4","title":"Numbers do not survive the JSON wire: serde_json lacks float_roundtrip, so browser yy/p and typed numbers land 1 ULP off","description":"Found by adversarial review of PR #178 (improvise-dsxq), head 5d1bf752.\n\nserde_json is built WITHOUT its `float_roundtrip` feature anywhere in the workspace (no Cargo.toml names it), so its f64 parser is best-effort and is NOT guaranteed to read back the f64 that serialized it. Measured on this tree through CellValue's own serde (the untagged Repr in model/cell.rs):\n\n- 1,000,000 finite random-bit f64s: 296,493 read back as a different f64.\n- 1,000,000 ordinary decimals (i*1.0371/7 + 0.1): 139,787 lossy, e.g. 0.24815714285714285 -\u003e 0.24815714285714283.\n- A bare f64 through serde_json does the same, so it is the parser, not the Repr.\n\nEnd to end (scratch test driving a real Host and a real wasm Client through serialized ClientFrame/ServerFrame JSON, the browser_round_trip.rs shape): South/Revenue = 0.24815714285714285; the browser presses j y y l p; the client yanks Number(0.24815714285714283) and the host stores Number(0.24815714285714283) in South/Cost. The TUI's yy/p copies the exact value. So after #178 the browser's copy is no longer rounded to 4 places, but it is still not the value, for about one ordinary number in seven.\n\nThe same loss applies client-\u003ehost to any typed number (typed() parses exactly in the client, then the host re-parses the SetCell JSON lossily), and host-\u003eclient to CellDisplay::value, which #178 introduced. tests/lockstep.rs cannot see it, because it folds Commands in-process and never serializes.\n\nRemedy verified: with `features = [\"float_roundtrip\"]` on serde_json (tried on improvise-wasm-client's dep; feature unification applied it to the test build), both probes are 0 lossy and the e2e paste is exact. It must be enabled for EVERY build that parses a frame. The wasm bundle is built per crate (the `wasm` CI job invokes each crate separately), so enabling it only through a native crate would leave the browser lossy. Cost: serde_json documents roughly 2x on float parsing.\n\nThe regression test belongs in browser_round_trip.rs (real Host, real JSON), with a value the default parser is known to get wrong and the source cursor off index 0.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-wirefloat","owner":"el-github@elangley.org","created_at":"2026-09-23T04:22:01Z","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-f2oo","title":"Collapsing a group with a hidden member changes the column total; the fold shows hidden values only while a sibling is visible","description":"Found in adversarial review of PR #176 (improvise-6qet), head b014a97.\n\nA collapsed group's cell includes **hidden** members, via `Category::items_in_group`. The expanded grid omits them, so collapsing a group changes the column total. The hidden values are also shown only if some sibling in the group is visible.\n\nMeasured, with Q1={Jan 3, Feb 4}, Q2={Apr 100}, Feb hidden, and Month on Row:\n- Q1 expanded: rows Jan 3, Apr 100. The totals strip reads **103.00**.\n- Q1 collapsed: rows Q1 **7.00**, Apr 100. The totals strip reads **107.00**.\n- Q1 collapsed with Jan hidden as well: there is **no Q1 row at all**. `expand_category` skips hidden items before it can emit the fold. Totals read 100.00.\n\nSo Feb's value appears exactly when some other member of its group is visible. The total moves on a `z` press, which is a view operation.\n\n`context/code/core.md` §14 says the inclusion is deliberate (\"hiding decides what is drawn, not what an aggregate covers\", as on Axis::None). The inconsistency above is not stated there, though, and the brief this review ran under required the fold to agree with the uncollapsed grid's total. Pick one:\n(a) fold only the visible members, so the fold agrees with the expanded rows and the total does not move; or\n(b) keep the hidden members, but emit the fold even when every member is hidden, and state in §14 that `z` can change the totals.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-collapse-fu","owner":"el-github@elangley.org","created_at":"2026-09-23T04:21:03Z","created_by":"agent-review-176","updated_at":"2026-09-23T15:28:30Z","started_at":"2026-09-23T04:38:24Z","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-176"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-eccl","title":"A collapsed group folds a ratio formula by summing the members' ratios (Margin 1.00 where Month-on-None gives 0.50)","description":"Found in adversarial review of PR #176 (improvise-6qet), head b014a97.\n\nA collapsed group's cell folds a **formula target** by aggregating the members' evaluated results with the measure's aggregation, which is Sum by default. For a ratio this gives a wrong number. The same data is already evaluated correctly elsewhere in the same model, once `Month` goes on `Axis::None`.\n\nMeasured:\n- Data: Jan Rev 10 / Cost 5, Feb Rev 10 / Cost 5, Apr Rev 10 / Cost 5.\n- Formula: `Margin = (Revenue - Cost) / Revenue`.\n- With Month on Row and Q1={Jan,Feb} collapsed, the Q1 row reads Revenue 20.00, Cost 10.00, **Margin 1.00**. That is a 100% margin, although each month is 0.50.\n- With the same document and Month on None, Margin is **0.50**. `recompute_formulas` strips the None categories from the stems, so the formula is evaluated over the aggregated inputs, which is the formula-of-aggregates reading.\n\n`context/code/core.md` §14 justifies this as \"a formula target has no stored records to pool … for a ratio formula that is the usual pivot-table limitation\". The premise does not hold here, because the model already evaluates a formula over aggregated inputs for Axis::None. The comparison to pivot tables is also doubtful: calculated fields in a spreadsheet pivot table evaluate the formula over the summed inputs. A collapsed group is conceptually a partial aggregation, just like a None axis, yet the two now give different answers for one formula.\n\nNote that the totals strip already sums a ratio column (1.50 here). That is pre-existing, and it is why the fold \"agrees with the total\". Agreement with that total therefore does not show the fold is correct.\n\nPossible fix: for a formula target at a group key, evaluate the formula with its refs resolved through `evaluate_aggregated` at the group key, since refs to raw measures already fold once over the group's records. Do not fold the members' formula results. A test should compare the fold against the Month-on-None value.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-collapse-fu","owner":"el-github@elangley.org","created_at":"2026-09-23T04:21:02Z","created_by":"agent-review-176","updated_at":"2026-09-23T15:28:30Z","started_at":"2026-09-23T04:38:24Z","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-176"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-gw18","title":"drill-into-cell (\u003e) on a collapsed group's row opens an empty drill, and o in it writes a record at the group coordinate","description":"Found in adversarial review of PR #176 (improvise-6qet), head b014a97.\n\n`\u003e` is bound directly to `drill-into-cell` in Normal mode, and it bypasses the `folds_a_group` refusal that #176 added to `EditOrDrill`. On a collapsed group's row (key `Month=Q1`), `DrillIntoCell` opens a `_Drill` view with **0 rows** (`drill_snapshot` matches no record, because no record carries the group name). That is exactly the empty drill that `EditOrDrill`'s refusal and `context/code/core.md` §14 say must not happen. The drill puts `Month` on `Filter` with the selection `Q1`, and `o` (`add-record-row`) then builds its key from `filtering_cats()` and writes `SetCell({Month=Q1, _Measure=Revenue}, 0)`. That is a record at a coordinate which is not an item, which is the write §14's settability section exists to prevent.\n\nThe result persists. `format_md` writes `Month=Q1, _Measure=Revenue = 0` into `## Data`, and it round-trips through `parse_md`. It is invisible in the pivot grid, because `Q1` is not an item, so `group_expansion` expands past it. It only shows up in records mode.\n\nRepro (dispatch through `App::dispatch_key`): Q1={Jan,Feb}, Q2={Apr}; Month on Row, _Measure on Column; collapse Q1; cursor (0,0) on the Q1 row; press `\u003e`, which gives status \"Drilled into cell: 0 rows\"; press `o`, which gives \"Added new record row\" and a stored cell at Month=Q1.\n\nThis is the third cursor-addressed write hole next to improvise-enz6 (Enter-to-advance) and improvise-l4mp (paste). The browser declines `drill-into-cell`, so this is TUI only.\n\nSuggested fix: `DrillIntoCell` asks `layout.folds_a_group` like `EditOrDrill` does. Alternatively, a drill into a fold could expand the group coordinate into its members' records, which would arguably be the more useful answer. A regression test should seat the cursor on a fold that is not at row 0.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-collapse-fu","owner":"el-github@elangley.org","created_at":"2026-09-23T04:20:50Z","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-94x2","title":"vocabulary_is_complete misses a simple_effect! written with a doc comment or line break: a new unlisted effect passes all 5 tests","description":"Found by the adversarial review of PR #172 (head 0b3fb05).\n\n`vocabulary_is_complete.rs::effects_named` reads an effect's name as the alphanumeric run immediately after the literal `simple_effect!(`. But `simple_effect!` itself accepts `$(#[$meta:meta])*` before the name (ui/effect.rs:60), and a `///` doc comment inside the invocation is exactly such an attribute. After `code_only` blanks the comment, what follows `simple_effect!(` is whitespace, so the name is empty and the effect is silently dropped. A manual line break after `(` does the same.\n\nInjected rival (applied, run, then reverted), in ui/effect.rs after SetTileCatIdx:\n\n simple_effect!(\n /// review172 rival\n SetBogusRival(usize) =\u003e |app| app.view_state.tile_cat_idx, EffectKind::ViewOnly\n );\n\nResult: all five vocabulary_is_complete tests pass, although SetBogusRival has no Command, no ModelCommand and no verdict. The control, the same rival on one line, fails every_effect_is_on_the_wire_or_has_a_verdict as intended.\n\nThe guard misses a form that the macro's own grammar documents. `the_scan_reaches_every_way_an_effect_is_declared` checks one simple_effect! name (SetRowOffset), so it cannot see this. Suggested fix: skip whitespace and `#[...]` attributes (after blanking) before reading the ident, and add the doc-commented form to the check_the_checker test.\n","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T04:15:48Z","created_by":"agent-review-172","updated_at":"2026-09-23T04:28:20Z","closed_at":"2026-09-23T04:28:20Z","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":["adversarial-review","pr-172","protocol","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-rzux","title":"AGENTS.md 'One PR per wave' + local integration merge contradicts wave SKILL's per-agent PRs + merge --ff-only gh/main","description":"Found reviewing PR #175 at b6d2c47. This predates the PR. The PR's push/merge rules sit on top of it.\n\nTwo incompatible shipping workflows are written down:\n\n- AGENTS.md \"Shipping a wave\": \"The orchestrator still merges agent branches into its *local* `main` to integrate and run the gates\", then \"**One PR per wave**, built by a **dedicated PR agent in its own worktree**\".\n- `.agents/skills/wave/SKILL.md` (description: \"shepherd **each** to a merged PR\"), \"Shepherding\": \"Merge on GitHub, then `git fetch gh \u0026\u0026 git merge --ff-only gh/main`\". The new `agent-rules.md` agrees with the skill: every agent opens its own PR (\"Before opening a PR, check ...\", \"Do not merge\").\n\nSo one source says agent branches are merged locally and shipped as one wave PR. The other says each agent's branch is its own PR, merged on GitHub and fast-forwarded locally. `--ff-only gh/main` also fails once the orchestrator has done the local integration merge AGENTS.md prescribes. Recent practice (PRs #166-#168, one per ticket) follows the skill.\n\n## Acceptance Criteria\n- One statement of how a wave's work reaches `main` (per-agent PRs or one wave PR), with the other place pointing to it. The `--ff-only` step is consistent with whatever local merging remains.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-docs-wave","owner":"el-github@elangley.org","created_at":"2026-09-23T04:12:39Z","created_by":"agent-review-175","updated_at":"2026-09-23T06:29:42Z","started_at":"2026-09-23T05:01:04Z","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":["adversarial-review","docs","pr-175"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-vjus","title":"code-review skill's gate list says cargo fmt --all --check, which CI and agent-rules forbid, and omits checkers/node-test","description":"Found reviewing PR #175 at b6d2c47. This predates the PR. The PR's \"one home\" goal missed it.\n\n`.agents/skills/code-review/SKILL.md` \"Verifying\" (and its byte-identical `.claude/skills/code-review` copy) says \"Run the gate **CI runs**, flags and all\", then lists `cargo fmt --all --check`. CI does **not** run that: `.github/workflows/ci.yml` ~line 143 says \"NOT `cargo fmt --all`, which reaches only ONE of the two workspace roots\" and runs `./scripts/fmt-workspaces.sh` (improvise-b4ks). The new `.agents/skills/wave/agent-rules.md` \"Gates\" says \"**Never `cargo fmt --all`**\". The same list also leaves out `direnv exec .`, `./scripts/checkers.sh` and `./scripts/node-test.sh`, all in CI and in agent-rules.\n\nThis matters because wave SKILL.md tells the orchestrator to dispatch an adversarial reviewer, and that reviewer loads the code-review skill. A reviewer following it runs a fmt gate that cannot see `harness/opfs-contract` and skips the node gate improvise-xolb calls the only guard on an unpainted RenderModel field. So two statements of \"the gate list\" disagree, the defect PR #175 set out to remove.\n\nAlso noted, not part of this ticket: `.claude/skills/{code-review,kb,pr-watch}` are real directories identical to their `.agents/skills` twins, which is the drift shape PR #175 fixed for `wave` with a symlink.\n\n## Acceptance Criteria\n- The code-review skill's gate list is a pointer to agent-rules.md \"Gates\" (or CI), not a restatement, and no longer says `cargo fmt --all`.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-tooling3","owner":"el-github@elangley.org","created_at":"2026-09-23T04:12:38Z","created_by":"agent-review-175","updated_at":"2026-09-23T04:51:04Z","started_at":"2026-09-23T04:30:40Z","closed_at":"2026-09-23T04:51:04Z","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'.","labels":["adversarial-review","docs","pr-175","tooling"],"comments":[{"id":"50380335-b013-5bba-9b55-115633168d04","issue_id":"improvise-vjus","author":"agent-tooling3","text":"PR #189 at 8641f9a (branch fix/5fzx-skill-symlinks)","created_at":"2026-09-23T04:41:58Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} +{"_type":"issue","id":"improvise-anmm","title":"Dead-ID audit does not scan .agents/.claude, and PR #175 puts the wave rulebook's ticket citations there","description":"Found reviewing PR #175 at b6d2c47.\n\nAGENTS.md \"Cite a ticket ID you have read back\" scans `context README.md AGENTS.md CLAUDE.md crates src tests examples web harness docs`. It does not scan `.agents/` or `.claude/`.\n\nAt gh/main there were **zero** `improvise-xxxx` IDs under `.agents`/`.claude` (`git grep -ohE 'improvise-[a-z0-9]{3,8}\\b' gh/main -- .agents .claude` is empty). PR #175 adds the first: `.agents/skills/wave/agent-rules.md` cites improvise-x1uk, improvise-b4ks, improvise-90ea and improvise-hzjo, and AGENTS.md's pointer names improvise-1f8e. So the file every wave agent is briefed from is now outside the audit. All five resolve today (checked by running the recipe with `.agents .claude` added).\n\nWidening the scan has one cost to handle in the same change: it surfaces `improvise-report` from the `crate(improvise_report,'crates/improvise-report',...)` example in `.agents/skills/kb/SKILL.md` line 35 (and its `.claude` copy). That is a crate name, not a ticket, and resolves to nothing, so it needs the anchored `^...$` exclusion list, per the recipe's own rule for short lowercase non-ticket names.\n\n## Acceptance Criteria\n- The audit recipe in AGENTS.md scans `.agents` (and `.claude`, or states why the symlinks make that redundant).\n- `improvise-report` is excluded (anchored), and the recipe reports \"all resolve\" at HEAD.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-docs-wave","owner":"el-github@elangley.org","created_at":"2026-09-23T04:12:36Z","created_by":"agent-review-175","updated_at":"2026-09-23T06:29:42Z","started_at":"2026-09-23T05:01:11Z","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":["adversarial-review","docs","pr-175","tooling"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_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-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} +{"_type":"issue","id":"improvise-hzjo","title":"Determinism law over two independently built, equal contexts, for every registered command","description":"User's suggestion on PR #156 (2026-09-23): restore the deleted determinism law, but build TWO CONTEXTS THAT ARE EQUAL YET DISTINCT INSTANCES.\n\nWHY THIS ISN'T VACUOUS when the old law was. `law_toggle_group_involution` called execute() twice against ONE immutable CmdContext. Every HashMap it reached was the same instance, iterated in the same order both times, so it could only restate that a pure function is a function. Build two contexts INDEPENDENTLY from equal data (two Apps loaded from the same document) and each HashMap/HashSet gets its own RandomState. A command whose effects depend on hash iteration order then gives DIFFERENT effects, and the law fails.\n\nThat's the one kind of nondeterminism this repo has actually shipped: gotcha 18. The drill snapshot froze records in HashSet order, so the same drill in two processes addressed different records and pending edits were misdirected. And import::analyzer built distinct values through a HashSet, so the same CSV imported twice gave two different .improv files.\n\nNAMING: the old test was called an \"involution\" law but tested determinism. Involution (z twice returns to the start) needs the effects applied in between, and is improvise-6qet's business. Keep the two properties separate and named correctly.\n\nSHAPE: a property over EVERY command in the registry, not just toggle-group. For each registered command that parses from a fixture context: build the context twice from the same document, execute, and compare the effect lists by their Debug text. Commands that legitimately depend on something outside the context (time, rng) must be listed with a reason, and that list must be checked in both directions, as vocabulary_is_composed does, so it can't go stale. Beware improvise-c7mu's lesson: Debug text is also a source-text proxy.\n\nINJECT THE RIVAL: make one command iterate a HashMap from the context, e.g. ViewState::cursors or a buffers map, and emit in that order. The law must fail, and must fail ONLY for two distinct instances, not for a single reused one. That second half is what proves the distinct-instance construction is load-bearing.","status":"closed","priority":2,"issue_type":"task","assignee":"agent-registry","owner":"el-github@elangley.org","created_at":"2026-09-23T03:51:25Z","created_by":"Edward Langley","updated_at":"2026-09-23T05:52:25Z","started_at":"2026-09-23T04:12:56Z","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-y1av","title":"lm5e's at-cursor assertions sit on cursor 0 of a one-element list: delete-formula-at-cursor ignoring its cursor passes the whole workspace","description":"Found in a post-merge adversarial review of PR #148 (improvise-lm5e), head 19eed82e. PR #148 strengthened sixteen assertions so they name a field. Three of them name a field that the FIXTURE supplies, not the cursor. Each one runs with its cursor at 0 of a one-element list:\n\n- `delete_formula_at_cursor_with_formulas`: one formula (Profit), formula_cursor 0.\n- `switch_view_at_cursor_with_valid_cursor`: two_cat_model has one view (Default), view_panel_cursor 0.\n- `delete_view_at_cursor_zero_does_not_adjust_cursor`: same.\n\nThe PR's rival was a constant wrong name (\"Other\"), and that rival is caught. The rival these fixtures cannot see is \"ignore the cursor and use index 0\". The evidence text says \"the formula the cursor is on\" and \"the view under the cursor\", and that is the claim a cursor-0 fixture cannot test.\n\nCONFIRMED by injection at gh/main 40b8913 (reverted afterwards, then the full suite was re-run: 1747 passed, 0 failed, 7 ignored):\n- `DeleteFormulaAtCursor::execute` with `let cursor = 0usize.min(...)` instead of `ctx.formula_cursor.min(...)`. The **whole workspace passes**: 0 failures. Nothing anywhere pins that `d` in the formula panel deletes the formula under the cursor rather than the first one. A regression there silently deletes the wrong formula.\n- SwitchViewAtCursor and DeleteViewAtCursor using `view_names.first()` instead of `.get(ctx.view_panel_cursor)`. The two named panel.rs tests PASS. Only `wasm-client/tests/lockstep.rs` catches it (`a_locally_resolved_view_step_lands_where_the_host_would_land` and `the_view_panel_bindings_agree_with_the_host`), plus `switch_view_at_cursor_out_of_bounds_returns_empty`, which fails only because `first()` also changes the out-of-bounds arm.\n\nRepair: seat each cursor at a nonzero index of a list with at least two elements (e.g. formula_cursor = 1 with two formulas, and assert the SECOND is removed). Then add an evidence entry naming the index-0 rival.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-registry","owner":"el-github@elangley.org","created_at":"2026-09-23T03:49:20Z","created_by":"agent-review-cmd","updated_at":"2026-09-23T05:09:59Z","started_at":"2026-09-23T04:12:57Z","closed_at":"2026-09-23T05:09:59Z","close_reason":"Fixed in PR #183, merged after adversarial review at a4910eb. The same class persists for ToggleGroupAtCursor: improvise-uv9e.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-c7mu","title":"vocabulary_is_composed counts a comment naming Command::X as a composer: delete the real composer, leave a comment, 4/4 pass","description":"Found by post-merge adversarial review of PR #160 (improvise-m0sx), head 21184bbf. Checked at 40b8913.\n\n`vocabulary_is_composed.rs::constructed_in` counts every `Command::\u003cName\u003e` text occurrence that is not followed by `=\u003e`, `|` or `if`, in non-test source. It does not skip comments or string literals, so a `//` or `///` comment that names a variant counts as a composer. That makes `every_command_variant_is_composed_by_production_code` WEAKER, which is the direction the evidence says a scan weakness cannot go (\"a broken walk ... gets stricter, not weaker\"). protocol.md §2 lists two limits (the cfg(test) line scan, and `matches!`/`if let`), but not this one.\n\nCONFIRMED. Fault injected in resolve.rs: deleted the real `Command::SetYanked(typed(..))` from the \"yank\" arm, leaving only SetStatus, and added the line `// TODO: Command::SetYanked(value) once yank resolves locally.` All four vocabulary_is_composed tests passed (4/4), so a variant with no composer was reported as composed. Reverted.\n\nIt is live today, not only hypothetical: a diagnostic dump of what the scan counts includes `crates/improvise-command/src/ui/project.rs`'s doc comment \"/// per-client and travels as `Command::SwitchView`, so it is not here.\" (SwitchView also has real composers, so no wrong verdict yet.) The mirror hazard: a comment explaining that a NOT_COMPOSED variant is not composed (e.g. \"`Command::SetSearchMode` is held back, fo0w\") makes `the_uncomposed_list_names_nothing_that_is_composed` fail with a false alarm.\n\nA second, latent weakness of the same kind: the `#[cfg(test)]` strip only works on items inside a file. A test-only FILE included by `#[cfg(test)] mod x;` (e.g. improvise-io's persistence/roundtrip_tests.rs, and this checker's own file), or a `#[cfg(any(test, feature = \"test-support\"))]` module (protocol's variants.rs), is scanned as production. No variant is wrongly counted through that route today.\n\nFix direction: strip `//` comments (line and doc) and string literals before matching. Or, sturdier, use syn to walk expressions. Then add a check_the_checker case that feeds `constructed_in` a comment and a string.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-wire2","owner":"el-github@elangley.org","created_at":"2026-09-23T03:47:01Z","created_by":"agent-review-wire","updated_at":"2026-09-23T04:28:20Z","started_at":"2026-09-23T03:49:49Z","closed_at":"2026-09-23T04:28:20Z","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":["adversarial-review","pr-160","protocol","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_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-t0ma","title":"records_steps is guarded by one incidental test","description":"records_steps (improvise-core/src/view/layout.rs) is guarded by exactly ONE test in the whole workspace, and that test is incidental: injecting is_index_or_dim() -\u003e is_measure() into records_steps failed only save_with_unfinished_record_is_readable, which is a persistence test that happens to enter records mode. Measured during PR #140.\n\nThat is thin for the spine GridLayout auto-detects records mode by (_Index on Row, _Dim on Column). A rival that got the spine wrong in a way that persistence does not observe would ship.\n\nWanted: a step-equivalence test asserting records_steps(model) produces the spine plus exactly one Axis::None step per non-index/dim category, and drill_steps likewise. It must be a test OF THE STEPS, not of the resulting effects -- the effects are cmd/grid.rs's translation and are already covered; the point of the split is that the steps are the model-side answer a browser will also consume.\n\nInject the is_measure() rival and show it fails the new test. Asked for by the ChatGPT Codex review on PR #140.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-23T01:36:26Z","created_by":"Edward Langley","updated_at":"2026-09-23T01:36:26Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-up8g","title":"Viewport extent is an external input on BOTH front ends, and it changes which record an edit writes","description":"The viewport extent is an EXTERNAL INPUT that reaches the document, and it is not a browser problem -- the TUI has exactly the same hole and always did.\n\nrun_headless_commands reads crossterm::terminal::size() into app.term_{width,height}, and CmdContext::{visible_rows, visible_cols} derive from it. Measured: term 80x24 -\u003e visible_rows=16; term 200x60 -\u003e visible_rows=52.\n\nAND IT REACHES THE DOCUMENT. Probe: a 40-row single-category grid, identical command sequence, one PageDown then clear-cell at the cursor:\n term 80x24 -\u003e selected=(12,0) -\u003e cleared Row=R12\n term 200x60 -\u003e selected=(39,0) -\u003e cleared Row=R39\nSame commands, DIFFERENT RECORD DELETED.\n\nSo Client::set_viewport is not a browser-introduced external input; it is the browser's SPELLING of an input the TUI has had all along. Gotcha 47's \"not derivable\" is true of BOTH front ends. Any ADR that treats extent as a browser problem will produce a log that replays in a browser and not in a terminal.\n\nTHE INTERACTION THAT MAKES THIS URGENT: today this hazard is INVISIBLE in `improvise script`, because the script path discards the cursor entirely (improvise-6uva). Fixing 6uva makes this live. The extent must be in the log -- or pinned by the replay harness -- IN THE SAME CHANGE that restores context-aware script parsing, or the repair will look like it works and then be flaky.\n\nThis is the cheapest of the five external inputs to close: it is one pair of integers and it already has a wire spelling (ClientFrame::Viewport). Make it a logged command (set-extent \u003crows\u003e \u003ccols\u003e) emitted on resize. The TUI has no such command today -- that is the only new vocabulary needed, and gotcha 42 already argues the extent is deployment-independent.\n\nA regression test must drive TWO sizes and assert the edit lands on the same record. One size cannot see this.\n\nFound by the improvise-ilvl replay audit.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T01:24:54Z","created_by":"Edward Langley","updated_at":"2026-09-23T01:24:54Z","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-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-f9qf","title":"Pin the shell: macOS bash 3.2 hid two bugs that failed 17/19 in CI on the same commit","description":"USER RULING 2026-09-22: \"Bash really should be gotten from nix: macOS bash is old\nand deprecated, or just use zsh for scripts. You can use a nix shebang line\n(nix-shell manpage has details here).\"\n\nWHY, MEASURED RATHER THAN ASSERTED. improvise-xgra (PR #133) shipped two bugs that\nwere invisible on the machine that wrote them:\n - `local n=\"$1\" name=\"$2\" f=\"...$n...\"` is NOT left-to-right on bash 5 -- every\n name is created before the assignments run, so $n is unbound under set -u.\n - macOS ships bash 3.2 (2007, GPLv2, frozen), where it works.\nRESULT: scripts/pr-reviews-test.sh passed 19/19 LOCALLY AND 2/19 IN CI ON THE SAME\nCOMMIT. Both bugs surfaced as exit 2 with no output, because a dead command\nsubstitution hands jq an empty --argjson and jq answers with a USAGE error.\n\nTHE INVENTORY, as of 22ab58f: 16 scripts, with 11 `#!/usr/bin/env bash`,\n4 `#!/bin/sh` and 1 `#!/usr/bin/env sh`. So the bash/sh split is already\ninconsistent, and the sh ones are running as macOS bash-in-sh-mode locally and\ndash in CI -- a SECOND undetected platform difference of the same kind.\n\nTHE TWO OPTIONS THE USER NAMED:\n a. A NIX SHEBANG, e.g.\n #!/usr/bin/env nix-shell\n #!nix-shell -i bash -p bash\n which pins ONE bash for every developer and CI alike. Cost: nix-shell\n startup on every invocation, which matters for scripts CI runs per job and\n for anything in a tight loop -- MEASURE IT before committing, and say what it\n costs on a cold and a warm store.\n b. ZSH, which macOS ships current and which is the login shell there. No startup\n cost, but it is a different language in the places that bite (word splitting,\n array indexing from 1, `setopt` needed for sh-compatible globbing), so\n PORTING IS NOT A SHEBANG CHANGE -- every script would need reading.\n\nDECIDE ONE AND APPLY IT UNIFORMLY. A per-script choice reintroduces exactly the\ninconsistency above.\n\nCHECK BEFORE CHOOSING, because it may decide it:\n - Does CI have nix on every job that runs a script? The kb job uses\n `nix shell nixpkgs#trealla`, so nix is present there; confirm for build, test\n and browser.\n - Does the git merge driver (scripts/beads-merge-driver.sh) tolerate a\n nix-shell startup? It runs inside git's merge machinery.\n - Does .envrc / direnv already put a nix bash on PATH? If so, `#!/usr/bin/env\n bash` may already be correct INSIDE the dev shell and wrong only outside it --\n which would make the fix \"run scripts through direnv\" rather than a shebang\n change, and would be cheaper than either option.\n\nWHATEVER LANDS, THE GUARD IS THE POINT: the bug this closes was invisible to a\nlocal run, so the fix must be verified by something that RUNS BOTH SHELLS, or by\npinning so there is only one. A checker wired into CI in the same change is the\nminimum -- improvise-xgra's lesson exactly.","notes":"FIXED in PR #137 (merge a9cad15).\n\nTHE ENVIRONMENT, NOT THE INTERPRETER, WAS THE LOAD-BEARING HALF -- and this\nticket, the originating commit, and the agent's own first hypothesis all blamed\nthe interpreter.\n\nDIRENV DOES NOT SOLVE IT, and the reason is the finding. .envrc puts nix bash\n5.3.15 on PATH, which IS CI's semantics. But pkgs.mkShell ALSO EXPORTS ~30\nGENERICALLY-NAMED LOWERCASE VARIABLES THE RUNNER DOES NOT HAVE -- name, out,\noutputs, system, shell, builder, stdenv, phases, patches, buildInputs, doCheck,\nstrictDeps and more. A script reading one by accident under set -u is SILENT IN\nTHE DEV SHELL AND FATAL IN CI.\n\nThat is exactly what happened. pr-reviews' broken third clause read $name, and in\nthe dev shell $name is \"nix-shell-env\". Reproduced at 051f541^, one machine, one\nsitting:\n macOS /bin/bash 3.2 19 passed, 0 failed\n direnv exec . (nix bash 5.3) 19 passed, 0 failed\n the same, with `name` unset 2 passed, 17 failed \u003c- matches CI exactly\nTWO DIFFERENT REASONS FOR GREEN, ONE OF THEM THE CORRECT INTERPRETER.\n\nNote also: on CI it is $name that is unbound, NOT $n -- `fetch` declares `local\nn`, so dynamic scoping keeps n bound. A fix aimed only at the shell would have\nshipped.\n\nVERIFIED BY THE ORCHESTRATOR, and my first check was confounded: `nix shell\nnixpkgs#bash` appeared to set `name` too, until I re-ran with `env -u name` and\nit came back UNSET while `direnv exec .` still gave nix-shell-env. My check had\ninherited the variable from my own direnv-loaded shell -- it answered \"does name\nend up set\" where the question is \"does nix shell set it\". The agent's control\nwas right and mine was not.\n\nTHE NIX SHEBANG WAS DECLINED ON AVAILABILITY, NOT SPEED. Measured warm, 20\ninvocations: ambient `bash -c true` 5.1ms; `nix shell nixpkgs#bash --command`\n61.8ms; `nix-shell -p bash --run` (THE MANPAGE FORM) 675.2ms, 132x. 675ms inside\ngit's merge machinery for beads-merge-driver.sh is bad -- but the decider is that\nNIX IS ON ONLY THE kb JOB. build, test, wasm, js, harness-lint, browser and\npages-deploy have none, so a nix shebang needs install-nix-action added to three\nmore jobs, paid every run, to reach a bash those runners already ship. zsh\ndeclined too: not a shebang change, sixteen scripts to read, and no zsh on the\nrunner either.\n\nWHAT LANDED: scripts/checkers.sh, the ONE entry point for every check_the_checker,\ncalled verbatim by CI and by the local gate. It pins a bash \u003e= 5 (ambient first,\nnix only when the ambient one is older, exit 3 when neither), SCRUBS THE\nENVIRONMENT TO AN ALLOWLIST, and refuses any shebang in scripts/ that is not\n#!/usr/bin/env bash. CI logs `checkers: bash 5.2 at /usr/bin/bash` -- ambient, no\nnix, zero startup.\n\nTHE INJECTED FAULT, which is the proof: restoring the multi-assignment `local`\ngives 22 passed under the OLD local gate, 22 passed under direnv, and\n2 passed / 20 failed / exit 1 under checkers.sh.\n\nTHE OLD GATE WAS ITSELF THE BUG. `for t in scripts/*-test.sh; do sh \"$t\"; done`\nOVERRIDES EVERY SHEBANG with /bin/sh, so on macOS it ran every suite under bash\n3.2 in sh-mode regardless of what the file said. The gate in my brief was the\ndisease. Seven other sites ran a repo script through an explicit `sh`, and one of\nthem is dist-workspace.toml -- which a first-line shebang gate STRUCTURALLY CANNOT\nSEE. The shebang rule closes declarations, never invocations.\n\nUNRUN, STATED: scripts/build-web-release.sh runs only under dist in\nbuild-global-artifacts, which no branch push exercises, so its new shebang and two\nchanged lines are mechanical and untested.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T22:31:17Z","created_by":"Edward Langley","updated_at":"2026-09-22T23:16:23Z","closed_at":"2026-09-22T23:16:23Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-clso","title":"Ctrl-S saves and also opens the browser's own save dialog","description":"Found by the improvise-jz6w agent (PR #134) as a consequence of its own fix, and\ncorrectly NOT fixed there because every available fix crosses a doctrine line.\n\nNOT A REGRESSION -- verified by the orchestrator. web/app.js:786 calls\npreventDefault only when `!e.ctrlKey \u0026\u0026 !e.altKey`, with the deliberate comment\n\"Ctrl/Alt combinations are left alone: they are the browser's\". So Ctrl-S ALWAYS\nopened Chrome's \"Save page as\" dialog; before improvise-jz6w it did that and\nnothing else. Now it saves the document AND opens the dialog. The save is\ncorrect; the dialog is the wart, and it is newly VISIBLE rather than newly\nintroduced.\n\nWHY IT WAS NOT FIXED IN #134, and this is the useful part:\n - Parsing the frame in app.js gives the page PROTOCOL KNOWLEDGE\n (design-principles section 5).\n - A `e.key === \"s\" \u0026\u0026 e.ctrlKey` test puts a KEYBINDING FACT in the page.\n - Widening the wasm surface so Rust can say \"this key is claimed exclusively\"\n is a new decision the ticket did not authorise.\n\nTHE HONEST SHAPE: the DECISION -- \"is this key exclusively claimed by the app?\" --\nbelongs in Rust as a third thing a resolver already knows, beside Local and\nRequest. That is a natural follow-up to the KeyOutcome variant #134 added, not a\npatch to app.js.\n\nWHATEVER LANDS, THE ASSERTION GOES IN harness/tests/shell.spec.js (gotcha 48: a\nchange to web/*.js belongs in that spec directory). Note harness/node_modules does\nnot exist in a fresh worktree, so `npx playwright test` fails with an error about\nthe PACKAGE rather than the harness -- run npm install in harness/ first.\n\nCHECK BEFORE BUILDING: whether any other Ctrl chord is now in the same position.\nThe preventDefault rule is blanket, so the answer is \"every Ctrl binding the\nresolver claims\", and today that is one.","notes":"SECOND INSTANCE, AND IT IS WORSE: Ctrl-W. Found by the improvise-htl6 agent while hunting for a free key; verified by the orchestrator at keymap.rs:611 --\n\n normal.bind_args(KeyCode::Char('w'), ctrl, \"enter-mode\", vec![\"save-prompt\".into()])\n\nwith the comment \"Ctrl-S saves to the known path; Ctrl-W asks for one\" (improvise-lae).\n\nIN A BROWSER, Ctrl-W CLOSES THE TAB. Same mechanism as the Ctrl-S wart -- web/app.js calls preventDefault only when !e.ctrlKey \u0026\u0026 !e.altKey -- but the consequence is not a spurious dialog the user dismisses, it is THE DOCUMENT'S TAB CLOSING. Whatever the save prompt does is invisible, because the surface it would draw on is gone.\n\nTHE GENERALISATION, which is why this belongs here rather than in its own ticket: THE KEYMAP IS SHARED BETWEEN THE TWO FRONT ENDS (improvise-bpq2, gotcha 47), so a Ctrl- chord chosen for a terminal is one the browser must live with, and the browser has a reserved set the terminal does not. Ctrl-R (reload), Ctrl-S (save page), Ctrl-W (close tab), Ctrl-T, Ctrl-N are all browser-owned. So the fix for this ticket should be stated over the CLASS -- what happens to a browser-reserved chord in a shared keymap -- rather than per key, or a third instance arrives the next time somebody needs a free Ctrl- binding.\n\nNote the constraint that makes this hard is already recorded above: every available fix crosses a doctrine line, because preventDefault-ing selectively needs the page to know which chords the app binds, which is protocol knowledge (design-principles section 5). The shared keymap is where that knowledge already lives, which is a hint at where the answer is.\n\nALSO WORTH RECORDING: Ctrl-R is NOT bound today, and the htl6 agent deliberately did not take it for view-forward for exactly this reason -- it chose ',' instead. So this ticket has already changed one decision.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T20:59:53Z","created_by":"Edward Langley","updated_at":"2026-09-23T02:36:17Z","labels":["browser"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_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":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T15:20:31Z","created_by":"Edward Langley","updated_at":"2026-09-22T15:20:31Z","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-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} @@ -93,21 +205,21 @@ {"_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":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T04:47:16Z","created_by":"Edward Langley","updated_at":"2026-09-22T04:47:16Z","labels":["browser","protocol"],"dependency_count":0,"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-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":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T00:49:15Z","created_by":"Edward Langley","updated_at":"2026-09-22T06:42:09Z","labels":["docs","testing","tooling"],"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":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T00:22:25Z","created_by":"Edward Langley","updated_at":"2026-09-22T02:27:13Z","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":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T00:17:00Z","created_by":"Edward Langley","updated_at":"2026-09-22T00:22:33Z","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":"{}"}],"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-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":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T23:03:30Z","created_by":"Edward Langley","updated_at":"2026-09-21T23:03:30Z","labels":["adr-001","protocol","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-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-b4ks","title":"The documented opfs-contract fmt gate exits 1 as a usage error, indistinguishable from a real failure","description":"The recipe circulated in six wave briefs and in an orchestrator message reads:\n\n cargo fmt -p opfs-contract -- --check\n\nFrom the workspace root this exits **1** with:\n\n package `opfs-contract` is not a member of the workspace\n This utility formats all bin and lib files of the current crate using rustfmt.\n\nThat is a USAGE error carrying the SAME EXIT CODE as a formatting failure. A\ncaller checking `$?` cannot tell \"this crate is misformatted\" from \"you spelled\nthe command wrong and nothing was checked\". Verified by the orchestrator.\n\nThe spelling that reaches the second workspace root:\n\n cargo fmt --manifest-path harness/opfs-contract/Cargo.toml -- --check # exit 0\n\nWHY THIS MATTERS MORE THAN A TYPO: an earlier agent reported this gate as\n\"clean\" during the wave's integration review. Either it used a different\nspelling and did not say so, or it read exit 1 as a pass -- and neither is\ndistinguishable from the report as written. A gate whose failure mode looks\nexactly like the thing it checks for will eventually be \"fixed\" by deleting it,\nand in the meantime it launders a non-run into a pass.\n\nThis is the same family as the pipe-into-tail trap that scripts/pr-reviews\ncarries a warning about: `$?` belonging to something other than the thing you\nmeant to test.\n\nWHERE THE RECIPE NEEDS CORRECTING: wherever the harness gate is written down --\nAGENTS.md's gate list, the wave brief template, and any CI step that spells it\nthis way. CI itself uses `cargo fmt -p opfs-contract` from INSIDE\nharness/opfs-contract, where it resolves, which is why CI has been right while\nevery local invocation of the documented form was not.\n\nCONTEXT: harness/opfs-contract carries its own [workspace] table, so\n`cargo fmt --all` does not reach it. Exactly two workspace roots exist --\n`grep -l '^\\[workspace\\]' $(find . -name Cargo.toml -not -path '*/target/*')` --\nso one extra gate is complete, not the first of an unknown number.\n","notes":"SHARPENED 2026-09-21: the boundary is POSITIONAL, and that changes who is at fault.\n\n cargo fmt -p opfs-contract -- --check from the REPO ROOT -\u003e exit 1, 'not a member of the workspace', --check never runs\n cargo fmt -p opfs-contract -- --check from INSIDE the crate -\u003e exit 0, works\n cargo fmt --manifest-path harness/opfs-contract/Cargo.toml -- --check from the root -\u003e exit 0, works\n\nSo CI AS WRITTEN IS CORRECT -- its working-directory: is exactly what makes the -p form resolve. The hazard is a BRIEF or a local pre-push habit telling someone to run it from the root, which is what the orchestrator did in six wave briefs.\n\nThe failure mode is worse than 'wrong spelling' in both directions: from the root the gate reports FAILURE when the crate is clean, and it cannot report SUCCESS when the crate is dirty. It is decoupled from the thing it checks entirely, while wearing the exit code of a real formatting failure.\n\nThis also explains a discrepancy in the wave's records: one agent reported this gate 'clean' and the orchestrator later measured exit 1. Both were telling the truth -- that agent had cd'd into the crate directory and never saw the error. A gate whose result depends on the working directory, with no indication in the output which one it ran in, cannot be audited from a report.\n\nFIX: correct the recipe wherever it is written for humans or agents (AGENTS.md gate list, the wave brief template). Prefer the --manifest-path form, which is position-independent and therefore says what it means. Leave CI alone.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-ci","owner":"el-github@elangley.org","created_at":"2026-09-21T23:03:27Z","created_by":"Edward Langley","updated_at":"2026-09-23T03:29:40Z","started_at":"2026-09-23T02:49:17Z","closed_at":"2026-09-23T03:29:40Z","close_reason":"Merged in PR #159. Closed by the orchestrator after the session handover; the implementing agent's claim was abandoned when its process exited. Details of the fix, the rival injected and what the ticket did not predict are on the PR thread.","labels":["ci","tooling"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-bok4","title":"A failing effect narrates instead of reporting, so a frame containing it is accepted","description":"Found by the wave's integration/review agent auditing PR #109, and confirmed and\nscoped by that PR's author. ADR-001 clause 4 now rolls back the document, the\nmirrors and the session slice -- but the partial-and-silent SHAPE it exists to\nkill survives one layer down, for every command whose target is not a record.\n\nMECHANISM: reduce::broken_chain asks records_named, which names only SetCell and\nClearCell. Everything else is unguarded. Separately, an effect that FAILS writes\na sentence to view_state.status_msg and does NOT set abort_effects, so the frame\ncontaining it is reported ACCEPTED.\n\nMEASURED AS ACCEPTED, both by the reviewer:\n\n [DeleteView(\"Scratch\"), SetNumberFormat { view: \"Scratch\" }]\n [RemoveCategory(\"Region\"), AddItem { category: \"Region\" }]\n\nThe second is the sharper one: it writes its error to the HOST's status line and\nnever tells the client anything at all. A connected browser sees a frame it\ncomposed reported accepted, with one of its commands having done nothing, and no\nchannel carrying the reason.\n\nWHY THIS IS NOT PART OF improvise-yqpd: closing the class needs effects to\nREPORT failure rather than narrate it -- roughly fifty of them -- which is a\ndifferent change from the snapshot-and-restore clause 4 landed. yqpd closed the\nrecord-addressed INSTANCE. That scope is stated in PR #109's body under \"what\nthis PR does NOT close\" rather than left implied.\n\nTHE SHAPE OF A FIX, not yet decided: an Effect currently returns nothing, so\nfailure has nowhere to go except status_msg. Either effects gain a fallible\nresult that apply_effects can turn into an abort, or the per-command check grows\nbeyond records_named to ask \"does this command's target still resolve\" for the\nview-, category- and item-addressed variants too. The first is larger and\ncloses the class; the second is narrower and closes the reachable cases.\n\nNote the interaction with gotcha 21: an abort is not a rollback, and tidy-up\ngoes at the HEAD of a batch where an abort cannot reach it. Any change here has\nto keep that property, because it is what stops a failed save stranding the user\nin a mode whose minibuffer hides the error.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T22:49:20Z","created_by":"Edward Langley","updated_at":"2026-09-21T22:49:20Z","labels":["adr-001","protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-hkbc","title":"Guard the position-free ModelCommand vocabulary, which 6nsd's answer silently depends on","description":"improvise-6nsd was closed as \"not a hole, conditionally\": a stale layout tier\ncannot misdirect a mutation BECAUSE no ModelCommand variant carries a row or\ncolumn index. Verified independently by two agents over all 24 variants.\n\nNothing guards it. A future positional variant reopens the hole SILENTLY, and\nlayout_version under-reports here by construction -- gotcha 35's expensive\ndirection, where under-reporting leaves a client wrong forever.\n\nThe guard: an exhaustive match over ModelCommand in improvise-protocol asserting\nevery variant names its target BY NAME (category, item, group, view, axis,\nformat, path) or by a position-free surrogate (RecordCell/RecordHash). Adding a\npositional variant then fails the build and forces the ADR conversation rather\nthan inheriting an answer that no longer holds.\n\nShape it like reduce.rs::records_named, which is already an exhaustive match\nwith an or-pattern for the variants that name no record -- same file, same\ndiscipline, and the two can be read together.\n\nNOT covered by this guard, and stated so nobody thinks it is: position-free ON\nTHE WIRE is not position-free END TO END. improvise-2wu3 is the live\ncounterexample -- web/app.js captures viewport (row, col) at paint time and\nresolves it against the current cache at commit. That is a client bug the\nprotocol's discipline makes possible to have, not one this test can catch.\n","status":"in_progress","priority":2,"issue_type":"task","assignee":"agent-wire2","owner":"el-github@elangley.org","created_at":"2026-09-21T21:59:19Z","created_by":"Edward Langley","updated_at":"2026-09-23T03:33:57Z","started_at":"2026-09-23T03:33:57Z","lease_expires_at":"2026-09-23T03:38:57Z","heartbeat_at":"2026-09-23T03:33:57Z","labels":["protocol","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-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-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} @@ -115,7 +227,7 @@ {"_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":"open","priority":2,"issue_type":"bug","owner":"box@local","created_at":"2026-09-21T20:49:03Z","created_by":"external-grok-bot","updated_at":"2026-09-21T21:27:47Z","external_ref":"gh-pr-100-F11-records","labels":["adversarial-review","pr-100","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-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-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} @@ -124,14 +236,14 @@ {"_type":"issue","id":"improvise-ociy","title":"WebSocket transport: CLOSING send can throw; non-OPEN drops silently","description":"## Summary\n\n`web/app.js` transport `send` only guards `readyState === OPEN`. During CLOSING, `socket.send` can throw and break the keyboard path. When not OPEN (CONNECTING/CLOSING/CLOSED), messages are otherwise silently dropped with no user feedback — edits can vanish on reconnect hiccups.\n\n## Steps to Reproduce\n\n1. Mock a WebSocket in CLOSING; call `transport.send` — uncaught throw.\n2. Or send while CONNECTING/CLOSED — message dropped, no status/retry.\n\n## Acceptance Criteria\n\n- Send never throws on non-OPEN states; non-OPEN sends queue, retry, or surface loud status (no silent drop of user edits).\n\n## Location\n\n`web/app.js` ~L133-134\n\n## PR / Attribution\n\nPR #100 F06 (CLOSING throw) + P3 F16 (silent drop when not OPEN) 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:01Z","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\nNon-OPEN sends are loud, with try/catch on CLOSING.\n\nTHE TRAP IT NEARLY RE-BROKE: a ResizeObserver firing after a drop would overwrite the precise 'disconnected' with 'not sent', silently regressing improvise-taxx and gotcha 48. Split: a VIEWPORT ANNOUNCEMENT IS NOT A USER EDIT, so it sends {loud:false}.","external_ref":"gh-pr-100-transport","labels":["adversarial-review","pr-100"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-492x","title":"Formula parser defaults unknown operators silently","description":"## Summary\n\nFormula parser `_ =\u003e` fallbacks default unknown aggregates to `AggFunc::Count` and unknown comparisons to `BinOp::Ge`. Adding a new grammar aggregate/operator silently mis-parses as COUNT/`\u003e=` instead of failing.\n\n## Steps to Reproduce\n\n1. Add `| ^\"MEDIAN\"` to `agg_func` in `formula.pest`.\n2. Parse `MEDIAN(Revenue)` — evaluates as `COUNT(Revenue)` with no error.\n\n## Acceptance Criteria\n\n- Unknown agg/cmp tokens error at parse time; adding a variant forces exhaustive match updates (no silent default).\n\n## Doctrine\n\nPrefer exhaustive matches so new variants produce compile errors at every site that needs updating.\n\n## Location\n\n`crates/improvise-formula/src/parser.rs` ~L158-159, L189\n\n## PR / Attribution\n\nPR #100 F05 (P2 formula parser). 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: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\nparse_agg_func/parse_cmp_op now spell every alternative and unreachable!(GRAMMAR_INVARIANT) on anything else.\n\nNote WHY this is a panic and not a compile error, which is a property of the input rather than a choice: agg_func is one pest rule over five string alternatives, so pest_derive generates no per-alternative Rule and the walker's input is a \u0026str. improvise-4lgj walks Rule VALUES and so got the real compile error -- it should not have settled for the panic just because this one had to. It did not.","external_ref":"gh-pr-100-F05-formula","labels":["adversarial-review","pr-100"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_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.","status":"open","priority":2,"issue_type":"bug","owner":"box@local","created_at":"2026-09-21T20:43:16Z","created_by":"box","updated_at":"2026-09-21T20:43:21Z","labels":["adversarial-review","wasm-client","web"],"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-7gip","title":"[bug] OpfsFs::open's dedicated-worker guard checks WorkerGlobalScope, which a SharedWorker satisfies","description":"MEASURED in all three engines at 1f28868.\n\nOpfsFs::open() guards with:\n\n .dyn_into::\u003cweb_sys::WorkerGlobalScope\u003e()\n .map_err(|_| anyhow!(\"OPFS sync access handles require a dedicated Web Worker\"))?\n\n`self instanceof WorkerGlobalScope` is TRUE in a SharedWorkerGlobalScope. So a\nshared worker sails past this check and fails much later as a raw TypeError deep\nin the wasm glue:\n\n [firefox] workerError \"TypeError: arg0.createSyncAccessHandle is not a\n function\" at opfs/pkg/opfs_contract.js:93\n\nThe message names the right requirement; the type cannot check it.\ndyn_into::\u003cweb_sys::DedicatedWorkerGlobalScope\u003e() would.\n\nThis is doctrine(an_invariant_is_made_unrepresentable_not_validated) HALF DONE:\na guard was reached for, and the type chosen is one level too wide, so the\ninvariant is validated-and-wrong rather than unrepresentable.\n\nopfs.rs is wasm32-only with no Rust tests (0t in the inventory), so there is no\ncargo test that can fail first. The instrument that CAN fail first is harness/ --\nthe probe above is exactly the reproduction, and harness/opfs-contract already\nboots the real bundle in a real worker.\n","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:40:31Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:58:57Z","closed_at":"2026-09-21T21:58:57Z","close_reason":"Fixed in the 2026-09-21 wave, integrated into main at 1574 passed / 0 failed / 7 ignored. Playwright 42 passed / 0 failed / 4 skipped (WebKit, no OPFS) -- up from 37, so the count MOVED. All gates green.\n\nGuard is now DedicatedWorkerGlobalScope.\n\nTHE ONE-LINER DOES NOT COMPILE: the type is web-sys feature-gated, so improvise-io/Cargo.toml needed a line too. That is why it looked free and was not.\n\nNOT VERIFIED: that a SharedWorker is actually refused -- the harness has no SharedWorker page. What is asserted is only that a DEDICATED worker still satisfies the narrow type, i.e. the fix did not break the shipped path. Stated explicitly in the evidence rather than implied. improvise-12ha owns the SharedWorker question.","labels":["browser","bug","opfs"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-7xyx","title":"FrameGroup::cat is dead in both front ends and its doc comment promises an echo nobody composes","description":"FrameGroup::cat is read by NEITHER front end -- dead in both. Its doc comment\nsays it is \"echoed back in ToggleGroup\", and resolve.rs has NO ToggleGroup arm\nat all, so the echo it promises cannot happen from a browser.\n\nBy design-principles.md section 6 this is the expensive class: a reason that\nlives only in a comment, where nothing checks it against the code. The comment\nis a promise about a client that does not exist.\n\nTwo outcomes are acceptable: compose ToggleGroup in resolve.rs and make the\ncomment true (z is on the silent list anyway, so this would close part of that),\nor delete the field and the comment together. What is not acceptable is leaving\na doc comment describing a protocol interaction no code performs.\n\nAlso noticed in the same pass: AxisFrame::cats is PARTIAL on both ends -- the\ncount is used, the category NAMES are drawn by neither, so the grid's corner\ncell is blank in both surfaces. Possibly intended; worth a decision either way.\n","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-21T20:39:58Z","created_by":"Edward Langley","updated_at":"2026-09-22T04:48:03Z","labels":["docs","protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-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-wk18","title":"[bug] the advertised-key guard cannot see help.rs literal() lines, so 17 keys are unchecked","description":"DEMONSTRATED at 1f28868.\n\nevery_advertised_command_answers_in_its_own_mode iterates help::advertised(\u0026mode),\nwhich yields (command, args, caption) triples. help.rs ALSO emits 17 literal(..)\nlines naming a KEY STRING and no command, so the guard cannot see them.\nweb/app.js paintHelp renders them verbatim.\n\nOf the 17:\n gg, yy, ZZ -- all three are prefix keys, all three silent in the browser\n :wq :q :q! :import :add-cat :add-item :formula :add-view :set :help and the\n rest -- the whole ex-command reference page; the browser declines at the door,\n so it says something, but it renders a full page of commands it cannot run.\n\nSo the browser paints a help overlay in which, conservatively, an entire page\nplus three scattered lines are inert, and the test that exists to stop exactly\nthat cannot see any of it.\n\nThis is the cheapest of the parity findings and it PROTECTS THE OTHERS: it is\nthe mechanism this repo uses to stop black holes, and it looks away by\nconstruction from the two lines that the gg and paste tickets are about. Without\nit the next gg lands the same way and nothing notices.\n\nFix: either give literal() a command name so the guard can reach it, or add a\nsecond guard over the help pages.\n","notes":"BLOCKED AND RE-SCOPED 2026-09-21 by the keymap agent (PR #112), which took improvise-636d and deliberately did NOT half-do this one. Its reasoning and its proposed design, which is worth more than the ticket had:\n\nWHY IT COULD NOT BE DONE FROM THE KEYMAP GROUND:\n - the guard needs resolve() AND the `furnished` fixture, both private to resolve.rs's mod tests. Duplicating the fixture elsewhere is the disease this repo names repeatedly.\n - the HelpLine reshape both tickets call for breaks the struct patterns at improvise-wasm-client/src/render.rs:346 and src/ui/help.rs:72 -- NEITHER uses `..`, so the reshape is a compile error in a file another agent owns.\n\nTHE PROPOSED SHAPE, designed but not written: keep HelpLine FLAT and PUBLIC, and introduce an internal\n\n HelpRow {\n Keys { mode, command, args, description },\n Prefix { mode, prefix, then, .. },\n Ex { line, .. },\n Prose (HelpLine),\n }\n\nthat pages() RENDERS into HelpLine. One source, no renderer edit, and the guard walks the rows rather than the rendered lines.\n\nThat satisfies uln5's stated blocking cost -- \"carry command/mode/args onto the variant\" -- WITHOUT touching render.rs, which is what made it look expensive.\n\nIT ALSO SPLITS wk18's 17 literal() lines honestly rather than treating them as one lump:\n - gg / yy / ZZ are checkable via Keymaps::prefix_keymap\n - the 14 ex-commands are checkable against the registry from improvise-command's tests\n\nSo the two beads are one change to HelpRow plus two different guards, not one guard over a uniform list.\n\nNOTE THE GROUND THIS NEEDS: resolve.rs's test module, help.rs, and a test that can reach the registry. That is a different partition from the one this wave used, so schedule it with whoever owns resolve.rs rather than with the keymap.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:39:19Z","created_by":"Edward Langley","updated_at":"2026-09-22T02:27:15Z","labels":["bug","parity","testing"],"dependencies":[{"issue_id":"improvise-wk18","depends_on_id":"improvise-uln5","type":"related","created_at":"2026-09-22T02:27:15Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-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} {"_type":"issue","id":"improvise-3b2b","title":"Deleting a virtual category from the category panel disables records mode","description":"Report-only hunt at 70025e2. No source changes.\n\nLocation: crates/improvise-command/src/command/cmd/panel.rs:597-609; ui/effect.rs:295-302; core/src/workbook.rs:71\n\nThe panel deliberately shows _Index/_Dim/_Measure (improvise-vjms), but its delete command treats them as ordinary removable categories. Execute DeleteCategoryAtCursor at initial cursor 0 (_Index), then ToggleRecordsMode. Status says Records mode but layout.is_records_mode() is false: the new view has no _Index to set on Row. The invariant that virtual categories always exist is broken through a shipped user action. Protect required categories at a shared authoritative boundary, with both TUI and wire tests; merely hiding the delete key leaves the wire mutation unguarded. Duplicate checks: virtual, reserved, category; read improvise-vjms, which decides visibility, not removability.\n\n## Steps to Reproduce\nStandalone Rust 2024 package with improvise and improvise-io path dependencies:\n```rust\nuse improvise_io::{workbook::Workbook,persistence::{format_md,parse_md}};\nfn revenue_book()-\u003eWorkbook {\n use improvise_io::model::cell::{CellKey,CellValue};\n let mut w=Workbook::new(\"T\");w.add_category(\"Region\").unwrap();w.add_item(\"Region\",\"East\").unwrap();w.add_item(\"_Measure\",\"Revenue\").unwrap();\n w.model.set_cell(CellKey::new(vec![(\"Region\".into(),\"East\".into()),(\"_Measure\".into(),\"Revenue\".into())]),CellValue::Number(10.0));w\n}\n#[test] fn category_panel_cannot_delete_required_virtual() {\n use improvise::{ui::app::App,command::{cmd::{Cmd,panel::DeleteCategoryAtCursor},key::{KeyCode,KeyModifiers}}};\n let mut app=App::new(revenue_book(),None);\n app.view_state.cat_panel_cursor=0;\n let fx=DeleteCategoryAtCursor.execute(\u0026app.cmd_context(KeyCode::Char('d'),KeyModifiers::NONE));app.apply_effects(fx);\n let fx=improvise::command::cmd::grid::ToggleRecordsMode.execute(\u0026app.cmd_context(KeyCode::Char('r'),KeyModifiers::NONE));app.apply_effects(fx);\n assert!(app.layout.is_records_mode(),\"deleting _Index prevents records mode; status {}\",app.view_state.status_msg);\n}\n```\nRun cargo test category_panel_cannot_delete_required_virtual -- --nocapture. Reproduced failing against unchanged code.\n\n## Acceptance Criteria\nCorrect the described observable behavior; preserve ordinary cases with focused regressions and explicit edge-case policy.\n","notes":"CORE HALF CLOSED by PR #114 (wave/s-resolve-keys, head 614d818), guard placed at the MODEL rather than in the two Cmds.\n\nModel::remove_category now refuses a virtual category outright. That placement is the point and it is what this bead asked for: hiding the key in DeleteCategoryAtCursor would leave ModelCommand::RemoveCategory unguarded ON THE WIRE, so a connected client could still delete _Index/_Dim/_Measure and disable records mode for everyone.\n\nLanded alongside improvise-uej8's cursor fix, since both are the same keypress.\n\nKeep this bead open until the reproduction category_panel_cannot_delete_required_virtual is un-ignored and passing -- that is the acceptance, and it lives in tests/bug_hunt_reproductions.rs.\nALREADY FIXED, same wave as improvise-uej8 (7a44616 / PR #114). Model::remove_category refuses _Index/_Dim/_Measure, guarded by model::types::model_tests::a_required_virtual_category_cannot_be_removed, passing at HEAD. Verified by the agent dispatched to uej8, which found this as uej8's second half. Stale-open.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:35:50Z","created_by":"Edward Langley","updated_at":"2026-09-22T06:39:21Z","closed_at":"2026-09-22T06:39:21Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -140,7 +252,7 @@ {"_type":"issue","id":"improvise-cj4u","title":"New views pivot Label categories that were explicitly excluded from pivot axes","description":"Report-only hunt at 70025e2. Location: crates/improvise-core/src/workbook.rs:133-140; crates/improvise-core/src/view/types.rs:71-89\n\nAdd Label category Description: Default correctly uses Axis::None. create_view(Other) puts Description on Row. Workbook::create_view passes only category names to on_category_added, whose CategoryKind::of_name cannot distinguish Label from Regular. With existing row/column categories this can instead place a high-cardinality label on Page and filter the document to one description. Keep Label on None in each new view and consider passing real category kind across the seating boundary. This is independent of improvise-dy3j: no save/load is needed. Duplicate checks: label, view, current in-progress list.\n\n## Steps to Reproduce\nStandalone Cargo test with improvise and improvise-io path dependencies:\n```rust\nuse improvise_io::workbook::Workbook;\n#[test] fn label_stays_off_axes_in_new_view() {\n use improvise_io::view::Axis;\n let mut w=Workbook::new(\"test\");w.add_label_category(\"Description\").unwrap();\n assert_eq!(w.active_view().axis_of(\"Description\"),Axis::None);\n assert_eq!(w.create_view(\"Other\").axis_of(\"Description\"),Axis::None);\n}\n```\nObserved failing against unchanged source.\n\n## Acceptance Criteria\nThe reproduction passes and ordinary new-view creation still works. Add a control for the intentional cases described above.\n","notes":"FIXED in PR #129 (merge bf83294). Reproduced at HEAD: Description landed on\nAxis::Row in a view created one line after add_label_category.\n\nA SHARED THEME WITH improvise-dy3j, NOT A SHARED CAUSE, and the agent checked\nrather than assumed: CategoryKind::of_name reads Label as Regular BY DESIGN, and\nthat one fact produced two bugs at two different layers. dy3j is a file format\nwith nowhere to write the kind; cj4u is a function signature that dropped it.\nNEITHER FIX REPAIRS THE OTHER -- with dy3j alone create_view still pivots labels;\nwith cj4u alone a label still reloads as Regular. Two commits, deliberately.\n\nTHE FIX IS THE SIGNATURE: View::on_category_added now TAKES the CategoryKind, so\n`unseated = !kind.is_regular() || self.is_records_layout()`. Verified. That is\nwhat made the compiler name every call site (design-principles section 2).\n\nAND IT DELETED A DUPLICATE ANSWER. Both Workbook set_axis(name, Axis::None)\ncorrections are gone -- verified, the only Axis::None left in workbook.rs is in\nassertions and a doc comment. They were a second answer to \"where does this\ncategory go\", and ONLY ONE OF THE TWO CALLERS knew to write it: add_label_category\nfixed the views existing at that moment, while create_view -- which had only names\n-- pivoted the label onto Row, or onto Page on an already-pivoted document,\nfiltering everything to one description.\n\nTHE CONTROL TESTS CARRY AS MUCH WEIGHT AS THE FIX. An injected blanket\n`unseated = true` satisfies EVERY label assertion in the repository. What\ndiscriminates is asserting that a regular category still takes Row and that a\nlabel does not consume the slot the next one wants.\n\nRESIDUE DOCUMENTED RATHER THAN TIDIED: of_name survives, narrowed to callers\nholding no Category. holds_a_regular still asks BY NAME -- but of the categories\nalready SEATED on an axis, so a label a user deliberately moved onto Row still\ncounts as occupying it. Right answer, different route; a future View storing kinds\nshould collapse the two.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:33:43Z","created_by":"Edward Langley","updated_at":"2026-09-22T08:32:23Z","closed_at":"2026-09-22T08:32:23Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-vw47","title":"Creating a view after deleting another silently resets a surviving view","description":"Report-only hunt at 70025e2. Location: crates/improvise-command/src/command/cmd/panel.rs:657; crates/improvise-protocol/src/resolve.rs:373; crates/improvise-core/src/workbook.rs:133\n\nCreate View 2 and View 3, customize View 3, delete View 2, then use the view panel new action. Both resolvers choose View {views.len()+1}, which is View 3 again. Workbook::create_view replaces that entry with defaults: its number format, axes, page selections and hidden/collapsed state are lost. The test executes the actual CreateAndSwitchView command and applies its effects; .4 becomes ,.0 and no view is added. Choose an unused name for user-created views in a shared helper. Preserve intentional replacement of _Records/_Drill; globally refusing create_view replacement would break those callers. Duplicate checks: create, View 3, view.\n\n## Steps to Reproduce\nStandalone Cargo test with improvise and improvise-io path dependencies:\n```rust\nuse improvise_io::workbook::Workbook;\n#[test] fn new_view_after_delete_does_not_overwrite_survivor() {\n use improvise::{ui::app::App,command::{cmd::{Cmd,panel::CreateAndSwitchView},key::{KeyCode,KeyModifiers}}};\n let mut w=Workbook::new(\"test\");w.create_view(\"View 2\");w.create_view(\"View 3\").number_format=\".4\".into();\n w.delete_view(\"View 2\").unwrap();\n let mut app=App::new(w,None);\n let effects=CreateAndSwitchView.execute(\u0026app.cmd_context(KeyCode::Char('n'),KeyModifiers::NONE));\n app.apply_effects(effects);\n assert_eq!(app.model_state.workbook.views[\"View 3\"].number_format,\".4\");\n assert_eq!(app.model_state.workbook.views.len(),3);\n}\n```\nObserved failing against unchanged source.\n\n## Acceptance Criteria\nThe reproduction passes and ordinary new-view creation still works. Add a control for the intentional cases described above.\n","notes":"NOT FIXED in PR #129, deliberately, and the reason is worth keeping: THE NAME IS\nDERIVED AT TWO SITES.\n\n improvise-command/src/command/cmd/panel.rs::CreateAndSwitchView\n improvise-protocol/src/resolve.rs, arm \"create-and-switch-view\"\n\nThe agent held resolve.rs off its allowlist because another branch was in flight\non it, and refused to fix ONE site -- that would leave the TUI and the browser\nderiving different names, which is exactly the divergence design-principles\nsection 5 exists to prevent. It implemented NOTHING rather than half. Correct\ncall.\n\nREPRODUCED AT HEAD before that decision: View 3's \",.4\" reset to \",.2\" and no view\nwas added.\n\nNOW UNBLOCKED: resolve.rs is free as of PR #128 (merge 76d909e).\n\nTHE SHAPE, from the agent that read both sites: an UNUSED-NAME HELPER in\nimprovise-core called by both resolvers. NOT a change to Workbook::create_view --\nits production callers are effect::CreateView, parse.rs::build_view and the\n_Records/_Drill rebuilders, and all of them RELY ON ITS REPLACEMENT SEMANTICS.\nMaking it refuse or rename turns the symptom from \"a view was reset\" into \"the key\ndoes nothing\", which is a worse bug wearing a better error message.\n\nSo the helper answers \"what is the next unused name\" and the two resolvers each\ncall it; create_view keeps replacing, because that is what its other callers need.\nFIXED in PR #130 (merge 69c873d).\n\nTHE FIX: view::unused_view_name in improvise-core -- takes an iterator of \u0026str,\nstarts the search at the count, returns the first free \"View \u003cn\u003e\". Both resolvers\ncall it: cmd/panel.rs::CreateAndSwitchView and resolve.rs's\n\"create-and-switch-view\" arm. It takes NAMES rather than a \u0026Workbook because the\nbrowser holds only ViewListCache -- and the agent checked that\nproject::view_summaries maps workbook.views ONE-FOR-ONE including virtual views,\nso the two ends genuinely see the same list. Had they not, a shared helper would\nstill have diverged.\n\nTHE PREVIOUS AGENT'S ANALYSIS HELD, WITH ONE CORRECTION WORTH RECORDING. It named\n\"effect::CreateView, parse.rs::build_view, and the _Records/_Drill rebuilders\" as\nif those were three callers. VERIFIED BY THE ORCHESTRATOR: there are exactly TWO\nproduction callers of Workbook::create_view -- ui/effect.rs:310 and\npersistence/parse.rs:419, the latter guarded by contains_key so it cannot observe\nreplacement at all. The rebuilders are not callers; they reach it THROUGH\neffect::CreateView, emitting a fixed name on every entry with the comment\n\"Create (or replace)\".\n\nSo the substance was right -- replacement is load-bearing and create_view must not\nbe touched -- and the dependency runs through the one effect that ALSO serves the\nuser-facing path. THAT IS PRECISELY WHY THE NAME MUST BE FIXED AT THE RESOLVER:\nthe effect cannot tell its two callers apart.\n\nTHE FINDING THAT MATTERS MOST, AND IT IS ABOUT LOCKSTEP TESTS GENERALLY:\nboth_ends PASSED AGAINST THE BUG. Both resolvers computed format!(\"View {}\",\nlen + 1) and were wrong IDENTICALLY, so the two documents agreed perfectly. Only\nthe assertions after it -- the view count must rise, and the survivor's ,.4 must\nbe untouched -- caught it, failing left: 3 right: 4.\n\n\"The two front ends agree\" and \"the two front ends are right\" are different\nquestions, and a DUPLICATED DERIVATION makes them come apart in the direction\nwhere agreement is evidence of nothing. A lockstep test written as agreement-only\nwould have shipped green over this bug.\n\nAND THE BUG LIVED DIRECTLY UNDERNEATH A TEST THAT NAMED IT.\ncreate_and_switch_view_names_incrementally has asserted \"the name follows the\nworkbook\" since improvise-aaw -- but every fixture it uses leaves len + 1 FREE, so\nit could not separate \"the count\" from \"the first unused name\". It did earn its\nkeep here: the rival that starts the search at 1 fails it.\n\nNOT VERIFIED: no Playwright spec presses `n` in the view panel in a real browser.\nThat is the existing harness boundary, not something this change moved.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:33:41Z","created_by":"Edward Langley","updated_at":"2026-09-22T15:31:06Z","closed_at":"2026-09-22T15:31:06Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-tg27","title":"[bug] Narrow window clips grid columns without usable horizontal scroll","description":"## Summary\nOn a narrow window the grid clips horizontally with no obvious horizontal scroll affordance, so rightmost columns are unreachable without widening the window.\n\n## Repro\n1. Open the web client with a demo that has several columns.\n2. Shrink the viewport width until columns overflow.\n3. Try to reach clipped columns (scrollbars / trackpad).\n\n## Expected\nOverflowing grid content is reachable via horizontal scroll (or another explicit navigation), consistent with `#grid { overflow: auto }` intent.\n\n## Actual\nContent appears clipped; horizontal overflow is not obviously scrollable in the smoke environment (may be a flex/min-width layout trap swallowing overflow).\n\n## Evidence\n- Browser smoke (external Grok Bot agent for Ed Langley), 2026-09-21 PT.\n- `web/style.css`: `#grid { overflow: auto; }` / main flex layout — suspected min-width:auto flex child clipping.\n- Closed `improvise-jq8l` was about `scrollIntoView` for selection, not narrow-window column clipping — different bug.\n\n## Notes\nFound by an external Grok Bot agent smoke/code hunt for Ed Langley (not Claude).\n","notes":"FIXED in PR #138 (merge 327a76b).\n\nTHIS TICKET'S DIAGNOSIS WAS WRONG, and the correction is the useful part. It\nsuspected a flex min-width:auto trap swallowing #grid's overflow. Not that --\n#grid has overflow:auto, which already zeroes a flex item's automatic minimum.\nTHE APP BAR WAS WHAT COULD NOT SHRINK.\n\ndocumentElement.scrollWidth read 1011 AT EVERY WINDOW NARROWER THAN 1011px -- A\nFLOOR, NOT A SLOPE, which is the signature of a sum of flex:none reserves\n(#build-version 14ch + #doc-name 34ch + #view-name 16ch + #mode 21ch + a\nnon-shrinking #status). main was correctly window-width throughout. So THE PAGE\ngrew the scrollbar, carrying the bar's tail outside the window while the grid slid\noff to the left. Those reserves are improvise-n6t8's, added yesterday -- this is\nthat fix's cost, arriving.\n\nTHE FIX IS header{flex-wrap:wrap} OVER A @media BREAKPOINT, because a flex item\nwraps exactly when the line cannot hold it, so the desktop bar is untouched BY\nCONSTRUCTION rather than by a chosen number. Plus main{min-width:0} (the trap the\nticket asked about, where it actually applies), 40vw caps on the three side\npanels, and max-width caps so a reserve cannot exceed its own line.\n\nIT REJECTED body{overflow-x:hidden} EXPLICITLY, because it would make\nscrollWidth == clientWidth unconditionally and RENDER THE WHOLE SPEC VACUOUS --\nthe check-answering-its-neighbour shape. The bar has to genuinely fit.\n\nTWO THINGS IT GOT WRONG AND CORRECTED, which is the more valuable half:\n 1. It claimed the fix made #hint legible. IT DOES NOT, AND ITS SPEC ASSERTED IT\n AND PASSED -- the spec measured immediately after the grid painted, before\n #status had composed the transport's fact (186px); settled, the same width is\n 1px. Assertion removed; the spec now waits for #status before measuring.\n 2. It asserted no reflow at 1024 and it failed: #status composes the DOCUMENT'S\n NAME, so the reflow threshold is document-dependent -- the same 1024px window\n is one line under the name \"shot\" and two under \"narrow-tg27\". The threshold\n is now asserted nowhere; only that 1280 (where n6t8's stability spec runs) is\n above it.\n\nWHAT THE TICKET DID NOT PREDICT: THE KEYBOARD ALREADY REACHED EVERY CLIPPED\nCOLUMN. ArrowRight walks all six of the grouped fixture's columns at 520px where\nonly one is projected, so the ticket's own \"or another explicit navigation\" was\nalready satisfied -- what was broken is that the page offered a FALSE scroll\naffordance beside it. An assertion now pins the keyboard walk so a future\nover-fetch \"fix\" has to keep it passing.\n\nMERGE VERIFIED STRUCTURALLY, not by reading: after merging eefd992,\n`git diff eefd992 -- context/` is 198 insertions and ZERO DELETIONS (confirmed by\nthe orchestrator), and evidence entries rose 812 -\u003e 818 while Playwright stayed at\n68/4. Had the resolution eaten nxah's entries, that number would have stayed at\n812 while every other gate still passed.\n\nNOT TOUCHED: the ResizeObserver arithmetic. Over-announcing the extent so #grid\nhad real content to scroll was considered and declined -- a fixed over-fetch\nreveals a fixed number of extra columns and solves nothing general, while\nannouncing the whole grid defeats the windowed viewport, and it would have been\nthe one change into untested arithmetic.","status":"closed","priority":2,"issue_type":"bug","created_at":"2026-09-21T20:32:39Z","created_by":"box","updated_at":"2026-09-22T23:07:54Z","closed_at":"2026-09-22T23:07:54Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-x0dc","title":"[bug] Esc from FORMULAS leaves formula_panel_open so panel chrome stays visible","description":"## Summary\nAfter opening the formulas panel with `F`, pressing Esc (or Tab→normal) returns mode to NORMAL but leaves formula-panel chrome visible because `formula_panel_open` stays true.\n\n## Repro\n1. Open web client with a document that has formulas.\n2. Press `F` — formulas panel opens and mode is FORMULAS.\n3. Press Esc (or the panel Tab binding that runs `enter-mode normal`).\n4. Mode indicator shows NORMAL; formulas sidebar/chrome may still be visible.\n\n## Expected\nLeaving the panel mode closes the panel (or Esc mirrors `F`'s toggle-shut: `SetPanelOpen { open: false }` + `ChangeMode(Normal)`), matching TUI intuition that Esc dismisses the panel.\n\n## Actual\n`PanelKeymap` binds Esc/Tab to `enter-mode normal` only. `entered()` emits `ChangeMode` and does not clear `formula_panel_open` / `view_panel_open`. Render still emits `formulas` rows while the flag is true (`render.rs` `formula_rows`), so `paintPanel` keeps the aside visible. By contrast, a second `F` correctly toggles shut (`SetPanelOpen false` + Normal) — see `a_focused_panel_toggles_shut` in `resolve.rs`.\n\n## Evidence\n- Browser smoke (external Grok Bot agent for Ed Langley), 2026-09-21 PT.\n- `crates/improvise-protocol/src/keymap.rs` `PanelKeymap::new`: Esc/Tab → `enter-mode normal`.\n- `crates/improvise-protocol/src/resolve.rs` `entered()` — ChangeMode only.\n- `crates/improvise-wasm-client/src/render.rs` `formula_rows` gated on `formula_panel_open`.\n- `web/app.js` `paintPanel` hides only when `rows.length === 0`.\n\n## Notes\nFound by an external Grok Bot agent smoke/code hunt for Ed Langley (not Claude). Category panel uses an explicit `category_panel_open` bool painted by the page; formula/view use empty-vec-as-closed, which makes this stickier.\n","notes":"RECOMMEND DECLINING AS WRITTEN -- needs a user ruling, so left OPEN rather than closed.\n\nTHE STATE REPRODUCES: after F the mode is FormulaPanel with open=true; after Esc\nthe mode is Normal and open is STILL true. Driven through a real App, not inferred.\n\nBUT THE TUI IS DELIBERATE, AND THE LAUNCH STATE SETTLES IT: category_panel_open\nstarts TRUE while the mode is Normal. An open panel in Normal mode is the state\nTHE APP BOOTS IN. If that were the bug, the app would boot in the bug.\n\nThree corroborations, each independent:\n - F/C/V are while Ctrl-F/Ctrl-C/Ctrl-V are a SEPARATE\n . Two commands means two concepts.\n - Tab in Normal is , which is meaningless unless panels stay\n open while unfocused.\n - The panel hint spells as 'back', not 'close'.\n\nSo this ticket's premise -- 'matching TUI intuition that Esc dismisses the panel'\n-- is the one claim that does not hold. Open and focus are different things here.\n\nTHE GENUINE DEFECT ITS OWN EVIDENCE POINTS AT IS ALREADY FILED: improvise-df2w.\nrender.rs::formula_rows returns vec![] when the panel is closed, and\napp.js::paintPanel hides on rows.length === 0 -- so an OPEN BUT EMPTY formula or\nview panel is INVISIBLE IN THE BROWSER while the TUI draws '(no formulas -- press\nn to add)'. That is two answers to 'is this panel visible', which is\ndesign-principles section 5's own defect shape, and it is a real divergence rather\nthan a preference.\n\nWHAT THE USER NEEDS TO DECIDE: whether Esc should close a panel as well as\nunfocus it. That is a UX preference, not a correctness question, and it would\nchange the TUI's boot state to match. If the answer is no, close this and let\nimprovise-df2w carry the real bug.\nCORRECTION TO THE NOTE ABOVE: it was appended through a shell string containing\nbackticks, which the shell evaluated away, so the three corroborations lost the\ncommand names that were their whole content. The corrected list follows. (The\ntrap is the same one AGENTS.md records for gh: put the body in a FILE and pass it\nwith a file redirect, never inline.)\n\nThree corroborations that panel OPEN and panel FOCUS are different concepts here,\neach independent:\n\n 1. F / C / V are bound to \"toggle-panel-and-focus\", while Ctrl-F / Ctrl-C /\n Ctrl-V are bound to a SEPARATE \"toggle-panel-visibility\". Two commands\n means two concepts, deliberately.\n 2. Tab in Normal is \"cycle-panel-focus\", which is meaningless unless panels\n stay open while unfocused.\n 3. The panel hint spells \"enter-mode normal\" as \"back\", not \"close\".\n\nEverything else in the note above stands, in particular the launch-state\nargument: category_panel_open starts TRUE while the mode is Normal, so an open\npanel in Normal is the state the app boots in -- if that were the bug, the app\nwould boot in the bug.","status":"open","priority":2,"issue_type":"bug","created_at":"2026-09-21T20:32:39Z","created_by":"box","updated_at":"2026-09-22T06:39:38Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-x0dc","title":"[bug] Esc from FORMULAS leaves formula_panel_open so panel chrome stays visible","description":"## Summary\nAfter opening the formulas panel with `F`, pressing Esc (or Tab→normal) returns mode to NORMAL but leaves formula-panel chrome visible because `formula_panel_open` stays true.\n\n## Repro\n1. Open web client with a document that has formulas.\n2. Press `F` — formulas panel opens and mode is FORMULAS.\n3. Press Esc (or the panel Tab binding that runs `enter-mode normal`).\n4. Mode indicator shows NORMAL; formulas sidebar/chrome may still be visible.\n\n## Expected\nLeaving the panel mode closes the panel (or Esc mirrors `F`'s toggle-shut: `SetPanelOpen { open: false }` + `ChangeMode(Normal)`), matching TUI intuition that Esc dismisses the panel.\n\n## Actual\n`PanelKeymap` binds Esc/Tab to `enter-mode normal` only. `entered()` emits `ChangeMode` and does not clear `formula_panel_open` / `view_panel_open`. Render still emits `formulas` rows while the flag is true (`render.rs` `formula_rows`), so `paintPanel` keeps the aside visible. By contrast, a second `F` correctly toggles shut (`SetPanelOpen false` + Normal) — see `a_focused_panel_toggles_shut` in `resolve.rs`.\n\n## Evidence\n- Browser smoke (external Grok Bot agent for Ed Langley), 2026-09-21 PT.\n- `crates/improvise-protocol/src/keymap.rs` `PanelKeymap::new`: Esc/Tab → `enter-mode normal`.\n- `crates/improvise-protocol/src/resolve.rs` `entered()` — ChangeMode only.\n- `crates/improvise-wasm-client/src/render.rs` `formula_rows` gated on `formula_panel_open`.\n- `web/app.js` `paintPanel` hides only when `rows.length === 0`.\n\n## Notes\nFound by an external Grok Bot agent smoke/code hunt for Ed Langley (not Claude). Category panel uses an explicit `category_panel_open` bool painted by the page; formula/view use empty-vec-as-closed, which makes this stickier.\n","notes":"RECOMMEND DECLINING AS WRITTEN -- needs a user ruling, so left OPEN rather than closed.\n\nTHE STATE REPRODUCES: after F the mode is FormulaPanel with open=true; after Esc\nthe mode is Normal and open is STILL true. Driven through a real App, not inferred.\n\nBUT THE TUI IS DELIBERATE, AND THE LAUNCH STATE SETTLES IT: category_panel_open\nstarts TRUE while the mode is Normal. An open panel in Normal mode is the state\nTHE APP BOOTS IN. If that were the bug, the app would boot in the bug.\n\nThree corroborations, each independent:\n - F/C/V are while Ctrl-F/Ctrl-C/Ctrl-V are a SEPARATE\n . Two commands means two concepts.\n - Tab in Normal is , which is meaningless unless panels stay\n open while unfocused.\n - The panel hint spells as 'back', not 'close'.\n\nSo this ticket's premise -- 'matching TUI intuition that Esc dismisses the panel'\n-- is the one claim that does not hold. Open and focus are different things here.\n\nTHE GENUINE DEFECT ITS OWN EVIDENCE POINTS AT IS ALREADY FILED: improvise-df2w.\nrender.rs::formula_rows returns vec![] when the panel is closed, and\napp.js::paintPanel hides on rows.length === 0 -- so an OPEN BUT EMPTY formula or\nview panel is INVISIBLE IN THE BROWSER while the TUI draws '(no formulas -- press\nn to add)'. That is two answers to 'is this panel visible', which is\ndesign-principles section 5's own defect shape, and it is a real divergence rather\nthan a preference.\n\nWHAT THE USER NEEDS TO DECIDE: whether Esc should close a panel as well as\nunfocus it. That is a UX preference, not a correctness question, and it would\nchange the TUI's boot state to match. If the answer is no, close this and let\nimprovise-df2w carry the real bug.\nCORRECTION TO THE NOTE ABOVE: it was appended through a shell string containing\nbackticks, which the shell evaluated away, so the three corroborations lost the\ncommand names that were their whole content. The corrected list follows. (The\ntrap is the same one AGENTS.md records for gh: put the body in a FILE and pass it\nwith a file redirect, never inline.)\n\nThree corroborations that panel OPEN and panel FOCUS are different concepts here,\neach independent:\n\n 1. F / C / V are bound to \"toggle-panel-and-focus\", while Ctrl-F / Ctrl-C /\n Ctrl-V are bound to a SEPARATE \"toggle-panel-visibility\". Two commands\n means two concepts, deliberately.\n 2. Tab in Normal is \"cycle-panel-focus\", which is meaningless unless panels\n stay open while unfocused.\n 3. The panel hint spells \"enter-mode normal\" as \"back\", not \"close\".\n\nEverything else in the note above stands, in particular the launch-state\nargument: category_panel_open starts TRUE while the mode is Normal, so an open\npanel in Normal is the state the app boots in -- if that were the bug, the app\nwould boot in the bug.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-refuse-fu","created_at":"2026-09-21T20:32:39Z","created_by":"box","updated_at":"2026-09-23T15:12:27Z","started_at":"2026-09-23T15:00:18Z","closed_at":"2026-09-23T15:12:27Z","close_reason":"USER RULING 2026-09-23: keep as is. Esc leaves a panel and the panel stays open (panel open is not panel focus; the app boots with an open, unfocused category panel, and Tab cycles focus across open panels).","comments":[{"id":"2be9cacc-1ca6-5fbe-8864-bb32c796ac7d","issue_id":"improvise-x0dc","author":"agent-refuse-fu","text":"agent-refuse-fu 2026-09-23: re-checked at gh/main 33cf496 and NOT fixed. It is not fixable without a user ruling, so I made no code change. PanelKeymap still binds Esc and Tab to \"enter-mode normal\" (keymap.rs:374-375). ViewState still boots with category_panel_open = true in Normal mode (lib.rs:448). \"toggle-panel-and-focus\" (F/C/V) and \"toggle-panel-visibility\" (Ctrl-F/C/V) are still separate commands, and Tab in Normal is still \"cycle-panel-focus\". So the earlier note stands: panel OPEN and panel FOCUS are two concepts by design. Making Esc also close the panel is a UX ruling for the user, and it would change the boot state too. improvise-df2w, the real divergence this ticket's evidence pointed at, closed via PR #173. kb.pl has decision('improvise-x0dc', panel_open_is_not_panel_focus, ...) and records no user ruling.\n","created_at":"2026-09-23T15:01:50Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} {"_type":"issue","id":"improvise-9cma","title":"[bug] Invalid ?doc= yields blank UI and only generic watchdog text","description":"## Summary\nInvalid `?doc=` values (e.g. empty, `///`) produce a blank UI and only the generic handshake watchdog message — no specific error that the document name/path was rejected.\n\n## Repro\n1. Open `/improvise/?doc=` or `?doc=///`\n2. Observe blank UI; status eventually like `the in-page worker did not answer` (or similar watchdog text).\n\n## Expected\nA specific, actionable status: invalid document name / path rejected (and what is allowed).\n\n## Actual\nWorker start fails or never answers; UI collapses to the same generic watchdog path used for OPFS/browser blocks.\n\n## Evidence\n- Browser smoke (external Grok Bot agent for Ed Langley), 2026-09-21 PT.\n- `crates/improvise-worker-server/src/bindings.rs`: `PathBuf::from(document)` with little/no validation before `attach`.\n\n## Notes\nFound by an external Grok Bot agent smoke/code hunt for Ed Langley (not Claude). Related to but distinct from OPFS AccessHandle conflicts.\n","status":"closed","priority":2,"issue_type":"bug","created_at":"2026-09-21T20:32:38Z","created_by":"box","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\nCLOSED -- AND THE REPORT UNDERSTATED IT. ?doc=a/b/c.improv was not rejected at all: it SILENTLY OPENED c.improv, because entry_name takes file_name. Accepted-and-wrong, not blank. That is an ID-shaped non-ID problem -- diagnosis identical, repair different. boot::document_name now answers before storage is touched.","dependencies":[{"issue_id":"improvise-9cma","depends_on_id":"improvise-1wft","type":"blocks","created_at":"2026-09-21T21:31:51Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-iobc","title":"[bug] Bad/missing ?seed= creates empty doc with no loud error","description":"## Summary\nA bad or missing `?seed=` is silent: the worker creates an empty document and the UI never surfaces a loud, actionable error. CORS/404 appears only in the console.\n\n## Repro\n1. Open `/improvise/?doc=probe-missing.improv\u0026seed=https://example.com/missing.improv`\n2. Or `?seed=nope.improv` (404 relative to the page).\n3. Observe an empty document with normal shell status; no toast/status naming the seed failure.\n\n## Expected\nStatus (or a blocking notice) names that the seed could not be fetched/parsed (404, CORS, corrupt), and ideally does not pretend the empty doc was intentional without saying so.\n\n## Actual\n`web/worker.js` `seedBytes()` returns `undefined` on `!res.ok` or fetch throw; comment says \"A missing seed is not an error\"; `start(DOC, undefined)` creates empty model. User sees empty grid.\n\n## Evidence\n- Browser smoke (external Grok Bot agent for Ed Langley), 2026-09-21 PT.\n- `web/worker.js` lines ~13–22 (`seedBytes`).\n- `crates/improvise-worker-server/src/vfs.rs` / `bindings.rs`: open-with-optional-seed.\n\n## Notes\nFound by an external Grok Bot agent smoke/code hunt for Ed Langley (not Claude).\n","status":"closed","priority":2,"issue_type":"bug","created_at":"2026-09-21T20:32:38Z","created_by":"box","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\nvfs::Origin{Stored,Seeded,Created}. A seed error is a notice only when the document really was created without it, so a reload of an existing document says nothing.","dependencies":[{"issue_id":"improvise-iobc","depends_on_id":"improvise-1wft","type":"blocks","created_at":"2026-09-21T21:31:52Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-55v2","title":"Nonfinite CellValue numbers serialize into JSON that the wire cannot read","description":"Report-only bug hunt at HEAD 70025e2, 2026-09-21. No implementation changes.\n\nLocation: crates/improvise-core/src/model/cell.rs:65-70; crates/improvise-protocol/src/resolve.rs:805-813\n\nserde_json serializes CellValue::Number(infinity) as {\"Number\":null}, which cannot deserialize as an f64. The test uses Command::SetYanked; ModelCommand::SetCell contains the same CellValue. typed() accepts inf, NaN and overflowing numeric literals, so a browser can compose a valid Rust command that cannot cross its JSON transport. The websocket reader skips unparseable frames and the worker reports only a console error. Choose an explicit representation for supported nonfinite numbers or refuse them before sending, with an observable error. Duplicate checks: serialization, finite, NaN. improvise-jc95 is the separate CSV zero-substitution bug.\n\n## Steps to Reproduce\nUse a standalone Rust 2024 package with path dependencies on improvise and improvise-io, plus serde_json=1 and ratatui=0.30.\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 nonfinite_cell_value_survives_wire() {\n use improvise::protocol::{Command,CellValue};\n let cmd=Command::SetYanked(Some(CellValue::Number(f64::INFINITY)));\n let json=serde_json::to_string(\u0026cmd).unwrap();\n assert!(serde_json::from_str::\u003cCommand\u003e(\u0026json).is_ok(),\"unreadable wire: {json}\");\n}\n```\nRun cargo test nonfinite_cell_value_survives_wire -- --nocapture. The current implementation failed this test.\n\n## Acceptance Criteria\nAddress the behavior above with a focused regression that fails before the fix and preserves ordinary input behavior.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-core","owner":"el-github@elangley.org","created_at":"2026-09-21T20:31:29Z","created_by":"Edward Langley","updated_at":"2026-09-23T02:44:34Z","started_at":"2026-09-23T01:50:18Z","closed_at":"2026-09-23T02:44:34Z","close_reason":"PR #152. A non-finite cell value crosses the wire in the spelling the format already uses. THE DESIGN QUESTION THE TICKET LEFT OPEN WAS ANSWERED BY MEASURING: .improv carries inf/-inf/nan as first-class values and all three round-trip losslessly, so 'refuse before sending' would make a legal saved document unprojectable. That also settles the second location -- resolve.rs's typed() accepting inf is CORRECT. Tokens are now one home (nonfinite_token/parse_nonfinite in core); three copies stood and a fourth was about to be written. ADVERSARIAL REVIEW FOUND A P2 THE PR'S OWN TESTS COULD NOT SEE: Repr::Nonfinite(\u0026str) only round-trips through BORROWING deserializers, so the same JSON document escaped as in\\\\u0066 was refused and to_value/from_value did not round-trip -- the wire accepted a subset of the encodings of the spelling it claims to share with the file. Both tests used from_str exclusively, the one path that borrows. Fixed with Cow\u003c'a, str\u003e (orchestrator reproduced before fixing; all five paths now pass, serialized form unchanged), plus a P3: the refusal assertion's second disjunct was exactly the message the P2 produces, so it passed in both worlds.","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -153,8 +265,8 @@ {"_type":"issue","id":"improvise-izx2","title":"Loading a saved workbook resurrects its deleted Default view","description":"Report-only bug hunt at HEAD 70025e2, 2026-09-21. No implementation changes.\n\nLocation: crates/improvise-io/src/persistence/parse.rs:399-425\n\nCreate Custom, delete Default, save and reopen. Expected only Custom; actual views are Default and Custom. build_workbook starts with Workbook::new, which inserts Default, then only adds/updates file views. Preserve the exact persisted set while retaining a sensible fallback for files with no view sections. Duplicate checks: Default, persist; improvise-azhb is about choosing the document default, not resurrecting a deleted view.\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 deleted_default_stays_deleted() {\n let mut w=Workbook::new(\"test\"); w.create_view(\"Custom\"); w.delete_view(\"Default\").unwrap();\n let r=parse_md(\u0026format_md(\u0026w)).unwrap(); assert_eq!(r.views.keys().collect::\u003cVec\u003c_\u003e\u003e(),w.views.keys().collect::\u003cVec\u003c_\u003e\u003e());\n}\n```\nRun cargo test deleted_default_stays_deleted. 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":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:27:43Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:32:03Z","closed_at":"2026-09-21T20:32:03Z","close_reason":"Duplicate of concurrently filed improvise-m4rv; retain that richer report. Independent failing regression preserved here and cross-linked there.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-3yv3","title":"Saving reorders category items when grouped and ungrouped items are interleaved","description":"Report-only bug hunt at HEAD 70025e2, 2026-09-21. No implementation changes.\n\nLocation: crates/improvise-io/src/persistence/format.rs:156-185\n\nAdd grouped item first under G, then ungrouped second. Before save the insertion order is [first, second]; parse_md(format_md(wb)) returns [second, first]. The writer collects every ungrouped item into bare and emits that entire list before grouped. Grid axis ordering consumes item insertion order, so reopening rearranges rows/columns. Preserve interleaving when writing, with a populated roundtrip regression. Duplicate checks: group, order, reorder, roundtrip; existing prune/group-boundary issues concern rendering, not serialization.\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 grouped_item_order_survives_save() {\n let mut w=Workbook::new(\"test\"); w.add_category(\"C\").unwrap();\n w.add_item_in_group(\"C\",\"first\",\"G\").unwrap(); w.add_item(\"C\",\"second\").unwrap();\n let r=parse_md(\u0026format_md(\u0026w)).unwrap();\n assert_eq!(r.model.category(\"C\").unwrap().ordered_item_names(),w.model.category(\"C\").unwrap().ordered_item_names());\n}\n```\nRun cargo test grouped_item_order_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":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:27:41Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:58:15Z","closed_at":"2026-09-21T21:58:15Z","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\nItems written in insertion order, a run of bare items sharing a line.\n\nNote the bead's own reproduction CANNOT discriminate: with one grouped and one ungrouped item, both the correct and incorrect writer emit one bare run followed by one grouped item. The landed test interleaves a, b, g1[G], c, g2[G] and asserts the emitted lines.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-qu4a","title":"View::prune_empty is never persisted, so hide-empty is lost on every save","description":"Found by the io bug hunt. DEMONSTRATED.\n\nView::prune_empty is never written to disk. format.rs:108 writes format:, hidden: and collapsed: for a view and nothing else; improv.pest:101's view_entry has no prune alternative. The string \"prune\" does not occur in format.rs, parse.rs, improv.pest or roundtrip_tests.rs.\n\n## Steps to Reproduce\nPress P in Normal mode (keymap.rs -\u003e toggle-prune-empty), :w, reopen. Every pruned row and column is back.\n\nThe toggle marks the document dirty, so the user is explicitly invited to save the setting that is about to be discarded.\n\nProbe: a view with prune_empty = true plus axes, page selection, format, hidden and collapsed. Everything else survived; only prune_empty was lost:\n\n ## View: Default\n _Index: none / _Dim: none / _Measure: page / A: row / B: page, y\n format: ,.2f\n hidden: A/x\n collapsed: A/G \u003c- no prune line anywhere\n\nInvisible to fmt(parse(x)) == fmt(parse(fmt(parse(x)))) because both sides drop it — doctrine(a_roundtrip_equality_is_blind_to_a_loss_both_sides_share).\n\n## The adjacent irony\nexport.rs:33 was fixed (improvise-iot6) precisely so CSV export HONOURS prune_empty — a setting the document cannot remember.\n\n## Acceptance Criteria\n- A prune line in view_entry (grammar first — improv.pest is the source of truth), one w! in format_md, one arm in walk_view_section/build_view. Emit only when true, as format: does.\n- A round-trip test asserting the FLAG, not just that the output is stable.\n- Decide what an older file without the line means: false is the compatible reading and is what happens today.\n","status":"closed","priority":2,"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:15Z","closed_at":"2026-09-21T21:58:15Z","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\nPersisted as 'prune: true|false' -- spelled with a VALUE rather than by the line's presence, so the grammar-walking generator can emit either. Absent means false, the compatible reading for a pre-existing file.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-exdg","title":"A records-mode coordinate cell projects id: None, which the contract says means no cell","description":"Found by the protocol bug hunt. DEMONSTRATED.\n\nproject.rs:331 — `RecordCol::Category(_) =\u003e return None` — consumed by CellDisplay::id, whose contract (render.rs:76 and gotcha 42) says id: None means \"NO PROJECTED CELL\". render.rs::editable, edit_or_drill and record_cell_at_cursor all read it that way.\n\ncell_id uses it for a THIRD thing: a cell that IS visible, DOES hold text, and whose write belongs to the host.\n\n (0,0) text=\"North\" id=None \u003c- the Region coordinate column\n (0,1) text=\"\" id=Some(Create(Category))\n (0,2) text=\"1200\" id=Some(Record(RecordCell{..}))\n (0,3) text=\"\" id=Some(Create(Measure))\n\nUser-visible: the browser paints \"North\" as an ordinary cell and every key that could act on it is a black hole — `i` resolves Upstream with no message.\n\n## Why this is an ADR 002 violation rather than a nit\nADR 002's own argument is that THE KIND OF IDENTITY IS THE PERMISSION. Here the ABSENCE of an identity is being used to encode a permission (\"this one is the host's\"), which is exactly the conflation the ADR was written to prevent. Gotcha 42 states the trap in as many words: None means \"no projected cell\", NOT \"aggregate\" — and conflating them either hides a cell or offers a write the type system exists to make unconstructible.\n\n## Acceptance Criteria\n- A CellId variant naming a coordinate cell (e.g. Coordinate { record, category }), so render.rs can mark it not-editable DELIBERATELY and resolve.rs can decline it out loud.\n- render.rs::editable must then be exhaustive over the new variant rather than falling through.\n- Until that lands, the edit_or_drill decline from improvise-bd9o covers the symptom but not the cause.\n","notes":"WAVE 2026-09-21: not done -- outside the assigned agent's ground, reported rather than guessed.\n\nimprovise-2wu3: the fix is edit() capturing the RecordCell in web/app.js plus a by-surrogate sibling of typed_cell_frame in wasm-client/src/lib.rs; the test belongs in wasm-client/tests/. It is also the live counterexample to improvise-6nsd's position-free argument -- the protocol vocabulary is position-free, and web/app.js still captures viewport (row, col) at paint time and resolves it against the CURRENT cache at commit.\n\nimprovise-exdg: needs a new CellId variant in improvise-protocol/src/render.rs AND exhaustiveness in improvise-wasm-client/src/render.rs. Note CellId already has a Create variant for the records-mode creating column, so a coordinate-cell variant slots in beside it naturally.\n\nimprovise-pv3g: NOT in improvise-core. The panic is improvise-gp/src/lib.rs:290 (number_text), the wrong-sided guard is improvise-gp/src/fit.rs:279, and the actual overflow is standardize in gp-tree/src/fit.rs:188-196 -- sum((t-mean)^2) in f32, plus a .max(1e-6) laundering a NaN into a finite std. Needs a gp-crate agent.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:24:48Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:59:57Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-2wu3","title":"A commit from the inline editor addresses the current cache while the screen is frozen","description":"Found by the browser bug hunt. ASSERTED. This one is a potential LOST/MISDIRECTED WRITE and deserves settling before it is dismissed.\n\nweb/app.js:146 — `if (editing) return;` in repaint. So while the DOM editor is open the screen is frozen. But onFrame() KEEPS FOLDING: client.on_frame(text) runs before repaint() and is not guarded. The cache moves while the screen does not.\n\nedit(td, row, col, seed) captures VIEWPORT COORDINATES at paint time (:336), and commit calls client.typed_cell(row, col, input.value) (:360), which resolves (row, col) against the CURRENT GridCache — not the one the user was looking at.\n\n## Steps to Reproduce\nTwo clients on one ws-served document. Browser user clicks the cell showing North / Revenue and starts typing. Another client (or the TUI host) hides an item above it, or toggles a group — the rows renumber. The browser folds the new RowFrame and Cells silently; the screen still shows the old table. The user presses Enter.\n\nrecord_cell_at(row, col) now returns the record that has MOVED INTO that position. basis.claim is satisfied because the client folded the fresh Projection::Basis in the same suppressed batch. ADR 001's staleness check PASSES — it is defeated precisely because folding continued. The write lands on a row the user never looked at, and the frame is reported accepted.\n\n## The shape, which is why this is not \"the basis check is wrong\"\nThe basis is current FOR THE CLIENT. What is stale is the SCREEN, and nothing measures that. The (row, col) captured at paint time is a claim about a frame, and the frame is not part of what is claimed.\n\n## Acceptance Criteria\n- A test in crates/improvise-wasm-client/tests/ — no harness needed. Connect A and B to one Host, have A open an editor at a position, apply a HideItem from B, deliver the resulting frames to A's Client WITHOUT repainting, then call typed_cell_frame(row, col, \"42\") and assert the RecordCell it names is the one the user clicked.\n- Fix: capture the RecordCell at edit() time rather than the coordinates, and commit against that surrogate. Client::record_cell_at already returns it; typed_cell_frame would gain a by-surrogate sibling. That makes the position-vs-identity confusion unrepresentable rather than validated, which is this repo's usual answer.\n","notes":"WAVE 2026-09-21: not done -- outside the assigned agent's ground, reported rather than guessed.\n\nimprovise-2wu3: the fix is edit() capturing the RecordCell in web/app.js plus a by-surrogate sibling of typed_cell_frame in wasm-client/src/lib.rs; the test belongs in wasm-client/tests/. It is also the live counterexample to improvise-6nsd's position-free argument -- the protocol vocabulary is position-free, and web/app.js still captures viewport (row, col) at paint time and resolves it against the CURRENT cache at commit.\n\nimprovise-exdg: needs a new CellId variant in improvise-protocol/src/render.rs AND exhaustiveness in improvise-wasm-client/src/render.rs. Note CellId already has a Create variant for the records-mode creating column, so a coordinate-cell variant slots in beside it naturally.\n\nimprovise-pv3g: NOT in improvise-core. The panic is improvise-gp/src/lib.rs:290 (number_text), the wrong-sided guard is improvise-gp/src/fit.rs:279, and the actual overflow is standardize in gp-tree/src/fit.rs:188-196 -- sum((t-mean)^2) in f32, plus a .max(1e-6) laundering a NaN into a finite std. Needs a gp-crate agent.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:24:14Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:59:57Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-exdg","title":"A records-mode coordinate cell projects id: None, which the contract says means no cell","description":"Found by the protocol bug hunt. DEMONSTRATED.\n\nproject.rs:331 — `RecordCol::Category(_) =\u003e return None` — consumed by CellDisplay::id, whose contract (render.rs:76 and gotcha 42) says id: None means \"NO PROJECTED CELL\". render.rs::editable, edit_or_drill and record_cell_at_cursor all read it that way.\n\ncell_id uses it for a THIRD thing: a cell that IS visible, DOES hold text, and whose write belongs to the host.\n\n (0,0) text=\"North\" id=None \u003c- the Region coordinate column\n (0,1) text=\"\" id=Some(Create(Category))\n (0,2) text=\"1200\" id=Some(Record(RecordCell{..}))\n (0,3) text=\"\" id=Some(Create(Measure))\n\nUser-visible: the browser paints \"North\" as an ordinary cell and every key that could act on it is a black hole — `i` resolves Upstream with no message.\n\n## Why this is an ADR 002 violation rather than a nit\nADR 002's own argument is that THE KIND OF IDENTITY IS THE PERMISSION. Here the ABSENCE of an identity is being used to encode a permission (\"this one is the host's\"), which is exactly the conflation the ADR was written to prevent. Gotcha 42 states the trap in as many words: None means \"no projected cell\", NOT \"aggregate\" — and conflating them either hides a cell or offers a write the type system exists to make unconstructible.\n\n## Acceptance Criteria\n- A CellId variant naming a coordinate cell (e.g. Coordinate { record, category }), so render.rs can mark it not-editable DELIBERATELY and resolve.rs can decline it out loud.\n- render.rs::editable must then be exhaustive over the new variant rather than falling through.\n- Until that lands, the edit_or_drill decline from improvise-bd9o covers the symptom but not the cause.\n","notes":"WAVE 2026-09-21: not done -- outside the assigned agent's ground, reported rather than guessed.\n\nimprovise-2wu3: the fix is edit() capturing the RecordCell in web/app.js plus a by-surrogate sibling of typed_cell_frame in wasm-client/src/lib.rs; the test belongs in wasm-client/tests/. It is also the live counterexample to improvise-6nsd's position-free argument -- the protocol vocabulary is position-free, and web/app.js still captures viewport (row, col) at paint time and resolves it against the CURRENT cache at commit.\n\nimprovise-exdg: needs a new CellId variant in improvise-protocol/src/render.rs AND exhaustiveness in improvise-wasm-client/src/render.rs. Note CellId already has a Create variant for the records-mode creating column, so a coordinate-cell variant slots in beside it naturally.\n\nimprovise-pv3g: NOT in improvise-core. The panic is improvise-gp/src/lib.rs:290 (number_text), the wrong-sided guard is improvise-gp/src/fit.rs:279, and the actual overflow is standardize in gp-tree/src/fit.rs:188-196 -- sum((t-mean)^2) in f32, plus a .max(1e-6) laundering a NaN into a finite std. Needs a gp-crate agent.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-viewhist","owner":"el-github@elangley.org","created_at":"2026-09-21T20:24:48Z","created_by":"Edward Langley","updated_at":"2026-09-23T05:28:40Z","started_at":"2026-09-23T04:38:10Z","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).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-2wu3","title":"A commit from the inline editor addresses the current cache while the screen is frozen","description":"Found by the browser bug hunt. ASSERTED. This one is a potential LOST/MISDIRECTED WRITE and deserves settling before it is dismissed.\n\nweb/app.js:146 — `if (editing) return;` in repaint. So while the DOM editor is open the screen is frozen. But onFrame() KEEPS FOLDING: client.on_frame(text) runs before repaint() and is not guarded. The cache moves while the screen does not.\n\nedit(td, row, col, seed) captures VIEWPORT COORDINATES at paint time (:336), and commit calls client.typed_cell(row, col, input.value) (:360), which resolves (row, col) against the CURRENT GridCache — not the one the user was looking at.\n\n## Steps to Reproduce\nTwo clients on one ws-served document. Browser user clicks the cell showing North / Revenue and starts typing. Another client (or the TUI host) hides an item above it, or toggles a group — the rows renumber. The browser folds the new RowFrame and Cells silently; the screen still shows the old table. The user presses Enter.\n\nrecord_cell_at(row, col) now returns the record that has MOVED INTO that position. basis.claim is satisfied because the client folded the fresh Projection::Basis in the same suppressed batch. ADR 001's staleness check PASSES — it is defeated precisely because folding continued. The write lands on a row the user never looked at, and the frame is reported accepted.\n\n## The shape, which is why this is not \"the basis check is wrong\"\nThe basis is current FOR THE CLIENT. What is stale is the SCREEN, and nothing measures that. The (row, col) captured at paint time is a claim about a frame, and the frame is not part of what is claimed.\n\n## Acceptance Criteria\n- A test in crates/improvise-wasm-client/tests/ — no harness needed. Connect A and B to one Host, have A open an editor at a position, apply a HideItem from B, deliver the resulting frames to A's Client WITHOUT repainting, then call typed_cell_frame(row, col, \"42\") and assert the RecordCell it names is the one the user clicked.\n- Fix: capture the RecordCell at edit() time rather than the coordinates, and commit against that surrogate. Client::record_cell_at already returns it; typed_cell_frame would gain a by-surrogate sibling. That makes the position-vs-identity confusion unrepresentable rather than validated, which is this repo's usual answer.\n","notes":"WAVE 2026-09-21: not done -- outside the assigned agent's ground, reported rather than guessed.\n\nimprovise-2wu3: the fix is edit() capturing the RecordCell in web/app.js plus a by-surrogate sibling of typed_cell_frame in wasm-client/src/lib.rs; the test belongs in wasm-client/tests/. It is also the live counterexample to improvise-6nsd's position-free argument -- the protocol vocabulary is position-free, and web/app.js still captures viewport (row, col) at paint time and resolves it against the CURRENT cache at commit.\n\nimprovise-exdg: needs a new CellId variant in improvise-protocol/src/render.rs AND exhaustiveness in improvise-wasm-client/src/render.rs. Note CellId already has a Create variant for the records-mode creating column, so a coordinate-cell variant slots in beside it naturally.\n\nimprovise-pv3g: NOT in improvise-core. The panic is improvise-gp/src/lib.rs:290 (number_text), the wrong-sided guard is improvise-gp/src/fit.rs:279, and the actual overflow is standardize in gp-tree/src/fit.rs:188-196 -- sum((t-mean)^2) in f32, plus a .max(1e-6) laundering a NaN into a finite std. Needs a gp-crate agent.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-wire2","owner":"el-github@elangley.org","created_at":"2026-09-21T20:24:14Z","created_by":"Edward Langley","updated_at":"2026-09-23T06:35:13Z","started_at":"2026-09-23T04:25:34Z","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-u6gc","title":"prune_empty drops a GroupEnd whose header was pruned, so an ungrouped item inherits the earlier group","description":"Found by the core bug hunt. DEMONSTRATED by the hunter; mechanism re-verified by the orchestrator by reading the arm.\n\nTHIS REGRESSES improvise-gd83 WHENEVER prune_empty IS ON. gd83 added AxisEntry::GroupEnd in PR #77 \"so membership is not inherited\". prune_axis_items can delete it.\n\nview/layout.rs:142 —\n\n AxisEntry::GroupEnd =\u003e match pending_header.take() {\n Some(_) =\u003e {} // drops the GroupEnd too\n None =\u003e kept.push(entry),\n },\n\nWhen the header a GroupEnd closes was itself pruned, the arm drops BOTH. But an EARLIER header may still be open in the kept list, and pruning the intervening header is exactly what re-opens it. The GroupEnd that would have closed it is now gone, so row_group_for / col_group_for — and AxisFrame::group_of in the protocol, gotcha 44 — walk back past the surviving items to the wrong header.\n\n## Steps to Reproduce\n Month: Jan[Q1], Feb[Q1], Mar[Q2], Sep(no group) Type: Food\n cells for Jan, Feb, Sep only (Mar's row is empty)\n view.prune_empty = true\n\nObserved: row_items == [GroupHeader{Month,Q1}, DataItem[\"Jan\"], DataItem[\"Feb\"], DataItem[\"Sep\"]]\n row_group_for(2) == Some((\"Month\",\"Q1\"))\nExpected: row_group_for(2) == None — Sep is in no group.\n\nThe TUI also DRAWS Sep under the Q1 header, and `z` on Sep collapses Q1.\n\n## The fix, already tried\n AxisEntry::GroupEnd =\u003e {\n pending_header.take();\n kept.push(entry);\n }\n\nThe hunter applied this temporarily: the probe goes to row_group_for(2) == None and cargo test --workspace was FULLY GREEN with it. It leaves a possible leading GroupEnd, which is harmless — nothing is drawn for one and data_index_to_visual counts only DataItems — but that is why it wants review rather than landing blind.\n\n## Acceptance Criteria\n- The repro above yields row_group_for(2) == None.\n- A test that fails on today's arm and passes after. Note the whole workspace is green today WITH the bug, so no existing test covers it.\n- Check the protocol side too: AxisFrame::group_of consumes these entries, so the same input should be asserted through a projected GridCache.\n\nIndependent of improvise-lhi4; both live in prune_axis_items and neither fixes the other.\n","notes":"GROOM 2026-09-21: pair with the other prune_empty bead -- improvise-lhi4 (a collapsed group's header is dropped, so the group cannot be re-expanded) and improvise-u6gc (a GroupEnd whose header was pruned is dropped too, so an ungrouped item inherits the group).\n\nONE AGENT, sequentially. Both live in prune_axis_items / prune_empty in crates/improvise-core/src/view/layout.rs, and AGENTS.md is explicit that tickets sharing a file go to one agent -- split across two they are a guaranteed conflict in the same match block.\n\nThey also interact rather than merely colocate: u6gc records that it REGRESSES improvise-gd83 whenever prune_empty is on. gd83 added AxisEntry::GroupEnd precisely so group membership is not inherited (gotcha 44), and pruning drops the GroupEnd that carries it. So a fix for lhi4 that re-adds headers without restoring their GroupEnd would close one and deepen the other.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:22:53Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:58:43Z","closed_at":"2026-09-21T21:58:43Z","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\nA GroupEnd is kept whenever a run is open in the OUTPUT, which closes a re-opened earlier header even though the header between them was pruned.\n\ngd83's property verified to hold WITH prune_empty on, three ways: a new test that FAILS on the old code (Some((\"Month\",\"Q1\")) vs None), gd83's own two tests still green, and measured through the projection -- group_of(2) == None, and the collapsed case reaches the wire as [Group(Q1, collapsed: true), GroupEnd, Item(Sep)] so a browser gets the header WITH its expand affordance.\n\nTHE FINDING, and it is a doctrine: this bead and lhi4 were filed as independent, and u6gc's OWN proposed patch (pending_header.take(); kept.push(entry);) unconditionally discards the pending header -- so applied after lhi4 it would delete every collapsed header again. It was verified green only against a tree where lhi4 was still open. Two independently-correct patches, mutually destructive. 'Independent' on a bug report must mean verified TOGETHER, not merely different symptoms.","dependencies":[{"issue_id":"improvise-u6gc","depends_on_id":"improvise-lhi4","type":"discovered-from","created_at":"2026-09-21T21:31:55Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-lhi4","title":"prune_empty drops a collapsed group's header, so the group cannot be re-expanded from the grid","description":"Found by the core bug hunt. DEMONSTRATED by the hunter; mechanism re-verified by the orchestrator by reading both functions.\n\nA collapsed group contributes ZERO AxisEntry::DataItems — expand_category (view/layout.rs:614) emits the GroupHeader and then `continue`s past every item, by design. prune_axis_items (view/layout.rs:131) keeps a header only if some data item under it survives the predicate. So with prune_empty ON, a collapsed group's header is ALWAYS dropped, and the group is not drawn at all.\n\n## Steps to Reproduce\n Month: Jan[Q1], Sep Type: Food\n cells: (Jan,Food)=1, (Sep,Food)=1\n view.toggle_group_collapse(\"Month\",\"Q1\")\n view.prune_empty = true\n GridLayout::with_frozen_records(\u0026model, \u0026view, None)\n\nObserved: row_items == [DataItem([\"Sep\"])] — no Q1 header anywhere.\nExpected: the GroupHeader{Month,Q1} survives. AxisEntry's own doc comment promises \"A GroupHeader is always visible so its label and collapse state show\".\n\n## Why it matters to a user\n`z` (toggle-group-under-cursor) resolves the group from row_group_for(cursor). With no header and no items, no row names Q1 — so COLLAPSE BECOMES ONE-WAY FROM THE GRID. Recovery needs the ex-command `:toggle-group Month Q1` and knowledge of the group name. Two keystrokes to reach, with pruning on.\n\n## Acceptance Criteria\n- A header whose run is empty BECAUSE IT IS COLLAPSED survives pruning.\n- A header whose run is empty because every item pruned away still goes.\n- A test distinguishing those two, or the fix is satisfied by never pruning any header.\n\n## Note on the fix\nNot a one-liner and worth deciding deliberately. prune_axis_items cannot tell the two cases apart: GridLayout drops the \u0026View after construction. Either prune_empty takes the \u0026View and the keeper consults is_group_collapsed, or expand_category marks a collapsed run's header unprunable.\n\nIndependent of improvise-\u003cF2 sibling\u003e; both live in prune_axis_items and neither fixes the other.\n","notes":"GROOM 2026-09-21: pair with the other prune_empty bead -- improvise-lhi4 (a collapsed group's header is dropped, so the group cannot be re-expanded) and improvise-u6gc (a GroupEnd whose header was pruned is dropped too, so an ungrouped item inherits the group).\n\nONE AGENT, sequentially. Both live in prune_axis_items / prune_empty in crates/improvise-core/src/view/layout.rs, and AGENTS.md is explicit that tickets sharing a file go to one agent -- split across two they are a guaranteed conflict in the same match block.\n\nThey also interact rather than merely colocate: u6gc records that it REGRESSES improvise-gd83 whenever prune_empty is on. gd83 added AxisEntry::GroupEnd precisely so group membership is not inherited (gotcha 44), and pruning drops the GroupEnd that carries it. So a fix for lhi4 that re-adds headers without restoring their GroupEnd would close one and deepen the other.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:22:42Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:58:43Z","closed_at":"2026-09-21T21:58:43Z","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\nResolved from the ENTRY STREAM alone, so prune_empty needed no \u0026View and NEITHER of the bead's two proposed mechanisms was used. A run holding no DataItem at all was never expanded -- it is collapsed, header survives. A run whose items were all rejected is genuinely empty and goes. gotcha 1's shape is intact: still a predicate over the data index, no \u0026View parameter, no flag vector.\n\nThe bead concluded prune_axis_items 'cannot tell the two cases apart' from the fact that GridLayout drops the \u0026View. It can -- the distinction was never about the view, it is about whether the items were ever EMITTED.\n\na_header_whose_items_all_prune_away_is_dropped passes BEFORE the change and is what stops 'keep every header' counting as a fix.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-x1ns","title":"encoded_width undercounts a forced category that has a missing coordinate","description":"Found by ChatGPT Codex reviewing PR #90 at 99cfb7f. THE REVIEW WAS NOT READ BEFORE THE PR MERGED — orchestrator process failure, see improvise-\u003cthis\u003e's sibling note. Re-verified on main at ce895ff: STILL LIVE.\n\nencoded_width (crates/improvise-gp/src/fit.rs:18) computes has_missing at line 32 and uses it ONLY in the numeric branch:\n\n true =\u003e 1 + usize::from(has_missing), // numeric: feature + .missing flag\n false =\u003e present.iter().collect::\u003cHashSet\u003c_\u003e\u003e().len().max(1), // categorical: present only\n\nbuild_categorical (crates/gp-tree/src/data.rs:359) maps every missing cell to MISSING_CAT and includes it in distinct(\u0026mapped), emitting an ADDITIONAL one-hot.\n\nSo a forced categorical column with N distinct present values plus one record lacking that coordinate passes the preflight as N and materializes N+1 columns. It is caught only by the LATER EXACT CHECK — which is precisely the allocation the preflight exists to prevent. At default max_records that is a large one-hot built before the error.\n\nThis is doctrine(guard_the_cost_not_the_result) defeated, and it is the SECOND time on this code path: improvise-zv8t's own adversarial round found forcing-by-name-while-budgeting-by-index, repaired as a data dependency. This one is the same class one level in — the two branches of one match disagree about what a column costs.\n\nNote the Wave K agent's own review had spotted a related shape (recorded as F5, \"build_categorical counts MISSING_CAT, the preflight does not — under-counts by one per category with an absent coordinate\") and recorded it as READ, NOT INJECTED and bounded by the exact check. Codex is right that \"bounded by the exact check\" is not a defence: the exact check runs after the allocation.\n\n## Acceptance Criteria\n- Count the missing level in the categorical branch.\n- A boundary regression with a FORCED NUMERIC CATEGORY containing a missing coordinate — the interesting case, because forcing is what makes a numeric column take the categorical branch.\n- Prove it by injection: without the fix, the preflight must under-report by exactly one and the test must fail naming that.\n- While there: gp.md §14 documents where the preflight and the exact check \"still differ\". Re-read it — it is the prose that made this look bounded.\n","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T17:13:05Z","created_by":"Edward Langley","updated_at":"2026-09-21T17:56:36Z","closed_at":"2026-09-21T17:56:36Z","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -222,7 +334,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":"{}"}],"dependency_count":1,"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-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} @@ -319,8 +431,138 @@ {"_type":"issue","id":"improvise-pby","title":"2.2 Write the README","description":"Replace existing README.md with new one under 250 lines. Sections in order: Title, one-sentence pitch, inline demo GIF, why this exists (Lotus Improv reference), quick start, key bindings, installation (Nix/crates.io/prebuilt - no Homebrew), codebase overview, expectations disclaimer, license. No badges, TOC, or contributing guide.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:32Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-09T07:02:52Z","close_reason":"README written with all 10 required sections, committed. License updated to Apache-2.0 per user request.","dependency_count":0,"dependent_count":2,"comment_count":0} {"_type":"issue","id":"improvise-ihv","title":"2.1 Add Nix tooling for asciinema, VHS, and cargo-dist","description":"Modify flake.nix to add pkgs.asciinema and pkgs.vhs to dev shell nativeBuildInputs. Add cargo-dist if packaged in nixpkgs. Add runtime deps for VHS (ttyd, ffmpeg) if needed. Verify tools work via nix develop --command. Add nix run app or shell script for demo recording workflow.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:30Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-09T07:30:19Z","close_reason":"asciinema, vhs, cargo-dist added to flake.nix dev shell and verified. cargo-dist wrapper enables 'cargo dist' subcommand. scripts/record-demo.sh added.","dependency_count":0,"dependent_count":3,"comment_count":0} {"_type":"issue","id":"improvise-yk4","title":"Phase 2: README and demo artifacts","description":"The main launch work. README is 80% of the launch, demo artifacts are 20%. Covers Nix tooling for asciinema/VHS, writing the README, creating demo GIF and asciinema casts, and verifying all artifacts.","status":"closed","priority":2,"issue_type":"feature","assignee":"spot","owner":"el-github@elangley.org","created_at":"2026-04-09T04:05:49Z","created_by":"Edward Langley","updated_at":"2026-04-14T07:47:43Z","closed_at":"2026-04-14T07:47:43Z","close_reason":"All 5 subtasks closed; artifacts verified present and tracked in git (README.md, docs/demo.gif, docs/demo.tape, docs/casts/*.cast).","dependencies":[{"issue_id":"improvise-yk4","depends_on_id":"improvise-abz","type":"blocks","created_at":"2026-04-08T23:37:37Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-yk4","depends_on_id":"improvise-d4w","type":"blocks","created_at":"2026-04-08T23:37:36Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-yk4","depends_on_id":"improvise-ihv","type":"blocks","created_at":"2026-04-08T23:37:34Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-yk4","depends_on_id":"improvise-odx","type":"blocks","created_at":"2026-04-08T23:37:35Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-yk4","depends_on_id":"improvise-pby","type":"blocks","created_at":"2026-04-08T23:37:36Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":5,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-8nd9","title":"ViewBack and ViewForward carry swzk's hazard and are safe only via forget_view","description":"effect::SwitchView was fixed by improvise-swzk (PR #154): it now pushes its history frame AFTER calling the guarded App::switch_to_view and only if active_view actually moved -- observation rather than prediction, the same answer cursor_epoch and document_generation give (gotchas 24/39/45).\n\nViewBack and ViewForward have THE SAME SHAPE and were not fixed there, deliberately, to keep #154 to its ticket. Both call switch_to_view unguardedly, pushing the current view onto the OPPOSITE stack before a switch they cannot be sure of.\n\nTHEY ARE CORRECT TODAY ONLY BY ACCIDENT: ViewState::forget_view purges both stacks when a view is deleted, so a popped frame always names a live view. That is A GUARD LIVING ONE LEVEL AWAY FROM THE HAZARD -- the exact shape that keeps returning in this repo (gotcha 18's empty `partial`, which was unreachable only because both callers guarded; gotcha 24's reset_cursor table). The class of bug returns the moment a second path can put a dead name on a stack.\n\nSo the question is not \"is this broken now\" (it is not) but \"what makes it stay correct\". Options: give ViewBack/ViewForward the same observe-after-the-fact shape as SwitchView, which costs three lines and removes the dependence entirely; or state the forget_view dependence where someone editing either side will see it.\n\nPrefer the first. A guard that is one level away is a guard that a refactor deletes without noticing, and the cheap fix here is the SAME fix already made next door -- so leaving two siblings with different shapes is itself the hazard.\n\nReachability note: a popped frame naming a deleted view needs forget_view to miss a path. Worth checking whether any code removes a view WITHOUT going through it before deciding how urgent this is. Reported by the improvise-htl6/swzk agent; recorded in context/code/command.md at the time.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T02:36:19Z","created_by":"Edward Langley","updated_at":"2026-09-23T02:36:19Z","dependencies":[{"issue_id":"improvise-8nd9","depends_on_id":"improvise-lrrx","type":"related","created_at":"2026-09-23T03:38:10Z","created_by":"agent-review-cmd","metadata":"{}"}],"comments":[{"id":"bad4d021-db16-5343-a024-1a0e13fb61b8","issue_id":"improvise-8nd9","author":"agent-review-cmd","text":"Post-merge review of #154: the 'second path that can put a dead name on a stack' already exists. install_workbook (LoadModel/AcceptWizardModel) clears cursors but not the view stacks. Confirmed in the headless CLI, and filed as improvise-lrrx. The 'it is not broken now' in this description is false.\n","created_at":"2026-09-23T03:38:10Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} -{"_type":"issue","id":"improvise-jgzw","title":"The tile bar offers _Index and _Dim, because every call site passes category_names()","description":"All four tile call sites pass Model::category_names() -- which returns EVERY category, virtual ones included -- to View::tileable_cats:\n\n ui/project.rs:403, cmd/tile.rs:94, cmd/tile.rs:119, src/ui/tile_bar.rs:180\n\ntileable_cats filters only on axis (it drops the coordinates a drill fixed). In a default view _Index and _Dim sit on Axis::None, which IS user-assignable, so they pass the filter. Consequence: TILE CURSOR 0 IS _Index, not the first regular category.\n\nFound while writing improvise-lm5e's field-naming assertions (PR #148): no assertion in the suite had ever named a category, so nothing said so. Behaviour is UNCHANGED by that PR -- this ticket is the question it exposed, not a regression.\n\nTHE NUANCE THAT DECIDES THIS, and it is why the obvious fix is wrong: _Measure MUST stay tileable. Putting _Measure on an axis is not exotic, it is what every shipped document does (`_Measure: column` is in web/demo.improv and in the format example). So this is NOT \"use regular_category_names() for user-facing pickers\" as the repo-map advises for prompts -- that would remove _Measure and break the common case.\n\nThe categories actually in doubt are _Index and _Dim, which are records-mode machinery: GridLayout AUTO-DETECTS records mode from _Index-on-Row plus _Dim-on-Column, so a user assigning them by hand through the tile bar can construct that state by accident, without the mode change and the keymap that ToggleRecordsMode brings. Worth checking what that actually does before deciding it is bad -- it may be harmless, in which case the answer is to leave it and say why here.\n\nSo the options are: (a) leave it, recording the reason; (b) filter _Index/_Dim specifically, keeping _Measure -- which needs a predicate named for the distinction, since CategoryKind::is_index_or_dim() already exists and says exactly this; (c) decide records mode should be reachable that way and leave it deliberately.\n\nWhichever is chosen, ASSERT A CATEGORY NAME in the tile tests -- the gap that hid this is that none of them did. The rival to inject is a filter that also drops _Measure: it must fail a test that puts _Measure on a column.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T02:16:27Z","created_by":"Edward Langley","updated_at":"2026-09-23T02:16:27Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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} +{"_type":"issue","id":"improvise-6eqi","title":"ub4o's toggle-group tests all sit at data row 0: z ignoring the cursor row passes the whole workspace","description":"Found in a post-merge adversarial review of PR #156 (improvise-ub4o), head 99fda1d2. All three replacement tests put the cursor at data row 0 (make_ctx's default). That is the \"group at row 0\" fixture accident. So none of them can tell \"names the group of the CURSOR's row\" apart from \"names the group of row 0\".\n\nCONFIRMED by injection at gh/main 40b8913: in ToggleGroupAtCursor::execute, `ctx.layout.row_group_for(ctx.selected.0)` was replaced with `row_group_for(0)`. The **whole workspace passes** (38 test binaries, 0 failures). The second test still passes because after Q1 collapses, row 0 is Apr in Q2, which is exactly what the rival also answers. The injection was reverted, and a full re-run gave 1747 passed, 0 failed.\n\nSo nothing pins that `z` addresses the row under the cursor. Repair: seat the cursor on a Q2 row (e.g. selected = (2, 0), Apr) and assert Q2 is named and Q1 is untouched. It is closely related to improvise-6qet (in progress on the same command). The fixer there may want to add this fixture at the same time.\n\nSeparately, not a finding: Edward's two questions on the PR #156 thread (\"isn't the old law about determinism?\" / \"two equal contexts with different identities?\") are unanswered. A note for whoever answers them: `f(x) == f(x)` against one borrow CAN see some non-determinism (e.g. HashSet iteration order in the output, gotcha 18). But the old fixture had no groups, so both sides were the empty string and it could not see even that.\n","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T03:49:42Z","created_by":"agent-review-cmd","updated_at":"2026-09-23T04:37:07Z","closed_at":"2026-09-23T04:37:07Z","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.","dependencies":[{"issue_id":"improvise-6eqi","depends_on_id":"improvise-6qet","type":"related","created_at":"2026-09-23T03:49:47Z","created_by":"agent-review-cmd","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-3ij0","title":"htl6's 'pins the key' assertion calls composed() by command name, so the ',' it passes is ignored: rebinding view-forward off ',' leaves it green","description":"Found in a post-merge adversarial review of PR #155 (improvise-htl6), head e645ec88.\n\n`resolve::tests::the_forward_step_mirrors_its_cmd_and_one_key_reaches_it` has a third assertion:\n composed(\u0026state, ev(KeyCode::Char(',')), \"view-forward\", \u0026[]) == Some(vec![Command::ViewForward])\nThe evidence entry (context/test-evidence/clients.md) says this \"pins the key itself ... so the binding cannot drift onto some other key while the presence check stays happy\". But `composed` dispatches on the command NAME (`match name { ... }`), and the `view-forward` arm does not read the event. So the `,` is ignored, and the assertion restates the first half of the same test.\n\nCONFIRMED by injection at gh/main 40b8913: rebinding view-forward from Char(',') to Char('~') in improvise-protocol/src/keymap.rs leaves this test PASSING. Only `ui::app::tests::the_view_history_walks_both_ways_from_the_keyboard` fails. The key is therefore still pinned by the TUI end-to-end test, since the keymap is shared, so no behaviour is unguarded. The defect is a vacuous assertion together with an evidence claim that is false. That is the \"check that answers a neighbouring question\" shape.\n\nRepair: resolve the KEY rather than the name. Go through the keymap lookup, or `resolve(state, BrowserKey(',' ...))`, and assert it reaches Command::ViewForward. Alternatively, delete the assertion and correct the evidence text so it credits the app.rs test.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-helpkeys2","owner":"el-github@elangley.org","created_at":"2026-09-23T03:49:31Z","created_by":"agent-review-cmd","updated_at":"2026-09-23T05:44:50Z","started_at":"2026-09-23T03:55:06Z","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} +{"_type":"issue","id":"improvise-3t5x","title":"every_advertised_command_answers_in_its_own_mode counts a refusal as an answer","description":"Found on improvise-s8h7. resolve.rs::every_advertised_command_answers_in_its_own_mode treats any outcome other than Upstream/Ignored as answered, so a Local[SetStatus(refusal)] satisfies it. It therefore proves 'no advertised key is a black hole', not 'every advertised key does what its caption says'. s8h7 was exactly that shape: the Normal status bar advertises edit-or-drill as 'edit', and on an aggregate the browser answered with a refusal while the TUI drilled; the guard stayed green. Neighbour of improvise-wk18 (unchecked literal() keys), not the same gap. Possible shapes: a named list of advertised keys that answer by refusing (like the five-entry 'expected' list already in the test), so adding a refusal to an advertised key has to come there and say so.","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-helpkeys2","owner":"el-github@elangley.org","created_at":"2026-09-23T03:49:06Z","created_by":"agent-web3","updated_at":"2026-09-23T05:44:50Z","started_at":"2026-09-23T03:55:06Z","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":["browser","parity","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_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} @@ -357,7 +599,7 @@ {"_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-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","status":"open","priority":3,"issue_type":"bug","owner":"box@local","created_at":"2026-09-21T20:49:06Z","created_by":"external-grok-bot","updated_at":"2026-09-21T20:49:06Z","external_ref":"gh-pr-100-P3-additem","labels":["adversarial-review","pr-100"],"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-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} @@ -512,6 +754,33 @@ {"_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-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-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-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-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-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} {"_type":"issue","id":"improvise-6nsd","title":"A prune-by-write renumbers the grid without moving layout_version -- ADR 001 middle tier","description":"A cell write that prunes a row renumbers the grid without moving layout_version.\n\nMeasured 2026-09-21 while answering improvise-9v0j, by driving each ModelCommand\nthrough apply_command and reading cursor_epoch() and layout_version().\n\nEvery command in reshaping_commands() moves both counters. A ClearCell that\nprunes a row out of a pruning view moves the cursor epoch and NOT layout_version:\n\n ClearCell(prunes a row) reframe=true reshape=false\n\nThat is not a defect in the observation. improvise-mcq4 designed it that way: a\ncell write changes DATA, not the view's CONFIGURATION, and view_shape hashes\nconfiguration. So 9v0j's candidate property test (\"is every reframe also a\nreshape?\") would FAIL and writing it would be wrong. That half is answered and\nrecorded in context/code/command.md.\n\nWHAT IS OPEN, and it is an ADR question rather than a test to land quietly.\nAfter such a write the grid has renumbered while layout_version has not moved,\nso a client still claiming the OLD layout tier is accepted. The record tier\ncovers a write to the PRUNED record -- that client's picture of Service is\nstale. It says nothing about a write to a SURVIVING record whose row index moved\nunderneath. Whether that is a hole in ADR 001's middle tier, or correctly\ndelegated to the record tier because the record surrogate is position-free\n(ADR 002), is the question.\n\nNote which way the asymmetry falls if it IS a hole: gotcha 35 says\nunder-reporting leaves a client wrong forever, and layout_version under-reports\nhere by construction.\n\nReachability: needs two clients on pruning views and a cell write that empties a\nrow. The cursor invalidation half already works (mcq4), so the user-visible\nsymptom would be a basis check accepting a batch composed against a grid shape\nthat no longer exists -- not a wrong cursor.\n","status":"closed","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T11:09:49Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:59:10Z","closed_at":"2026-09-21T21:59:10Z","close_reason":"ANSWERED WITH A CONDITION rather than fixed -- and the answer is that it is not a hole.\n\nThe harm a renumbering could do requires a POSITIONAL mutation, and no ModelCommand variant carries a row or column index. Verified independently by two agents over all 24 variants (grep for usize|u32|u64|isize|index|row|col in the enum body: zero hits). SetCell/ClearCell take RecordCell { record: RecordHash, measure: Option\u003cString\u003e }, position-free by ADR 002; every other variant names a category, item, group, view, axis, format or path. So a client composing against a stale grid shape has nothing positional to misdirect.\n\nThe only thing a renumbering actually invalidates is WHERE THE USER IS LOOKING, which is per-client UI, and that half already works through cursor_epoch.\n\nTHE CONDITION, which is why this is filed rather than merely closed: it holds BECAUSE the vocabulary is position-free, and NOTHING GUARDS THAT. A future positional ModelCommand reopens the hole silently. Filed as a follow-up.\n\nTwo caveats recorded by the second agent, worth keeping: position-free ON THE WIRE is not position-free END TO END -- improvise-2wu3 is the live counterexample, where web/app.js captures viewport (row, col) at paint time and resolves it against the current cache at commit. And the tiers PARTITION rather than one covering for the other: every LayoutGeometry variant returns Vec::new() from records_named, so 'the record tier is doing the layout tier's work' is too strong. The accurate statement is that the positional misdirection the layout tier would otherwise guard against is UNCONSTRUCTIBLE.","dependency_count":0,"dependent_count":0,"comment_count":0} From 4989bd55f740e3b4a495403f93a3e6dbfb21cf2b Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 23:14:30 -0700 Subject: [PATCH 45/48] checkers-test: run section 5b on the hermetic utilbin PATH Section 5b (the bash 5.1 floor, #237) was written on a branch cut before the NixOS-runner change (af44653f). That change moved section 5 off /usr/bin:/bin, because on NixOS that path holds only env and sh. The two merged with no conflict. On NixOS the runner then dies on `dirname` with exit 1, which turned the KB job red on every PR at 666de199. 5b now uses utilbin too, and utilbin gains head and env for the 5.1 control. Evidence and rivals: context/test-evidence/tooling.md. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/test-evidence/tooling.md | 30 ++++++++++++++++++++++++++++++ scripts/checkers-test.sh | 7 ++++--- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/context/test-evidence/tooling.md b/context/test-evidence/tooling.md index 32bc4846..cda31503 100644 --- a/context/test-evidence/tooling.md +++ b/context/test-evidence/tooling.md @@ -1048,3 +1048,33 @@ scanned" alone, so the deleted-file case is what stops the fix from over-refusing. The rival ran from an ignored copy inside the worktree. A copy under `/tmp` fails "the real tree does not pass against a silent bd" for a reason unrelated to the rival: there is no repository there. + +### Section 5b runs on a hermetic PATH — `scripts/checkers-test.sh` + +**What broke.** Every PR's "Knowledge base consistency" job went red at +`666de199` with "a bash 5.0 with no nix is exit 3, not 5.x (exit 1, wanted 3)" +and "a bash 5.1 is accepted (exit 1, wanted 0)". Neither PR alone was wrong. The +NixOS-runner change (`af44653f`) found that `/usr/bin:/bin` on NixOS holds only +`env` and `sh`, so the runner dies on `dirname` with exit 1. It fixed section 5 +by moving it to a `utilbin` of linked tools. #237 (`e45a9c42`) added section 5b +on its own branch, with the old `PATH=$work/fakebin:/usr/bin:/bin`. The two +merged with no conflict, and each had passed CI only on its own side. + +**Reproduced** on macOS by running both 5b invocations with `/usr/bin:/bin` +replaced by a directory holding only `env` and `sh`. Both exited 1 on +`dirname: command not found`. Those are the CI exits. + +**Fix.** 5b uses `utilbin` as well. `utilbin` gains `head` and `env`, because +the 5.1 control runs past the version check into `check_shebangs` and +`run_suite`. + +**Rivals**, each run in the suite: +- **`dirname` left out of `utilbin`** fails all three section-5 cases with the + exact CI pair: exit 1 where 3 was wanted, and exit 1 where 0 was wanted. The + NixOS failure now reproduces on any host, because no case reads the host's + `/usr/bin` any more. +- **`env` left out** fails "a bash 5.1 is accepted" alone. +- **`head` left out** fails nothing. `check_shebangs` calls `head` inside + `check_shebangs || rc=1`, where `set -e` is off. A missing `head` makes every + first line empty, and the shebang check then passes without checking + anything. That is a defect in `checkers.sh` and is not fixed here. diff --git a/scripts/checkers-test.sh b/scripts/checkers-test.sh index a3995f55..67a9b846 100755 --- a/scripts/checkers-test.sh +++ b/scripts/checkers-test.sh @@ -136,9 +136,10 @@ chmod +x "$work/fakebin/bash" # utilbin carries only the utilities the runner itself needs, and no nix, so # the fake bash is the only candidate and the nix fallback is absent. Linked # one by one rather than taking /usr/bin:/bin, which on NixOS hold only `env` -# and `sh` -- the runner died on `dirname` there, exit 1 instead of 3. +# and `sh` -- the runner died on `dirname` there, exit 1 instead of 3. `head` +# and `env` are for 5b's 5.1 control, which runs past the version check. mkdir -p "$work/utilbin" -for tool in dirname; do +for tool in dirname head env; do ln -sf "$(command -v "$tool")" "$work/utilbin/$tool" done env PATH="$work/fakebin:$work/utilbin" HOME="$HOME" \ @@ -154,7 +155,7 @@ for v in "5 0:3:a bash 5.0 with no nix is exit 3, not 5.x" \ printf '#!/bin/sh\ncase "$*" in *BASH_VERSINFO*) echo %s; exit 0 ;; esac\nexit 0\n' \ "${v%%:*}" > "$work/fakebin/bash" want="${v#*:}"; name="${want#*:}"; want="${want%%:*}" - env PATH="$work/fakebin:/usr/bin:/bin" HOME="$HOME" \ + env PATH="$work/fakebin:$work/utilbin" HOME="$HOME" \ "${BASH:-bash}" "$d/checkers.sh" any > "$work/out" 2>&1 got=$? [ "$got" = "$want" ] && ok "$name" || bad "$name" "exit $got, wanted $want" From 68f32a8a55c0059e3062017f52e440017e3dec0d Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 23:23:46 -0700 Subject: [PATCH 46/48] pr-reviews-test: resolve sleep and date from PATH, not /bin (improvise-3uow) On the NixOS runners /bin holds only sh. With no /bin/sleep, the watchdog in bounded_reviews exits at once and wins the `wait -n`, so every --wait case reports 124. Both tools are now resolved with `type -P` before any case stubs PATH. Red: with both paths pointed at /nonexistent/, the suite fails 25 of 25. Rival: REAL_SLEEP=/nonexistent/sleep fails 25 of 25 again. The date pass-through has no discriminating rival, because pr-reviews only calls `date +%s`. Evidence: context/test-evidence/tooling.md. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/test-evidence/tooling.md | 31 +++++++++++++++++++++++++++++++ scripts/pr-reviews-test.sh | 7 ++++--- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/context/test-evidence/tooling.md b/context/test-evidence/tooling.md index cda31503..5cffd844 100644 --- a/context/test-evidence/tooling.md +++ b/context/test-evidence/tooling.md @@ -1078,3 +1078,34 @@ the 5.1 control runs past the version check into `check_shebangs` and `check_shebangs || rc=1`, where `set -e` is off. A missing `head` makes every first line empty, and the shebang check then passes without checking anything. That is a defect in `checkers.sh` and is not fixed here. + +### The pr-reviews watchdog resolves its tools from PATH — `scripts/pr-reviews-test.sh` (improvise-3uow) + +**What broke.** The suite hard-coded `/bin/sleep` for the watchdog in +`bounded_reviews` and `/bin/date` for the fake clock's pass-through. On the +NixOS runners `/bin` holds only `sh`. With no `/bin/sleep`, the watchdog exits +at once. `wait -n` then sees it finish before `pr-reviews`, and every bounded +call reports 124 ("killed after 20s"). CI never showed this, because the kb job +stopped one step earlier on the section-5b failure (#296). + +**Reproduced** by pointing both paths at `/nonexistent/`. The result was 0 +passed and 25 failed, every `--wait` case exiting 124: the same shape the +reviewer simulated. + +**Fix.** `REAL_SLEEP` and `REAL_DATE` come from `type -P`, before any case puts +its stub `sleep` and `date` first on PATH. `type -P` returns a path even if +`sleep` has been loaded as a builtin. `REAL_DATE` is exported, because the fake +clock is a separate script. + +**Rivals:** +- **`REAL_SLEEP=/nonexistent/sleep`** fails the whole suite, 0 of 25, so the + resolved path is the one the watchdog runs. +- **The `REAL_DATE` half has no discriminating rival.** `pr-reviews` only ever + calls `date +%s`, so the fake clock's pass-through branch never runs. It was + fixed so that it cannot break later, but no test can see it today. + +**Scope sweep.** `grep -rnE '/(usr/)?bin/[a-z]'` over `scripts/`, `web/*.sh`, +`harness/*.sh`, `harness/tests` and `.github/workflows` finds only `#!/bin/sh` +shebangs, and `/bin/sh` exists on NixOS. It also finds the +`${PATH:-/usr/bin:/bin}` fallback in `checkers.sh`, which is used only when +PATH is unset. diff --git a/scripts/pr-reviews-test.sh b/scripts/pr-reviews-test.sh index 443be281..755ef46f 100755 --- a/scripts/pr-reviews-test.sh +++ b/scripts/pr-reviews-test.sh @@ -9,12 +9,13 @@ work="$(mktemp -d)" trap 'rm -rf "$work"' EXIT # Every run is bounded, so a loop that never stops FAILS (exit 124) rather than -# hanging the suite. /bin/sleep, because some cases put a no-op `sleep` first on PATH. +# hanging the suite. Real tools resolved before any case stubs PATH; NixOS has no /bin/sleep. BOUND=20 +REAL_SLEEP="$(type -P sleep)"; export REAL_DATE; REAL_DATE="$(type -P date)" bounded_reviews() { "$here/pr-reviews" "$@" & local pid=$! dog done rc - /bin/sleep "$BOUND" & + "$REAL_SLEEP" "$BOUND" & dog=$! wait -n -p done "$pid" "$dog"; rc=$? if [ "$done" = "$pid" ]; then kill "$dog" 2>/dev/null; wait "$dog" 2>/dev/null; return "$rc"; fi @@ -218,7 +219,7 @@ clock="$work/clock" mkdir -p "$clock" cat > "$clock/date" <<'EOF' #!/usr/bin/env bash -[ "${1:-}" = "+%s" ] || exec /bin/date "$@" +[ "${1:-}" = "+%s" ] || exec "$REAL_DATE" "$@" n=$(( $(cat "$CLOCK_TICKS" 2>/dev/null || echo 0) + 1 )) echo "$n" > "$CLOCK_TICKS" echo $(( 1000 + n )) From 754add31e2c3def6c93683e3fda0b819cd7907b8 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 23:32:54 -0700 Subject: [PATCH 47/48] ci: take bash and tools from the .#ci dev shell; stop policing versions This follows the user's rulings: CI should not care which bash or which standard utilities it runs, and jobs take what the ci dev shell provides. - checkers.sh: remove find_modern_bash, the 5.1 floor (improvise-fd0j) and exit 3. Suites run under $BASH with PATH unchanged. - checkers-test.sh: remove sections 2, 5 and 5b (the fake bashes and utilbin). They existed only to pin the floor. - scripts/kb: drop the BSD `stat -f` fallback. kb needs the dev shell for tpl, and that shell's stat is GNU. - ci.yml: remove every `shell: bash` override, so every step runs in the dev shell. Remove the web-toolchain version probe and the Playwright version/browser probe. The shell's PLAYWRIGHT_BROWSERS_PATH pins the browsers. npm ci stays because @playwright/test is not nix-provided. - flake.nix: update the comments that promised those CI checks. Reasoning: context/code/tooling.md section 5. Evidence: context/test-evidence/tooling.md. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- .github/workflows/ci.yml | 53 +++---------------------- context/code/tooling.md | 45 ++++++++++++++------- context/test-evidence/tooling.md | 36 +++++++++++++++-- flake.nix | 9 ++--- scripts/checkers-test.sh | 67 ++------------------------------ scripts/checkers.sh | 49 +++++------------------ scripts/kb | 2 +- 7 files changed, 88 insertions(+), 173 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2fdaca1d..e4d51add 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,29 +53,6 @@ jobs: # below, so it fails under this job's name rather than another's. - name: The stamp gate refuses what it claims to run: ./scripts/checkers.sh assemble-stamp - # scripts/web-toolchain-versions.env stays the one home for the pins, so - # the release capture (scripts/build-web-release.sh, improvise-oujb) - # provisions the same versions. CI no longer installs them — the dev - # shell supplies wasm-pack and node — so this checks the shell against - # the pins instead: a nixpkgs bump that moves either fails here, by name. - # wasm-pack runs its own bundled `wasm-opt`, which is why - # improvise-wasm-client's Cargo.toml names `--enable-bulk-memory`. - - name: The dev shell supplies the pinned web toolchain - run: | - . scripts/web-toolchain-versions.env - test "$(wasm-pack --version)" = "wasm-pack ${WASM_PACK_VERSION#v}" - test "$(node -p 'process.versions.node.split(".")[0]')" = "$NODE_VERSION" - wasm-pack --version - node --version - # wasm-pack uses the shell's wasm-bindgen only at Cargo.lock's exact - # version; otherwise it cargo-installs one (71s) — so a lock bump - # must move flake.nix's wasmBindgenCli too, and this says so. - lock="$(awk '/^name = "wasm-bindgen"$/ {getline; gsub(/[^0-9.]/, ""); print}' Cargo.lock)" - test "$(wasm-bindgen --version)" = "wasm-bindgen $lock" || { - echo "flake.nix wasmBindgenCli is $(wasm-bindgen --version); Cargo.lock wants $lock" >&2 - exit 1 - } - # Built by Nix (packages.web and packages.site in nix/ci.nix), which runs # the same harness/build.sh and web/assemble.sh a developer runs, so there # is still one answer to "how is the bundle built". `web` is the three @@ -84,14 +61,12 @@ jobs: # the harness tree is stamped `harness`. Dependencies are their own # derivations, substituted from titan's cache when Cargo.lock is unchanged. - name: Build the bundles, the harness tree and the site - shell: bash run: | nix build --print-build-logs --out-link result-web .#web nix build --print-build-logs --out-link result-site .#site # The upload steps below read these paths. Copied rather than linked: # an artifact of a symlink into /nix/store is a dangling link elsewhere. - name: Lay the outputs out where the uploads read them - shell: bash run: | rm -rf harness/serve web/pkg web/worker-pkg site cp -rL result-web/harness-serve harness/serve @@ -143,12 +118,10 @@ jobs: # checks.clippy and checks.test in nix/ci.nix: the same cargo commands, # on the dev/test profiles, with dependencies as a cached derivation. - name: cargo clippy --workspace --all-targets -- -D warnings - shell: bash run: nix build --print-build-logs .#checks.x86_64-linux.clippy # Also runs the test-evidence gate (check-comment-runs.py # --check-evidence), which lists the tests this derivation compiled. - name: cargo test --workspace, and test-evidence headings name real tests - shell: bash run: nix build --print-build-logs .#checks.x86_64-linux.test # The comment cap and the test-evidence keys (improvise-9c8c); without # this step the rule is advisory. See context/design-principles.md §6. @@ -174,9 +147,7 @@ jobs: runs-on: [self-hosted, nixos, microvm] steps: - uses: actions/checkout@v7 - # Plain bash, not the dev shell: this step needs nix and nothing else. - name: nix build .#improvise - shell: bash run: nix build --print-build-logs .#improvise wasm: @@ -190,7 +161,6 @@ jobs: # depends on crossterm, which does not build for wasm — that is correct, # not a gap (improvise-ywd). The log names each crate as it is checked. - name: cargo check --target wasm32-unknown-unknown, per bundle crate - shell: bash run: nix build --print-build-logs .#checks.x86_64-linux.wasm-check # --------------------------------------------------------------------------- @@ -235,9 +205,9 @@ jobs: # bypass also found that nothing ran this suite. - name: The kb tool refuses what it claims to run: ./scripts/checkers.sh kb - # Every suite goes through scripts/checkers.sh, which pins a bash >= 5 - # and scrubs the environment, so the shell and the variables a suite - # passes under here are the ones it passes under locally (improvise-f9qf). + # Every suite goes through scripts/checkers.sh, which scrubs the + # environment, so the variables a suite passes under here are the ones it + # passes under locally (improvise-f9qf). Its bash is the dev shell's. - name: The checker runner refuses what it claims to run: ./scripts/checkers.sh --self-test # pr-watch is driven entirely from fixtures. The release preflight is the @@ -273,7 +243,6 @@ jobs: # checks.harness-lint in nix/ci.nix, run from inside harness/opfs-contract: # `cargo fmt -p opfs-contract -- --check` and clippy for wasm32. - name: cargo fmt -p opfs-contract and clippy --target wasm32 - shell: bash run: nix build --print-build-logs .#checks.x86_64-linux.harness-lint # --------------------------------------------------------------------------- @@ -301,19 +270,9 @@ jobs: - name: Install the harness working-directory: harness run: npm ci - # No browser install step: the dev shell points PLAYWRIGHT_BROWSERS_PATH - # at nixpkgs' Chromium, Firefox and WebKit, since Playwright's own - # downloads cannot run on NixOS. All three, because improvise-xf1's - # acceptance criterion names Chrome, Firefox and Safari and - # `createSyncAccessHandle` is implemented separately by each engine. - # WebKit's OPFS is absent today; the spec skips on a runtime probe. - # @playwright/test must equal nixpkgs' playwright-driver version, or it - # looks for browser builds that are not there; this says which. - - name: Playwright matches the dev shell's browsers - working-directory: harness - run: | - npx playwright --version - ls "$PLAYWRIGHT_BROWSERS_PATH" + # No browser install step and no version probe: the dev shell sets + # PLAYWRIGHT_BROWSERS_PATH to nixpkgs' browsers, which pin them. `npm ci` + # stays because @playwright/test itself is not nix-provided. # The tree `build` assembled, not one this job rebuilds. Same bytes as # the Pages artifact, modulo the cache-bust token `web/assemble.sh` # stamps — the harness tree is stamped `harness` and the site the commit diff --git a/context/code/tooling.md b/context/code/tooling.md index bed33ec1..0edc9366 100644 --- a/context/code/tooling.md +++ b/context/code/tooling.md @@ -660,32 +660,47 @@ sixteen scripts, and the review cost is the whole diff rather than the shebang line. The runner also does not ship zsh, so every job would need an `apt-get`. Declined. -What was adopted instead pins the *interpreter and the environment together*, in +What was adopted instead fixes the *environment* a suite runs in, in one place, with no per-invocation nix cost: **`scripts/checkers.sh` is the one entry point for every check_the_checker.** CI calls it and the local gate calls it, so there is no second invocation to drift. -It does three things a bare `./scripts/X-test.sh` does not: +It runs every suite under the bash that runs it: in CI and under direnv, that +is the dev shell's bash. It does two things a bare `./scripts/X-test.sh` does +not: -1. **Pins a bash ≥ 5.1.** The ambient one is tried first, so a runner that - already has bash 5.2 pays nothing; nix supplies one only when the ambient - bash is older, which on macOS outside the dev shell it is. No bash ≥ 5.1 and - no nix is **exit 3** — a refusal, never a quiet run under 3.2. The floor is - 5.1 and not 5 because `pr-reviews-test.sh` uses `wait -n -p`, and `-p` - arrived in 5.1. Under 5.0 it is an invalid option (improvise-fd0j). -2. **Scrubs the environment to an allowlist** (`env -i` plus `PATH`, `HOME`, +1. **Scrubs the environment to an allowlist** (`env -i` plus `PATH`, `HOME`, `TMPDIR`, the `NIX_*` and `GITHUB_*` names, extensible through `CHECKERS_KEEP`). This is the part that closes the direnv hole above, and it is a *positive* list on purpose: the failure mode of a missing name is a loud "command not found", where the failure mode of an extra name is the silence this section is about. -3. **Enforces the shebang rule**, so the `sh`/`bash` split cannot come back. +2. **Enforces the shebang rule**, so the `sh`/`bash` split cannot come back. -The pinned bash's directory goes on the front of `PATH`, so a script the suite -invokes through its *own* `#!/usr/bin/env bash` — or through `sh`, which -nixpkgs' bash also provides — gets the pinned one too. Pinning only the outer -shell would have left every inner one on the system bash, which is where the -fault actually lived. +**The runner no longer polices the bash version or where tools live.** It used +to pin a bash of at least 5.1 (`find_modern_bash`: try the ambient bash, fall +back to `nix shell nixpkgs#bash`, and exit 3 if neither was available). The +floor was 5.1 because `pr-reviews-test.sh` uses `wait -n -p` (improvise-fd0j). +All of that was removed on the user's ruling, when CI moved to the NixOS +runners: "we shouldn't care which version of bash and other standard utilities +are running in CI", and "most jobs should just take what comes in the ci dev +shell". + +So every CI step runs inside `nix develop .#ci`, which provides the bash and the +GNU coreutils. The runner calls suites with `$BASH` and an unchanged `PATH`. A +script that needs a newer bash feature fails where it uses it, not at a version +gate. + +Two things went with the floor: + +- **Its self-tests.** `checkers-test.sh` sections 2, 5 and 5b faked old + bashes, or stripped `PATH` down to hand-picked utilities, to prove the floor + held. Those were the tests that broke on the NixOS runners, where `/usr/bin` + and `/bin` hold only `env` and `sh` (#296, improvise-3uow). +- **The hard-coded tool paths** in the suites (`/bin/sleep` and `/bin/date` in + `pr-reviews-test.sh`) and `scripts/kb`'s BSD `stat -f` fallback. `scripts/kb` + already needs the dev shell for `tpl`, so GNU `stat` is always present when it + runs. **A suite is `X-test.sh` where `X` or `X.sh` is present beside it.** That rule exists because `scripts/node-test.sh` matches `*-test.sh` and is a test diff --git a/context/test-evidence/tooling.md b/context/test-evidence/tooling.md index 5cffd844..23328d4e 100644 --- a/context/test-evidence/tooling.md +++ b/context/test-evidence/tooling.md @@ -1106,6 +1106,36 @@ clock is a separate script. **Scope sweep.** `grep -rnE '/(usr/)?bin/[a-z]'` over `scripts/`, `web/*.sh`, `harness/*.sh`, `harness/tests` and `.github/workflows` finds only `#!/bin/sh` -shebangs, and `/bin/sh` exists on NixOS. It also finds the -`${PATH:-/usr/bin:/bin}` fallback in `checkers.sh`, which is used only when -PATH is unset. +shebangs, and `/bin/sh` exists on NixOS. It also found a +`${PATH:-/usr/bin:/bin}` fallback in `checkers.sh`, which is now removed (next +entry). + +### The bash floor and its self-tests are retired — `scripts/checkers.sh` + +**The ruling.** The user ruled that CI should not care which version of bash or +of the standard utilities it runs, and that CI jobs should use whatever the +`.#ci` dev shell provides. So `checkers.sh` no longer pins a bash: +`find_modern_bash`, the 5.1 floor (improvise-fd0j) and exit 3 are gone. Suites +run under `$BASH`, the bash running the runner, with `PATH` unchanged. The +reasoning is in `context/code/tooling.md` §5. + +**Tests removed.** Three sections of `checkers-test.sh` were removed. They +existed only to pin the floor, and the entries above that cite them describe +cases that no longer exist: +- Section 2, "a bash-3.2-only construct fails under the runner's pinned bash". + It asserted a bash-5 behaviour, so its result depended on the bash version. +- Section 5, "an old bash with no nix is exit 3". +- Section 5b, "a bash 5.0 with no nix is exit 3, not 5.x" and "a bash 5.1 is + accepted", together with the `utilbin` that #296 added for them. + +The suite keeps 10 cases: environment scrubbing (with its direct-run pair), the +shebang rule (with its control), fail and pass, unknown suite, and `--list`. + +**Kept deliberately.** The environment allowlist. That is what the +`set -u`/`$name` evidence above earns, and it does not depend on any version. + +**Also removed with the ruling.** `scripts/kb`'s `stat -f` fallback: `kb` +already requires the dev shell for `tpl`, and the dev shell's `stat` is GNU. +From `ci.yml`, the toolchain version probes ("the dev shell supplies the pinned +web toolchain", "Playwright matches the dev shell's browsers") and every +`shell: bash` override, so every step runs in `nix develop .#ci`. diff --git a/flake.nix b/flake.nix index 7f06ffe0..47dfc078 100644 --- a/flake.nix +++ b/flake.nix @@ -43,7 +43,7 @@ # behind the runners' TLS-auditing proxy those downloads fail (binaryen # with no fallback). On PATH, it downloads nothing. wasm-pack only takes # a PATH wasm-bindgen whose version equals Cargo.lock's `wasm-bindgen`, - # so this pin moves with the lock; CI checks that it does. + # so this pin moves with the lock: on a mismatch the sandboxed build fails. wasmBindgenCli = pkgs.buildWasmBindgenCli rec { src = pkgs.fetchCrate { pname = "wasm-bindgen-cli"; @@ -73,14 +73,13 @@ # `web/build.sh` — and so the Pages job's local dry run — needs this. # The `wasm` CI job only runs `cargo check`, which does not, so the # shell was missing the one tool that turns a passing check into a - # loadable page (improvise-d31). Must match WASM_PACK_VERSION in - # scripts/web-toolchain-versions.env; CI checks that it does. + # loadable page (improvise-d31). CI takes whatever version this is. pkgs.wasm-pack wasmBindgenCli pkgs.binaryen - # NODE_VERSION's major, same check. The js and browser jobs ran 22 - # while `build` ran the pinned 24; one shell means one node. + # The js and browser jobs once ran 22 while `build` ran 24; one shell + # means one node. pkgs.nodejs_24 pkgs.python3 pkgs.jq diff --git a/scripts/checkers-test.sh b/scripts/checkers-test.sh index 67a9b846..564ccacf 100755 --- a/scripts/checkers-test.sh +++ b/scripts/checkers-test.sh @@ -67,21 +67,7 @@ direct=$? unset name # --------------------------------------------------------------------------- -# 2. The bash-5 `local` semantics. A multi-assignment `local` does not read -# left to right on bash 5, so this suite passes on macOS bash 3.2 and dies -# under `set -u` on bash 5 -- PR #133's 19/19 local against 2/19 in CI. -d="$(sandbox bash5)" -suite "$d" vers <<'EOF' -#!/usr/bin/env bash -set -euo pipefail -f() { local a="$1" b="$a/x"; echo "$b"; } -f one -EOF -expect "a bash-3.2-only construct fails under the runner's pinned bash" \ - 1 "$d/checkers.sh" vers - -# --------------------------------------------------------------------------- -# 3. The shebang rule. `#!/bin/sh` is dash in CI and bash-in-sh-mode on macOS. +# 2. The shebang rule. `#!/bin/sh` is dash in CI and bash-in-sh-mode on macOS. d="$(sandbox shebang)" suite "$d" fine <<'EOF' #!/usr/bin/env bash @@ -101,7 +87,7 @@ rm -f "$d/legacy.sh" expect "and the same sandbox passes once it is removed" 0 "$d/checkers.sh" fine # --------------------------------------------------------------------------- -# 4. A failing suite is reported, and a passing one is not. +# 3. A failing suite is reported, and a passing one is not. d="$(sandbox rc)" suite "$d" red <<'EOF' #!/usr/bin/env bash @@ -117,52 +103,7 @@ EOF expect "a passing suite passes the runner" 0 "$d/checkers.sh" blue # --------------------------------------------------------------------------- -# 5. No bash >= 5 and no nix is exit 3, not a silent run under whatever is -# there. This is the arm that would have refused the macOS system bash. -d="$(sandbox oldbash)" -suite "$d" any <<'EOF' -#!/usr/bin/env bash -exit 0 -EOF -mkdir -p "$work/fakebin" -cat > "$work/fakebin/bash" <<'EOF' -#!/bin/sh -case "$*" in *BASH_VERSINFO*) echo 3; exit 0 ;; esac -exit 0 -EOF -chmod +x "$work/fakebin/bash" -# The runner is invoked through a REAL bash, so the fake one below is only -# what its own `command -v bash` probe finds -- not its interpreter. -# utilbin carries only the utilities the runner itself needs, and no nix, so -# the fake bash is the only candidate and the nix fallback is absent. Linked -# one by one rather than taking /usr/bin:/bin, which on NixOS hold only `env` -# and `sh` -- the runner died on `dirname` there, exit 1 instead of 3. `head` -# and `env` are for 5b's 5.1 control, which runs past the version check. -mkdir -p "$work/utilbin" -for tool in dirname head env; do - ln -sf "$(command -v "$tool")" "$work/utilbin/$tool" -done -env PATH="$work/fakebin:$work/utilbin" HOME="$HOME" \ - "${BASH:-bash}" "$d/checkers.sh" any > "$work/out" 2>&1 -got=$? -[ "$got" = 3 ] && ok "an old bash with no nix is exit 3" \ - || bad "an old bash with no nix is exit 3" "exit $got, wanted 3" - -# 5b. The floor is 5.1, not 5: `wait -n -p` is 5.1 (improvise-fd0j). The 5.1 -# control proves the 5.0 refusal is the version and not the fake itself. -for v in "5 0:3:a bash 5.0 with no nix is exit 3, not 5.x" \ - "5 1:0:a bash 5.1 is accepted"; do - printf '#!/bin/sh\ncase "$*" in *BASH_VERSINFO*) echo %s; exit 0 ;; esac\nexit 0\n' \ - "${v%%:*}" > "$work/fakebin/bash" - want="${v#*:}"; name="${want#*:}"; want="${want%%:*}" - env PATH="$work/fakebin:$work/utilbin" HOME="$HOME" \ - "${BASH:-bash}" "$d/checkers.sh" any > "$work/out" 2>&1 - got=$? - [ "$got" = "$want" ] && ok "$name" || bad "$name" "exit $got, wanted $want" -done - -# --------------------------------------------------------------------------- -# 6. A named suite that does not exist is a usage error, not a silent no-op. +# 4. A named suite that does not exist is a usage error, not a silent no-op. d="$(sandbox missing)" suite "$d" present <<'EOF' #!/usr/bin/env bash @@ -171,7 +112,7 @@ EOF expect "an unknown suite name is exit 4" 4 "$d/checkers.sh" absent # --------------------------------------------------------------------------- -# 7. --list names every suite in the directory and nothing else. +# 5. --list names every suite in the directory and nothing else. d="$(sandbox listing)" suite "$d" alpha <<'EOF' #!/usr/bin/env bash diff --git a/scripts/checkers.sh b/scripts/checkers.sh index 7cf099d7..e98e9e1e 100755 --- a/scripts/checkers.sh +++ b/scripts/checkers.sh @@ -3,15 +3,14 @@ # and so does the local gate, so a suite cannot be green under one shell and # red under the other. Reasoning: context/code/tooling.md section 3. # -# It does three things a bare `./scripts/X-test.sh` does not: +# It does two things a bare `./scripts/X-test.sh` does not. It runs every +# suite under the bash running it (the dev shell's), and it: # -# 1. pins a bash >= 5.1, taken from nix when the ambient one is older, so the -# macOS system bash 3.2 can never be the shell a suite passes under; -# 2. scrubs the environment down to an allowlist, because `nix develop` +# 1. scrubs the environment down to an allowlist, because `nix develop` # exports `name`, `out`, `system`, `shell`, `builder` and two dozen more # generically-named variables that CI does not have -- a `set -u` fault # reading one of them is SILENT inside the dev shell and fatal in CI; -# 3. refuses a shell script in scripts/ whose shebang is not +# 2. refuses a shell script in scripts/ whose shebang is not # `#!/usr/bin/env bash`, so the sh/bash split cannot come back. set -euo pipefail @@ -30,7 +29,6 @@ usage: checkers.sh [--list] [--self-test] [name ...] exit 0 every suite passed exit 1 a suite failed, or a shebang is not `#!/usr/bin/env bash` -exit 3 no bash >= 5.1 could be found and nix could not supply one exit 4 usage error THIS SCRIPT'S `$?` MUST BE THE ONE YOU READ. Nothing may follow it in the same @@ -46,22 +44,6 @@ ALLOW="HOME TMPDIR TMP TEMP TERM LANG LC_ALL USER LOGNAME NIX_PATH NIX_REMOTE NIX_SSL_CERT_FILE SSL_CERT_FILE XDG_CACHE_HOME XDG_CONFIG_HOME XDG_DATA_HOME" -# Prints the absolute path of a bash >= 5.1, or nothing. The ambient one is tried -# first so a machine that already has a modern bash pays no nix startup at all. -find_modern_bash() { - local candidate major minor - candidate="$(command -v bash || true)" - if [ -n "$candidate" ]; then - read -r major minor < <("$candidate" -c 'echo "${BASH_VERSINFO[0]} ${BASH_VERSINFO[1]}"' \ - 2>/dev/null || echo 0 0) - if [ "$(( ${major:-0} * 100 + ${minor:-0} ))" -ge 501 ] 2>/dev/null; then - echo "$candidate"; return 0 - fi - fi - command -v nix >/dev/null 2>&1 || return 0 - nix shell nixpkgs#bash --command bash -c 'command -v bash' 2>/dev/null || true -} - # Every shell script in scripts/ says `#!/usr/bin/env bash` and nothing else. # `#!/bin/sh` is dash in CI and bash 3.2 in sh-mode on macOS: a second platform # split, undetected for the same reason the first one was. @@ -94,19 +76,16 @@ suites() { done } -# $1 the pinned bash, $2 the suite file. The suite runs with the pinned bash -# FIRST on PATH, so a script it invokes through its own `#!/usr/bin/env bash` -# -- or through `sh` -- gets the pinned one too, not the system 3.2. +# $1 the suite file, run by the bash running this script, with PATH unchanged. run_suite() { - local pinned="$1" file="$2" - local bindir; bindir="$(dirname "$pinned")" + local file="$1" local -a env_args=(-i) local v for v in $ALLOW ${CHECKERS_KEEP:-}; do if [ -n "${!v+set}" ]; then env_args+=("$v=${!v}"); fi done - env_args+=("PATH=$bindir:${PATH:-/usr/bin:/bin}") - env "${env_args[@]}" "$pinned" "$file" + env_args+=("PATH=$PATH") + env "${env_args[@]}" "$BASH" "$file" } main() { @@ -123,15 +102,7 @@ main() { shift done - local pinned; pinned="$(find_modern_bash)" - if [ -z "$pinned" ]; then - echo 'checkers: no bash >= 5.1 on PATH and nix cannot supply one.' >&2 - echo ' Enter the dev shell (direnv allow / nix develop) and retry.' >&2 - return 3 - fi - printf 'checkers: bash %s at %s\n' \ - "$("$pinned" -c 'echo "${BASH_VERSINFO[0]}.${BASH_VERSINFO[1]}"')" "$pinned" - + printf 'checkers: bash %s at %s\n' "$BASH_VERSION" "$BASH" local rc=0 check_shebangs || rc=1 @@ -149,7 +120,7 @@ main() { rc=4; continue fi printf '\n=== %s ===\n' "$n" - run_suite "$pinned" "$file" || rc=1 + run_suite "$file" || rc=1 done return $rc } diff --git a/scripts/kb b/scripts/kb index f4465e6f..dcc571ff 100755 --- a/scripts/kb +++ b/scripts/kb @@ -87,7 +87,7 @@ ledger_path() { # first line; bytes alone are not enough, since a restore can reproduce them. file_stamp() { printf '%s %s' "$(stamp_of "$1")" \ - "$(stat -c '%i %.9Y' "$1" 2>/dev/null || stat -f '%i %Fm' "$1")" + "$(stat -c '%i %.9Y' "$1")" } ledger_holds() { [ -f "$1" ] && [ "$(head -n 1 "$1")" = "% kb-stamp $(file_stamp "$2")" ]; } From c29e32f1bb0cf8fadee85a05700f3d687bcfd1e1 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Thu, 24 Sep 2026 01:38:37 -0700 Subject: [PATCH 48/48] pr-reviews-test: delete the fake clock's untested date pass-through Copilot on #297: `pr-reviews` only calls `date +%s`, so the REAL_DATE pass-through never ran. It is deleted rather than tested. The fake now refuses any other arguments with exit 2, so a new `date` call shows up as a failure. Rival: with pr-reviews calling `date -u +%s`, "a review arriving on the third poll is seen" and "--wait keeps polling until its deadline" fail (23/25). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/test-evidence/tooling.md | 17 ++++++++++------- scripts/pr-reviews-test.sh | 6 +++--- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/context/test-evidence/tooling.md b/context/test-evidence/tooling.md index 23328d4e..c7b8d46c 100644 --- a/context/test-evidence/tooling.md +++ b/context/test-evidence/tooling.md @@ -1092,17 +1092,20 @@ stopped one step earlier on the section-5b failure (#296). passed and 25 failed, every `--wait` case exiting 124: the same shape the reviewer simulated. -**Fix.** `REAL_SLEEP` and `REAL_DATE` come from `type -P`, before any case puts -its stub `sleep` and `date` first on PATH. `type -P` returns a path even if -`sleep` has been loaded as a builtin. `REAL_DATE` is exported, because the fake -clock is a separate script. +**Fix.** `REAL_SLEEP` comes from `type -P`, before any case puts its stub +`sleep` first on PATH. `type -P` returns a path even if `sleep` has been loaded +as a builtin. The fake clock's `/bin/date` pass-through was deleted rather than +re-pointed. `pr-reviews` only ever calls `date +%s`, so the branch never ran +(found by Copilot on #297). The fake now refuses any other arguments with +exit 2, so a new `date` call in `pr-reviews` shows up as a failure instead of +untested code. **Rivals:** - **`REAL_SLEEP=/nonexistent/sleep`** fails the whole suite, 0 of 25, so the resolved path is the one the watchdog runs. -- **The `REAL_DATE` half has no discriminating rival.** `pr-reviews` only ever - calls `date +%s`, so the fake clock's pass-through branch never runs. It was - fixed so that it cannot break later, but no test can see it today. +- **`pr-reviews` changed to call `date -u +%s`** fails "a review arriving on + the third poll is seen" and "--wait keeps polling until its deadline" (23 of + 25 pass). The fake's refusal is therefore visible, not swallowed. **Scope sweep.** `grep -rnE '/(usr/)?bin/[a-z]'` over `scripts/`, `web/*.sh`, `harness/*.sh`, `harness/tests` and `.github/workflows` finds only `#!/bin/sh` diff --git a/scripts/pr-reviews-test.sh b/scripts/pr-reviews-test.sh index 755ef46f..5050958d 100755 --- a/scripts/pr-reviews-test.sh +++ b/scripts/pr-reviews-test.sh @@ -9,9 +9,9 @@ work="$(mktemp -d)" trap 'rm -rf "$work"' EXIT # Every run is bounded, so a loop that never stops FAILS (exit 124) rather than -# hanging the suite. Real tools resolved before any case stubs PATH; NixOS has no /bin/sleep. +# hanging the suite. The real sleep, resolved before any case stubs PATH; NixOS has no /bin/sleep. BOUND=20 -REAL_SLEEP="$(type -P sleep)"; export REAL_DATE; REAL_DATE="$(type -P date)" +REAL_SLEEP="$(type -P sleep)" bounded_reviews() { "$here/pr-reviews" "$@" & local pid=$! dog done rc @@ -219,7 +219,7 @@ clock="$work/clock" mkdir -p "$clock" cat > "$clock/date" <<'EOF' #!/usr/bin/env bash -[ "${1:-}" = "+%s" ] || exec "$REAL_DATE" "$@" +[ "$*" = "+%s" ] || { echo "fake date: only +%s is faked, got: $*" >&2; exit 2; } n=$(( $(cat "$CLOCK_TICKS" 2>/dev/null || echo 0) + 1 )) echo "$n" > "$CLOCK_TICKS" echo $(( 1000 + n ))