diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index a9267260..58c9b550 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,10 +1,16 @@ -{"_type":"issue","id":"improvise-cq1q.12","title":"ADR 007 step 2b: displays in the grid frame, page bar, records labels, item rows and CSV export","description":"The second slice of improvise-cq1q.3 (ADR 007 step 2). The first slice put `display` on Category, Item and View, and made the category-panel headers, the tile bar and the view list paint it in both front ends. This slice covers the rest of the grid and the panels:\n- item rows in the category panel (CategoryRow.items needs an item display beside each key);\n- the grid frame's label tuples and group headers;\n- the page bar (PageAxis cat/selected/items);\n- records-mode column headers and coordinate cells;\n- CSV export headers (persistence/export.rs render_csv).\n\nConstraint: the frame tuples are also cell coordinates (`cell_key`, `innermost_group`, `run_start`), so the display must be added at projection time (for example, a display tuple beside the key tuple in AxisFrame), never swapped in place. The page bar's items are cycled by the browser (`SetPageSelection`), so keys stay beside displays there too. See context/code/core.md §21.\n\n## Acceptance Criteria\n- Tests first, each seen to fail. With a category, an item and a group each displayed differently from its key:\n - the TUI grid, the browser render model and CSV export draw the displays;\n - `cell_key` and every command a client composes still name keys.\n- Rival: a frame whose tuple carries the display instead of the key. Cell addressing must fail.\n","status":"open","priority":1,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T19:10:38Z","created_by":"agent-adr007","updated_at":"2026-09-25T19:10:38Z","dependencies":[{"issue_id":"improvise-cq1q.12","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T19:10:39Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.12","depends_on_id":"improvise-cq1q.3","type":"blocks","created_at":"2026-09-25T19:10:48Z","created_by":"agent-adr007","metadata":"{}"}],"comments":[{"id":"2c593fb5-4faa-5695-9e21-3e00aa3ee75b","issue_id":"improvise-cq1q.12","author":"Edward Langley","text":"From the adversarial review of PR #328 at 3116d524 (Claude agent, in the owner's session):\n\n1. **One more surface that still paints category keys.** The view panel's axis summary, `viewmodel::view_axis_summary` (for example `R:Region,Product`), is drawn by `src/ui/view_panel.rs` and by `wasm-client/render.rs` (`RenderViewRow.axes`). `ViewSummary.axes` holds `(category key, Axis)` only, so it needs a display beside each key, just as the frame does. It is not listed here or in cq1q.13.\n2. **A test gap to close in this slice.** A fault in which `view_summaries` gives every view the ACTIVE view's display passes the whole workspace. Add a second view whose display differs from both the active view's display and its own key.\n","created_at":"2026-09-25T19:38:50Z"}],"dependency_count":1,"dependent_count":1,"comment_count":1} -{"_type":"issue","id":"improvise-cq1q.5","title":"ADR 007 step 4: .improv ids — new version literal, id grammar, loader refusals, writer, round-trip properties","description":"ADR 007 clauses 5, 9 and 10: persistence, and migration step 4.\n- A second version literal. The grammar's `file` rule gets one arm per literal, and the version line alone picks the regime.\n- The new arm reads declarations with an id (header_decl, item_decl, grouped_item) and references as id_ref. id_token is spelled with ranges (improvise-rxz9). axis_line is tried before format_line (improvise-le4p).\n- The old arm is read exactly as today: whole-line headers, references resolved against displays, ids assigned by slugify, and undeclared items declared at load (answer 24).\n- The loader refuses a new-version file with a duplicate id, a missing id, an id outside the alphabet or a reserved `_` id. The error names the line, and for a duplicate it names both lines.\n- The writer always writes the new literal, and pipe-quotes any display that would otherwise split (`## Category: |Region(code)|(regioncode)`, improvise-80rm).\n- The round-trip properties from clause 5, plus a formula-line generator strategy (improvise-rxz9), so the formula properties are not vacuous.\n\nThe formula-line syntax waits for improvise-m56i.\n\n## Acceptance Criteria\n- Tests first, each failing before the change:\n - an old file with `## Category: Region(code)` loads with display `Region(code)` and id `regioncode`, and its data stays drawn;\n - a new-version file keeps every id verbatim, including `east-3`;\n - duplicate, missing and capitalised ids are each refused with the line named;\n - a category displayed `Format` on a row axis round-trips;\n - format(parse(format(parse(x)))) == format(parse(x)) holds.\n- Evidence that the formula strategy emits `=` lines the walker keeps: count them.\n","status":"open","priority":1,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T04:49:41Z","created_by":"agent-adr007","updated_at":"2026-09-25T04:49:41Z","dependencies":[{"issue_id":"improvise-cq1q.5","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T04:49:41Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.5","depends_on_id":"improvise-cq1q.1","type":"blocks","created_at":"2026-09-25T04:50:54Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.5","depends_on_id":"improvise-cq1q.4","type":"blocks","created_at":"2026-09-25T04:50:53Z","created_by":"agent-adr007","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0} -{"_type":"issue","id":"improvise-cq1q.4","title":"ADR 007 step 3: re-key storage by id (Model, Workbook views, CellKey, DataStore, View config, RecordHash)","description":"ADR 007 clauses 2, 3 and 8, and migration step 3: the wide re-key. Every creation site takes its key from the workbook's IdAllocator (step 1): add_category, add_item, add_item_in_group, create_view and add_formula's target item. The display is the typed name.\n- Model::categories and Workbook::views are keyed by id.\n- CellKey is a sorted list of (CategoryId, ItemId), sorted by category id, so gotcha 5's canonical form holds under the new sort key. DataStore interns ids.\n- View config is keyed by id: axis maps, page selections, and hidden and collapsed sets (collapsed stays (CategoryId, group name), per improvise-p003).\n- RecordHash is computed over id text (clause 8). Update ADR 002's prose in the same PR, since the ADR assigns that update to the implementation PR.\n- Display-\u003eid resolution moves to the input edges: minibuffer commits, the command-line/script parser (coordinate with improvise-31oe.2 and improvise-31oe.3), and formula reference resolution.\n\nThis step is blocked by the user question in improvise-m56i on how typed formula names resolve (item 6) and on a non-_Measure formula target's identity (item 2).\n\n## Acceptance Criteria\n- Tests first: after add-category \"Time Period\", the key is `time_period` and the display is \"Time Period\".\n- Adding `East` to Region and to Direction gives `east` and `east-1` (answer 22).\n- A display rename (a test hook until fz77) moves no CellKey, no RecordHash and no layout_version. This is the property the design exists for, so assert that the labels changed and the surrogates did not. The rival, re-keying on rename, must fail it.\n- The wasm gate passes for every bundle crate.\n","notes":"Scope added for improvise-cq1q.11 (agent-adr007, 2026-09-25). This step now owns the OLD-format .improv edge, because it is the only format until cq1q.5: the old arm's loader assigns ids by slugify and resolves references against displays (answer 24 included), and the writer writes DISPLAYS in the old format. cq1q.5 keeps the new version literal, the id grammar, the refusals and the writer switch. Wire operands are already keys, via cq1q.7, which now lands first. Added acceptance criteria: (1) a document with categories displayed Region and Café, and item East, survives save and load at this step with every display intact. The rival, a writer that writes keys, gives region/caf/east and fails. (2) The browser round-trip suite (tests/browser_round_trip.rs, tests/lockstep.rs) stays green with no display-\u003eid resolver in ui/reduce.rs. The main branch is shippable after this step.\nUpdated for the user's decisions of 2026-09-25 (ADR 007 answers 30 and 31; PR #326). A formula target outside _Measure keeps a real item with its own ItemId, allocated like any item, and the formula gets a separate FormulaId (answer 30). Typed names are resolved at the CLIENT, displays first and then ids, and an ambiguous display is refused with the candidates' ids listed (answer 31). The host-side edges in this step, the minibuffer commits and the script parser, follow the same order. Formula references are resolved by the client/editor edge, never by the host from display text (answer 32). The dependency on improvise-m56i is satisfied: it is answered.","status":"open","priority":1,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T04:49:27Z","created_by":"agent-adr007","updated_at":"2026-09-25T18:59:37Z","dependencies":[{"issue_id":"improvise-cq1q.4","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T04:49:27Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.4","depends_on_id":"improvise-cq1q.12","type":"blocks","created_at":"2026-09-25T19:10:50Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.4","depends_on_id":"improvise-cq1q.13","type":"blocks","created_at":"2026-09-25T19:10:50Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.4","depends_on_id":"improvise-cq1q.2","type":"blocks","created_at":"2026-09-25T06:41:57Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.4","depends_on_id":"improvise-cq1q.3","type":"blocks","created_at":"2026-09-25T04:50:50Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.4","depends_on_id":"improvise-cq1q.7","type":"blocks","created_at":"2026-09-25T06:41:47Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.4","depends_on_id":"improvise-m56i","type":"blocks","created_at":"2026-09-25T04:50:52Z","created_by":"agent-adr007","metadata":"{}"}],"dependency_count":6,"dependent_count":4,"comment_count":0} -{"_type":"issue","id":"improvise-cq1q.3","title":"ADR 007 step 2: a display field on Category, Item and View, read by every renderer (keys unchanged)","description":"ADR 007 clause 2, first half. Category, Item and View each gain `display: String`. Every place that shows a name to a person reads `display`: the grid frame labels, the category, formula and view panels, the tile bar, the page bar, CSV export headers, the minibuffer prompts and the status messages. The key is still the name, and `display == name` everywhere at the end of this step, so behaviour does not change. This splits \"what people see\" from \"what keys the map\" before any key moves, so the re-key step (the next bead) changes only what goes into the key. It follows the user's answer 1: \"reserve `name` ... for the id\".\n\nCoordinate with improvise-felo.6, which is working in category.rs and view/layout.rs. Land after it, or merge.\n\n## Acceptance Criteria\n- A test-only document whose display differs from its key (constructed through a test hook, since no production path can build one yet) shows the display in the grid frame, the category panel, the view panel, the tile bar and the CSV export header. The key never appears.\n- Rival: revert one renderer to read the key, and the matching test fails.\n- The whole suite is otherwise unchanged.\n","notes":"Split for review size (agent-adr007, 2026-09-25). This bead is the FIRST slice: display on Category/Item/View; CategoryRow/TileEntry/ViewSummary carry display beside the key; the category-panel headers, the tile bar and the view list paint it in the TUI and the browser. The rest is improvise-cq1q.12 (grid frame, page bar, records labels, item rows, CSV export) and improvise-cq1q.13 (status messages, view title). cq1q.4 now depends on both.","status":"in_progress","priority":1,"issue_type":"task","assignee":"agent-adr007","owner":"el-github@elangley.org","created_at":"2026-09-25T04:49:14Z","created_by":"agent-adr007","updated_at":"2026-09-25T19:10:51Z","started_at":"2026-09-25T18:59:52Z","lease_expires_at":"2026-09-25T19:04:52Z","heartbeat_at":"2026-09-25T18:59:52Z","dependencies":[{"issue_id":"improvise-cq1q.3","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T04:49:14Z","created_by":"agent-adr007","metadata":"{}"}],"dependency_count":0,"dependent_count":4,"comment_count":0} +{"_type":"issue","id":"improvise-59nw.2","title":"ADR 005 step 2: improvise-core depends on numtower; CellValue::Number(Num) across the f64 sites","description":"ADR 005 Migration step 2 (672 sites, mechanical). Needs step 1.","acceptance_criteria":"CellValue::Number carries Num; as_f64 keeps the seams compiling; hash_value hashes the representation including scale (clause 9); the suite is unchanged; test names diffed.","status":"open","priority":1,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-10-09T04:35:45Z","created_by":"mercury","updated_at":"2026-10-09T04:35:45Z","dependencies":[{"issue_id":"improvise-59nw.2","depends_on_id":"improvise-59nw","type":"parent-child","created_at":"2026-10-09T04:35:45Z","created_by":"mercury","metadata":"{}"},{"issue_id":"improvise-59nw.2","depends_on_id":"improvise-59nw.1","type":"blocks","created_at":"2026-10-09T04:35:53Z","created_by":"mercury","metadata":"{}"}],"dependency_count":1,"dependent_count":3,"comment_count":0} +{"_type":"issue","id":"improvise-59nw.1","title":"ADR 005 step 1: the numtower crate (Num, contagion, fractions, exact accumulator, number language) — first and alone","description":"ADR 005 Migration step 1 (clauses 3-6, 8, 14; question 2 decided (d); question 13 decided (b); K = 8; the 8,192-bit ceiling is the steward's proposal confirmed by approval). A numtower holding only ExactSum may precede it for ADR 009. Read context/adr/005-exact-decimal-arithmetic.md in full first.","acceptance_criteria":"numtower lands with its own property tests exactly as ADR 005 Migration step 1 lists them (each rival injected and watched failing by name; the to_f64 test spans 96-bit mantissas and scales 0-28); differential tests against a BigRational oracle; benchmark in numtower/examples/ with the machine named; the crate is added to the wasm CI job; nothing in improvise depends on it yet and no behaviour changes.","status":"in_progress","priority":1,"issue_type":"task","assignee":"numtower","owner":"el-github@elangley.org","created_at":"2026-10-09T04:35:44Z","created_by":"mercury","updated_at":"2026-10-09T04:36:59Z","started_at":"2026-10-09T04:36:59Z","dependencies":[{"issue_id":"improvise-59nw.1","depends_on_id":"improvise-59nw","type":"parent-child","created_at":"2026-10-09T04:35:44Z","created_by":"mercury","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"improvise-felo.13","title":"GridLayout is O(rows^2): cell_key does data_items(..).nth(row); every keypress rebuilds layout+widths; 8 ms holds only to ~700 rows","description":"From bench/scale-bisect (gh/bench/scale-bisect 13918bb9, context/test-evidence/scale-bisect.md; M4 Max release). Keypress inside 8 ms only to 729 rows (3 cats on Row, prune off), 256 with prune on; over 100 ms at ~4.9k rows; over 1 s at ~19.7k. Cost is O(rows^2) (reasoned from code+exponents, not a flame graph): GridLayout::cell_key does data_items(\u0026row_items).nth(row) (view/layout.rs ~253/292/807); compute_col_widths and grid_of walk every cell. Every keypress rebuilds layout and widths (and prune) though nothing changed. A 45x12 browser window costs the same as the whole-grid TUI frame because the widths pass is whole-grid. Prune toggle is the worst reshape (~730 ms at 2k rows). Fixable today without ADR 008: O(1) index-\u003eitem, cache layout across keypresses that don't change the model/view, window the widths pass.","acceptance_criteria":"Keypress/scroll within 8 ms at \u003e=20k rows (prune off) on the bench harness bench/scale_bisect; a test that fails if cell_key is linear in row index; layout not rebuilt on a keypress that changes neither model nor view.","status":"closed","priority":1,"issue_type":"bug","assignee":"felo13","owner":"el-github@elangley.org","created_at":"2026-10-03T01:19:54Z","created_by":"mercury","updated_at":"2026-10-03T05:31:00Z","started_at":"2026-10-03T01:41:10Z","closed_at":"2026-10-03T05:31:00Z","close_reason":"merged: #316 (66931785) and #333 (8da5a310)","dependencies":[{"issue_id":"improvise-felo.13","depends_on_id":"improvise-felo","type":"parent-child","created_at":"2026-10-03T01:19:54Z","created_by":"mercury","metadata":"{}"},{"issue_id":"improvise-felo.13","depends_on_id":"improvise-felo.10","type":"related","created_at":"2026-10-03T01:19:54Z","created_by":"mercury","metadata":"{}"},{"issue_id":"improvise-felo.13","depends_on_id":"improvise-felo.3","type":"related","created_at":"2026-10-03T01:19:54Z","created_by":"mercury","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-felo.11","title":"format_md takes 2.0s for 8.4 MB at 3x1000/100k records; per-command persistence blocks 'edit one cell within a frame'","description":"Measured by the ADR 008 rev 4 bench (M4 Max, release): format_md of the 3x1000 sparse document takes 2.0 s (8.4 MB); parse_md 0.4 s. The browser worker persists on every accepted command and the TUI autosaves synchronously, so the felo.10 criterion 'edit one cell within a frame' cannot be met. Needs: faster writer and/or incremental/debounced persistence (note Storage is whole-document, gotcha 41).","status":"open","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-10-03T00:36:57Z","created_by":"mercury","updated_at":"2026-10-03T00:36:57Z","dependencies":[{"issue_id":"improvise-felo.11","depends_on_id":"improvise-felo","type":"parent-child","created_at":"2026-10-03T00:36:57Z","created_by":"mercury","metadata":"{}"},{"issue_id":"improvise-felo.11","depends_on_id":"improvise-felo.10","type":"blocks","created_at":"2026-10-03T00:36:57Z","created_by":"mercury","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-felo.10","title":"High-cardinality target: three 1,000-item dimensions, sparse (~100k records), within frame budget","description":"USER REQUIREMENT 2026-10-02: handle at least three 1,000-element dimensions (10^9 possible combinations, usually much sparser). Not the same problem as a large grid: with three such categories on Row the cross product must never be enumerated; rows are discovered from DataStore's secondary index, so prune_empty is the default behaviour, and index-\u003etuple needs an ordered index over occupied tuples (mixed-radix arithmetic does not survive pruning, groups, collapse, hidden items). Also covers single 1,000-item categories in the category panel, tile bar and item pickers.","acceptance_criteria":"A benchmark/fixture of 3 categories x 1,000 items each with ~100k sparse records; open, scroll, edit one cell, and column totals each within a frame budget on the TUI and the browser; memory not proportional to 10^9; ADR 008 states how it meets this and ADR 009 how totals stay cheap.","notes":"ADR 008 rev 4 (PR #308, a50a9b10) + benches/grid_sparse.rs (Apple M4 Max). Today: 3x1000 on Row cannot open (~90s/150GB to build 10^9 rows; widths+prune quadratic, 10.5s at 64k rows). Prototype ordered occupied axis: tuple-\u003erow 64ns, 50-row window 0.1us, insert 4.5us, bulk by sort 3ms (one-at-a-time 217ms). Reshape by item-grain remap 2.3ms vs 45ms rebuild. Records mode 100k records 52-60s today (sk32; fix shape 70ms). Totals today 17-26ms/column (needs ADR 009 step 6). format_md 2.0s (blocks 'edit one cell' in browser: persists per command) - needs own ticket. prune_empty default is false: ADR Q11. New ADR questions 11-13. Item picker does not exist; category panel resends 20KB per write.\nUSER RULING 2026-10-03: normal UI operations (navigation etc.) must fit the 120 FPS budget (~8 ms) OR be deferred to the background (web worker, a generator yielding promises at arbitrary ticks). Supersedes the proposed 16 ms/50 ms budgets (ADR 008 Q13). Consequence: slow ops (reshape, open, persist, totals before ADR 009) need an incremental/yielding/async form, not just a faster one.\nUSER 2026-10-03: slow LOADING is acceptable; intelligently organizing loading to minimize waiting on IO is a plus (e.g. stream/parse in the background, show the grid as soon as the visible window is resolvable, overlap read and parse). Not a hot-path requirement; do not trade hot-path latency for it.\nADR 008 rev 4 round 2 (PR #308): BLOCK items fixed (rebuild cost stated as 6-45 ms range; visible-grain counts for collapsed-axis removal, measured 7 us/write, equals full remap; 3(a)-(b) superseded for collapse/hide). 8 ms ruling folded in as clause 11: hot path vs deferred jobs; sliced forms measured (axis rebuild 2.7 ms in 2 ms slices, column total 18 ms in ten 2 ms slices). Q13 reworded, Q14 added (load organisation / pending projection).\nscale-bisect (branch bench/scale-bisect, context/test-evidence/scale-bisect.md, M4 Max 64GB): today inside 8ms keypress: 729 rows (K=9, 3 cats Row, prune off) / 256 rows prune on / 1,200 records in records mode; 100ms at 4,913 rows / 4,600 records; 1s at 19,683 rows / 14,500 records. 3x1000 on Row cannot open (1e9 rows); records mode 100k: open 25s, keypress 51s; format_md 2.0s per accepted command at 200k cells. Cause: O(rows^2) nth in widths/grid_of. Render+JSON \u003c=0.5ms.","status":"open","priority":1,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-10-03T00:24:00Z","created_by":"mercury","updated_at":"2026-10-03T01:19:27Z","dependencies":[{"issue_id":"improvise-felo.10","depends_on_id":"improvise-felo","type":"parent-child","created_at":"2026-10-03T00:24:00Z","created_by":"mercury","metadata":"{}"},{"issue_id":"improvise-felo.10","depends_on_id":"improvise-felo.3","type":"related","created_at":"2026-10-03T00:24:00Z","created_by":"mercury","metadata":"{}"},{"issue_id":"improvise-felo.10","depends_on_id":"improvise-felo.4","type":"related","created_at":"2026-10-03T00:24:00Z","created_by":"mercury","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"improvise-cq1q.12","title":"ADR 007 step 2b: displays in the grid frame, page bar, records labels, item rows and CSV export","description":"The second slice of improvise-cq1q.3 (ADR 007 step 2). The first slice put `display` on Category, Item and View, and made the category-panel headers, the tile bar and the view list paint it in both front ends. This slice covers the rest of the grid and the panels:\n- item rows in the category panel (CategoryRow.items needs an item display beside each key);\n- the grid frame's label tuples and group headers;\n- the page bar (PageAxis cat/selected/items);\n- records-mode column headers and coordinate cells;\n- CSV export headers (persistence/export.rs render_csv).\n\nConstraint: the frame tuples are also cell coordinates (`cell_key`, `innermost_group`, `run_start`), so the display must be added at projection time (for example, a display tuple beside the key tuple in AxisFrame), never swapped in place. The page bar's items are cycled by the browser (`SetPageSelection`), so keys stay beside displays there too. See context/code/core.md §21.\n\n## Acceptance Criteria\n- Tests first, each seen to fail. With a category, an item and a group each displayed differently from its key:\n - the TUI grid, the browser render model and CSV export draw the displays;\n - `cell_key` and every command a client composes still name keys.\n- Rival: a frame whose tuple carries the display instead of the key. Cell addressing must fail.\n","notes":"Added 2026-10-02 (agent-adr007, per the orchestrator): (1) view_axis_summary (viewmodel.rs; drawn by src/ui/view_panel.rs and wasm-client/render.rs RenderViewRow.axes) still paints category keys: ViewSummary.axes needs a display beside each key. (2) Add a second view to the view_summaries test whose display differs from both the active view's display and its own key, so a fault that gives every view the active view's display fails. (3) State in context/code/core.md §21 or this PR's body, as one explicit sentence, that .improv neither writes nor reads display in this step: a saved file still carries keys only, and display persists only with the new version literal (cq1q.5).","status":"in_progress","priority":1,"issue_type":"task","assignee":"adr007-display","owner":"el-github@elangley.org","created_at":"2026-09-25T19:10:38Z","created_by":"agent-adr007","updated_at":"2026-10-09T04:34:48Z","started_at":"2026-10-09T04:34:48Z","dependencies":[{"issue_id":"improvise-cq1q.12","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T19:10:39Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.12","depends_on_id":"improvise-cq1q.3","type":"blocks","created_at":"2026-09-25T19:10:48Z","created_by":"agent-adr007","metadata":"{}"}],"comments":[{"id":"2c593fb5-4faa-5695-9e21-3e00aa3ee75b","issue_id":"improvise-cq1q.12","author":"Edward Langley","text":"From the adversarial review of PR #328 at 3116d524 (Claude agent, in the owner's session):\n\n1. **One more surface that still paints category keys.** The view panel's axis summary, `viewmodel::view_axis_summary` (for example `R:Region,Product`), is drawn by `src/ui/view_panel.rs` and by `wasm-client/render.rs` (`RenderViewRow.axes`). `ViewSummary.axes` holds `(category key, Axis)` only, so it needs a display beside each key, just as the frame does. It is not listed here or in cq1q.13.\n2. **A test gap to close in this slice.** A fault in which `view_summaries` gives every view the ACTIVE view's display passes the whole workspace. Add a second view whose display differs from both the active view's display and its own key.\n","created_at":"2026-09-25T19:38:50Z"}],"dependency_count":1,"dependent_count":1,"comment_count":1} +{"_type":"issue","id":"improvise-cq1q.5","title":"ADR 007 step 4: .improv ids — new version literal, id grammar, loader refusals, writer, round-trip properties","description":"ADR 007 clauses 5, 9 and 10: persistence, and migration step 4.\n- A second version literal. The grammar's `file` rule gets one arm per literal, and the version line alone picks the regime.\n- The new arm reads declarations with an id (header_decl, item_decl, grouped_item) and references as id_ref. id_token is spelled with ranges (improvise-rxz9). axis_line is tried before format_line (improvise-le4p).\n- The old arm is read exactly as today: whole-line headers, references resolved against displays, ids assigned by slugify, and undeclared items declared at load (answer 24).\n- The loader refuses a new-version file with a duplicate id, a missing id, an id outside the alphabet or a reserved `_` id. The error names the line, and for a duplicate it names both lines.\n- The writer always writes the new literal, and pipe-quotes any display that would otherwise split (`## Category: |Region(code)|(regioncode)`, improvise-80rm).\n- The round-trip properties from clause 5, plus a formula-line generator strategy (improvise-rxz9), so the formula properties are not vacuous.\n\nThe formula-line syntax waits for improvise-m56i.\n\n## Acceptance Criteria\n- Tests first, each failing before the change:\n - an old file with `## Category: Region(code)` loads with display `Region(code)` and id `regioncode`, and its data stays drawn;\n - a new-version file keeps every id verbatim, including `east-3`;\n - duplicate, missing and capitalised ids are each refused with the line named;\n - a category displayed `Format` on a row axis round-trips;\n - format(parse(format(parse(x)))) == format(parse(x)) holds.\n- Evidence that the formula strategy emits `=` lines the walker keeps: count them.\n","notes":"Updated for ADR 007 revision 9 (PR #326, agent-adr007, 2026-10-02). New in the new-version arm of view_entry: (1) axis_line is tried before format_line (le4p), and (2) the WRITER pipe-quotes a number format whenever 'format: \u003cvalue\u003e' would also match axis_line (an axis keyword row/column/page/filter/none alone or followed by ', \u003cname\u003e', or a value beginning with '|'); the new arm's format_line is 'pipe_quoted ~ \u0026NEWLINE | rest_of_line', as header_name does. An old-version file's 'format: none' is read as before (the old arm keeps format_line first) and written quoted on its first save. Added acceptance criteria: a category displayed Format on a row axis round-trips; a view whose number format is each of the five axis keywords round-trips in a document whose same view also holds a category 'format' on an axis. Rival: write the format bare, and the test fails with the format lost or an unknown-id refusal. The formula-line spelling waits for Q23 on PR #326 (recommendation (a)); under (a) a _Measure display that looks like Name(x) is pipe-quoted in the target position, e.g. '- |Margin(%)|(margin) = ...'.","status":"open","priority":1,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T04:49:41Z","created_by":"agent-adr007","updated_at":"2026-10-02T22:52:45Z","dependencies":[{"issue_id":"improvise-cq1q.5","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T04:49:41Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.5","depends_on_id":"improvise-cq1q.1","type":"blocks","created_at":"2026-09-25T04:50:54Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.5","depends_on_id":"improvise-cq1q.4","type":"blocks","created_at":"2026-09-25T04:50:53Z","created_by":"agent-adr007","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"improvise-cq1q.4","title":"ADR 007 step 3: re-key storage by id (Model, Workbook views, CellKey, DataStore, View config, RecordHash)","description":"ADR 007 clauses 2, 3 and 8, and migration step 3: the wide re-key. Every creation site takes its key from the workbook's IdAllocator (step 1): add_category, add_item, add_item_in_group, create_view and add_formula's target item. The display is the typed name.\n- Model::categories and Workbook::views are keyed by id.\n- CellKey is a sorted list of (CategoryId, ItemId), sorted by category id, so gotcha 5's canonical form holds under the new sort key. DataStore interns ids.\n- View config is keyed by id: axis maps, page selections, and hidden and collapsed sets (collapsed stays (CategoryId, group name), per improvise-p003).\n- RecordHash is computed over id text (clause 8). Update ADR 002's prose in the same PR, since the ADR assigns that update to the implementation PR.\n- Display-\u003eid resolution moves to the input edges: minibuffer commits, the command-line/script parser (coordinate with improvise-31oe.2 and improvise-31oe.3), and formula reference resolution.\n\nThis step is blocked by the user question in improvise-m56i on how typed formula names resolve (item 6) and on a non-_Measure formula target's identity (item 2).\n\n## Acceptance Criteria\n- Tests first: after add-category \"Time Period\", the key is `time_period` and the display is \"Time Period\".\n- Adding `East` to Region and to Direction gives `east` and `east-1` (answer 22).\n- A display rename (a test hook until fz77) moves no CellKey, no RecordHash and no layout_version. This is the property the design exists for, so assert that the labels changed and the surrogates did not. The rival, re-keying on rename, must fail it.\n- The wasm gate passes for every bundle crate.\n","notes":"Scope added for improvise-cq1q.11 (agent-adr007, 2026-09-25). This step now owns the OLD-format .improv edge, because it is the only format until cq1q.5: the old arm's loader assigns ids by slugify and resolves references against displays (answer 24 included), and the writer writes DISPLAYS in the old format. cq1q.5 keeps the new version literal, the id grammar, the refusals and the writer switch. Wire operands are already keys, via cq1q.7, which now lands first. Added acceptance criteria: (1) a document with categories displayed Region and Café, and item East, survives save and load at this step with every display intact. The rival, a writer that writes keys, gives region/caf/east and fails. (2) The browser round-trip suite (tests/browser_round_trip.rs, tests/lockstep.rs) stays green with no display-\u003eid resolver in ui/reduce.rs. The main branch is shippable after this step.\nUpdated for the user's decisions of 2026-09-25 (ADR 007 answers 30 and 31; PR #326). A formula target outside _Measure keeps a real item with its own ItemId, allocated like any item, and the formula gets a separate FormulaId (answer 30). Typed names are resolved at the CLIENT, displays first and then ids, and an ambiguous display is refused with the candidates' ids listed (answer 31). The host-side edges in this step, the minibuffer commits and the script parser, follow the same order. Formula references are resolved by the client/editor edge, never by the host from display text (answer 32). The dependency on improvise-m56i is satisfied: it is answered.\nAdded 2026-10-02 (PR #326): clause 2 now types a CellKey coordinate as (CategoryId, ItemHandle), ItemHandle = {Item(ItemId), FormulaTarget(FormulaId)}, and clause 11 step 2 lists ItemHandle among the types, so it lands in THIS step before cq1q.7 uses it in projections. A _Measure formula cell's coordinate is FormulaTarget; every other coordinate is an Item. RecordHash serializes the id text; a record strips _measure, so it never holds a FormulaTarget.","status":"open","priority":1,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T04:49:27Z","created_by":"agent-adr007","updated_at":"2026-10-02T22:52:47Z","dependencies":[{"issue_id":"improvise-cq1q.4","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T04:49:27Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.4","depends_on_id":"improvise-cq1q.12","type":"blocks","created_at":"2026-09-25T19:10:50Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.4","depends_on_id":"improvise-cq1q.13","type":"blocks","created_at":"2026-09-25T19:10:50Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.4","depends_on_id":"improvise-cq1q.2","type":"blocks","created_at":"2026-09-25T06:41:57Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.4","depends_on_id":"improvise-cq1q.3","type":"blocks","created_at":"2026-09-25T04:50:50Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.4","depends_on_id":"improvise-cq1q.7","type":"blocks","created_at":"2026-09-25T06:41:47Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.4","depends_on_id":"improvise-m56i","type":"blocks","created_at":"2026-09-25T04:50:52Z","created_by":"agent-adr007","metadata":"{}"}],"dependency_count":6,"dependent_count":4,"comment_count":0} +{"_type":"issue","id":"improvise-cq1q.3","title":"ADR 007 step 2: a display field on Category, Item and View, read by every renderer (keys unchanged)","description":"ADR 007 clause 2, first half. Category, Item and View each gain `display: String`. Every place that shows a name to a person reads `display`: the grid frame labels, the category, formula and view panels, the tile bar, the page bar, CSV export headers, the minibuffer prompts and the status messages. The key is still the name, and `display == name` everywhere at the end of this step, so behaviour does not change. This splits \"what people see\" from \"what keys the map\" before any key moves, so the re-key step (the next bead) changes only what goes into the key. It follows the user's answer 1: \"reserve `name` ... for the id\".\n\nCoordinate with improvise-felo.6, which is working in category.rs and view/layout.rs. Land after it, or merge.\n\n## Acceptance Criteria\n- A test-only document whose display differs from its key (constructed through a test hook, since no production path can build one yet) shows the display in the grid frame, the category panel, the view panel, the tile bar and the CSV export header. The key never appears.\n- Rival: revert one renderer to read the key, and the matching test fails.\n- The whole suite is otherwise unchanged.\n","notes":"Split for review size (agent-adr007, 2026-09-25). This bead is the FIRST slice: display on Category/Item/View; CategoryRow/TileEntry/ViewSummary carry display beside the key; the category-panel headers, the tile bar and the view list paint it in the TUI and the browser. The rest is improvise-cq1q.12 (grid frame, page bar, records labels, item rows, CSV export) and improvise-cq1q.13 (status messages, view title). cq1q.4 now depends on both.","status":"in_progress","priority":1,"issue_type":"task","assignee":"agent-adr007","owner":"el-github@elangley.org","created_at":"2026-09-25T04:49:14Z","created_by":"agent-adr007","updated_at":"2026-09-25T19:10:51Z","started_at":"2026-09-25T18:59:52Z","dependencies":[{"issue_id":"improvise-cq1q.3","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T04:49:14Z","created_by":"agent-adr007","metadata":"{}"}],"dependency_count":0,"dependent_count":4,"comment_count":0} {"_type":"issue","id":"improvise-cq1q.2","title":"ADR 007 step 1: slugify and the gensym id allocator (pure, improvise-core)","description":"ADR 007 clause 4: slugify, and the gensym-style id allocator. A pure module in improvise-core with no callers yet. It is migration step 2's first piece and depends on nothing.\n\n- `slugify(display)` does:\n 1. Unicode full case folding (answer 26). The `unicase` crate provides it; it is no_std, has no dependencies and is wasm-clean.\n 2. Each run of spaces becomes one `_`.\n 3. Every character outside a-z, 0-9, `-` and `_` is stripped.\n 4. Leading `_` is removed.\n 5. An empty result falls through to the allocator.\n- `IdAllocator`: one increment-only counter per document, starting at 1. A generated id stays bare unless it collides. A collision takes `-\u003cn\u003e` from the counter, advancing again until the candidate is free (answers 27, 28 and 29). An empty slug becomes `\u003ckind\u003e-\u003cn\u003e`. The allocator keeps every id it has ever seen, so a removed id is never reissued within a session.\n- `claim(id)` is how a hand-written id enters the allocator. It refuses a duplicate (answer 29), an id outside the alphabet, and a leading `_` other than the virtual ids `_index`, `_dim` and `_measure` (answer 23). A claimed id is never renamed, because hand-written ids are canonical.\n\n## Acceptance Criteria\n- Tests first, each shown failing: the ADR's examples (`East`-\u003e`east`, `Time Period`-\u003e`time_period`, `Region(code)`-\u003e`regioncode`, `Café`-\u003e`caf`, `Straße`-\u003e`strasse`, `_Draft`-\u003e`draft`, `Ü`-\u003e`category-\u003cn\u003e`); a collision across kinds; a claimed `east-1` that forces `east-2`; retirement; determinism across two independently built allocators.\n- Each test's rival injected and seen to fail: `to_lowercase` instead of folding (`Straße`), a lowest-free-number suffix, and replacing each single space instead of the run.\n- The wasm check passes for improvise-core, and harness/opfs-contract's lock is updated for the new dependency.\n","status":"closed","priority":1,"issue_type":"task","assignee":"agent-adr007","owner":"el-github@elangley.org","created_at":"2026-09-25T04:49:02Z","created_by":"agent-adr007","updated_at":"2026-09-25T17:51:51Z","started_at":"2026-09-25T04:51:42Z","closed_at":"2026-09-25T17:51:51Z","close_reason":"PR #318 merged (6da6a040, head 7ddc50ac); reviewed PASS (issuecomment-5836573405); cq1q.11 closed by the re-sequenced bead graph","dependencies":[{"issue_id":"improvise-cq1q.2","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T04:49:02Z","created_by":"agent-adr007","metadata":"{}"}],"comments":[{"id":"a07b9c7f-8972-53a8-8b97-aed300617104","issue_id":"improvise-cq1q.2","author":"agent-adr007","text":"PR #318 opened, branch adr007/slugify, head a2ddb06e. It adds model::id (slugify, IdAllocator, claim) and the unicase dependency. All 11 tests were written first, and each of the six rivals was caught (context/test-evidence/core.md). Every gate is green locally after merging gh/main c91cc055.","created_at":"2026-09-25T05:15:38Z"},{"id":"13895058-8d4e-538d-9939-3ed93a325d69","issue_id":"improvise-cq1q.2","author":"agent-adr007","text":"Pushed 689480bf: Cargo.nix regenerated with crate2nix for unicase. The first CI run's crate2nix job would have failed without it. nix build .#improvise passes locally.","created_at":"2026-09-25T05:37:45Z"}],"dependency_count":0,"dependent_count":1,"comment_count":2} {"_type":"issue","id":"improvise-31oe.1.1","title":"Complete command names on the : command line","description":"First child of the auto-complete epic; the user's top priority. On the ':' command line, completing the command name offers every registered command (names read off the registry's prototypes, gotcha 15 -- no string list), filtered by the typed prefix. Decide and test: the key (Tab, with what on an exact match), single-match insertion, multi-match display (cycle vs list), and that the TUI and the browser use the same engine (improvise-y11g brings the ':' line to the browser). Tests first: every registered command is completable; a prefix narrows; an unknown prefix offers nothing; the candidate set equals the registry's name set (so a new command cannot be missing).","status":"open","priority":1,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-25T03:41:48Z","created_by":"Edward Langley","updated_at":"2026-09-25T03:41:48Z","labels":["command","protocol","ux"],"dependencies":[{"issue_id":"improvise-31oe.1.1","depends_on_id":"improvise-31oe.1","type":"parent-child","created_at":"2026-09-25T03:41:48Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-8jk6.2","title":"Import drops whole records that lack a category value; keep them with the coordinate absent","description":"USER 2026-09-25, verbatim: 'this limit in import should be fixed, it dates to when this wasn't sparse.' Screenshot: 'Skipped 6 of 342 records missing category values: Settlement Date (6)'. build_model_with_warnings (crates/improvise-io/src/import/wizard.rs ~262-284) skips any record with no value for an accepted Category field; improvise-5j9 only made the loss visible. Records are sparse, so such a record should be KEPT with that coordinate absent from its CellKey. Same mechanism as the sparse folder merge (improvise-lpzu), where a whole file lacks a category column -- so it belongs with that work. Decide and test how an absent coordinate behaves downstream: a view with that category on Row/Column (is the record invisible there, shown under a blank/(none) row, or counted only in totals?), on Page (which selection shows it), on None (aggregates include it), records mode (blank cell in that column), .improv round-trip (a Data line simply omits the category), drill, and ADR 007/identity. The display question for Row/Column is the user's to confirm; state the recommendation in the PR.","status":"closed","priority":1,"issue_type":"bug","assignee":"agent-lpzu","owner":"el-github@elangley.org","created_at":"2026-09-25T03:37:31Z","created_by":"Edward Langley","updated_at":"2026-09-25T05:02:21Z","started_at":"2026-09-25T03:37:52Z","closed_at":"2026-09-25T05:02:21Z","close_reason":"Landed in PR #314; reviewed PASS at c43ee724","labels":["import"],"dependencies":[{"issue_id":"improvise-8jk6.2","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T03:37:31Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-sk32","title":"Records-mode layout build is quadratic in records: RecordRow::group scans rows linearly per cell","description":"Found while measuring improvise-felo.1 (crates/improvise-core/examples/category_width_bench.rs, Apple M4 Max, release). GridLayout::with_frozen_records in records mode: 2,500 records 79-82 ms, 5,000 records 302-321 ms, i.e. ~4x for 2x records, at BOTH 12 and 24 categories (width barely moves it). Cause: view/layout.rs RecordRow::group finds a cell's row with rows.iter().position(|r| r.coords == coords) -- a linear scan per cell, so O(cells x records). Drawing the built layout is linear (3 ms / 7 ms). Fix shape: index rows by coords (HashMap\u003cCellKey, usize\u003e) while keeping first-appearance order, which gotcha 18 / core.md section 8 require; a test must pin the order is unchanged. Not caused or worsened by the category cap.","status":"open","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T02:14:46Z","created_by":"wave-felo1","updated_at":"2026-10-03T00:36:56Z","labels":["core"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-felo.1","title":"Raise MAX_CATEGORIES from 12 to 24","description":"USER DECISION 2026-09-25, verbatim: '12 categories is feeling small, we should increase to 24.' Prompted by an Etrade transactions import that hit exactly 12 categories, which left no room for the duplicate-disambiguating category of improvise-cgqt (PR #304). Supersedes context/plan.md:260 ('Do not raise or restructure the MAX_CATEGORIES = 12 limit'). The limit lives in crates/improvise-core/src/model/types.rs and is MIRRORED by a private copy in crates/improvise-io/src/import/wizard.rs — make the wizard read the model's (one home), rather than changing two numbers. Update every doc that states 12 (README, design-principles, context/code/*, plan.md) and every test that fills to the cap by literal count; tests should derive the cap, not spell it. Before changing it, find and state why 12 was chosen (any cost that scales with category count: CellKey size, the secondary index, layout cross products, formula fold) and measure one representative operation at 24 categories.","status":"closed","priority":1,"issue_type":"feature","assignee":"wave-felo1","owner":"el-github@elangley.org","created_at":"2026-09-25T02:06:57Z","created_by":"Edward Langley","updated_at":"2026-09-25T03:10:53Z","started_at":"2026-09-25T02:07:37Z","closed_at":"2026-09-25T03:10:53Z","close_reason":"Landed in PR #307; reviewed PASS at 74e460be","labels":["core","import"],"dependencies":[{"issue_id":"improvise-felo.1","depends_on_id":"improvise-felo","type":"parent-child","created_at":"2026-09-25T02:06:57Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-gtn7.1","title":"TUI horizontal scrolling loses the cursor: grid cursor hides behind the category panel, tile cursor goes off screen","description":"Reported by the user 2026-09-25, possibly a regression. (1) Navigating the data grid, the selected cell can end up BEHIND the category panel -- the viewport's column window is computed as if the grid had the full frame width, not the width left after the side panel. (2) Navigating the tile bar, the tile cursor can move completely off screen -- the tile bar does not scroll to keep its cursor visible. Hypotheses to check, not facts: viewport_effects / compute_col_widths / visible_cols (improvise-command ui/viewport.rs, cmd/navigation.rs) take an extent that ignores panel_open; the tile bar widget in src/ui has no horizontal offset. Bisect for a regression (e.g. the grid-from-cache work improvise-anr9, extent/panel changes) and say which commit if so. Tests first: a unit test over the viewport computation with the category panel open that fails today, and one for the tile cursor at the far right of a bar wider than the screen.","status":"closed","priority":1,"issue_type":"bug","assignee":"agent-gtn7-1","owner":"el-github@elangley.org","created_at":"2026-09-25T01:20:39Z","created_by":"Edward Langley","updated_at":"2026-09-25T01:41:37Z","started_at":"2026-09-25T01:21:15Z","closed_at":"2026-09-25T01:41:37Z","close_reason":"Fixed in PR #303 (merged); reviewed PASS at 241cc5f3. Follow-ups: gtn7.2, j7ij, j4no.","labels":["regression?","tui","ux"],"dependencies":[{"issue_id":"improvise-gtn7.1","depends_on_id":"improvise-gtn7","type":"parent-child","created_at":"2026-09-25T01:20:39Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-pmt0","title":"Epic: Wire vocabulary, projection and multi-client sessions","description":"The shared session machinery in improvise-protocol and improvise-command/ui (reduce.rs, project.rs, host.rs): commands a client cannot yet name on the wire (1ohv, 2mqz, the browser : line y11g), where effects and their wire mirrors disagree, the per-view scoping of the active view and aggregate addressing (azhb, h074, obtu, 8id2, znuk), projection cost and the frame/shape fingerprints behind cursor invalidation (swre, mcq4, b8ng, 9v0j), and protocol-level test gaps (nmm5, 9xsp). Out of scope: the ADR 001 basis and force work (its own epic), browser key resolution and ClientState (improvise-6jk), and security of wire input (the security epic).","status":"open","priority":1,"issue_type":"epic","owner":"el-github@elangley.org","created_at":"2026-09-25T01:04:10Z","created_by":"Edward Langley","updated_at":"2026-09-25T01:04:10Z","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -33,24 +39,24 @@ {"_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":"{}"},{"issue_id":"improvise-y11g","depends_on_id":"improvise-pmt0","type":"parent-child","created_at":"2026-09-25T01:05:00Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-fdk1","title":"A records coordinate rename has no wire form, and the surrogate is why","description":"Found by the improvise-h3y7 completeness audit, WHICH CORRECTED THE TICKET'S OWN\nDESCRIPTION OF THIS GAP -- and the correction changes the fix.\n\nimprovise-h3y7 said \"grepping reduce.rs for Rename returns zero\". True, and\nMISLEADING. Two paths do this:\n\n - effect::apply_coordinate_rename (ui/effect.rs:655), inside ApplyAndClearDrill,\n which is deliberately off the wire (gotcha 38).\n - commit.rs::rename_record_coordinate (cmd/commit.rs:366), the plain records path,\n which ALREADY decomposes into [AddItem, ClearCell(old), SetCell(new)] -- three\n commands that all have ModelCommand forms.\n\nSo it reads as though the plain path were already wire-expressible. IT IS NOT, for a\nreason the ticket does not state: ModelCommand::SetCell takes a RecordCell, whose\nRecordHash is an OPAQUE SURROGATE the server resolves against cells that ALREADY\nEXIST (App::resolve_record, ui/app.rs:516). The renamed record does not exist yet, so\na client cannot name it.\n\nTwo things that look like they solve this and do not. `will_be_created_by_the_frame`\n(ui/reduce.rs:284) exempts such a record from the BASIS CHECK -- it says nothing about\nhow to NAME one, and the two are easy to confuse. And RecordHash::of being pub is not\na licence: a client computing surrogates is precisely the invention ADR 002 exists to\nprevent.\n\nWHAT TO ADD: one position-free variant naming the existing record by surrogate and the\nnew coordinate by value --\n\n ModelCommand::RenameRecordCoordinate { record: RecordHash, category: String, item: String }\n\n-- mapped to a SINGLE effect factored out of the two copies above. Position-free, so\nimprovise-hkbc's rule still holds. Add it to records_named's match: it names one\nrecord, and creating the ITEM is internal to the effect, as both existing copies\nalready do.\n\nCONSOLIDATING THE TWO COPIES IS WORTH DOING REGARDLESS. They differ today only in how\nthey report the empty-value refusal, and gotcha 19 already warns that the synthetic-\nrecords branch and the plain branch drift.\n\nREMEMBER gotcha 1: renaming a category coordinate in wide records mode moves EVERY\nmeasure cell in the row, not just the one under the cursor. That is also why the basis\nunit is the record (gotcha 40) -- a cell-level unit is violated by this one legal edit.\n\nRELATED: improvise-o9uq is about the same code. Read it in full and say whether this\nsubsumes it before starting.","notes":"agent-wire2 2026-09-22, NOT BUILT, and here is the argument. There is no producer for the variant. A browser cannot enter records editing: edit_or_drill declines when state.grid.records is set (improvise-bd9o), toggle-records-mode and drill-into-cell are both in declined(), and a records coordinate cell projects id: None (improvise-exdg), so a client has no surrogate for it to echo. RenameRecordCoordinate would be a ModelCommand that nothing composes, which is the promise-not-kept shape vocabulary_is_composed.rs refuses for Command. Composing it needs the browser half of commit_plain_records_edit, and that needs RecordCol, which is session-local by gotcha 1. bd9o records that as a design question. So this ticket is BLOCKED on that design, not on vocabulary. What stands regardless: consolidating effect::apply_coordinate_rename with commit.rs::rename_record_coordinate (gotcha 19 drift). That work is in the command layer, outside the wire lane.","status":"closed","priority":1,"issue_type":"task","assignee":"agent-hzjo2","owner":"el-github@elangley.org","created_at":"2026-09-22T04:47:16Z","created_by":"Edward Langley","updated_at":"2026-09-24T21:45:10Z","started_at":"2026-09-23T06:06:11Z","closed_at":"2026-09-24T21:45:10Z","close_reason":"Landed in main via #221 (reviewed; merged 2026-09-24).","labels":["protocol"],"comments":[{"id":"16e0ef09-54e4-5ca9-86d6-4dddfa573ef0","issue_id":"improvise-fdk1","author":"Edward Langley","text":"agent-review-193-196, reviewing PR #196 (6b3dd8c): #196 fits this ticket and does not foreclose it. It names the coordinate cell CellId::Coordinate but gives it no operand. This ticket's NOTES list 'a records coordinate cell projects id: None, so a client has no surrogate for it to echo' as a blocker. That blocker still stands after #196: a unit variant gives a client nothing to echo. When a producer exists, RenameRecordCoordinate needs the row's RecordHash from the PROJECTION, as Coordinate { record: RecordHash }. A client could also borrow it from a sibling Record cell in the same row, but that would be a second derivation of which cells share a record, which ADR 002 forbids. Growing the variant then is an additive wire change. protocol.md §3's line 'the operand belongs with the command that consumes it' should not be read as saying the command's operand can come from anywhere but the cell.","created_at":"2026-09-23T05:20:39Z"}],"dependency_count":0,"dependent_count":1,"comment_count":1} -{"_type":"issue","id":"improvise-2mqz","title":"SaveAs and ExportCsv have no wire form, which is why two prompts are stranded","description":"Found by the improvise-h3y7 completeness audit (direction A: effects -\u003e vocabulary).\nOf 32 ViewOnly effects, these are the only two that write storage and have NO wire\nform of any kind -- exactly the hole `Save` was in before improvise-368h.\n\neffect::SaveAs(path) and effect::ExportCsv(path) are unreachable from any client.\nresolve.rs::stranding() refuses SavePrompt and ExportPrompt for precisely this\nreason, so THE DECLINE IS A SYMPTOM: closing it means closing this, not editing the\ndecline.\n\nSHAPE: request FRAMES, not Commands -- improvise-368h's stated reason applies\nunchanged. A runtime-fallible write inside a ClientFrame::Commands batch breaks the\nall-or-nothing promise; [Save, SetCell] reported a failed write and then applied the\nedit. And neither mutates shared state or any client's slice, so reduce_view has\nnothing to fold and Checked has no basis to answer for.\n\n ClientFrame::SaveAs(PathBuf) / ClientFrame::ExportCsv(PathBuf)\n\nanswered ServerFrame::Rejected(..) on failure, to the asker alone, and SILENTLY on\nsuccess -- nothing moved, so no widget is dirty.\n\nBLOCKED ON the KeyOutcome shape gap (a resolver cannot emit a frame today). Land that\nfirst.\n\nTHE OPEN QUESTION THIS RAISES AND DOES NOT ANSWER -- flag it in the PR and get a\nruling before shipping: a browser client naming a path in the HOST's Storage is a\ndifferent act from a TUI user doing it (gotcha 2 -- the host picks the backend). The\nalternative is that a browser gets a download and never names a host path. Say which\nin context/code/protocol.md; different protocols follow from each.","notes":"=== USER RULING 2026-09-22 -- THE SHAPE ABOVE IS SUPERSEDED. READ THIS FIRST. ===\n\n'Save can save to OPFS, but exports should be downloads. There should also be a\nway to download the .improv file.'\n\nSo the description's proposed ClientFrame::SaveAs(PathBuf) / ExportCsv(PathBuf) is\nWRONG and must not be built. A browser client never names a path in the host's\nStorage. That also ANSWERS the open question the description flagged, in the safe\ndirection -- do not re-open it.\n\nTHE CORRECT SHAPE: a request frame that asks the host for BYTES, and a page that\nturns bytes into a download. Uniform across both deployments, names no path, and\ncarries no filesystem semantics across the wire at all.\n\n ClientFrame -\u003e a request for the rendered document or export\n ServerFrame -\u003e the bytes (or Rejected)\n web/app.js -\u003e Blob + an \u003ca download\u003e, which is the ONLY new browser-side code\n\nTWO ARTEFACTS ARE WANTED, not one: the CSV export, and THE .improv FILE ITSELF.\nThe second is new scope the original ticket did not have and is arguably the more\nimportant of the two -- it is how a browser user gets their document out of OPFS\nand into a file they own. persistence::format_md already renders it; nothing new\nis needed server-side beyond the frame.\n\nWHY BYTES AND NOT CLIENT-SIDE RENDERING, which is the tempting shortcut:\nimprovise-io IS in the browser bundle, so the worker deployment could render CSV\nlocally. The socket deployment could not -- a thin client holds no Model. Rendering\nlocally where possible and asking otherwise would be TWO derivations of one answer,\nwhich is the disease design-principles §5 names and improvise-edp is the worked\nexample of. Ask the host in both.\n\nNOTE render_csv must build its layout with with_frozen_records, NOT GridLayout::new\n(gotcha 1, improvise-iot6) -- export.rs already does; do not regress it.\n\nSAVE ITSELF NEEDS NOTHING NEW: the worker already persists to OPFS on the dirty\nflag (gotcha 45), and ClientFrame::Save exists for the socket deployment. What is\nmissing there is only the KEY that reaches it -- improvise-jz6w.\n\nSTILL BLOCKED ON improvise-jz6w for the same reason as before: a resolver cannot\nemit a frame today.\nagent-wire2 2026-09-22: NOT TAKEN. It is not small, and by its own text it is a design question. (1) It is blocked on the KeyOutcome shape gap: a resolver cannot emit a frame today. (2) It needs a ruling first: should a browser name a path in the HOST's Storage (gotcha 2), or get a download and never name one? The PR #172 forward guard records SaveAs/ExportCsv as Verdict::Gap(improvise-2mqz), so closing this must also delete those two NOT_ON_THE_WIRE entries, or the_verdict_list_names_nothing_that_is_on_the_wire fails. That is only true if the closure adds Command variants of those names; ClientFrame variants would change the verdict to Frame(..).","status":"open","priority":1,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T04:47:04Z","created_by":"Edward Langley","updated_at":"2026-09-23T04:24:12Z","labels":["protocol"],"dependencies":[{"issue_id":"improvise-2mqz","depends_on_id":"improvise-jz6w","type":"blocks","created_at":"2026-09-22T04:48:02Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-2mqz","depends_on_id":"improvise-owep","type":"blocks","created_at":"2026-09-22T05:24:35Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-2mqz","depends_on_id":"improvise-pmt0","type":"parent-child","created_at":"2026-09-25T01:04:59Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-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":"closed","priority":1,"issue_type":"task","assignee":"external-grok-bot","owner":"el-github@elangley.org","created_at":"2026-09-22T04:47:04Z","created_by":"Edward Langley","updated_at":"2026-09-27T20:07:05Z","started_at":"2026-09-25T18:48:29Z","closed_at":"2026-09-27T20:07:05Z","close_reason":"PR #329 merged (8220a06): SaveAs/ExportCsv as host bytes → browser download (USER RULING). Session-local CSV + host prompt lockstep. Dropped stale blocks-on improvise-owep (bytes path, no host PathBuf).","labels":["protocol"],"dependencies":[{"issue_id":"improvise-2mqz","depends_on_id":"improvise-jz6w","type":"blocks","created_at":"2026-09-22T04:48:02Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-2mqz","depends_on_id":"improvise-owep","type":"blocks","created_at":"2026-09-22T05:24:35Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-2mqz","depends_on_id":"improvise-pmt0","type":"parent-child","created_at":"2026-09-25T01:04:59Z","created_by":"Edward Langley","metadata":"{}"}],"comments":[{"id":"e17e0d16-b382-58ef-9ce5-eb6c22889315","issue_id":"improvise-2mqz","author":"external-grok-bot","text":"Claimed for E2E fix via cloud agent PR. Dolt sync preferred.","created_at":"2026-09-25T18:48:29Z"},{"id":"dd15eb71-9e73-5dfa-b94c-7707f3962907","issue_id":"improvise-2mqz","author":"external-grok-bot","text":"Implementation PR (draft): https://github.com/fiddlerwoaroof/improvise/pull/329 — DownloadDocument/DownloadCsv request frames, DocumentBytes/CsvBytes responses, web Blob download. Matches USER RULING (bytes not PathBuf). Leave open until PR merges; cloud agent bc-8b825dac.","created_at":"2026-09-25T19:17:09Z"}],"dependency_count":2,"dependent_count":0,"comment_count":2} {"_type":"issue","id":"improvise-jz6w","title":"KeyOutcome cannot express 'send a frame', so Ctrl-S cannot reach the ClientFrame::Save that already exists","description":"Found by the improvise-h3y7 completeness audit. It is a SHAPE gap, not a vocabulary\ngap, which is why an audit that enumerates Command/ModelCommand against effects finds\nnothing here.\n\nClientFrame::Save landed with improvise-368h and Host::handle answers it (ui/host.rs:83).\nBut KeyOutcome is `Local(Vec\u003cCommand\u003e) | Upstream(KeyEvent) | Ignored`\n(improvise-protocol/src/resolve.rs:20), so a resolver has NO WAY to say \"send this\nframe\". Consequently `save` is still on the_command_names_that_still_need_the_host_are_these,\nresolves to Upstream, and Host::handle answers Upstream with an empty vec (gotcha 47:\nan upstream key is a BLACK HOLE, not a slow path).\n\nSo: an implemented server feature that no key can reach.\n\nWHAT TO ADD: a fourth KeyOutcome variant carrying a ClientFrame -- or a narrower\n`Request` enum, if a resolver handing out arbitrary frames is too wide. ARGUE THE\nCHOICE in the PR rather than picking silently; the narrow form is the one that keeps\na resolver from composing a Viewport or a Commands batch by accident.\n\nThen bind Ctrl-S through it and REMOVE `save` from the host list in the same commit --\ngotcha 47: closing a gap has to come to that list and say so.\n\nTHE GUARD: end to end, in the style of tests/browser_round_trip.rs -- Ctrl-S on a\nClientState yields the save request, and Host::handle on that request writes the\ndocument through a real App. Not two half-tests that never meet.\n\nTHIS UNBLOCKS the SaveAs/ExportCsv frames (filed separately), which need the same\nmechanism. Do this one first; it is small on its own.\n\nNOTE for whoever reads KeyOutcome afterwards: context/code/clients.md should say that\nKeyOutcome's variants are part of the protocol surface and belong in any completeness\nenumeration. That omission is why this gap was invisible to the audit's first pass.","notes":"FIXED in PR #134 (merge a799e24). Reproduced at HEAD first: the new end-to-end\ntest failed with left: Key { code: Char('s'), modifiers: CONTROL } right: Save --\nCtrl-S went Upstream -\u003e ClientFrame::Key -\u003e Host::handle's empty vec.\n\nTHE WIDTH WAS THE DECISION, and it is argued rather than defaulted:\nKeyOutcome::Request(Request) with a NARROW enum (Request::Save, plus\nRequest::frame as the one mapping to an envelope), NOT a ClientFrame. Two reasons:\n 1. An arbitrary frame lets a resolver compose a Viewport, whose extent is the\n PAGE's fact and reaches no derivation from a keymap.\n 2. The serious one: it lets a resolver compose a Commands batch, which is\n INDISTINGUISHABLE ON THE WIRE FROM A Local while skipping the local fold\n Local's contract requires. That desync has nothing to observe it.\n\nTwo consequences guarded rather than asserted in prose: `requested` is checked\nFIRST in step, so no name sits in two lists; and a request cannot travel inside a\nBinding::Sequence (a sequence is one frame), with\nno_binding_puts_a_request_inside_a_sequence keeping that refusal arm unreachable\nrather than merely untested. NOT routed through Commands -- gotcha 45's reason\nholds. `save` left the host-needed list in the same commit.\n\nTHE LOAD-BEARING RIVAL: Request::Save =\u003e ClientFrame::Commands(vec![]). The\nSTORAGE assertion alone cannot separate \"reached the host\" from \"reached the host\nas the wrong kind of thing\", because an empty batch writes nothing either way.\nAsserting the FRAME is the discriminator.\n\nTWO FINDINGS WORTH MORE THAN THE FIX:\n\n1. THE AUDIT THAT FOUND THIS TICKET COULD NOT HAVE FOUND IT. improvise-h3y7\n enumerated Command/ModelCommand against the effects and nothing was missing\n from either -- the gap was in KEYOUTCOME'S SHAPE. KeyOutcome's variants are\n protocol surface too, and an enumeration that stops at the frames and the\n commands will miss the next one the same way.\n\n2. THE HOST SIDE WAS ALREADY TESTED, by a_client_can_ask_its_host_to_save, WHICH\n CONSTRUCTS THE FRAME ITSELF. A passing test over a feature nothing can reach is\n the \"green suite is not evidence\" shape exactly -- improvise-368h shipped that\n way. Worth checking whether other Host::handle arms have only\n self-constructed-frame coverage.\n\nNOTE ON repo-map: the agent measured 1686 at d672985 over 38 binaries, where\nrepo-map still says 1464/35 -- stale by 222. The File Inventory's own preamble\nsays those are a dated snapshot, so this is the preamble being right rather than\na defect.","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T04:47:03Z","created_by":"Edward Langley","updated_at":"2026-09-22T20:59:52Z","closed_at":"2026-09-22T20:59:52Z","close_reason":"Closed","labels":["browser","protocol"],"dependency_count":0,"dependent_count":2,"comment_count":0} {"_type":"issue","id":"improvise-m0sx","title":"Command::ClearBuffer is invented, and disagrees with the only implementation of its own name","description":"Found by the improvise-h3y7 completeness audit (direction B: vocabulary -\u003e effects),\nand VERIFIED INDEPENDENTLY by the orchestrator before filing.\n\nTHE DIVERGENCE. reduce_view's arm (improvise-protocol/src/reduce.rs:269) does\n`view.buffers.remove(name)` -- it REMOVES the key. The host's `clear-buffer`\ncommand (command/cmd/effect_cmds.rs:226) composes `core::write_buffer(name, String::new())`,\nwhich is `effect::SetBuffer` -- and SetBuffer INSERTS, leaving `buffers[name] == \"\"`.\nresolve.rs::write_buffer composes the same SetBuffer. So the one behaviour both\nends actually implement leaves an empty string, and the wire variant says otherwise.\n\nThere is NO `ClearBuffer` effect anywhere in the workspace. The variant is therefore\nnot derived from the effect set, violating decision(improvise-cqi,\ncommand_set_derived_not_invented). It is the exact INVERSE of the ViewBack defect\nthat improvise-h3y7 was opened over: ViewBack was an effect with no Command; this is\na Command with no effect.\n\nREACHABILITY. `grep -rn \"Command::ClearBuffer\" crates/ src/ web/` returns exactly\nthree hits, ALL inside reduce.rs: the definition at :39 and two of reduce.rs's own\ntests at :405 and :465. No production code composes it. (Note `ClearBufferCmd` in\neffect_cmds.rs and registry.rs is the command-layer NAME, not the wire variant --\nthey are different things that read the same, which is part of why this survived.)\n\nImpact if it were ever sent: a Snapshot seeded from a ViewState where the host left\n`buffers[\"edit\"] == \"\"` against an incrementally-folded client with no key -- two\nclients whose ViewStates are PartialEq-unequal for the \"same\" state.\n\nDECIDE ONE, AND SAY WHICH IN context/code/protocol.md:\n(a) delete the variant and its two tests -- free today, since nothing composes it; or\n(b) keep it as the derived form and change `clear-buffer`'s effect to remove the key,\n which makes ViewState equality agree between a folded client and a snapshot-seeded one.\n\nTHEN ADD THE GUARD that makes this whole class visible: an exhaustive test over\n`variants::declared::\u003cCommand\u003e()` asserting every variant is composed by some\nproduction resolver or relay. The machinery is already in\ncrates/improvise-protocol/src/variants.rs. Prove it discriminating by deleting one\nresolver arm and watching it fail -- a variant nothing composes is either dead or a\npromise, and both need naming.\n\nNOTE the two existing tests currently PIN the divergent behaviour (they assert\n`!view.buffers.contains_key(\"edit\")`), so whichever answer is taken, they change.","status":"closed","priority":1,"issue_type":"bug","assignee":"agent-wire","owner":"el-github@elangley.org","created_at":"2026-09-22T04:46:49Z","created_by":"Edward Langley","updated_at":"2026-09-23T03:29:39Z","started_at":"2026-09-23T02:48:53Z","closed_at":"2026-09-23T03:29:39Z","close_reason":"Merged in PR #160. Closed by the orchestrator after the session handover; the implementing agent's claim was abandoned when its process exited. Details of the fix, the rival injected and what the ticket did not predict are on the PR thread.","labels":["protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-h3y7","title":"The wire vocabulary must be complete against what needs the server, not merely correct","description":"USER RULING 2026-09-21: \"the wire protocol needs to cover the full range of commands that need the server side.\"\n\nThis is a COMPLETENESS requirement on the vocabulary, and it lands on a\nweakness the wave just demonstrated rather than on a suspicion.\n\nTHE PRECEDENT THAT MAKES IT URGENT. PR #115 found that gotcha 38's account of\nwhy ViewBack/ViewForward were off the wire was FALSE: it said they were\nsession-local residue like ApplyAndClearDrill, but view_back_stack is in\nViewState -- the portable slice -- so by the view half's own rule\n(decision(improvise-cqi): the ViewOnly effects targeting the portable slice)\nthey were always Commands and were simply MISSING. The derivation had been\nchecked for CORRECTNESS -- every listed variant belongs -- and never for\nCOMPLETENESS. The cost was a live divergence nobody could see: reduce_view's\nSwitchView arm set active_view and nothing else while effect::SwitchView pushes\nthe back stack, so NO CLIENT EVER HAD A BACK HISTORY.\n\nSo this bead is: enumerate the vocabulary AGAINST the rule, in both directions,\nand close what the enumeration finds.\n\nTHE KNOWN GAPS AT 523d571, to be treated as a starting list rather than the\nanswer:\n\n1. NO ModelCommand FOR A COORDINATE RENAME. ApplyAndClearDrill's\n RecordCol::Category(_) arm is apply_coordinate_rename -- add an item, then\n move EVERY measure cell in the row (gotcha 1) -- and grepping reduce.rs for\n Rename returns zero. A drill's category-column commit therefore has no wire\n form. This is a missing ModelCommand, NOT a missing addressed form, and is\n much smaller than it was assumed to be.\n\n2. SIX NAMES IN declined() (resolve.rs:232) that a browser cannot ask for at all:\n drill-into-cell, toggle-prune-empty, toggle-records-mode,\n add-record-row, delete-formula-at-cursor, view-back\n plus enter-mode via stranding (SavePrompt, ExportPrompt, CommandMode).\n view-back becomes removable when PR #115 merges -- the comment blaming the\n drill commit is wrong as of that PR. The others each need their own answer:\n some want a ModelCommand, some want a projection, and some may be genuinely\n host-only. SAY WHICH, PER NAME.\n\n3. EIGHT NAMES in the_command_names_that_still_need_the_host_are_these:\n hide-selected-row-item, toggle-group-under-cursor, search,\n search-navigate, search-or-category-add, enter-advance, save, force-quit\n (search-or-category-add leaves with improvise-koh6/PR #114.) Note gotcha 47:\n an UPSTREAM key is a black hole, not a slow path -- Host::handle answers\n ClientFrame::Key with an explicit empty vec -- so every name on this list\n does NOTHING in a browser rather than being merely slower.\n\n4. ClientFrame::Key ITSELF. It exists, is deployment-independent by gotcha 42's\n argument, and is answered with an empty vec. Either it should carry\n something or it should not exist; today it is a promise the protocol does\n not keep.\n\nWHAT THIS BEAD IS NOT: a licence to put everything on the wire. gotcha 38\nrecords two effects deliberately EXCLUDED -- ApplyAndClearDrill and\nAcceptWizardModel -- because their operands are session-local residue addressed\nto precisely one session, and an unaddressed form lets client B commit client\nA's typing (demonstrated against Sessions::apply before removal). The rule is\n\"everything that NEEDS the server\", and residue does not. The test for each\ncandidate is the one gotcha 38 already states: does the argument for excluding\nit name the thing it differs in?\n\nACCEPTANCE: a per-name verdict for every entry in (2) and (3) -- on the wire,\ndeliberately excluded with the argument, or blocked on a named prerequisite --\nplus the enumeration in (1) closed or filed. A list of what is missing is worth\nmore than a partial fix, and doctrine(a_stale_list_of_what_is_missing_is_worse_than_no_list)\nmeans the verdict list has to be maintained where it can be checked, not left\nin this bead.\n","notes":"AUDIT COMPLETE 2026-09-22 against 44b8819. Both derivations run.\n\nMECHANISM: brace-matched impl Effect blocks in ui/effect.rs =\u003e 26 EffectKind::Model(_),\nverified against gotcha 38's stated 26/24. NOTE THE MECHANISM UNDER-REPORTS BY 9: it\nmisses the 8 simple_effect! macro expansions and the one impl Effect in\ncommand/keymap.rs (SetTransientPrefix). Total effects = 58: 26 Model(_), 32 ViewOnly.\nAll 9 missed are ViewOnly, which is exactly the half the ViewBack bug lived in.\nGotcha 38 warns the naive awk OVER-reports by picking up HelpPageSet; the brace-matcher\nit recommends instead UNDER-reports, and nothing in context/ said so.\n\nDIRECTION A (effects -\u003e vocabulary): no gap in the model half -- all 26 accounted for.\nIn the view half, 21 of 32 have a Command; 9 of the remaining 11 are soundly excluded\nand TWO ARE A GAP: SaveAs and ExportCsv write storage and have no wire form at all\n(=\u003e improvise-2mqz).\n\nDIRECTION B (vocabulary -\u003e effects), NEVER RUN BEFORE, three finds:\n - Command::ClearBuffer is INVENTED and contradicts the only implementation of its\n name (=\u003e improvise-m0sx, P1, orchestrator-verified independently)\n - effect::HelpPageSet unclamped where its mirror clamps (=\u003e improvise-4n4i)\n - effect::SwitchView pushes the back stack before a guarded switch (=\u003e improvise-swzk)\n\nSTRUCTURAL PROPERTY FOUND AND NOT PREVIOUSLY WRITTEN DOWN: apply_command handles only\nSwitchView and Model; every other Command falls to _ =\u003e Dirty::NOTHING and is folded\ninto the asking session's mirror by reduce_view -- THE SAME FUNCTION both ends run. So\nmirror-vs-client drift is structurally impossible, and the whole direction-B risk\ncollapses to ~21 pairs of 'does reduce_view's arm do what its effect does'. That is an\nafternoon, and re-running it after any wave touching reduce_view or effect.rs is the\nonly thing standing between this repo and another ViewBack.\n\nTHE HEADLINE, which corrects this ticket's own framing: THE VOCABULARY IS FAR MORE\nCOMPLETE THAN ASSUMED. Of 13 gaps examined, EIGHT are closed in the vocabulary and open\nonly in the RESOLVER. Taken literally, this ticket would have produced new variants for\nthings already on the wire. Only three things need new wire surface: SaveAs/ExportCsv\n(frames), the coordinate rename (a ModelCommand), add-record-row (a new naming mode).\n\nGENERALISABLE FINDING: declined() is NOT evidence of a vocabulary gap and is NOT\nmaintained against the vocabulary. It is a sentence written when a name was hard, and\nnothing re-checks it when the thing that made it hard lands. view-back is the proven\ninstance (improvise-cfly is cleaning up after PR #115); toggle-prune-empty and\ntoggle-records-mode are two more sitting in that state now. The structural fix is that\nthe decline list and the host list must be DERIVED FROM or CHECKED AGAINST the\nvocabulary, not hand-kept beside it.\n\nALSO: a SHAPE gap can hide behind a vocabulary audit. 'save' looks like a missing\nCommand and is not -- ClientFrame::Save exists and is answered; the only missing thing\nis a KeyOutcome variant that can express sending a frame (=\u003e improvise-jz6w).\nKeyOutcome's variants are part of the protocol surface and belong in any completeness\nenumeration; context/code/clients.md should say so.\n\nAND: the surrogate is the binding constraint on the whole CREATION class. RecordHash is\nresolved server-side against cells that exist, so a client cannot name a record it is\nabout to create. Invisible from the ModelCommand list. will_be_created_by_the_frame\nexempts such a record from the BASIS CHECK and says nothing about NAMING one -- the two\nare easy to confuse. Deserves a line in ADR 002.\n\nCHILDREN FILED: improvise-m0sx (P1), improvise-jz6w (P1), improvise-2mqz (P1, blocked\nby jz6w), improvise-fdk1 (P1), improvise-fo0w (P2), improvise-1ohv (P2, blocked by\nfdk1), improvise-4n4i (P3), improvise-swzk (P3).\nALREADY FILED, not duplicated: improvise-7xyx (raised P3-\u003eP2: it is a black-hole key,\nnot just a dead field), improvise-hs0u (prefixes), improvise-gd28 (drill),\nimprovise-bd9o (records mode), improvise-uln5 (advertised-vs-resolvable).\nFOUR PRODUCT RULINGS OWED -- see the orchestrator's hand-off.","status":"closed","priority":1,"issue_type":"task","assignee":"agent-wire2","owner":"el-github@elangley.org","created_at":"2026-09-22T02:51:02Z","created_by":"Edward Langley","updated_at":"2026-09-23T04:28:18Z","started_at":"2026-09-23T03:33:55Z","closed_at":"2026-09-23T04:28:18Z","close_reason":"PR #172 MERGED (confirmed at 119f84f), adversarially reviewed then re-reviewed after fixes (merge). h3y7: the forward vocabulary guard (ui/reduce/vocabulary_is_complete.rs) requires every Effect to be a same-named Command/ModelCommand variant or carry a NOT_ON_THE_WIRE verdict (Command/Frame/Off/Gap), checked in both directions. hkbc: position_free destructures every ModelCommand with no '..'. c7mu: one shared scanner, improvise_protocol::source_scan::production_code, blanks comments and literals and drops test code for BOTH vocabulary guards. 94x2: name_after skips attributes, so an attributed simple_effect! is still seen; re-review injected three attribute spellings and all were caught. Residual: improvise-59q8 (P3, the scanner's latent line-scan holes).","labels":["architecture","protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-koh6","title":"[bug] Following the help overlay's N to add a category overwrites a data cell","description":"DEMONSTRATED end to end under Chromium at 523d571, on web/demo.improv, with\nthree controls (C, P, T) in the same loop that DID move -- otherwise a blind\nprobe and a black hole look identical.\n\n cell (0,0) before: \"18,400\"\n keys: N R e g i o n Enter\n cell (0,0) after: \"18400on\" \u003c- Espresso/Revenue, overwritten with text\n categories: UNCHANGED -- no category was created\n status: \"...records mode is not available in the browser yet\"\n\nTHE SEQUENCE, measured one key at a time:\n\n - `N` resolves Upstream. Host::handle answers a Key frame with an empty vec, so\n the FULL DOM snapshot -- mode, hint, status, grid, tiles, minibuffer -- is\n BYTE-IDENTICAL before and after. Completely silent.\n - The user then types the category name into NORMAL, where every character is a\n live Normal binding:\n R -\u003e status \"records mode is not available in the browser yet\"\n e, g -\u003e nothing\n i -\u003e INSERT on the grid cell under the cursor, PRE-FILLED with its value\n o, n -\u003e appended to that edit buffer\n - Enter commits.\n\n`N` is advertised by the browser's OWN help overlay, page 4:\n\"N -- Quick-add a new category (from anywhere)\".\n\nWHY THIS IS NOT THE MINIBUFFER BUG IT WAS REPORTED AS: the minibuffer works\nperfectly. Measured: C then n gives mode NEW CATEGORY, minibuffer \"new category:\"\nin yellow, 1280x28.6 at y=691, and each typed character appears live; Enter\ngives 'Added category \"Reg\"'. Verified in two more modes (F,n -\u003e \"formula:\" cyan;\nC,a -\u003e \"add item to Product:\" green). The user's \"typing blind\" was LITERAL -- a\ndead key followed by characters landing as commands.\n\nTHE CATEGORY THIS ADDS TO THE DOCTRINE: clients.md enumerates black holes as\nkeys that do nothing. A black hole that leaves the keyboard in a MUTATING mode\nis strictly worse and is not currently distinguished. The cost here is a\ncorrupted value, not a confused user.\n\nSECOND, COMPOUNDING PROBLEM: the decline messages actively mislead. Pressing N\nthen R answers \"records mode is not available in the browser yet\" -- confident,\ntrue, and about a different subject entirely, arriving adjacent in time to an\nunanswered gesture. clients.md already names the failure (\"a reader who is told\nsomething plausible stops looking\"); this is a new route to it.\n\nIS THERE A ONE-LINE FIX? NO, and the investigating agent resisted calling it one.\nSearchOrCategoryAdd::execute (command/cmd/search.rs:181) is:\n\n if !ctx.search_query.is_empty() { SearchNavigate(false).execute(ctx) }\n else { vec![SetPanelOpen{Category,true}, change_mode(AppMode::category_add())] }\n\nThe else-branch is exactly two commands the browser already composes elsewhere,\nand search_query is on ViewState, so the client HAS everything it needs. But a\nconditional arm makes N's outcome STATE-DEPENDENT, and\nenter_in_an_empty_view_panel_resolves_locally_rather_than_upstream\n(resolve.rs:1364) exists precisely to forbid making the host-needed list\nstate-dependent. So it is a decision, not a line.\n\nCHEAP INTERIM that stops the data loss without deciding that: one arm in\ndeclined() giving search-or-category-add its own true subject. That ends the\nsilence but NOT the sequel, because the user's next keystrokes still land in\nNORMAL where `i` opens an editor.\n","notes":"ALREADY FIXED by 7a44616, an ancestor of 7470b61. Stale-open -- the fifth ticket\nclosed by that single commit.\n\nresolve.rs:379 answers search-or-category-add in composed() in BOTH arms: the\nempty-query arm builds SetPanelOpen{Category,true} + ChangeMode(category_add),\nand the other declines out loud. So N is no longer Upstream, and therefore no\nlonger a black hole that left the keyboard in Normal where i opens an editor over\na data cell -- which was this ticket's whole mechanism.\n\nNOTE A MISREADING WORTH RECORDING: an earlier agent reported that\nsearch-or-category-add \"left the host-needed list WITH improvise-koh6\", which I\nread as a side effect of that PR. It was the whole close. A ticket named in\nanother ticket's departure note is easy to read as context rather than as\nresolution.\n\nNOT VERIFIED END TO END: the browser half was confirmed by reading the resolver\narm and its ancestry, not by driving Chromium -- harness/ was outside the\nverifying agent's file scope, and this ticket's original evidence was gathered\nthere.","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T00:22:11Z","created_by":"Edward Langley","updated_at":"2026-09-22T07:40:28Z","closed_at":"2026-09-22T07:40:28Z","close_reason":"Closed","labels":["browser","data-loss","ux"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-cgqt","title":"Import collision policy should be a wizard step, not a fixed default","description":"improvise-2cw9 decided that two source rows landing on one coordinate tuple are\nSUMMED and reported. That decision is recorded on the bead and is right as a\ndefault -- it is what a pivot table does and what measure_agg defaults to.\n\nIt is WRONG for a measure the user later sets to Avg: the value is already\npre-summed, so the average is taken over the wrong denominator. The warning is\nwhat currently keeps that from being invisible.\n\nThe user is the only one who knows which they want. The shape is a wizard step\nthat asks, on the field where the collision was detected -- not a change to the\ndefault, and not a silent guess from the field's inferred kind.\n\nSearch `duplicate`, `collision`, `aggregate` before filing anything adjacent.\n","status":"closed","priority":1,"issue_type":"feature","assignee":"agent-cgqt","owner":"el-github@elangley.org","created_at":"2026-09-21T21:59:43Z","created_by":"Edward Langley","updated_at":"2026-09-25T02:22:13Z","started_at":"2026-09-25T01:10:23Z","closed_at":"2026-09-25T02:22:13Z","close_reason":"Landed in PR #304 (merged); reviewed PASS at 22cbfa67","labels":["import","ux"],"dependencies":[{"issue_id":"improvise-cgqt","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T01:05:28Z","created_by":"Edward Langley","metadata":"{}"}],"comments":[{"id":"0a4ce58b-d381-5b92-bc64-ba1e32c2f5f4","issue_id":"improvise-cgqt","author":"Edward Langley","text":"USER DECISION 2026-09-25 (answered in orchestrator session, after an Etrade transactions import summed 31 colliding cells; user called it critical): when import rows collide on every coordinate, the wizard ASKS, offering (a) sum them (today's default, 2cw9), (b) keep them separate by adding a row-number category, (c) cancel/go back. Chosen over 'always keep every row via a hidden virtual row category' and over 'refuse the import'. Raised P3 -\u003e P1. Also in scope: the Confirm step truncates the collision warning to one line, so the user cannot see which rows collided -- the step must show them. Open detail the implementer must surface, not guess: option (b) adds a REGULAR category, and the reported import already had 12 (MAX_CATEGORIES), so (b) cannot be offered at the cap as described.","created_at":"2026-09-25T01:10:07Z"},{"id":"5c2662ac-64d5-58fa-8a75-4200c9c180a7","issue_id":"improvise-cgqt","author":"Edward Langley","text":"USER REFINEMENT 2026-09-25, verbatim: 'ask during import and offer to generate a disambiguating category.' Option (b) is therefore a generated category whose job is only to tell colliding rows apart (preferred: occurrence index within each collision group, items 1..n in source order), with a user-editable suggested name.","created_at":"2026-09-25T01:10:17Z"},{"id":"98b45303-397e-559a-a284-cb51ff3ff6b3","issue_id":"improvise-cgqt","author":"Edward Langley","text":"USER, 2026-09-25, verbatim: 'a message like this should wrap not truncate so I can read the whole thing.' In scope here for every import-wizard warning; other surfaces to be filed separately.","created_at":"2026-09-25T01:10:48Z"}],"dependency_count":0,"dependent_count":0,"comment_count":3} {"_type":"issue","id":"improvise-1wft","title":"Browser boot failures have no diagnosis channel, so five different causes all read as silence","description":"Five open beads, filed independently by three different reviewers, are one\nfailure mode: the browser's boot can fail for several reasons and the page\neither says nothing specific or nothing at all.\n\n improvise-i0v9 OPFS handle held by another context (or a prior session)\n improvise-86nw start() rejects for a non-OPFS reason; onFrame then drops\n every frame while `server` is null\n improvise-9cma invalid ?doc= -- generic watchdog text only\n improvise-iobc bad or missing ?seed= -- silent empty document, CORS/404\n visible only in the console\n improvise-vep8 the watchdog arms AFTER `await transport.ready`, so a\n connection hang never arms it at all\n\nTHE SHARED ROOT: there is no channel carrying a boot failure's REASON from the\nworker to the page. gotcha 46 already records the mechanism -- a worker that\nrejects in start() fires NO `error` on the main thread, because worker.js\ncatches its own -- so the only survivor is a generic first-frame watchdog, and\nimprovise-vep8 shows that watchdog does not even arm in one case.\n\nThe result is the worst shape a failure can take, measured on i0v9: `#status`\nreads \"in-page worker - \u003cdoc\u003e\" at 13ms, which looks like SUCCESS, and 8 seconds\nlater is replaced by \"this browser may not allow it\" -- blaming the browser for\na condition the code knows precisely, since it is holding the exact\nNoModificationAllowedError naming the exact document.\n\nWHAT THIS TICKET IS: the diagnosis channel, not the five triggers. The worker\nmust be able to report a structured boot failure across postMessage, and the\npage must render it instead of a timeout. Each of the five then becomes a\nmatter of producing the right reason rather than of plumbing.\n\nTwo things to decide while building it, both raised by the triggers above:\n - a boot failure is a STATE, not a rejected promise (i0v9 needs the page to\n open and paint read-only, which means boot must succeed without a handle)\n - the watchdog stays, but as a LAST resort that is armed before anything can\n hang (vep8), and is disarmed rather than pre-empted by a precise failure\n (gotcha 48 already states that rule and PR #21 implemented half of it)\n\nNot a new investigation: every one of the five is already demonstrated. This\nexists so the five are not fixed five different ways.\n","status":"closed","priority":1,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-21T21:26:34Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:58:54Z","closed_at":"2026-09-21T21:58:54Z","close_reason":"Fixed in the 2026-09-21 wave, integrated into main at 1574 passed / 0 failed / 7 ignored. Playwright 42 passed / 0 failed / 4 skipped (WebKit, no OPFS) -- up from 37, so the count MOVED. All gates green.\n\nTHE CHANNEL: a protocol frame crosses the port as a STRING, a shell notice as an OBJECT. Total discriminator, zero protocol change, since ServerFrame JSON is a string by construction of on_message.\n\nA boot failure is now a STATE, not a rejected promise: a document another context holds the handle to opens READ-ONLY over vfs::ReadOnly rather than dying. Only BootFailed is fatal. The watchdog is armed BEFORE transport.ready and every notice routes through shell.fail, so gotcha 48's disarm half holds.\n\nMoving the watchdog forced its message to change, and that is load-bearing: armed before ready, it can no longer claim 'accepted the connection but sent no document' -- the page does not know that yet. The existing spec asserted the OLD wording and failed, which is the right way round and is how it was found.","labels":["architecture","browser","ux"],"dependency_count":0,"dependent_count":6,"comment_count":0} -{"_type":"issue","id":"improvise-55l5","title":"dispatch swallows interactive() errors silently","description":"## Summary\n\n`dispatch` uses `.ok()?` on `interactive()`, swallowing `Err` (argument parse failures, mode mismatch for `commit-buffer item`, etc.). The user presses a key and nothing happens — no status message. Violates `doctrine(an_upstream_key_is_a_black_hole)` for failures that *are* resolvable as user-visible errors.\n\n## Expected / Actual\n\n- **Expected:** Construction/resolve errors surface as status (or equivalent loud feedback).\n- **Actual:** `.ok()?` turns Err into silent no-op.\n\n## Evidence / verify\n\nBind a key to `commit-buffer item` in Normal mode (not ItemAdd). Press the key — command fails to construct with no status message.\n\n## Doctrine\n\n`doctrine(an_upstream_key_is_a_black_hole)` — silent no-ops that look like black holes when the failure is knowable.\n\n## Location\n\n`crates/improvise-command/src/command/keymap.rs` ~L37\n\n## PR / Attribution\n\nPR #100 F05. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","notes":"FIXED in PR #126 (merge 4625593).\n\nTHE CAUSE: dispatch spelled construction as\nregistry.interactive(name, args, ctx).ok()?, so Option::None carried TWO FACTS --\n\"no binding matched\" and \"a binding matched but would not build\" -- and\nApp::dispatch_key answers None with Dirty::NOTHING and no status. A bound key was\na black hole.\n\nTHE ERROR CLASSES, separated, which is the part worth keeping:\n\n CLASS A -- reaches a user, and NOTHING STATIC CAN SEE IT. \"no cell at cursor\",\n from six registrations that read ctx.cell_key(): clear-cell, yank-cell,\n paste-cell, drill-into-cell, commit-cell-edit, commit-and-advance-right. It is\n STATE-dependent, so no registration-time check applies. The two commit entries\n are the worst case: the user is in Editing, has typed a value, presses Enter,\n and the batch is discarded with the minibuffer still up -- where\n draw_bottom_bar paints INSTEAD of the status line (gotcha 21). This is the\n whole ticket.\n\n CLASS B -- already guarded and unreachable from a shipped keymap. \"Unknown\n command\", require_args, \"Unknown mode\", and \"commit-buffer item requires\n item-add mode\". every_bound_command_name_is_registered refuses all of these at\n test time, checking BOTH halves of the old expression precisely because\n improvise-s13 made a typo'd mode name compile into a dead key (gotcha 15).\n\n SO THE REPRO THIS TICKET ITSELF PROPOSED IS THE ONE CASE THAT CANNOT HAPPEN.\n\nTHE FIX: None means \"no binding matched\" and nothing else; a construction failure\nbecomes Some(vec![set_status(\"\u003cname\u003e: \u003cerr\u003e\")]). Class B surfaces too, and that is\ndeliberate -- a list of blessed errors would be a second description of which\nfailures matter, free to disagree with the first.\n\nTHE TEST PAIR IS THE INTERESTING PART, because one test alone cannot separate the\ntwo facts the Option used to carry:\n - rival \"return Some, swallow the error\": fails\n a_bound_key_whose_command_cannot_be_built_says_so; the control passes.\n - rival \"report a failed LOOKUP as a status too\": fails\n an_unbound_key_stays_silent; the other passes. Without that control, the fix\n would write a status for every unmapped character in every mode.\n\nNOT VERIFIED: whether x/y/p on an empty grid is reachable in a SHIPPED document.\nThe fixture constructs the state (an item-less category on Axis::Row) rather than\nreaching it by keystrokes. Real for a user mid-add-cat; not driven.\n\nFOLLOW-UP NOT FILED: a \"no cell at cursor\" refusal from commit-cell-edit still\nleaves the user in Editing with the minibuffer covering the status line. The\nmessage is now written; whether it is DRAWN in that mode is gotcha 21's rendering\ntrap and a separate question.","status":"closed","priority":1,"issue_type":"bug","owner":"box@local","created_at":"2026-09-21T20:48:19Z","created_by":"external-grok-bot","updated_at":"2026-09-22T07:40:42Z","closed_at":"2026-09-22T07:40:42Z","close_reason":"Closed","external_ref":"gh-pr-100-F05","labels":["adversarial-review","pr-100"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-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","assignee":"external-grok-bot","owner":"box@local","created_at":"2026-09-21T20:48:19Z","created_by":"external-grok-bot","updated_at":"2026-09-25T18:46:39Z","started_at":"2026-09-25T18:42:59Z","closed_at":"2026-09-25T18:46:39Z","close_reason":"Already fixed on main in PR #126 (merge ~907a3aa4 / work_done in kb.pl). Cloud agent bc-105af6b7 confirmed keymap tests a_bound_key_whose_command_cannot_be_built_says_so and an_unbound_key_stays_silent pass; effects_of uses refuse(e) instead of .ok()?.","external_ref":"gh-pr-100-F05","labels":["adversarial-review","pr-100"],"comments":[{"id":"987fd4ff-af60-552e-8301-d3de386220f6","issue_id":"improvise-55l5","author":"external-grok-bot","text":"Claimed for E2E fix via cloud agent PR. Prefer Dolt sync.","created_at":"2026-09-25T18:43:00Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} {"_type":"issue","id":"improvise-86nw","title":"Worker startup failure leaves page in silent dead state","description":"## Summary\n\nIf worker `started` rejects (e.g. WASM `init()`/`start()` throws for a non-OPFS reason), `.catch()` only logs. The `onFrame` listener stays installed and calls `handle()` while `server` is null. Frames accumulate in the error log with no UI indication beyond the eventual watchdog; if the watchdog never arms (worker never posts), the page sits dead.\n\n## Expected / Actual\n\n- **Expected:** Startup failure surfaces a loud, specific status (and stops accepting frames / arms watchdog).\n- **Actual:** Console-only catch; silent dead UI until generic watchdog (if any).\n\n## Distinct from improvise-16af\n\nimprovise-16af tracks intermittent blank grid from OPFS `SyncAccessHandle` / `NotAllowedError` conflicts. This bead is the general **startup-without-OPFS-conflict** path: WASM init/start rejection leaves `server === null` with only a logged catch. Related symptom, different root.\n\n## Location\n\n`web/worker.js` ~L43-60\n\n## PR / Attribution\n\nPR #100 F03. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","status":"closed","priority":1,"issue_type":"bug","owner":"box@local","created_at":"2026-09-21T20:48:18Z","created_by":"external-grok-bot","updated_at":"2026-09-21T21:58:55Z","closed_at":"2026-09-21T21:58:55Z","close_reason":"Fixed in the 2026-09-21 wave, integrated into main at 1574 passed / 0 failed / 7 ignored. Playwright 42 passed / 0 failed / 4 skipped (WebKit, no OPFS) -- up from 37, so the count MOVED. All gates green.\n\nstart() rejecting for any reason posts BootFailed; the page renders it and disarms the watchdog.","external_ref":"gh-pr-100-F03","labels":["adversarial-review","pr-100"],"dependencies":[{"issue_id":"improvise-86nw","depends_on_id":"improvise-16af","type":"relates-to","created_at":"2026-09-21T20:48:19Z","created_by":"external-grok-bot","metadata":"{}"},{"issue_id":"improvise-86nw","depends_on_id":"improvise-1wft","type":"blocks","created_at":"2026-09-21T21:31:50Z","created_by":"Edward Langley","metadata":"{}"}],"comments":[{"id":"91dcae3c-8f35-56e5-b794-18191771873a","issue_id":"improvise-86nw","author":"external-grok-bot","text":"Related to improvise-16af (OPFS handle conflict blank grid). This bead covers non-OPFS WASM startup rejection only. — PR #100","created_at":"2026-09-21T20:48:19Z"}],"dependency_count":1,"dependent_count":0,"comment_count":1} {"_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-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","assignee":"external-grok-bot","owner":"box@local","created_at":"2026-09-21T20:48:17Z","created_by":"external-grok-bot","updated_at":"2026-09-25T18:43:32Z","started_at":"2026-09-25T16:32:09Z","closed_at":"2026-09-25T18:43:32Z","close_reason":"Already fixed on main in PR #107 (commit 5a0ba067) as improvise-jc95; cloud agent bc-8573830e confirmed tests pass (a_nonfinite_numeric_token_stays_text_rather_than_becoming_zero). Closed as duplicate/done.","external_ref":"gh-pr-100-F01","labels":["adversarial-review","pr-100"],"comments":[{"id":"f3e3330d-2296-5076-aa08-e12bdb1949e1","issue_id":"improvise-eiv2","author":"external-grok-bot","text":"Claimed for E2E fix. Dolt realigned via bootstrap+JSONL import+dolt push. Cloud agent bc-8573830e fixing on PR.","created_at":"2026-09-25T16:33:01Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} {"_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.\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} +{"_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.","dependencies":[{"issue_id":"improvise-16af","depends_on_id":"improvise-86nw","type":"relates-to","created_at":"2026-09-21T20:48:19Z","created_by":"external-grok-bot","metadata":"{}"}],"comments":[{"id":"3e79ba60-e729-5d7c-b73d-130d63f747aa","issue_id":"improvise-16af","author":"external-grok-bot","text":"PR #100 F03 filed as distinct startup-without-OPFS-conflict case (WASM init/start reject → server null, catch-only). See related bead improvise-86nw. Overlap is symptom (silent dead UI), not OPFS SyncAccessHandle root. — external Grok Bot from cloud adversarial review PR #100 for Ed Langley","created_at":"2026-09-21T20:48:19Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} {"_type":"issue","id":"improvise-2cw9","title":"Import silently overwrites repeated coordinate tuples with the last row","description":"Report-only bug hunt at HEAD 70025e2, 2026-09-21. No implementation changes.\n\nLocation: crates/improvise-io/src/import/wizard.rs:230-236\n\nCSV Region,Revenue / East,10 / East,20 imports successfully with one cell East/Revenue=20 and no warnings. A sum over the imported data is 20 instead of 30; the first row disappeared. Each input record directly calls set_cell at the same key. The desired collision policy needs an explicit choice: preserve records, aggregate appropriately, or reject/warn; silent last-row-wins is not acceptable. The reproducer deliberately accepts a warning so it does not impose sum as the only valid policy. Duplicate checks: import, collision, coordinates, overwrite; improvise-5j9 covers missing category values, not complete repeated rows.\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 repeated_import_coordinates_not_silently_overwritten() {\n use improvise_io::import::wizard::ImportPipeline;\n let (records,_)=parse_csv_bytes_with_warnings(b\"Region,Revenue\\nEast,10\\nEast,20\\n\").unwrap();\n let (wb,warnings)=ImportPipeline::new(serde_json::Value::Array(records)).build_model_with_warnings().unwrap();\n let total:f64=wb.model.data.iter_cells().filter_map(|(_,v)|v.as_f64()).sum();\n assert!(total==30.0 || !warnings.is_empty(),\"total {total}, no warning {warnings:?}\");\n}\n```\nRun cargo test repeated_import_coordinates_not_silently_overwritten -- --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":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:31:28Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:58:04Z","closed_at":"2026-09-21T21:58:04Z","close_reason":"Fixed in the 2026-09-21 wave, integrated into main at 1574 passed / 0 failed / 7 ignored, with fmt, clippy, --check-evidence and the 8-crate wasm gate green. Every guard was fault-injected and watched failing before being believed.\n\nbuild_model_with_warnings accumulates into a Vec\u003cPendingCell\u003e with a HashMap\u003cCellKey, usize\u003e beside it that ONLY deduplicates, then writes in first-seen order. Duplicate coordinates are SUMMED and reported.\n\nPOLICY, stated because the ticket left it open: the model cannot preserve both records -- a cell is named by its coordinate tuple and there is no second address -- so the lossless options are aggregating or inventing a row-identity category, and the latter reshapes every imported document. Summing is what a pivot table does and is measure_agg's default. It is WRONG for a measure the user later sets to Avg, and the warning is what makes that the user's problem rather than an invisible one.\n\nNote the fix CREATES gotcha 18's hazard: set_cell writes into an IndexMap persistence depends on, so a hash-ordered write would give the same CSV a different .improv per process. Guarded by a five-iteration test.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-8l0c","title":"Formula panel truncation panics on a valid Unicode formula","description":"Report-only bug hunt at HEAD 70025e2, 2026-09-21. No implementation changes.\n\nLocation: src/ui/formula_panel.rs:54-55\n\nRendering |é| = 1 in a formula panel with outer width 7 panics: end byte index 4 is not a char boundary; it is inside é (bytes 3..5). Formula identifiers support pipe-quoted Unicode names. The panel truncates bytes instead of display columns, so ordinary resizing can crash the TUI. Use a shared Unicode/display-width safe truncator. Duplicate checks: formula panel, UTF, unicode. Test drove the actual Panel widget, not a copied substring expression.\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 formula_panel_unicode_does_not_panic() {\n use improvise::{protocol::FormulaCache,ui::{app::AppMode,formula_panel::FormulaContent,panel::{Panel,PanelContent}}};\n use ratatui::{buffer::Buffer,layout::Rect,widgets::Widget};\n let cache=FormulaCache{formulas:vec![\"|é| = 1\".into()]};\n let area=Rect::new(0,0,7,5);let mut buffer=Buffer::empty(area);\n Panel::new(FormulaContent::new(\u0026cache,\u0026AppMode::Normal),\u0026AppMode::Normal,0).render(area,\u0026mut buffer);\n}\n```\nRun cargo test formula_panel_unicode_does_not_panic -- --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","notes":"FIXED in PR #124 (merge e8f66e6). Reachability is MUCH WIDER than this ticket said, and that is the finding worth keeping.\n\nTHE CRASH NEVER NEEDED UNICODE. draw.rs:297 lays the side panel out as\n[Constraint::Min(40), Constraint::Length(side_w)] -- verified by the orchestrator --\nand the solver starves the panel first: terminal \u003c=40 gives the panel width ZERO,\n50 gives 10, 72+ gives 32. Frame::render_widget has no empty-area guard. So ANY\nTERMINAL AT OR BELOW ~42 COLUMNS WITH A PANEL OPEN PANICKED THE TUI ON AN ORDINARY\nASCII FORMULA, via a subtract-with-overflow on the unchecked inner.width. The\nUnicode title in this ticket is the NARROWER case; resizing was the common one.\n\nTHE FIX: grid.rs's existing display-width truncate moved to a new src/ui/text.rs\nand shared; the formula panel calls it. Not a third truncator -- import_wizard_ui's\nbyte-measured one stays deliberately, as context/code/tui.md argues.\nprefix_by_width replaced a chars().take(max_width) tail that over-ran by a cell\nfor a wide char at width 1.\n\nTWO RIVAL FIXES INJECTED AND WATCHED FAIL, which is what makes the tests evidence:\n - a char-count truncator (the obvious 'unicode-safe' repair) passes BOTH panic\n tests and fails a_result_never_exceeds_the_width_it_was_given on '日本語 = 1'.\n A char count is not a cell count.\n - repairing only the subtraction (saturating_sub) still panics at the char\n boundary, and again at width 1 where the bound saturates to 0 for a string\n needing one char. A TEST WRITTEN ONLY AT WIDTH 0 WOULD HAVE ACCEPTED IT.\n\nSIBLINGS FOUND BY SEARCHING FOR THE CLASS, not the symptom: category_panel.rs and\nview_panel.rs both place a second string at inner.x + name.len() -- a BYTE COUNT\nUSED AS A COLUMN. No panic, because Buffer::set_string clips, so it shows only as\na gap widening with each non-ASCII character. Both fixed, each test drawing the\nrow twice (Cafe and Café) and requiring them to differ only by that character.\n\nFILED improvise-n8vh: neither of those panels truncates AT ALL, so a long name\noverwrites the panel border. Different bug -- no truncation rather than wrong\ntruncation.","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:31:27Z","created_by":"Edward Langley","updated_at":"2026-09-22T06:39:19Z","started_at":"2026-09-22T06:31:23Z","closed_at":"2026-09-22T06:39:19Z","close_reason":"Closed","comments":[{"id":"661a4de2-dee0-52c2-ac12-e095e1ee414c","issue_id":"improvise-8l0c","author":"Edward Langley","text":"Same truncation line also panics on ASCII when inner width is zero. Demonstrated by rendering FormulaCache { formulas: vec![\"A = 1\".into()] } through Panel into Rect::new(0,0,2,5): formula_panel.rs:55 subtracts 1 from width 0. In release the wrapped slice bound also panics. Include widths 0/1/2 in the shared safe-truncation regression; no separate bead filed.","created_at":"2026-09-21T20:35:50Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} {"_type":"issue","id":"improvise-jc95","title":"CSV nonfinite and overflowing numeric tokens are silently replaced with zero","description":"Report-only bug hunt at HEAD 70025e2, 2026-09-21. No implementation changes.\n\nLocation: crates/improvise-io/src/import/csv_parser.rs:133-136\n\nA CSV Value column containing NaN imports as JSON number 0 with no warning. inf, -inf and 1e999 take the same parse::\u003cf64\u003e / from_f64(...).unwrap_or(Number::from(0)) branch. A missing/nonrepresentable measurement becomes an invented real zero. Refuse with a located error or preserve/report the token; never fabricate 0. Duplicate checks: CSV, NaN, non-finite; existing gp-tree numeric tickets affect a separate pipeline.\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 csv_nonfinite_is_not_zero() {\n for token in [\"NaN\",\"inf\",\"-inf\",\"1e999\"] {\n let (r,_)=parse_csv_bytes_with_warnings(format!(\"Value\\n{token}\\n\").as_bytes()).unwrap();\n assert_ne!(r[0][\"Value\"].as_f64(),Some(0.0),\"{token} became zero\");\n }\n}\n```\nRun cargo test csv_nonfinite_is_not_zero. 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","notes":"Raised P2 -\u003e P1 on closing improvise-eiv2 as a duplicate: an external Grok Bot review found the same defect and rated it P1, and silent numeric corruption on import deserves that. Mechanism from that report, worth keeping: serde_json::Number::from_f64 returns None for NaN and Infinity, and parse_csv_field's .unwrap_or(Number::from(0)) turns that None into a zero. Reproduction exists in PR #101 as csv_nonfinite_is_not_zero (ignored until fixed).","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:27:43Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:58:01Z","closed_at":"2026-09-21T21:58:01Z","close_reason":"Fixed in the 2026-09-21 wave, integrated into main at 1574 passed / 0 failed / 7 ignored, with fmt, clippy, --check-evidence and the 8-crate wasm gate green. Every guard was fault-injected and watched failing before being believed.\n\nparse_csv_field falls through to the string arm when Number::from_f64 returns None, instead of unwrap_or(Number::from(0)). NaN/inf/-inf/1e999 keep their own text. Reproduction csv_nonfinite_is_not_zero un-ignored. Downstream consequence worth knowing: a column carrying NaN is no longer all-numeric, so the analyzer proposes it as Category/Label rather than Measure -- that is the honest answer and it is VISIBLE in the wizard, where a fabricated zero was not.","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -70,7 +76,7 @@ {"_type":"issue","id":"improvise-gv9r","title":"[bug] A wire model command never marks the document dirty","description":"Found by improvise-9x6 (2026-09-20) the hard way: the worker first persist trigger was `if dirty`, and it SILENTLY NEVER FIRED.\n\nreduce::model_effects maps ModelCommand::SetCell to effect::SetCell, which writes the cell and does NOT touch model_state.dirty. Dirtiness is raised by a SEPARATE MarkDirty effect that only the command/key path emits -- so on every transport, a client edit leaves the authoritative document reporting CLEAN.\n\nTwo consequences, and the second is the one that will bite someone:\n * \"Save if dirty\" is a trigger that never fires. Any host built on it loses every client edit, silently.\n * Anything else that reads model_state.dirty about a client-edited document gets the wrong answer -- an unsaved-changes prompt, an autosave, a UI marker.\n\nWHY IT HAPPENED: MarkDirty being a separate effect is deliberate and reasonable on the key path, where a command composes the pair. The derivation that turned effects into ModelCommands (improvise-kmgs) took the effects ONE AT A TIME, so the pairing was lost -- the wire has SetCell without its MarkDirty companion. That is the interesting shape: a derivation that is correct per element and wrong for a set.\n\nCANDIDATE FIXES, and this wants deciding alongside improvise-9x6 sibling ticket about Save: make model_effects emit the MarkDirty companion where the key path does; or fold dirtiness into the effects that change the document so it cannot be forgotten (the unrepresentable-not-validated answer, and probably right); or decide that dirty is a host-local concept the wire has no opinion on, and then say so where someone will read it.","status":"closed","priority":1,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T02:10:13Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:29:47Z","started_at":"2026-09-20T05:32:28Z","closed_at":"2026-09-20T07:29:47Z","close_reason":"Closed by PR #26 (WU-C), with the unrepresentable answer the ticket leaned toward.\n\nmodel_state.dirty is now raised by App::apply_effects for every EffectKind::Model effect, off the SAME classification the Dirty and the two lower basis tiers already read -- one classification, three readers. MarkDirty, mark_dirty() and ALL 17 CALL SITES ARE DELETED, so no path can compose a document change without it.\n\nVERIFIED RATHER THAN ASSERTED: the reviewer diffed all 19 MarkDirty call sites on main against the branch and traced each one's companion effect -- ClearCell, SetCell, TransposeAxes, AddItem, AddCategory, RemoveCategory, RemoveItem, RemoveFormula, CreateView/SwitchView, DeleteView, HideItem, ToggleGroup, SetNumberFormat, the tile axis_effect -- every one is EffectKind::Model, so apply_effects covers it. The two conditional sites reached no mark_dirty before either. Nothing was lost.\n\nTHE WORKER TRIGGER BECAME AN OBSERVATION: touches_document enumerated command variants BECAUSE dirty was unusable. With dirty trustworthy the trigger is the document itself -- the same choice cursor_epoch makes over CycleAxis and document_generation makes over LoadModel. Strictly tighter, and it retired the \"was anything rejected\" guard beside it.\n\nTHE ROOT CAUSE, which is the generalisable part and is now doctrine(a_derivation_correct_per_element_can_be_wrong_for_a_set): improvise-kmgs derived ModelCommand from the effects ONE AT A TIME and was right about every element -- but the key path never emits SetCell alone, it emits the PAIR with MarkDirty. So the wire got the mutation without its companion and no client edit ever marked the document dirty. When deriving a vocabulary from a set of primitives, ask what the existing callers COMPOSE, not only what each primitive is.\n\nTHREE BEHAVIOUR CHANGES CAME WITH IT, all recorded in the PR body:\n 1. Switching views, paging and stepping view history now mark the document dirty, so :q refuses after them. The user ruled this CORRECT -- Initial View and page selections are persisted, so discarding them silently was the bug, and :q! remains.\n 2. LoadModel leaves the document dirty, because it does not move file_path -- so the in-memory document really is a different one from the file the path names. A test pins the REASON, not just the flag.\n 3. A drill exited with no edits, and view-back, also mark it dirty -- a WRONG answer to no divergence, unlike (2). Filed as improvise-748f.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-368h","title":"[bug] No client on any transport can ask its host to save","description":"Found by improvise-9x6 (2026-09-20) while building the worker persist trigger, and it affects improvise-ws-server too -- which has shipped without anyone noticing.\n\neffect::Save is EffectKind::ViewOnly, and CORRECTLY so: it writes to THIS HOST file path and clears THIS HOST dirty flag, mutating no document state. But the wire vocabulary is DERIVED from the model effects (gotcha 38), so the derivation cannot reach it. There is no Command::Save and no ModelCommand::Save.\n\nConsequence: a connected client can edit the document and can never cause it to be written. On the socket deployment that means a ws-server session edits in memory and the file on disk never changes unless a human is at a TUI that is not there.\n\nTHIS IS A PROTOCOL DECISION, NOT A PATCH. Two coherent answers:\n * ADD Command::Save. But Save is genuinely ViewOnly and genuinely per-host -- a client asking a host to write to a path the client cannot see is a different thing from a client mutating the document, and it needs an answer for \"which path\" and \"what if there is none\" (improvise-lae already exists for the no-path case).\n * SAY EXPLICITLY THAT HOSTS OWN PERSISTENCE, and make each host trigger on its own terms. That is what improvise-9x6 did for the worker, and in a browser it is arguably RIGHT rather than a workaround: there is no file the user owns and no Ctrl-S semantics -- the document IS the OPFS entry. But then ws-server needs its own answer, and today it has none.\n\nDecide which. The gap is that neither was chosen, so the socket server silently does nothing.","status":"closed","priority":1,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T02:10:11Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:29:47Z","started_at":"2026-09-20T05:32:28Z","closed_at":"2026-09-20T07:29:47Z","close_reason":"Closed by PR #26 (WU-C). The user ruled \"add Command::Save to the wire\"; what shipped is ClientFrame::Save -- still on the wire, one layer out -- and the user accepted the deviation.\n\nWHY IT MOVED: shipped as Command::Save first, and review found it put a RUNTIME-FALLIBLE operation inside a batch documented as all-or-nothing -- [Save, SetCell] reported a failed write and then applied the edit anyway. The argument for the frame: a Command is something the shared state FOLDS, and saving mutates no shared state and no client's slice, so reduce_view had nothing to fold and Checked had no basis to answer for -- both arms were shims. As a frame it cannot be composed with a mutation BY CONSTRUCTION.\n\nTHE TWO QUESTIONS THE TICKET SAID IT WOULD FORCE, answered:\n * WHICH PATH: none. It is a REQUEST, not an instruction. A client cannot see the host's storage, so the host supplies the path.\n * WHAT IF THERE IS NONE: Rejection::NoDocumentPath, decided before anything runs, plus Rejection::SaveFailed for a write that fails. Reuses the existing refusal channel (gotcha 39, addressed to the asker alone) rather than inventing a second one. App::last_batch_aborted is the one new observable and its sole consumer is Host::save, where the batch is literally [Save].\n\nTHE GAP THE TICKET NAMED IS HALF CLOSED, and the remaining half is filed: the feature works end to end in Rust and is UNREACHABLE FROM A BROWSER -- keys.rs still lists \"save\" (Ctrl-S) as upstream, and an upstream key is a black hole (doctrine(an_upstream_key_is_a_black_hole)). One line to resolve it locally, but it owes an answer on a status-line divergence (success is silent on the wire while the host sets \"Saved to ...\"). Not wired here because keys.rs was another unit's ground.\n\nThe socket server, which the ticket noted had shipped with this hole, is covered by the same frame.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-ondj","title":"[bug] reduce_full/apply_command apply a Command::Model without the basis check","description":"Found by the code review of ADR 001 layer 1 (2026-09-19). CONFIRMED, not live today, and it should be closed BEFORE the browser worker lands.\n\nui/reduce.rs is documented as \"the one place a command is applied to the authoritative state\" and \"Every deployment mode -- the native TUI, a websocket server, a browser worker -- reaches the model through the functions here\". Both pub fn apply_command and pub fn reduce_full accept a \u0026Command including Command::Model, and:\n\n Command::Model { cmd, .. } =\u003e { // the basis is DISCARDED by `..`\n let effects = model_effects(app, cmd);\n app.apply_effects(effects)\n }\n\nThe check lives ONLY in Sessions::apply_model.\n\nWHY THIS IS THE INTERESTING SHAPE: making Command::Model a struct variant made the basis impossible to OMIT FROM THE MESSAGE. It did nothing to make it impossible to IGNORE ON ARRIVAL -- and `..` in a pattern is exactly the syntax for ignoring it, with no compile error. So the design got half of what a struct variant is usually reached for.\n\nNothing in-tree routes a client command through reduce_full (only tests), so nothing is broken now. But improvise-9x6 (the browser worker) is precisely the host that will reach for the function the module doc points it at, and it would apply client-supplied model commands unchecked.\n\nREMEDIES, pick deliberately:\n * Have apply_command take a ModelCommand from a private caller and expose only Sessions::apply for a wire Command -- makes the door unreachable rather than guarded.\n * Or have reduce_full return a Rejected-shaped result for Command::Model.\n * At absolute minimum, a compile-visible note AND a test that names the door, so the next person meets it.\n\nThe first is the one that matches this repo: invariants are made unrepresentable, not validated (doctrine(an_invariant_is_made_unrepresentable_not_validated)).","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T01:05:13Z","created_by":"Edward Langley","updated_at":"2026-09-20T01:54:29Z","closed_at":"2026-09-20T01:54:29Z","close_reason":"Fixed as option 1 -- make the door unreachable -- but as a TYPE rather than as visibility.\n\napply_command and reduce_full now take Checked\u003c'a\u003e, a private-field newtype over a \u0026Command whose ONLY constructor is Checked::against(\u0026App, \u0026Command) -\u003e Result\u003cSelf, Rejection\u003e, which is also the only caller of stale_basis. So there is no longer a spelling of 'apply this batch and ignore what it claims': the that discarded the basis is now a Rejection the caller must handle. Sessions::apply answers ONCE, before the dispatch, and hands the proof to apply_switch/apply_model -- so neither has to remember to ask, and apply_model lost its basis/model parameters entirely.\n\nWHY NOT pub(crate), which is what the ADR literally suggested: it works, but reduce_full then has NO non-test caller anywhere in the workspace, so it becomes a dead_code warning in the non-test build, and the only ways out are deleting a documented host API or cfg(test)-gating it. The newtype keeps both functions public and useful to a future host -- including the browser worker, which now gets a door that WORKS and is safe -- while making the unchecked path unconstructible. It also holds INSIDE the crate, which visibility does not: a second in-crate mutation path cannot be written without the check either.\n\nEVERYTHING THE TICKET SAID MUST SURVIVE, DOES: the check is still a property of the session door and not of every mutation (the key path, model_effects and App::apply_effects are untouched, and a host's own ModelCommand still needs no basis); caught_up(\u0026App, cmd) is unchanged and still not a bypass; the 10 basis_check tests are untouched and all pass.\n\nTHE REGRESSION GUARD IS THE SIGNATURE. The demonstration test (committed first, failed on the old code) NO LONGER COMPILES after the fix, which is the better outcome. What survives asserts that Checked::against returns Err(Rejection::LayoutMoved) and the document is untouched, with a doc-comment saying in prose that the signature is the real guard. Check-the-checker: making against() never refuse fails 8 tests -- the new one plus 7 of the 10 basis_check ones.\n\nNATIVE CLI EXERCISED FOR REAL, not merely compiled: improvise cmd and improvise script both land their changes on disk. They go through the keymap/registry path and never touched reduce_full.\n\nTHE THING THE TICKET DID NOT PREDICT, and it is the reason this defect could sit unnoticed: reduce_full HAS NO PRODUCTION CALLER IN THE ENTIRE WORKSPACE, and apply_command's view-slice arm is unreached outside tests. Every host reaches the model through Sessions::apply or reduce_key. So ui/reduce.rs's 'the signature the three hosts share' is ASPIRATIONAL -- a door pointed at by documentation and used by nobody, which is exactly the shape that hides a defect until the next host arrives. Filed as a successor.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-gd28","title":"Drill in the browser: the view stack is client-local, so what does the server still owe?","description":"From the ADR 002 spine landing (2026-09-19). The RESOLVER exists and is tested -- App::resolve_aggregate, layout-only by necessity since an aggregate has no existence outside the layout that derives it -- and CellId::Aggregate is emitted. What is missing is the command.\n\nModelCommand::Drill was deliberately NOT added. Drilling creates the _Drill view and writes drill_state, which gotchas 30 and 38 rule session-local and therefore unaddressable by a NULLARY Command -- exactly the reason ApplyAndClearDrill was removed from the wire in improvise-3x3u.\n\nSo a wire Drill needs the ADDRESSED form: the variant carries a SessionId and the staging map moves off the single App onto the session. That is ADR 001 layer 2 (context/adr/001-concurrent-edits.md), so this is blocked on it rather than being independent work.\n\nThe seam is ready: emit, resolve and the type-level guarantee (Drill takes an Aggregate and nothing else) are all in place and tested.","acceptance_criteria":"Drilling works over the wire. It needs the ADDRESSED command form — the variant carries a SessionId and the staging map moves off the single App onto the session — because drilling writes drill_state, which gotchas 30 and 38 rule session-local and therefore unaddressable by a nullary Command. That is ADR 001 layer 2, so this is blocked on improvise-7b8v rather than independent. The seam is ready: App::resolve_aggregate, CellId::Aggregate emission and the type-level guarantee that Drill takes an Aggregate and nothing else all exist and are tested.","notes":"USER RULING 2026-09-21, PROMOTED P3 -\u003e P1: 'drill / undrill is a core feature that has to be present in the web ui'.\n\nThis bead is the mechanism that ruling needs, so it stops being an architectural nicety and becomes the blocker for a stated product requirement.\n\nWHAT IS DECLINED TODAY, all in improvise-protocol/src/resolve.rs's declined() list, which is the one home for that wording:\n drill-into-cell ('\u003e') -\u003e 'drill'\n view-back ('\u003c') -\u003e 'stepping back to the previous view', and the\n comment there says explicitly it is NOT 'not built'\n -- it COMMITS A DRILL FIRST, so composing it in the\n browser would silently commit pending edits\n edit-or-drill ('i' on an aggregate) -\u003e a SetStatus refusal (improvise-s8h7)\n\nWHY IT IS NOT MERELY UNBUILT, which is what makes this bead the real work: drill\nstate is SESSION-LOCAL RESIDUE (gotcha 30). DrillState holds an Rc, so it fails\nthe portability half of ViewState membership, and drill_state.pending_edits are\nuncommitted writes belonging to whoever staged them -- which is exactly why\nApplyAndClearDrill was REMOVED from ModelCommand (gotcha 38, improvise-tom1): a\nCommand::Model is routed with no SessionId, so an unaddressed\nApplyAndClearDrill lets client B commit client A's uncommitted typing. That was\ndemonstrated against Sessions::apply before removal, not argued.\n\nSo browser drill needs the ADDRESSED command form -- a mutation carrying the\nSessionId it belongs to -- which is ADR 001 layer 2 and is this bead.\n\nGotcha 1 is the other half anyone implementing this must read: drill pending\nedits are keyed by (row, RecordCol), and gotcha 18 records that a drill snapshot\nfrozen in hash order made the same drill address DIFFERENT RECORDS in a second\nprocess. Any wire form has to carry an order that is stable across processes.\n\nNote improvise-s8h7 (P1) is the user-visible symptom on the 'i' key and\nimprovise-l3hm (P3) is its sibling on CellId::Create. Both should close with or\nafter this, not before -- a louder refusal is not what the ruling asks for.\nUSER RULING 2026-09-21: \"The state drill manipulates (the view stack) should be local to the client.\"\n\nThis REDIRECTS the ticket. It had been scoped to the addressed command form (ADR 001 layer 2) -- a mutation carrying a SessionId. The ruling puts the view stack in the class of state this repo has ALREADY taken off the wire, which is a much cheaper shape.\n\nTHE PRECEDENT IS EXACT. Gotcha 38: ViewBack and ViewForward were removed from ModelCommand for precisely this reason -- \"their operand is ViewState::view_back_stack, session-local residue belonging to whoever navigated.\" And gotcha 29 records the earlier ruling that the model is shared and the UI is not, with ViewState::active_view already AUTHORITATIVE PER CLIENT; two clients may sit on different views of one document.\n\nWHAT IS SHARED AND WHAT IS NOT, on this reading:\n - the view stack, and which _Drill view this client sees: CLIENT-LOCAL (the ruling)\n - drill_state.pending_edits: already session-local residue (gotcha 30 -- holds an Rc, uncommitted writes belonging to whoever staged them)\n - the COMMIT: a genuine model change -- but as ordinary SetCell/ClearCell commands it is already on the wire, already basis-checked, and needs NO addressed form\n\nIf that holds, the SessionId-carrying mutation leaves this ticket entirely and ApplyAndClearDrill never returns to the wire -- which is what gotcha 38 removed it for.\n\nTHE OPEN QUESTION, and it may be the real work: a client has no Model. Entering a drill needs the record set for a cell, which is GridLayout::drill_records(r, c) and is server-side. So either the server PROJECTS the drilled record set -- the \"enrich the projection rather than move resolution back\" answer gotcha 47 already prescribes -- or a client cannot compose a drill locally at all. Settle that first; it decides whether this is a projection ticket or something larger.\n\nTRAPS THAT SURVIVE EITHER SHAPE:\n - gotcha 1: pending edits are keyed by (row, RecordCol), and _Dim carries the column INDEX, not a label, because a category and a measure may share a name.\n - gotcha 18: a drill snapshot frozen in HASH ORDER made the same drill address DIFFERENT RECORDS in a second process -- silent misdirected writes. Whatever the client holds needs an order stable across processes.\n\nNote the view-back decline in resolve.rs says explicitly it is NOT \"not built\": it COMMITS A DRILL FIRST, so composing it in a browser would silently commit pending edits. Under the client-local shape that commit is the client's own to sequence, which removes the reason for the decline rather than working around it.\nUSER RULING 2026-09-21 (second): \"Drilling should be an ephemeral view, not something that gets persisted.\"\n\nHALF OF THIS ALREADY HOLDS, and the other half is the work. Verified at 523d571:\n\n - NOT PERSISTED: already true. VIRTUAL_VIEW_NAMES is [\"_Records\", \"_Drill\"] (core/src/view/types.rs:14). Both are never written and are dropped on load if an older file carries them; saving from drill mode writes the first real view as Initial View.\n - BUT IT IS SHARED: DrillIntoCell (command/cmd/grid.rs:348) builds the _Drill view and puts it in the WORKBOOK's views map, which is shared document state. So today drill is ephemeral with respect to the FILE and not with respect to other CLIENTS.\n\nTAKEN WITH THE FIRST RULING -- that the view stack is client-local -- the consequence is that _Drill should not be in Workbook::views at all. It is a client-side construct: built by the client that drilled, seen by nobody else, and discarded when that client leaves it. \"Ephemeral\" and \"client-local\" are the same statement viewed from the file and from the session.\n\nWHAT THAT BUYS, and why it is cheaper than the addressed command form this bead originally proposed:\n - no SessionId-carrying mutation is needed for the view itself\n - ApplyAndClearDrill never returns to the wire, which is what gotcha 38 removed it for\n - the view-back decline stops needing a workaround: its comment says it is not \"not built\" but that it COMMITS A DRILL FIRST, and a client that owns its own stack sequences that itself\n\nWHAT THE SERVER STILL OWES, which is the open question: DrillIntoCell captures a RECORDS SNAPSHOT at drill time (grid.rs:365 onward, \"Capture the records snapshot NOW\"), built from ctx.model.data -- which a client does not have. So either that snapshot is projected, or a client cannot compose a drill. gotcha 47's rule applies: enrich the projection rather than move resolution back.\n\nNote gotcha 10 while you are here: drilling a FORMULA cell strips the _Measure coordinate when it names a formula target, or matching_cells returns nothing instead of the raw records. Whatever composes a drill has to keep that, and a client does not know which measures are formula targets unless told -- another thing the projection may owe.\n\nAnd gotcha 18's warning is sharpened by \"ephemeral\": the snapshot is frozen at drill time and pending_edits are keyed by ROW INDEX into it, so the order must be stable for as long as the drill lives, even though the drill itself is not.\nUSER RULING 2026-09-21, answering the product question PR #115 left open: \"The staging scheme should be identical, because they're solving different problems.\"\n\nTHE QUESTION IT ANSWERS. The agent proposed that a browser could WRITE THROUGH with ordinary SetCell commands instead of staging, on the grounds that those are already basis-checked. The ruling rejects that, and the reason is the important part: BASIS-CHECKING AND STAGING SOLVE DIFFERENT PROBLEMS, so the first does not substitute for the second.\n\n - the basis check is ANTI-STALENESS COORDINATION between clients. It answers \"is your picture of this record current\" (ADR 001, gotcha 43). It is explicitly NOT a security boundary and explicitly not about intent.\n - staging is an EDITING-SESSION abstraction for one user. It answers \"does this person want to keep what they just typed\" -- several edits accumulate and commit atomically on leaving, or are discarded on cancel.\n\nWrite-through would have silently removed the ability to ABANDON a drill. That is a user-facing capability, not an implementation detail, and losing it is not visible in any test that only asks whether an edit lands.\n\nSO: the browser stages, identically to the TUI. DrillState::pending_edits becomes client state -- which is consistent with the other two rulings rather than in tension with them, since the view stack is client-local and drill is an ephemeral view. All three say the same thing: a drill is one person's transient workspace.\n\nTHE CONVERGENCE WORTH NOTING, because it makes the commit cheap: clause 4 IS NOW DELIVERED (improvise-yqpd). ClientFrame::Commands is answered whole and applied whole -- CheckedBatch::against checks the frame before anything runs, and a failure rolls the document, the mirrors and the session slice back. So a staged drill's commit is a BATCH OF SetCells and gets its atomicity from the transaction that already exists, with no new mechanism. Before clause 4 that batch could have half-applied, which is precisely the partial-and-silent failure a staged commit must not have.\n\nTWO THINGS THE COMMIT STILL NEEDS, from the same PR's findings:\n - the category-column arm has NO ModelCommand. ApplyAndClearDrill's RecordCol::Category(_) path is apply_coordinate_rename -- add an item, then move EVERY measure cell in the row (gotcha 1) -- and no wire variant exists for it. Filed separately.\n - gotcha 18's order warning binds the staging, not just the snapshot: pending_edits are keyed by ROW INDEX into a frozen list, and a list frozen in hash order made the same drill address DIFFERENT RECORDS in a second process. Client-side staging inherits that requirement exactly.\nTRAP OPENED BY PR #117 (improvise-cfly), found in orchestrator review 2026-09-22.\n\nThe browser's view-back resolver emits [Command::ViewBack] where the TUI's\nViewNavigate{forward:false} emits [ApplyAndClearDrill, ViewBack]. That omission is\ncorrect TODAY only because a browser cannot stage a drill: drill-into-cell is\ndeclined, and edit_or_drill's CellId::Aggregate arm answers with a sentence.\n\nWHEN THIS TICKET LANDS, THAT STOPS BEING TRUE AND NOTHING FAILS. Stepping back in\na browser would silently discard staged drill edits that the TUI commits -- a\ndata-loss divergence, invisible across the whole suite.\n\nWorse, the guard points the wrong way.\nresolve::tests::the_view_history_keys_answer_in_both_directions asserts the arm\nemits EXACTLY vec![Command::ViewBack], so it PINS the current behaviour rather\nthan guarding the transition. It keeps passing while the bug exists, and a change\nthat FIXED the bug would fail it.\n\nSO THIS TICKET'S ACCEPTANCE GAINS A CLAUSE: making drill reachable in a browser\nmust also answer what stepping back does with staged edits, and must change that\ntest rather than work around it.\n\nRecorded here rather than as its own bead because the addressed-command form this\nticket is about is exactly what makes ApplyAndClearDrill expressible at all\n(gotcha 38 / improvise-3x3u) -- they are the same piece of work, and a separate\nbead would sit unread until someone hit the bug.","status":"closed","priority":1,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T00:18:34Z","created_by":"Edward Langley","updated_at":"2026-09-23T01:37:03Z","closed_at":"2026-09-23T01:37:03Z","close_reason":"Decomposition landed in PR #140 (ViewStep/records_spine/records_steps/drill_steps in improvise-core; cmd/grid.rs keeps only steps_into_active_view). PR #141 landed the diagnosis. THE DEPENDENCY ON improvise-7b8v WAS REMOVED, NOT FORCED PAST: the whole finding is that browser drill is NOT blocked on ADR 001 layer 2 -- that argument is about the COMMIT path (ApplyAndClearDrill is addressed to one session), while ENTERING a drill needs no commit and uses ModelCommands that already name their view unaddressed. The real blocker is AggregateHash being LAYOUT-scoped. Remaining implementation is improvise-obtu; contention is improvise-t219; thin spine coverage is improvise-t0ma. improvise-fdk1 is also not needed.","labels":["area:protocol"],"comments":[{"id":"7fce3874-00de-5f06-a481-e66fb873f24f","issue_id":"improvise-gd28","author":"wave3-webui","text":"BROWSER EVIDENCE, gathered under Chromium rather than read. The user reported\n\"drill also doesn't work in the webui: it was never confirmed working.\" This\nticket is the answer; nothing here asks to change the design. Confirming the\nticket's own claims empirically, plus one asymmetry it does not record.\n\nWHAT WORKS, verified in the running browser and not inferred:\n - project::grid_of emits CellId::Aggregate and the client RECEIVES it. In\n render_state: {\"Aggregate\":\"750ab1f5ffa96d5e00f605b3c8e36aac\"}.\n - render.rs::editable correctly answers false for it (ADR 002), so the page\n offers no editor on an aggregate.\n - resolve.rs reaches the CellId::Aggregate arm of edit_or_drill.\n - The TUI path is intact: 12 drill tests pass across cmd/grid.rs, cmd/mode.rs,\n ui/effect.rs and ui/app.rs. So this is isolated to the client/wire and is\n NOT a general regression.\n\nTHE ONE MISSING LINK, confirmed: reduce.rs contains no `Drill` variant at all —\nneither Command nor ModelCommand. `grep -n Drill crates/improvise-protocol/src/\nreduce.rs` returns nothing. The ticket's \"what is missing is the command\" is\nexactly right, and the seam really is ready.\n\nTHE ASYMMETRY THIS TICKET DOES NOT RECORD, and the reason it reads to a user as\nbroken rather than as refused — the two entry points fail DIFFERENTLY:\n\n - `i` / `a` on an aggregate declines OUT LOUD. edit_or_drill answers the\n Aggregate arm with Command::SetStatus(\"drilling into an aggregate needs the\n host — not in the browser yet\"). The user sees why.\n\n - `\u003e` (drill-into-cell) is SILENT. It is a command name resolve::step does not\n handle, so it falls through to KeyOutcome::Upstream -\u003e ClientFrame::Key -\u003e\n Host::handle, whose Key arm is an explicit `Vec::new()`. Gotcha 47: an\n upstream key is a black hole, not a slow path. Pressing `\u003e` produces no\n status, no mode change, nothing at all.\n\n`\u003e` is the key the help hint advertises for drill (\"\u003e:drill\" in the Normal-mode\nhint line), so the advertised gesture is the silent one and the honest refusal\nis on a key the user was not told to press. Whoever picks this ticket up may\nwant to route drill-into-cell through the same declining arm in the interim, so\nthe gap SAYS something on the key that names it — that is a one-line change and\nis independent of the addressed-command design work. I have not made it: it is\nthis ticket's call, not mine.\n\nA FIXTURE FACT worth knowing before anyone tries to reproduce: web/demo.improv\nhas NO aggregate cell. Gotcha 46 puts exactly one category on each visible axis\nprecisely so every cell is a Record, and tests/web_demo_model.rs asserts it. So\non the shipped demo you cannot reach the refusal at all without first changing\nan axis — which plausibly explains \"never confirmed working\". To reproduce:\npress T, select the Quarter tile, Enter once (Page -\u003e None); every cell is then\nan aggregate.\n\nREGRESSION GUARD LANDED: harness/tests/drill.spec.js, two tests, documenting the\nrefusal honestly rather than leaving folklore. It makes the aggregate through\nthe tile bar the way a user would and ASSERTS the precondition (td.editable\ncount 0) so it cannot pass against a grid of Records. Both proved by injection:\nsilencing the aggregate arm fails both tests (the second on its\ncheck_the_checker control, which is the point — with nothing answering, the\ncontrol can no longer distinguish \"`\u003e` is silent\" from \"the page is dead\");\nmaking `\u003e` resolve fails only the second. The tests assert what is TRUE today,\nso they will fail the day drill lands over the wire — which is the correct\nsignal to whoever closes this ticket, not an obstacle.\n","created_at":"2026-09-20T20:14:35Z"}],"dependency_count":0,"dependent_count":1,"comment_count":1} +{"_type":"issue","id":"improvise-gd28","title":"Drill in the browser: the view stack is client-local, so what does the server still owe?","description":"From the ADR 002 spine landing (2026-09-19). The RESOLVER exists and is tested -- App::resolve_aggregate, layout-only by necessity since an aggregate has no existence outside the layout that derives it -- and CellId::Aggregate is emitted. What is missing is the command.\n\nModelCommand::Drill was deliberately NOT added. Drilling creates the _Drill view and writes drill_state, which gotchas 30 and 38 rule session-local and therefore unaddressable by a NULLARY Command -- exactly the reason ApplyAndClearDrill was removed from the wire in improvise-3x3u.\n\nSo a wire Drill needs the ADDRESSED form: the variant carries a SessionId and the staging map moves off the single App onto the session. That is ADR 001 layer 2 (context/adr/001-concurrent-edits.md), so this is blocked on it rather than being independent work.\n\nThe seam is ready: emit, resolve and the type-level guarantee (Drill takes an Aggregate and nothing else) are all in place and tested.","acceptance_criteria":"Drilling works over the wire. It needs the ADDRESSED command form — the variant carries a SessionId and the staging map moves off the single App onto the session — because drilling writes drill_state, which gotchas 30 and 38 rule session-local and therefore unaddressable by a nullary Command. That is ADR 001 layer 2, so this is blocked on improvise-7b8v rather than independent. The seam is ready: App::resolve_aggregate, CellId::Aggregate emission and the type-level guarantee that Drill takes an Aggregate and nothing else all exist and are tested.","notes":"USER RULING 2026-09-21, PROMOTED P3 -\u003e P1: 'drill / undrill is a core feature that has to be present in the web ui'.\n\nThis bead is the mechanism that ruling needs, so it stops being an architectural nicety and becomes the blocker for a stated product requirement.\n\nWHAT IS DECLINED TODAY, all in improvise-protocol/src/resolve.rs's declined() list, which is the one home for that wording:\n drill-into-cell ('\u003e') -\u003e 'drill'\n view-back ('\u003c') -\u003e 'stepping back to the previous view', and the\n comment there says explicitly it is NOT 'not built'\n -- it COMMITS A DRILL FIRST, so composing it in the\n browser would silently commit pending edits\n edit-or-drill ('i' on an aggregate) -\u003e a SetStatus refusal (improvise-s8h7)\n\nWHY IT IS NOT MERELY UNBUILT, which is what makes this bead the real work: drill\nstate is SESSION-LOCAL RESIDUE (gotcha 30). DrillState holds an Rc, so it fails\nthe portability half of ViewState membership, and drill_state.pending_edits are\nuncommitted writes belonging to whoever staged them -- which is exactly why\nApplyAndClearDrill was REMOVED from ModelCommand (gotcha 38, improvise-tom1): a\nCommand::Model is routed with no SessionId, so an unaddressed\nApplyAndClearDrill lets client B commit client A's uncommitted typing. That was\ndemonstrated against Sessions::apply before removal, not argued.\n\nSo browser drill needs the ADDRESSED command form -- a mutation carrying the\nSessionId it belongs to -- which is ADR 001 layer 2 and is this bead.\n\nGotcha 1 is the other half anyone implementing this must read: drill pending\nedits are keyed by (row, RecordCol), and gotcha 18 records that a drill snapshot\nfrozen in hash order made the same drill address DIFFERENT RECORDS in a second\nprocess. Any wire form has to carry an order that is stable across processes.\n\nNote improvise-s8h7 (P1) is the user-visible symptom on the 'i' key and\nimprovise-l3hm (P3) is its sibling on CellId::Create. Both should close with or\nafter this, not before -- a louder refusal is not what the ruling asks for.\nUSER RULING 2026-09-21: \"The state drill manipulates (the view stack) should be local to the client.\"\n\nThis REDIRECTS the ticket. It had been scoped to the addressed command form (ADR 001 layer 2) -- a mutation carrying a SessionId. The ruling puts the view stack in the class of state this repo has ALREADY taken off the wire, which is a much cheaper shape.\n\nTHE PRECEDENT IS EXACT. Gotcha 38: ViewBack and ViewForward were removed from ModelCommand for precisely this reason -- \"their operand is ViewState::view_back_stack, session-local residue belonging to whoever navigated.\" And gotcha 29 records the earlier ruling that the model is shared and the UI is not, with ViewState::active_view already AUTHORITATIVE PER CLIENT; two clients may sit on different views of one document.\n\nWHAT IS SHARED AND WHAT IS NOT, on this reading:\n - the view stack, and which _Drill view this client sees: CLIENT-LOCAL (the ruling)\n - drill_state.pending_edits: already session-local residue (gotcha 30 -- holds an Rc, uncommitted writes belonging to whoever staged them)\n - the COMMIT: a genuine model change -- but as ordinary SetCell/ClearCell commands it is already on the wire, already basis-checked, and needs NO addressed form\n\nIf that holds, the SessionId-carrying mutation leaves this ticket entirely and ApplyAndClearDrill never returns to the wire -- which is what gotcha 38 removed it for.\n\nTHE OPEN QUESTION, and it may be the real work: a client has no Model. Entering a drill needs the record set for a cell, which is GridLayout::drill_records(r, c) and is server-side. So either the server PROJECTS the drilled record set -- the \"enrich the projection rather than move resolution back\" answer gotcha 47 already prescribes -- or a client cannot compose a drill locally at all. Settle that first; it decides whether this is a projection ticket or something larger.\n\nTRAPS THAT SURVIVE EITHER SHAPE:\n - gotcha 1: pending edits are keyed by (row, RecordCol), and _Dim carries the column INDEX, not a label, because a category and a measure may share a name.\n - gotcha 18: a drill snapshot frozen in HASH ORDER made the same drill address DIFFERENT RECORDS in a second process -- silent misdirected writes. Whatever the client holds needs an order stable across processes.\n\nNote the view-back decline in resolve.rs says explicitly it is NOT \"not built\": it COMMITS A DRILL FIRST, so composing it in a browser would silently commit pending edits. Under the client-local shape that commit is the client's own to sequence, which removes the reason for the decline rather than working around it.\nUSER RULING 2026-09-21 (second): \"Drilling should be an ephemeral view, not something that gets persisted.\"\n\nHALF OF THIS ALREADY HOLDS, and the other half is the work. Verified at 523d571:\n\n - NOT PERSISTED: already true. VIRTUAL_VIEW_NAMES is [\"_Records\", \"_Drill\"] (core/src/view/types.rs:14). Both are never written and are dropped on load if an older file carries them; saving from drill mode writes the first real view as Initial View.\n - BUT IT IS SHARED: DrillIntoCell (command/cmd/grid.rs:348) builds the _Drill view and puts it in the WORKBOOK's views map, which is shared document state. So today drill is ephemeral with respect to the FILE and not with respect to other CLIENTS.\n\nTAKEN WITH THE FIRST RULING -- that the view stack is client-local -- the consequence is that _Drill should not be in Workbook::views at all. It is a client-side construct: built by the client that drilled, seen by nobody else, and discarded when that client leaves it. \"Ephemeral\" and \"client-local\" are the same statement viewed from the file and from the session.\n\nWHAT THAT BUYS, and why it is cheaper than the addressed command form this bead originally proposed:\n - no SessionId-carrying mutation is needed for the view itself\n - ApplyAndClearDrill never returns to the wire, which is what gotcha 38 removed it for\n - the view-back decline stops needing a workaround: its comment says it is not \"not built\" but that it COMMITS A DRILL FIRST, and a client that owns its own stack sequences that itself\n\nWHAT THE SERVER STILL OWES, which is the open question: DrillIntoCell captures a RECORDS SNAPSHOT at drill time (grid.rs:365 onward, \"Capture the records snapshot NOW\"), built from ctx.model.data -- which a client does not have. So either that snapshot is projected, or a client cannot compose a drill. gotcha 47's rule applies: enrich the projection rather than move resolution back.\n\nNote gotcha 10 while you are here: drilling a FORMULA cell strips the _Measure coordinate when it names a formula target, or matching_cells returns nothing instead of the raw records. Whatever composes a drill has to keep that, and a client does not know which measures are formula targets unless told -- another thing the projection may owe.\n\nAnd gotcha 18's warning is sharpened by \"ephemeral\": the snapshot is frozen at drill time and pending_edits are keyed by ROW INDEX into it, so the order must be stable for as long as the drill lives, even though the drill itself is not.\nUSER RULING 2026-09-21, answering the product question PR #115 left open: \"The staging scheme should be identical, because they're solving different problems.\"\n\nTHE QUESTION IT ANSWERS. The agent proposed that a browser could WRITE THROUGH with ordinary SetCell commands instead of staging, on the grounds that those are already basis-checked. The ruling rejects that, and the reason is the important part: BASIS-CHECKING AND STAGING SOLVE DIFFERENT PROBLEMS, so the first does not substitute for the second.\n\n - the basis check is ANTI-STALENESS COORDINATION between clients. It answers \"is your picture of this record current\" (ADR 001, gotcha 43). It is explicitly NOT a security boundary and explicitly not about intent.\n - staging is an EDITING-SESSION abstraction for one user. It answers \"does this person want to keep what they just typed\" -- several edits accumulate and commit atomically on leaving, or are discarded on cancel.\n\nWrite-through would have silently removed the ability to ABANDON a drill. That is a user-facing capability, not an implementation detail, and losing it is not visible in any test that only asks whether an edit lands.\n\nSO: the browser stages, identically to the TUI. DrillState::pending_edits becomes client state -- which is consistent with the other two rulings rather than in tension with them, since the view stack is client-local and drill is an ephemeral view. All three say the same thing: a drill is one person's transient workspace.\n\nTHE CONVERGENCE WORTH NOTING, because it makes the commit cheap: clause 4 IS NOW DELIVERED (improvise-yqpd). ClientFrame::Commands is answered whole and applied whole -- CheckedBatch::against checks the frame before anything runs, and a failure rolls the document, the mirrors and the session slice back. So a staged drill's commit is a BATCH OF SetCells and gets its atomicity from the transaction that already exists, with no new mechanism. Before clause 4 that batch could have half-applied, which is precisely the partial-and-silent failure a staged commit must not have.\n\nTWO THINGS THE COMMIT STILL NEEDS, from the same PR's findings:\n - the category-column arm has NO ModelCommand. ApplyAndClearDrill's RecordCol::Category(_) path is apply_coordinate_rename -- add an item, then move EVERY measure cell in the row (gotcha 1) -- and no wire variant exists for it. Filed separately.\n - gotcha 18's order warning binds the staging, not just the snapshot: pending_edits are keyed by ROW INDEX into a frozen list, and a list frozen in hash order made the same drill address DIFFERENT RECORDS in a second process. Client-side staging inherits that requirement exactly.\nTRAP OPENED BY PR #117 (improvise-cfly), found in orchestrator review 2026-09-22.\n\nThe browser's view-back resolver emits [Command::ViewBack] where the TUI's\nViewNavigate{forward:false} emits [ApplyAndClearDrill, ViewBack]. That omission is\ncorrect TODAY only because a browser cannot stage a drill: drill-into-cell is\ndeclined, and edit_or_drill's CellId::Aggregate arm answers with a sentence.\n\nWHEN THIS TICKET LANDS, THAT STOPS BEING TRUE AND NOTHING FAILS. Stepping back in\na browser would silently discard staged drill edits that the TUI commits -- a\ndata-loss divergence, invisible across the whole suite.\n\nWorse, the guard points the wrong way.\nresolve::tests::the_view_history_keys_answer_in_both_directions asserts the arm\nemits EXACTLY vec![Command::ViewBack], so it PINS the current behaviour rather\nthan guarding the transition. It keeps passing while the bug exists, and a change\nthat FIXED the bug would fail it.\n\nSO THIS TICKET'S ACCEPTANCE GAINS A CLAUSE: making drill reachable in a browser\nmust also answer what stepping back does with staged edits, and must change that\ntest rather than work around it.\n\nRecorded here rather than as its own bead because the addressed-command form this\nticket is about is exactly what makes ApplyAndClearDrill expressible at all\n(gotcha 38 / improvise-3x3u) -- they are the same piece of work, and a separate\nbead would sit unread until someone hit the bug.","status":"closed","priority":1,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T00:18:34Z","created_by":"Edward Langley","updated_at":"2026-09-23T01:37:03Z","closed_at":"2026-09-23T01:37:03Z","close_reason":"Decomposition landed in PR #140 (ViewStep/records_spine/records_steps/drill_steps in improvise-core; cmd/grid.rs keeps only steps_into_active_view). PR #141 landed the diagnosis. THE DEPENDENCY ON improvise-7b8v WAS REMOVED, NOT FORCED PAST: the whole finding is that browser drill is NOT blocked on ADR 001 layer 2 -- that argument is about the COMMIT path (ApplyAndClearDrill is addressed to one session), while ENTERING a drill needs no commit and uses ModelCommands that already name their view unaddressed. The real blocker is AggregateHash being LAYOUT-scoped. Remaining implementation is improvise-obtu; contention is improvise-t219; thin spine coverage is improvise-t0ma. improvise-fdk1 is also not needed.","labels":["area:protocol"],"dependencies":[{"issue_id":"improvise-gd28","depends_on_id":"improvise-7b8v","type":"blocks","created_at":"2026-09-20T16:20:30Z","created_by":"Edward Langley","metadata":"{}"}],"comments":[{"id":"7fce3874-00de-5f06-a481-e66fb873f24f","issue_id":"improvise-gd28","author":"wave3-webui","text":"BROWSER EVIDENCE, gathered under Chromium rather than read. The user reported\n\"drill also doesn't work in the webui: it was never confirmed working.\" This\nticket is the answer; nothing here asks to change the design. Confirming the\nticket's own claims empirically, plus one asymmetry it does not record.\n\nWHAT WORKS, verified in the running browser and not inferred:\n - project::grid_of emits CellId::Aggregate and the client RECEIVES it. In\n render_state: {\"Aggregate\":\"750ab1f5ffa96d5e00f605b3c8e36aac\"}.\n - render.rs::editable correctly answers false for it (ADR 002), so the page\n offers no editor on an aggregate.\n - resolve.rs reaches the CellId::Aggregate arm of edit_or_drill.\n - The TUI path is intact: 12 drill tests pass across cmd/grid.rs, cmd/mode.rs,\n ui/effect.rs and ui/app.rs. So this is isolated to the client/wire and is\n NOT a general regression.\n\nTHE ONE MISSING LINK, confirmed: reduce.rs contains no `Drill` variant at all —\nneither Command nor ModelCommand. `grep -n Drill crates/improvise-protocol/src/\nreduce.rs` returns nothing. The ticket's \"what is missing is the command\" is\nexactly right, and the seam really is ready.\n\nTHE ASYMMETRY THIS TICKET DOES NOT RECORD, and the reason it reads to a user as\nbroken rather than as refused — the two entry points fail DIFFERENTLY:\n\n - `i` / `a` on an aggregate declines OUT LOUD. edit_or_drill answers the\n Aggregate arm with Command::SetStatus(\"drilling into an aggregate needs the\n host — not in the browser yet\"). The user sees why.\n\n - `\u003e` (drill-into-cell) is SILENT. It is a command name resolve::step does not\n handle, so it falls through to KeyOutcome::Upstream -\u003e ClientFrame::Key -\u003e\n Host::handle, whose Key arm is an explicit `Vec::new()`. Gotcha 47: an\n upstream key is a black hole, not a slow path. Pressing `\u003e` produces no\n status, no mode change, nothing at all.\n\n`\u003e` is the key the help hint advertises for drill (\"\u003e:drill\" in the Normal-mode\nhint line), so the advertised gesture is the silent one and the honest refusal\nis on a key the user was not told to press. Whoever picks this ticket up may\nwant to route drill-into-cell through the same declining arm in the interim, so\nthe gap SAYS something on the key that names it — that is a one-line change and\nis independent of the addressed-command design work. I have not made it: it is\nthis ticket's call, not mine.\n\nA FIXTURE FACT worth knowing before anyone tries to reproduce: web/demo.improv\nhas NO aggregate cell. Gotcha 46 puts exactly one category on each visible axis\nprecisely so every cell is a Record, and tests/web_demo_model.rs asserts it. So\non the shipped demo you cannot reach the refusal at all without first changing\nan axis — which plausibly explains \"never confirmed working\". To reproduce:\npress T, select the Quarter tile, Enter once (Page -\u003e None); every cell is then\nan aggregate.\n\nREGRESSION GUARD LANDED: harness/tests/drill.spec.js, two tests, documenting the\nrefusal honestly rather than leaving folklore. It makes the aggregate through\nthe tile bar the way a user would and ASSERTS the precondition (td.editable\ncount 0) so it cannot pass against a grid of Records. Both proved by injection:\nsilencing the aggregate arm fails both tests (the second on its\ncheck_the_checker control, which is the point — with nothing answering, the\ncontrol can no longer distinguish \"`\u003e` is silent\" from \"the page is dead\");\nmaking `\u003e` resolve fails only the second. The tests assert what is TRUE today,\nso they will fail the day drill lands over the wire — which is the correct\nsignal to whoever closes this ticket, not an obstacle.\n","created_at":"2026-09-20T20:14:35Z"}],"dependency_count":1,"dependent_count":1,"comment_count":1} {"_type":"issue","id":"improvise-xks6","title":"One home for the client/server frame envelopes","description":"Two crates now define the same frames independently, which is a live divergence risk:\n improvise-wasm-client::wire -- ClientMessage / ServerMessage\n improvise-ws-server::wire -- ClientMsg / ServerMsg\nThe ws-server agent asked for one version to be landed and will delete its wire.rs and re-export; the change on its side is two type names.\n\nPUT THEM IN improvise-protocol. Both sides already depend on it, and this is the one thing there that is FRAMING rather than vocabulary -- the protocol says what a Command is, but not what distinguishes one JSON blob from another on the wire.\n\nTHE SHAPE, as the ws-server landed it and the client should adopt:\n client -\u003e server: Viewport { rows: usize, cols: usize } | Command(Command)\n server -\u003e client: Snapshot(Box\u003cSnapshot\u003e) | Commands(Vec\u003cCommand\u003e)\nNotes that matter: Snapshot is BOXED because clippy large_enum_variant fires otherwise and a Snapshot is sent once per connection while Commands is every frame. Server-\u003eclient is ONE shape carrying both projections (as Command::Project) and a peer broadcast, because on the wire they are the same thing and a client folds both -- a separate Projections variant was rejected for that reason.\n\nViewport is the only addition to the shared vocabulary and it is derived, not invented: the extent is the one session input no Command carries by construction (gotcha 40).","notes":"RAISED TO P1 AND SEQUENCED AHEAD OF improvise-9x6 (2026-09-20).\n\nThere are now THREE envelope vocabularies for TWO deployments:\n * improvise-ws-server::wire -- ClientMsg/ServerMsg, the socket, server side\n * improvise-wasm-client::socket -- ClientFrame/ServerFrame, the same socket MIRRORED, because a native crate cannot be a wasm dependency\n * improvise-wasm-client::wire -- ClientMessage/ServerMessage, the WORKER deployment (improvise-tm6), currently unused by anything\n\nimprovise-9x6 gives the worker its own Host. If it lands before this ticket, it will speak a FOURTH arrangement and consolidation gets harder rather than easier -- so this comes first, or is at least DECIDED first.\n\nThe answer is almost certainly improvise-protocol owning the framing, since both ends already depend on it and its charter is exactly 'the wire vocabulary'. The open question this ticket must actually answer is whether the WORKER and the SOCKET want the SAME vocabulary. They differ today in ways that may be essential or may be accident: the socket has Viewport and no Key; the worker envelope was written before the projection architecture landed. Decide that before moving either, or the move produces two homes instead of one.\n\nThe mirror is currently held honest by two tests (gotcha 42) -- the_socket_framing_matches_the_server and every_server_variant_is_one_this_crate_mirrors. Both DELETE when this lands, and that is the measure of success.","status":"closed","priority":1,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T23:46:23Z","created_by":"Edward Langley","updated_at":"2026-09-20T01:52:29Z","closed_at":"2026-09-20T01:52:29Z","close_reason":"Done. Three envelope vocabularies for two deployments become ONE pair: improvise-protocol::frame owns ClientFrame { Viewport, Command, Key } and ServerFrame { Snapshot, Commands(Vec\u003c_\u003e), Rejected }; improvise-ws-server and improvise-wasm-client each pub use them and neither defines one. Both wire.rs files DELETED.\n\nTHE QUESTION THE TICKET SAID TO DECIDE FIRST -- do the worker and the socket want the same vocabulary -- IS ANSWERED YES, and the test applied to each difference was whether the ARGUMENT for it names the thing they differ in. None did:\n * Viewport is deployment-independent. Its justification is that the extent is a fact about a terminal or a browser window, is not in ViewState, and no effect produces it, so no derivation reaches it. Nothing transport-shaped. Its handshake role is a property of GridCache::seed (a Snapshot seeds the VISIBLE cells), not of sockets. A worker hosting the same Sessions owes set_extent the same answer.\n * Key is deployment-independent. Its justification (gotcha 38) is that a binding names an INTENT resolved against a CmdContext a thin client does not hold -- which says nothing about what the bytes travel over. Decisive evidence: the wasm client ALREADY PRODUCES a Key frame on both paths and tests/lockstep.rs round-trips it. So the socket's 'no Key' was never a statement about the socket, it was an unfinished feature (improvise-bpq2) that the separate envelope made invisible.\n * Commands(Vec\u003c_\u003e) vs Command(_): the batching is a property of the FOLD, not the transport -- a fan-out must arrive whole and in sequence because a cell update that overtakes its labels is DROPPED (gotcha 33), and an in-process postMessage interleaves exactly as a socket does. Commands(vec![c]) expresses everything the singleton could; the reverse is false.\n\nAND THE WORKER PAIR WAS INCOHERENT, NOT MERELY DIFFERENT -- the strongest evidence its differences were staleness. Written before ADR 001, it could carry Command::Model { basis, .. } and had NO Rejected variant, so a client speaking it composed a claim that could never be answered. It was missing a variant its own payload type demands.\n\nA FIND THE TICKET DID NOT PREDICT: the worker envelope was NOT 'unused by anything'. It was the return type of Client::set_cell/set_cell_at/key/on_key_event and the input to apply/handle_message/on_message. The wasm client was carrying TWO PARALLEL FOLD SURFACES OVER ONE STATE, identical except that the worker one had no Rejected arm. Once the envelopes unified they were literal duplicates, so the worker trio is deleted; web/app.js calls neither.\n\nBOTH MIRROR TESTS DELETED, which was the stated measure of success. What they checked is now TYPE IDENTITY: a rename is a compile error at every call site in both crates, and 'a variant added on one side and not the other' is not a constructible state. Strictly stronger than a hand-written fixture list. The check-the-checker MOVES rather than dies -- the_retired_spellings_do_not_parse keeps the injected-fault discipline and aims it at the one thing still possible, the retired Bootstrap/Command spellings returning as serde ALIASES, which would be two homes again wearing one type. End-to-end compatibility is now browser_round_trip.rs handing the wasm client's own serialized frame to a real Host::handle.\n\nHost::handle gains an explicit ClientFrame::Key =\u003e Vec::new() arm. Behaviour unchanged -- the frame previously failed to parse and was skipped -- but the gap is now A LINE OF CODE WITH improvise-bpq2 ON IT rather than a variant that does not exist.\n\n1134 -\u003e 1132 passing (5 deleted, 3 added), fully accounted for. Clippy, fmt, wasm32 all clean.\n\nWHAT improvise-9x6 CAN NOW ASSUME: nothing to define and no fourth arrangement to invent; first inbound frame is ClientFrame::Viewport for the same reason; ClientFrame::Key is already on the wire and already produced by the client, and a worker holds the authoritative App so it can resolve it through reduce_key where the socket cannot -- the worker may close bpq2's mechanism first; ServerFrame::Commands must be delivered whole and in order across a MessagePort; ServerFrame::Rejected must be answered, since ADR 001 is not socket-only.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-bpq2","title":"Client-side key resolution: what must the projections carry?","description":"USER RULING (2026-09-19): keys are converted into commands IN THE BROWSER; the keymap is a client-only concept the server must not know. Follows from the standing principle -- the model is shared, the UI is not, and a keymap is UI. It also buys something real: two clients may have DIFFERENT bindings over the same document, which is impossible if the server owns the keymap.\n\nThis inverts improvise-gsw conclusion (which had keys travelling upstream unresolved) and closes the keypress-door ticket. The work is now to make client-side resolution POSSIBLE.\n\nTHE PROBLEM TO SOLVE. A binding names an INTENT resolved against a CmdContext -- Model, GridLayout, cursor, yanked cell, expanded cats, mode. A Command is the RESOLVED MUTATION, e.g. SetSelected { row, col }. A thin client holds ViewState plus the widget caches. So for each binding ask: can a client holding only that state compose the Command? Where the answer is no, the fix is to ENRICH THE PROJECTION, not to move resolution to the server.\n\nKNOWN GAPS, from the gsw agent own analysis of what it could not resolve:\n * CURSOR MOVEMENT -- GridCache is indexed by position WITHIN THE VIEWPORT, so a client cannot distinguish \"last visible row\" from \"last row\" and cannot clamp. It needs the total row/col count, or an explicit \"can I move further\" fact.\n * PANEL CURSOR MOVEMENT -- move-panel-cursor walks a flattening that depends on expanded_cats. Either the cache carries the flattened list, or the client re-derives it -- and re-deriving is how two ends drift.\n * MODIFIED CHARACTERS -- KeyPattern::AnyChar matches a Char under ANY modifiers on the host. A client mirroring that is fine now that the client owns the keymap outright; the old constraint (local resolution may only ever be a strict subset) DISSOLVES once there is no host keymap to be a subset of.\n\nMETHOD: enumerate the bindings, not the commands -- the keymap is the thing being made client-side, so walk it. doctrine(binding_dump_diff) already says most bindings have no direct test, so a green suite is not evidence here either.\n\nALSO IN SCOPE: move ClientMessage/ServerMessage into improvise-protocol. The vocabulary is there and the framing that says which a JSON blob is, is not; the ws-server will want the same envelopes and two crates should not define them.\n\nBlocks improvise-1ey.","notes":"\n--- THE COMPOSABILITY LIST (improvise-q08 agent, 2026-09-19) ---\nWhich Commands a thin client holding ViewState + the five widget caches CANNOT compose. This is the real content of this ticket.\n\nHARD BLOCKERS:\n1. SetCell { key: CellKey } and ClearCell(CellKey) -- THE BIG ONE, and it blocks\n the most basic action there is. A client sees a grid of LABEL STRINGS indexed\n by viewport position. A CellKey is the full sorted coordinate tuple: every Row\n and Column category, plus every Page AND Filter selection (gotcha 27). Nothing\n in GridCache says which category a row label belongs to; nested row axes\n flatten several categories into one label; the page/filter constraints are not\n projected at all; and records mode encodes a COLUMN INDEX in _Dim (gotcha 1).\n There is no client-side derivation. NOTE THIS BLOCKS improvise-1ey DIRECTLY --\n its stated scope is \"type a number into a cell and see the value update\".\n2. ToggleGroup { category, group } and AddItemInGroup -- CategoryRow.items is a\n flat Vec\u003cString\u003e; groups, group membership and collapsed state are not\n projected. The server does the cat_tree flattening.\n3. RemoveFormula { target, target_category } -- FormulaCache is Vec\u003cString\u003e of RAW\n text. The client would have to re-parse to get target (dragging\n improvise-formula into a thin client), and target_category is simply absent, so\n a formula written [CustomCat] cannot be addressed at all.\n4. LoadModel(PathBuf) / ImportJsonHeadless -- paths name documents in the HOSTs\n Storage (gotcha 2) and nothing projects a listing, so a client cannot compose a\n path it has reason to believe exists.\n5. ApplyAndClearDrill / AcceptWizardModel -- nullary so constructible, but nothing\n projects drill or wizard state, so a client cannot know when either is\n meaningful. (Both are already off the wire per improvise-3x3u.)\n\nCOMPOSABLE BUT TRAPPED:\n* SetSelected { row, col } is ABSOLUTE; GridCache is VIEWPORT-RELATIVE. The client\n must add row_offset/col_offset from its own ViewState -- it has them, but the two\n index spaces are not distinguished by any type and getting it wrong is silent.\n* The grids TOTAL row/column count is not projected, so a client cannot clamp its\n cursor or size a scrollbar.\n\nFINE TODAY: SetAxis/CycleAxis/TransposeAxes/TogglePruneEmpty/SortData/\nCleanEmptyRecords, AddCategory/RemoveCategory, AddItem/RemoveItem/HideItem/\nShowItem/SetPageSelection WHEN DRIVEN FROM THE CATEGORY PANEL (not from the grid\ncursor -- same reason as 1), CreateView/DeleteView/SwitchView/ViewBack/ViewForward,\nAddFormula, SetNumberFormat (though the CURRENT format is in no cache, so it cannot\nbe pre-filled), and the whole view half.\n\nSUGGESTED ORDER: item 1 first and alone -- it is what 1ey needs and it is a\nprojection design question (how does a client name a cell?), not a plumbing one.\n\n--- ADR 002 ACCEPTED (user, 2026-09-19): see context/adr/002-naming-a-cell.md ---\nA projected cell carries an OPAQUE SURROGATE KEY, and its ABSENCE means the\ncell is an aggregate:\n CellDisplay { text, numeric, error, key: Option\u003cCellId\u003e }\nSome -\u003e a stored cell this client may set, named by a token it echoes back.\nNone -\u003e an aggregate: drill, do not set.\n\nWHY THE NULLABILITY IS THE POINT: a client cannot compose SetCell for an\naggregate because there is NO KEY TO PUT IN IT. That is make-invalid-states-\nunrepresentable (design-principles 3), strictly stronger than a server-side\nrejection. It also mirrors a fork the model ALREADY makes -- EditOrDrill in\ncmd/mode.rs branches on exactly this -- so the wire projects an existing\ndistinction rather than inventing one. And it follows the precedent\nCellDisplay::numeric set, whose doc says the server decides \"because it is the\nside that knows\".\n\n_INDEX WAS CONSIDERED AND REJECTED, and the reason is worth keeping: right\nontology, unsafe representation. CategoryKind::VirtualIndex is documented as\n\"Items are 0, 1, ... N where N = number of matching cells\" -- POSITIONAL over\nthe currently matching set, not an identity. _Index = \"3\" names a different\nrecord after a filter change, an insert or a delete, and a different one again\nin another view. A client echoing a stale _Index writes to the WRONG CELL,\nSILENTLY -- worse than a surrogate that fails to resolve, and the same hazard\nclass as viewport-relative-vs-absolute and the stale cursor under a renamed key.\n\nBUT KEEP WHAT IT POINTS AT: the surrogate should name the RECORD, not the cell.\nA record is the coord tuple minus _Measure (record_row_identity), which is\nalready ADR 001 unit of atomicity and already what record_versions is keyed by.\nAddress a cell as (record surrogate, measure) and ONE IDENTITY serves both\naddressing and staleness, instead of each ADR inventing its own.\n\nOPEN, for the implementer to decide with evidence: hash vs interned id (the\ncodebase interns Symbols already); how the server resolves a surrogate back --\nnote that REGENERATING FROM THE CURRENT LAYOUT means a stale surrogate simply\nfails to resolve, which IS ADR 001 stale-basis rejection at cell granularity,\nso the two compose; and what DRILL addresses with, which this does not answer.\n\n--- SURROGATE DECIDED (user, 2026-09-19): hash of the normalized record, maintained as an index ---\nNOT a hash of the cell -- of the RECORD (the coord tuple minus _Measure), which\nis already ADR 001 unit of atomicity and already what record_versions is keyed\nby. A cell is (record hash, measure).\n\nWHY: it makes stale edits cheap to identify, three ways at once. The surrogate\na client already holds IS the lookup key for its own version, so record_versions\nbecomes HashMap\u003cRecordHash, u64\u003e -- fixed size, no allocation, no CellKey\nreconstruction on the hot path. A basis is a list of (record hash, version)\npairs: compact on the wire, O(1) to check. And a record that no longer exists\nsimply fails to resolve, so that whole class of staleness needs no version\ncomparison at all.\n\nIt is well-defined because CellKey ALREADY has a canonical form -- CellKey::new\nsorts by category name and equality/hashing rely on it (gotcha 5). Normalized\nmeans that form with _Measure removed; the normalization is a property the type\nguarantees, not a new convention.\n\nRESOLUTION IS A MAINTAINED INDEX, IN DataStore -- a hash is one-way, and\nregenerating from the layout would be O(visible cells) per resolve AND would\nonly cover the visible window, while a client may hold a surrogate for a record\nthat scrolled away. DataStore is the right home: it already interns every name\ninto a SymbolTable and already maintains HashMap\u003c(Symbol,Symbol), HashSet\u003cInternedKey\u003e\u003e\nfor hot-path lookups. The RecordHash index is the same shape, updated at the same\npoints, and that existing (Symbol,Symbol) index is what makes INVALIDATION\ntractable -- when a category or item disappears, the records mentioning it are\nfindable without a scan.\n\nTWO THINGS THAT FALL OUT: the index and record_versions want to be ONE structure\n(RecordHash -\u003e (record, version)) since both are keyed the same and invalidated\ntogether, giving both ADRs their answer in one lookup. And structural vs data\nstaleness separate cleanly: does not resolve = the record is GONE (reject,\nnothing to write to); resolves with a differing version = the record CHANGED\n(reject as stale, offer force). Regenerating from the layout would have\nconflated those, because a record scrolled out of view would look identical to\none that no longer exists.\n\nTHREE SILENT-BREAKAGE TRAPS, each producing a WRONG WRITE rather than an error:\n * NOT DefaultHasher / RandomState -- randomly seeded PER PROCESS, so hashes\n would differ across a restart and between processes.\n * NOT #[derive(Hash)] as the definition -- Rust guarantees no stability across\n compiler versions or platforms. Hash an explicit BYTE SERIALIZATION of the\n sorted (category, item) pairs.\n * Pick the WIDTH BY CONSEQUENCE: a collision is a silent write to the wrong\n record, which is exactly why _Index was rejected. 64 bits hits birthday risk\n near 2^32 records; 128 bits costs 8 more bytes per VISIBLE cell (~8KB on a\n 50x20 viewport). Argue it, do not default to u64 for convenience.\n\n--- CellId IS A TWO-VARIANT ENUM (user, 2026-09-19), not Option ---\n enum CellId { Record(RecordHash), Aggregate(AggregateHash) }\nSetCell takes a Record and nothing else; Drill takes an Aggregate and nothing\nelse. An aggregate surrogate is a hash of its COORDINATE CONSTRAINT -- the row\nand column coordinates plus page/filter selections -- so it costs one hash per\ncell, the same width and the same DataStore index as a record.\n\nWHY NOT Option\u003cCellId\u003e with None meaning aggregate: an aggregate is NOT the\nabsence of an identity, it is a different KIND of identity. Option made SetCell\nsafe and left Drill with nothing to address, which is exactly how drill\naddressing became an open question. Two variants close it by construction and\nmake BOTH operations unrepresentable-if-wrong rather than just one.\n\nDEFERRED, filed separately: shipping a bounded keyset per aggregate for hover\npreviews. The unbounded version is untenable -- the payload would scale with the\nDATA while the rest of a projection scales with the VIEWPORT, ~16MB to render\n1000 cells over a million-record document -- and drill-on-demand works without\nit.\n\n--- ADR 002 SPINE LANDED (2026-09-19). What remains here is the KEY RESOLUTION half. ---\nMerged: RecordHash/AggregateHash, the record index in DataStore, CellId on\nCellDisplay, SetCell/ClearCell taking a surrogate, and Client::set_cell /\nset_cell_at in the wasm client. 1017 tests, 16 faults injected and all fired.\n\nSO A CLIENT CAN NOW COMPOSE A WRITE -- improvise-cr3 can call set_cell_at\ntoday. What is NOT done, and is this ticket own subject: WIRING IT TO A\nKEYSTROKE. The wasm client still forwards editing keys upstream unresolved,\nwhich is exactly the thing the user ruling (keymap is client-only) says must\nchange. The composability list earlier in these notes is still the map; item 1\n(SetCell) is now answered, items 2-5 are not.\n\nDECISIONS THE IMPLEMENTER MADE THAT ARE WORTH KNOWING:\n * The wire form of a surrogate is a 32-char HEX STRING, not a number. serde_json\n writes u128 as a bare number and a JavaScript host parses every JSON number as\n f64, so anything above 2^53 comes back CHANGED. This trap was not in the ADR.\n * CellId::Record carries RecordCell { record: RecordHash, measure: Option\u003cString\u003e },\n not a bare hash. The ADR says both \"hash the record\" and \"a cell is (record\n hash, measure)\"; both cannot hold if the hash covers the measure, because then\n record_versions could not be keyed by it -- which is the reason the ADR chose\n the record over the cell. Carrying the measure beside the hash satisfies both.\n * id: None is NOT \"aggregate\" (that is Some(CellId::Aggregate)). It means no\n projected cell: a padded/invalidated GridCache slot, or a records-mode\n COORDINATE column, which renames across the whole row (gotcha 1) and is a\n different intent from SetCell.\n * A zero cell count is a TOMBSTONE, not a removal -- dropping the entry would let\n a delete-and-recreate reset the version to 0, so a client holding version 0\n against the OLD record would be told its basis is current and its write\n applied. resolve_record filters on liveness; record_version reads through.\n * Resolution is INDEX FIRST, LAYOUT SECOND. The index covers every record holding\n data on screen or not; the layout answers cells with NO DATA YET -- and typing\n into an empty cell IS improvise-1ey.","status":"closed","priority":1,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-19T23:42:49Z","created_by":"Edward Langley","updated_at":"2026-09-20T02:36:27Z","closed_at":"2026-09-20T02:36:27Z","close_reason":"Done for the cursor half, with a named list of what still needs the host so closing the rest has to come here and say so.\n\nTHE FINDING THAT REFRAMED THE TICKET: Host::handle answers ClientFrame::Key with an EXPLICIT EMPTY VEC, so an upstream key is a BLACK HOLE, not a slow path. Combined with the improvise-9x6 finding (nothing in the projection stream carries the host's ViewState, so host-side resolution DIVERGES), every KeyOutcome::Upstream was a binding that does nothing at all in a browser. That turns the ticket's measure into simply 'how much of the keymap can the client answer'.\n\nMETHOD WAS THE TICKET'S: walked KeymapSet::default_keymaps() -- all 16 modes, ~170 bindings -- via a throwaway dump test, per doctrine(binding_dump_diff). Resolved client-side now: hjkl/arrows, 0/$/Home/End, G, PageUp/Down, Ctrl-d/Ctrl-u, panel cursor, tile cursor. ~15 more need NO enrichment and were deliberately left rather than half-done.\n\nTHREE PROTOCOL ADDITIONS, each argued as the smallest thing:\n * GridExtent { rows, cols } via GridUpdate::Extent -- the ticket's gap 1. Deliberately NOT framed by the labels: it is the size of the grid the labels are a window ONTO, so fit_to_labels leaves it alone and its batch position is not load-bearing (it addresses no position, so it can neither overtake nor be overtaken). The producer reads GridLayout::row_count()/col_count(), never the projected ranges -- reading it off the ranges is INVISIBLE IN THE TUI, which builds at offsets zero, and is exactly the browser bug.\n * ClientFrame::Command -\u003e Commands(Vec\u003cCommand\u003e). FORCED: one cursor key is SetSelected plus up to two scroll offsets, and a host that folds the selection but not the offset projects the wrong window. Same argument ServerFrame::Commands already carried.\n * ClientState::window (session-local) + Client::set_viewport. NOT DERIVABLE: row_labels.len() is the window CLIPPED to the grid, which equals the window everywhere except the bottom edge -- exactly where the scroll arithmetic matters. Nothing added to ViewState; both gating tests hold; transient_prefix untouched.\n\nTWO CORRECTIONS TO THE TICKET'S OWN GAP LIST:\n * Panel cursor movement WAS NEVER A GAP. improvise-edp already made viewmodel::flatten_cat_tree the one home both ends call, so calling it is the SHARED derivation, not a second one. Nobody had noticed. Worth checking other old 'known gaps' against what has landed since.\n * The unmodified-characters rule was MIS-SCOPED, not wrong. It was about KeyPattern::AnyChar, which is bound ONLY in the text-entry modes; in Normal there is no such fallback, so Ctrl-d has an exact binding and mirroring it is a faithful subset. Kept where it was earned, dropped where it was never true.\n\na_key_resolved_on_the_host_moves_state_no_client_can_hear STILL HOLDS and was not flipped -- it states a true property of host-side resolution, which bpq2 now AVOIDS by construction rather than contradicts.\n\nEach resolver mirrors its Cmd LINE FOR LINE, including where siblings disagree about a no-op move (Move and MoveTileCursor emit anyway; MovePanelCursor does not). Unifying them would make the client a second derivation, which is what lockstep testing exists to rule out.\n\nSIX INJECTED FAULTS, ALL FIRED. The five new lockstep tests build the client's cache with the server's OWN grid_of, drive client and real reduce_key over the same keypresses, and compare ViewState. nothing_in_a_non_text_mode_resolves_locally became FALSE and was replaced by a named list of the Normal-mode keys that still need the host.\n\nSTILL NEEDS THE HOST, truthfully: all prefix keys (transient_prefix is an address, not a keymap -- gotcha 13, left intact); save/wq/commit-export (Storage by definition); : execute-command; the wizard's Any binding (WizardCache has no Projection by design); CommandMode Backspace; enter-advance (its AbortChain is a fact about the effect chain, not a cursor); records/drill toggles (session-local residue).\n\nOrchestrator applied the one line the agent flagged and could not make: web/app.js now calls client.set_viewport rather than the associated Client.viewport, without which the client never learns its window and every cursor key vanishes upstream.\n\n1172 passing, clippy/fmt clean, wasm32 clean on all seven bundle crates.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-c4er","title":"[bug] There is no server door for a keypress; the asker gets no commands back","description":"Found by improvise-gsw (2026-09-19) and it is THE seam the browser MVP depends on. Blocks improvise-1ey.\n\nA browser client cannot resolve keys locally -- a binding names an INTENT resolved against a CmdContext while a Command is the RESOLVED MUTATION (see the improvise-gxi close reason and gotcha 38). So the client sends the raw keypress upstream as ClientMessage::Key { code, modifiers }.\n\nTHE GAP: Sessions::apply(app, id, cmd) takes a Command. ui::reduce::reduce_key(app, key) exists but returns only PROJECTIONS -- not the view-slice commands the ASKER must fold into its own ViewState. With keys travelling unresolved the host owes the asker back the commands its own keypress produced, and nothing returns them.\n\nNOTE this is NOT Applied::Diff.broadcast, which is what the OTHER clients fold (gotcha 36). The asker needs its own, and there is nowhere for them to come from.\n\nSUGGESTED SHAPE (from the gsw agent): Sessions::apply_key(app, id, key) -\u003e Applied, with the askers own view-slice commands in the result. Derive it rather than inventing -- doctrine(derive_the_enum_from_the_effects). The keypress is already KeyCode + KeyModifiers, which Command::SetTransientPrefix puts on the wire today; note improvise_protocol::KeyEvent itself does NOT derive Serialize/Deserialize, only its two components do, which is worth fixing at the same time.\n\nAlso from gsw: ClientMessage/ServerMessage envelopes currently live in the client crate because the VOCABULARY is in improvise-protocol but the FRAMING that says which a JSON blob is, is not. Moving them is a one-file lift and the ws-server will want the same ones -- do it here rather than letting two crates define them.","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T23:40:37Z","created_by":"Edward Langley","updated_at":"2026-09-19T23:42:47Z","closed_at":"2026-09-19T23:42:47Z","close_reason":"WRONG PREMISE -- CLOSED AND INVERTED by a user ruling (2026-09-19): keys are converted into commands IN THE BROWSER, and the keymap is a CLIENT-ONLY concept the server must not know at all.\n\nThis ticket asked for a server door for a keypress (Sessions::apply_key). There must not be one. The reasoning follows from the standing principle -- the model is shared, the UI is not -- and A KEYMAP IS UI. If the server knows the keymap, two clients cannot have different bindings, and that is a thing this architecture should permit: a browser client with browser-native keys and a TUI with vim keys, over the same document.\n\nSo the gap this ticket named does not exist. The asker never needs its own commands handed back, because the asker COMPOSED them and already knows what it sent. ClientMessage::Key should not exist; a client sends Commands only. ui::reduce::reduce_key stays, but it belongs to the native TUI -- which is a client that happens to run in-process -- not to the server client-facing surface.\n\nTHE REAL WORK THIS DISPLACES INTO: a client must hold enough PROJECTED STATE to resolve a binding into a Command. That is a requirement on what the projections carry, not a reason to move resolution serverward. Filed separately.\n\nThe one part of this ticket that survives independently: ClientMessage/ServerMessage envelopes belong in improvise-protocol rather than the client crate -- the vocabulary is there, the framing is not. Refiled with the projection work.","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -102,7 +108,27 @@ {"_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-cq1q.13","title":"ADR 007 step 2c: displays in status messages and the view title","description":"The third slice of improvise-cq1q.3 (ADR 007 step 2): the remaining places that show a key to a person.\n- Status and minibuffer messages that interpolate a category, item or view name: about 10 sites in improvise-command (ui/effect.rs, cmd/panel.rs, cmd/commit.rs, cmd/core.rs), plus `non_item_refusal` in improvise-protocol and the anyhow errors in workbook.rs that reach the status line.\n- The view title: `grid.rs` \" View: {}\" in the TUI, and `render.rs` `view_name` in the browser. Both read the key from `ViewState::active_view`; they need the active view's display from a projection (the ViewListCache already carries it since the first slice).\n\n## Acceptance Criteria\n- Tests first: with a view and a category displayed differently from their keys, the title and a representative status message (for example, the axis-change status) show the display.\n- Rival: interpolating the key. The test fails.\n","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T19:10:41Z","created_by":"agent-adr007","updated_at":"2026-09-25T19:10:41Z","dependencies":[{"issue_id":"improvise-cq1q.13","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T19:10:41Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.13","depends_on_id":"improvise-cq1q.3","type":"blocks","created_at":"2026-09-25T19:10:49Z","created_by":"agent-adr007","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"improvise-bw4p.4","title":"improvise-incremental differential probes: cover the cells each view actually draws (MAX_DROPPED=3 checks no drawn cell on views with 4+ None categories; sub_partials walks 2^n subsets)","description":"Found by Opus review of PR #343: on any view with 4 or more categories on None the differential checks none of the drawn cells (5-category model, wrong only at {C0=a,_Measure=Revenue} passes). Probe the keys each view draws (GridLayout cells, totals) instead of subsets; sub_partials is exponential in categories regardless of the limit (93ms at 20, 225ms at 22 in debug). Belongs with step 3/4.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-10-10T07:31:18Z","created_by":"mercury","updated_at":"2026-10-10T07:31:18Z","dependencies":[{"issue_id":"improvise-bw4p.4","depends_on_id":"improvise-bw4p","type":"parent-child","created_at":"2026-10-10T07:31:18Z","created_by":"mercury","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-bw4p.3","title":"ADR 009 follow-up: Derived has no hidden-item covers, totals or occupancy reads; the differential cannot probe them","description":"Step 2 trait is value(key, agg) and raw_fold(partial). The ADR's differential test compares every drawn cell, total and occupancy count, and hidden items are view-level covers (Model::evaluate_covering / evaluate_spanning). Add them to the trait and probes with step 4.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-10-10T07:18:50Z","created_by":"adr009-derived","updated_at":"2026-10-10T07:18:50Z","dependencies":[{"issue_id":"improvise-bw4p.3","depends_on_id":"improvise-bw4p","type":"parent-child","created_at":"2026-10-10T07:18:50Z","created_by":"adr009-derived","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-bw4p.2","title":"ADR 009 follow-up: move Derived into improvise-core and expose core's raw fold (aggregate_raw) so Eager stops wrapping the cache path","description":"Step 2 (PR 343) put Derived in improvise-incremental because the brief forbade touching core. ADR 009 clause 2 says core gains the trait. Move it (one file plus a pub use), have the evaluator read through it, and expose a public Model raw fold so Eager::raw_fold stops going through evaluate_aggregated. Flagged to the user, not decided.","notes":"User ruling 2026-10-10: Derived stays in improvise-incremental (seam crate). ADR 009 clause 2 to be amended to say so. ADR amendments may be stacked on the implementation PRs.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-10-10T07:18:47Z","created_by":"adr009-derived","updated_at":"2026-10-10T18:13:40Z","dependencies":[{"issue_id":"improvise-bw4p.2","depends_on_id":"improvise-bw4p","type":"parent-child","created_at":"2026-10-10T07:18:47Z","created_by":"adr009-derived","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-bw4p.1","title":"ADR 009 clause 1 deferred zset scope: Circuit, delay, flat_map, index_by, join_indexed, aggregate, resumable step(deadline), trace interface","description":"Step 1 (PR #341) built only the pure parts (group, map, filter, distinct, integrate/differentiate over slices). ADR 009 clause 1 also lists the Circuit and operators above; no step 2-8 ticket owns them. Place them with the step that first needs a clock or state between steps (likely step 4), or amend the ADR to say where they go.","notes":"User ruling 2026-10-10: zset weights stay i64, checked arithmetic, panic on overflow (debug+release). ADR 009 'abelian group' wording needs a qualifier (partial at the i64 boundary). Open follow-up: validate weights at wire entry (clause 9) so hostile frames are rejected rather than panicking.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-10-10T06:52:49Z","created_by":"mercury","updated_at":"2026-10-10T18:12:29Z","dependencies":[{"issue_id":"improvise-bw4p.1","depends_on_id":"improvise-bw4p","type":"parent-child","created_at":"2026-10-10T06:52:49Z","created_by":"mercury","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-z2vr","title":"ADR 009 step 8: delivery (notices per committed command, watermarks, corrections, shared bounded log, lossless wire, edge adapters)","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-10-10T05:52:17Z","created_by":"mercury","updated_at":"2026-10-10T05:52:17Z","dependencies":[{"issue_id":"improvise-z2vr","depends_on_id":"improvise-bw4p","type":"parent-child","created_at":"2026-10-10T05:52:30Z","created_by":"mercury","metadata":"{}"},{"issue_id":"improvise-z2vr","depends_on_id":"improvise-rnak","type":"blocks","created_at":"2026-10-10T05:52:17Z","created_by":"mercury","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-rnak","title":"ADR 009 step 7: live sources (change-reporting and query-pushdown)","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-10-10T05:52:15Z","created_by":"mercury","updated_at":"2026-10-10T05:52:15Z","dependencies":[{"issue_id":"improvise-rnak","depends_on_id":"improvise-bw4p","type":"parent-child","created_at":"2026-10-10T05:52:29Z","created_by":"mercury","metadata":"{}"},{"issue_id":"improvise-rnak","depends_on_id":"improvise-hrjz","type":"blocks","created_at":"2026-10-10T05:52:16Z","created_by":"mercury","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"improvise-hrjz","title":"ADR 009 step 6: Sum, Avg and totals through ExactSum (numtower)","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-10-10T05:52:13Z","created_by":"mercury","updated_at":"2026-10-10T05:52:13Z","dependencies":[{"issue_id":"improvise-hrjz","depends_on_id":"improvise-bw4p","type":"parent-child","created_at":"2026-10-10T05:52:29Z","created_by":"mercury","metadata":"{}"},{"issue_id":"improvise-hrjz","depends_on_id":"improvise-shbc","type":"blocks","created_at":"2026-10-10T05:52:14Z","created_by":"mercury","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"improvise-shbc","title":"ADR 009 step 5: formulas and the aggregate-over-expression pipeline (incremental SCCs, circular error at the depth bound)","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-10-10T05:52:12Z","created_by":"mercury","updated_at":"2026-10-10T05:52:12Z","dependencies":[{"issue_id":"improvise-shbc","depends_on_id":"improvise-bw4p","type":"parent-child","created_at":"2026-10-10T05:52:28Z","created_by":"mercury","metadata":"{}"},{"issue_id":"improvise-shbc","depends_on_id":"improvise-u2le","type":"blocks","created_at":"2026-10-10T05:52:12Z","created_by":"mercury","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"improvise-u2le","title":"ADR 009 step 4: order-free aggregates and occupancy, measured at ADR 008's fixture","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-10-10T05:52:10Z","created_by":"mercury","updated_at":"2026-10-10T05:52:10Z","dependencies":[{"issue_id":"improvise-u2le","depends_on_id":"improvise-5r8c","type":"blocks","created_at":"2026-10-10T05:52:11Z","created_by":"mercury","metadata":"{}"},{"issue_id":"improvise-u2le","depends_on_id":"improvise-bw4p","type":"parent-child","created_at":"2026-10-10T05:52:27Z","created_by":"mercury","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"improvise-5r8c","title":"ADR 009 step 3: the cell delta, the record tier and the dirty flag (values compare by float rules, NaN canonical)","status":"in_progress","priority":2,"issue_type":"task","assignee":"adr009-step3","owner":"el-github@elangley.org","created_at":"2026-10-10T05:52:08Z","created_by":"mercury","updated_at":"2026-10-10T18:16:43Z","started_at":"2026-10-10T18:16:43Z","dependencies":[{"issue_id":"improvise-5r8c","depends_on_id":"improvise-bw4p","type":"parent-child","created_at":"2026-10-10T05:52:27Z","created_by":"mercury","metadata":"{}"},{"issue_id":"improvise-5r8c","depends_on_id":"improvise-t8ow","type":"blocks","created_at":"2026-10-10T05:52:09Z","created_by":"mercury","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"improvise-t8ow","title":"ADR 009 step 2: the Derived trait with Eager as the temporary differential oracle","status":"in_progress","priority":2,"issue_type":"task","assignee":"adr009-derived","owner":"el-github@elangley.org","created_at":"2026-10-10T05:52:07Z","created_by":"mercury","updated_at":"2026-10-10T06:46:32Z","started_at":"2026-10-10T06:46:32Z","dependencies":[{"issue_id":"improvise-t8ow","depends_on_id":"improvise-bw4p","type":"parent-child","created_at":"2026-10-10T05:52:26Z","created_by":"mercury","metadata":"{}"},{"issue_id":"improvise-t8ow","depends_on_id":"improvise-tk51","type":"blocks","created_at":"2026-10-10T05:52:07Z","created_by":"mercury","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"improvise-tk51","title":"ADR 009 step 1: the zset crate (std-only Z-sets, group operations, no serde)","status":"in_progress","priority":2,"issue_type":"task","assignee":"adr009-zset","owner":"el-github@elangley.org","created_at":"2026-10-10T05:52:06Z","created_by":"mercury","updated_at":"2026-10-10T05:53:51Z","started_at":"2026-10-10T05:53:51Z","dependencies":[{"issue_id":"improvise-tk51","depends_on_id":"improvise-bw4p","type":"parent-child","created_at":"2026-10-10T05:52:26Z","created_by":"mercury","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"improvise-bw4p","title":"Epic: Incremental evaluation — implementing ADR 009","description":"Implements context/adr/009-incremental-evaluation.md (accepted 2026-10-10, PR #309). Steps are the ADR's Order list; each is a ticket behind the differential test. improvise-felo.4 records the ADR itself.","status":"open","priority":2,"issue_type":"epic","owner":"el-github@elangley.org","created_at":"2026-10-10T05:52:04Z","created_by":"mercury","updated_at":"2026-10-10T05:52:04Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-59nw.5","title":"ADR 005 step 5: entry points — typed(), the import measure read, formula literals; the JSON reader keeps source text","description":"ADR 005 Migration step 5 (clauses 3, 7, 12). The largest part is a hand-written JSON deserializer keeping each number's source text through raw_value. Needs step 3.","acceptance_criteria":"The import tests listed in ADR 005 Migration step 5 pass: CSV 12.50 and JSON 12.50 both keep scale; 1.5e3 imports as the exact 1500 and 1_000 as text; nan/inf/1e999/-1e999 import as text so #321's a_nonfinite_numeric_token_stays_text_rather_than_becoming_zero stays green unchanged; typed() reads inf, -inf and nan as clause 7's tokens and Infinity, NaN, 1e999 as text; 3/4 is text in an inferred column and a fraction in a column forced to a measure.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-10-09T04:35:48Z","created_by":"mercury","updated_at":"2026-10-09T04:35:48Z","dependencies":[{"issue_id":"improvise-59nw.5","depends_on_id":"improvise-59nw","type":"parent-child","created_at":"2026-10-09T04:35:48Z","created_by":"mercury","metadata":"{}"},{"issue_id":"improvise-59nw.5","depends_on_id":"improvise-59nw.3","type":"blocks","created_at":"2026-10-09T04:35:56Z","created_by":"mercury","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"improvise-59nw.4","title":"ADR 005 step 4: aggregates partition by exactness and use numtower's accumulator","description":"ADR 005 Migration step 4 (clause 8). Needs step 3. ADR 009's incremental Sum depends on the exact accumulator.","acceptance_criteria":"apply_agg and eval_agg route through numtower's accumulator; the review's [0.30000000000000004, 1e12, -1e12] seed passes through the evaluator and Decimal's own + fails it (rival injected, failing by name).","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-10-09T04:35:47Z","created_by":"mercury","updated_at":"2026-10-09T04:35:47Z","dependencies":[{"issue_id":"improvise-59nw.4","depends_on_id":"improvise-59nw","type":"parent-child","created_at":"2026-10-09T04:35:47Z","created_by":"mercury","metadata":"{}"},{"issue_id":"improvise-59nw.4","depends_on_id":"improvise-59nw.3","type":"blocks","created_at":"2026-10-09T04:35:55Z","created_by":"mercury","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-59nw.3","title":"ADR 005 step 3: both evaluators move to Num; FLOAT_EQ_EPSILON deleted; EQUAL(a, b, epsilon)","description":"ADR 005 Migration step 3. Needs step 2.","acceptance_criteria":"FLOAT_EQ_EPSILON is gone; the improvise-0bf tests assert one zero predicate (clause 6); EQUAL(a, b, epsilon) has its grammar and AST in improvise-formula, evaluation in both evaluators, and EQUAL in quote_name's keyword guard; 0.1 + 0.2 = 0.3 in a formula; one whole recompute measured at 18,000 and 90,000 cells against today, machine named.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-10-09T04:35:46Z","created_by":"mercury","updated_at":"2026-10-09T04:35:46Z","dependencies":[{"issue_id":"improvise-59nw.3","depends_on_id":"improvise-59nw","type":"parent-child","created_at":"2026-10-09T04:35:46Z","created_by":"mercury","metadata":"{}"},{"issue_id":"improvise-59nw.3","depends_on_id":"improvise-59nw.2","type":"blocks","created_at":"2026-10-09T04:35:54Z","created_by":"mercury","metadata":"{}"}],"dependency_count":1,"dependent_count":3,"comment_count":0} +{"_type":"issue","id":"improvise-59nw","title":"Epic: Exact decimal arithmetic — implementing ADR 005","description":"Implements ADR 005 (context/adr/005-exact-decimal-arithmetic.md, accepted by the user's 'approved' on PR #320, merged f0fb6880 on 2026-10-09). Migration steps 1-8 are the children, in order; each lands on its own with the suite green. Decided: three levels (exact Decimal, bounded exact fractions, inexact f64), K = 8 distinct-denominator groups in the exact accumulator, the 8,192-bit widening ceiling (the steward's proposal, confirmed by approval), numtower crate (clause 14). See the ADR's 'What the user decided' and 'Migration' sections.","acceptance_criteria":"Success Criteria: ADR 005's eight migration steps have each landed on main as their own PR with the suite green; formulas compute in exact decimal by default (0.1 + 0.2 = 0.3), SUM/AVG are order-independent, FLOAT_EQ_EPSILON is deleted, .improv and the wire carry exact values, and the 1.005 display regression test passes.","status":"open","priority":2,"issue_type":"epic","owner":"el-github@elangley.org","created_at":"2026-10-09T04:35:22Z","created_by":"mercury","updated_at":"2026-10-09T04:35:43Z","dependencies":[{"issue_id":"improvise-59nw","depends_on_id":"improvise-l2b8","type":"related","created_at":"2026-10-09T04:35:22Z","created_by":"mercury","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-cq1q.14","title":"model::id: slugify, id syntax, natural_cmp and display_suffixes per ADR 007 amendment 1 (pure parts)","description":"From ADR 007 answer 34 (PR #326, user comment: 'space should generally mean whitespace'). The merged slugify (PR #318, crates/improvise-core/src/model/id.rs, underscore_space_runs) implements U+0020 only. Nothing calls slugify yet, so no stored ids change. BLOCKED on the user's Approved of ADR 007: the steward chose the Unicode White_Space property as the ADR's own reading of whitespace.","acceptance_criteria":"slugify treats any char::is_whitespace run as one underscore, with a failing test first (Time\u003cTAB\u003ePeriod, Time\u003cNBSP\u003ePeriod, 'Time \\t Period' all give time_period); the c == ' ' test slugify_collapses_a_run_of_spaces_to_one_underscore is updated; core.md section 20 no longer says a space is U+0020. Do not start until the user has approved ADR 007.","status":"in_progress","priority":2,"issue_type":"task","assignee":"mercury","owner":"el-github@elangley.org","created_at":"2026-10-05T20:29:18Z","created_by":"mercury","updated_at":"2026-10-09T19:46:03Z","started_at":"2026-10-09T19:41:57Z","dependencies":[{"issue_id":"improvise-cq1q.14","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-10-05T20:29:18Z","created_by":"mercury","metadata":"{}"},{"issue_id":"improvise-cq1q.14","depends_on_id":"improvise-cq1q.2","type":"related","created_at":"2026-10-05T20:29:18Z","created_by":"mercury","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-31oe.13","title":"A slash inside a pipe-quoted Cat/Item coordinate mis-splits silently","description":"From the Opus review of #316 (issuecomment-5965516931): set-cell 5 |a/b|/x exits 0 and writes category a, item b/x. cli.md section 13 documents the mis-split, but it is the silent-wrong class improvise-31oe.3 exists to remove. Also: create-view || makes a view with an empty name and exits 0 (already possible with an empty quoted string).","acceptance_criteria":"A slash inside a pipe-quoted coordinate token is part of the name, not the Cat/Item separator, with a test written first; or the ambiguity is refused out loud.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-10-03T04:27:59Z","created_by":"mercury","updated_at":"2026-10-03T04:27:59Z","dependencies":[{"issue_id":"improvise-31oe.13","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-10-03T04:27:59Z","created_by":"mercury","metadata":"{}"},{"issue_id":"improvise-31oe.13","depends_on_id":"improvise-31oe.3","type":"related","created_at":"2026-10-03T04:27:59Z","created_by":"mercury","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-felo.12","title":"Item picker for high-cardinality categories (reuse the windowed category-panel list)","description":"ADR 008 rev 4 finding: no item picker exists; page selection moves one item per key, unusable at 1,000 items. Must reuse the windowed, diffed category-panel list (ADR 008 clause 10), not a second derivation.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-10-03T00:36:58Z","created_by":"mercury","updated_at":"2026-10-03T00:36:58Z","dependencies":[{"issue_id":"improvise-felo.12","depends_on_id":"improvise-felo","type":"parent-child","created_at":"2026-10-03T00:36:58Z","created_by":"mercury","metadata":"{}"},{"issue_id":"improvise-felo.12","depends_on_id":"improvise-felo.10","type":"related","created_at":"2026-10-03T00:36:58Z","created_by":"mercury","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-cq1q.13","title":"ADR 007 step 2c: displays in status messages and the view title","description":"The third slice of improvise-cq1q.3 (ADR 007 step 2): the remaining places that show a key to a person.\n- Status and minibuffer messages that interpolate a category, item or view name: about 10 sites in improvise-command (ui/effect.rs, cmd/panel.rs, cmd/commit.rs, cmd/core.rs), plus `non_item_refusal` in improvise-protocol and the anyhow errors in workbook.rs that reach the status line.\n- The view title: `grid.rs` \" View: {}\" in the TUI, and `render.rs` `view_name` in the browser. Both read the key from `ViewState::active_view`; they need the active view's display from a projection (the ViewListCache already carries it since the first slice).\n\n## Acceptance Criteria\n- Tests first: with a view and a category displayed differently from their keys, the title and a representative status message (for example, the axis-change status) show the display.\n- Rival: interpolating the key. The test fails.\n","status":"in_progress","priority":2,"issue_type":"task","assignee":"adr007-2c","owner":"el-github@elangley.org","created_at":"2026-09-25T19:10:41Z","created_by":"agent-adr007","updated_at":"2026-10-09T19:41:56Z","started_at":"2026-10-09T19:41:56Z","dependencies":[{"issue_id":"improvise-cq1q.13","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T19:10:41Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.13","depends_on_id":"improvise-cq1q.3","type":"blocks","created_at":"2026-09-25T19:10:49Z","created_by":"agent-adr007","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"improvise-nn4s.10","title":"ADR 004 spells reverse selection as background=accent, which REVERSED draws inverted; the accent must be the foreground","description":"Found in the post-merge review of PR #319 (merged head 3583359a). This needs the user,\nbecause it is a correction to accepted ADR 004 text, not to code.\n\nADR 004 clause 3 and the vocabulary section say the twelve `fg(Black).bg(accent)`\nselection sites become faces that inherit `selection` (which carries `reverse`) and\n\"override `background` from its own surface's accent\". That spelling is inverted.\nREVERSED (SGR 7) swaps the cell's foreground and background. So `bg = accent` plus\n`reverse` draws accent-coloured text on the terminal's default-foreground colour. It\ndoes not draw default-background ink on the accent. Clause 5's CSS rule does the same\nswap (`.reversed { color: var(--face-bg) }`), so the browser inverts identically.\n\nTo reproduce \"ink on accent\" with reverse, the derived face must state the accent as\nits FOREGROUND, leave background unstated, and set reverse. That is also what makes the\nlight-terminal fix in clause 3 work: the ink becomes the terminal's own background.\n\nThis blocks improvise-nn4s.6. If the ADR is migrated as written, every selection\nhighlight renders inverted. It is also not a no-op on a TestBackend buffer. Clause 8\nrequires that change to be named as an exception, with who decided it. So nn4s.6 needs\nboth the corrected spelling and the user's ruling on the exception.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T17:09:00Z","created_by":"Edward Langley","updated_at":"2026-09-25T17:09:00Z","labels":["adr","adversarial-review","needs-user","pr-319"],"dependencies":[{"issue_id":"improvise-nn4s.10","depends_on_id":"improvise-nn4s","type":"parent-child","created_at":"2026-09-25T17:09:00Z","created_by":"Edward Langley","metadata":"{}"}],"comments":[{"id":"04cf9807-5d5e-5926-a40c-56dfa32f62a7","issue_id":"improvise-nn4s.10","author":"Edward Langley","text":"USER RULING 2026-09-25, verbatim: 'ADR 004 is allowed to specify visual changes that make the UI more consistent'. So clause 8's exceptions are not limited to category-add: the selection faces using reverse (with the accent as FOREGROUND, per this ticket's correction) are an allowed visual change. Unblocks nn4s.6 once the ADR text is corrected.","created_at":"2026-09-25T17:15:08Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} {"_type":"issue","id":"improvise-cq1q.11","title":"ADR 007 plan: cq1q.4 (re-key) omits two input edges it reaches before their own steps — the old-format .improv reader/writer (before .5) and wire operands/projection names (before .7/.8)","description":"Found by the adversarial review of PR #318 (head 689480bf), while checking the cq1q plan's ordering.\n\nimprovise-cq1q.4 (re-key storage by id) lists these input edges where display→id resolution moves: minibuffer commits, the command-line/script parser, and formula reference resolution. Two more edges reach the re-key before their own steps land, and the ticket does not name either.\n\n1. **The .improv reader and writer, before cq1q.5.** After .4, the keys are slugs (`region`, `east`) and the displays are the typed names. Until .5 lands, the only file format is the old one. So:\n - the old-format loader must already assign ids by slugify and resolve references against displays (clause 5's old arm, currently .5's scope);\n - the writer must write displays, not keys.\n\n If the writer writes keys, every save between .4 and .5 lowercases and strips the user's names (`Region` → `region`, `Café` → `caf`), and nothing can restore them.\n\n2. **Wire operands and projection names, before cq1q.7/.8.** After .3, the browser's panels show displays taken from the projections, and the browser composes `SetAxis { category, .. }`, `HideItem` and the rest from the same strings. After .4 the host keys by id, so between .4 and .8 every such command names a key that does not exist. That covers almost every real name, because slugify folds case. The fix is one of:\n - `ui/reduce.rs::model_effects` resolves display→id as an interim input edge;\n - the projections keep carrying the key alongside the display.\n\n ViewState's name-keyed cursors, expanded_cats and view history (cq1q.7) meet the same seam.\n\nADR 007 clause 11 orders the migration the same way (re-key, then persistence, then projections, then vocabulary), so this is a gap in the step boundaries rather than in the ADR's decisions. What is needed: .4's acceptance criteria say the suite stays green, and they name these two interim edges. Or .4 is split so that each step leaves main shippable. A test for edge 1: a document with display `Region` survives save/load between .4 and .5 with its display intact.\n\nA smaller point: cq1q.3 depends on cq1q.2, but adding a display field needs neither slugify nor the allocator. The dependency serializes work for no reason.\n","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T06:19:38Z","created_by":"Edward Langley","updated_at":"2026-09-25T17:51:52Z","closed_at":"2026-09-25T17:51:52Z","close_reason":"PR #318 merged (6da6a040, head 7ddc50ac); reviewed PASS (issuecomment-5836573405); cq1q.11 closed by the re-sequenced bead graph","dependencies":[{"issue_id":"improvise-cq1q.11","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T06:19:38Z","created_by":"Edward Langley","metadata":"{}"}],"comments":[{"id":"ca207052-0f62-5f31-b8d9-3d4a3a30b68f","issue_id":"improvise-cq1q.11","author":"agent-adr007","text":"agent-adr007: resolved by reordering, with no new beads. (1) cq1q.7 (projections and ViewState carry an id field) now depends only on cq1q.3 and lands before the re-key, using the display-first trick: id == key == name until cq1q.4 changes what the key is. So the wire never names a key that does not exist, and no interim resolver in model_effects is needed. (2) cq1q.4 now owns the old-format reader and writer edge: the loader slugifies, the writer writes displays, and a save/load test keeps the displays Region and Café intact. (3) The cq1q.3 -\u003e cq1q.2 dependency is removed. cq1q.4 now depends on .2, .3 and .7, and cq1q.8 depends on .4 and .7. Details are appended to the notes of cq1q.4 and cq1q.7.","created_at":"2026-09-25T06:42:12Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} {"_type":"issue","id":"improvise-8jk6.16","title":"A --measure value that does not read as a number is dropped silently and the import exits 0","description":"Found in the adversarial review of PR #321 at efaa5050. A value in a column the user forced to Measure that does not read as a number is dropped with no warning, and the import exits 0. This is the same \"saved less than was asked\" family that context/code/cli.md section 13 closes for --category, --formula and kept-nothing, but it is not covered there.\n\nRepro, identical on main at c91cc055 and on the PR head:\n\n printf 'k,v\\na,abc\\nb,7\\nc,\"1,000\"\\n' \u003e nn.csv\n improvise import nn.csv --no-wizard --measure v -o nn.improv; echo \"exit=$?\"\n\nWhat happens: only `_Measure=v, k=b = 7` is written, \"Saved to\" is printed, and the exit status is 0. The `abc` and `1,000` values are gone with no report. Row `a` also loses its whole record, which is the improvise-8jk6.14 loss, but here the value was present and could not be read. That is a different reason.\n\nSince 8jk6.4, `csv_parser::number_in` is the one gate that decides whether a measure's CSV text is a number (via `Scalars::number` in `Plan::place`). So there is one place to count what it refused. Decide: warn with a count and the column (the 5j9 and 8jk6.6 partial-skip shape), or refuse headless. A thousands separator such as \"1,000\" is worth deciding about explicitly, since `number_in` does not read it.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T06:14:58Z","created_by":"Edward Langley","updated_at":"2026-09-25T06:14:58Z","labels":["headless","import"],"dependencies":[{"issue_id":"improvise-8jk6.16","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T06:14:58Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -116,38 +142,37 @@ {"_type":"issue","id":"improvise-8jk6.14","title":"Import silently stores nothing for a record with no measure value; a measureless CSV in a folder vanishes","description":"Found in the delta review of PR #314 at c43ee724. A record whose accepted measures are all absent writes no cell, and no warning says so. The categories and items it named are created, but the record is gone. The same thing held for a single-file import before #314, but folder import makes it easy to hit. Reproduced: improvise import \u003cdir\u003e --no-wizard, where dir holds age.csv (entity,age-months / src/a.rs,2) and tags.csv (entity,tag / e1,red). The output has items e1 and red and a records view for tags, but ## Data holds only the age line, and the tags view draws zero rows. The CLI prints only \"Saved to\". The test a_file_with_no_measure_gets_a_records_view_not_another_files_measure asserts row_count 0 (\"a record with no measure has no cell\"), which pins the loss rather than the intent. Decide: warn and count (as 5j9 did for skips), or store the record, e.g. as a Text cell or a presence marker. Records are sparse, and the user wants sparse records kept.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T04:58:03Z","created_by":"Edward Langley","updated_at":"2026-09-25T04:58:03Z","labels":["import"],"dependencies":[{"issue_id":"improvise-8jk6.14","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T04:58:03Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-n28d.1","title":"Drill shows only informative columns: drop categories constant across the drilled records","description":"USER 2026-09-25: 'I think 1+2 make sense, reseting is fine'. When drilling a cell, the _Drill view's records columns include the categories the drill fixed (they are on Axis::Filter at one item, gotcha 27) -- constant across every drilled record, so they carry no information. Default drill columns: categories that vary among the drilled records, plus the measures the drilled cell shows (not every measure in the model). Drill columns RESET on each drill (the user: 'reseting is fine'); _Drill stays virtual and unpersisted. Builds on improvise-31oe.5 (records columns follow the view). Tests first: a drill fixes Region and Month, so neither is a column; a category that varies stays; a hidden measure is absent; re-drilling resets. Mind pending-edit addressing by (row, RecordCol) (gotcha 1): column indices change with the column set.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-25T04:57:25Z","created_by":"Edward Langley","updated_at":"2026-09-25T04:57:25Z","labels":["drill","records","ux"],"dependencies":[{"issue_id":"improvise-n28d.1","depends_on_id":"improvise-31oe.5","type":"blocks","created_at":"2026-09-25T04:57:32Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-n28d.1","depends_on_id":"improvise-n28d","type":"parent-child","created_at":"2026-09-25T04:57:25Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-cq1q.10","title":"ADR 007 step 9: import generates ids by slugify and never parses them from headers","description":"ADR 007 answer 8 and clause 5, \"Import\". CSV and JSON dimension names are displays and are never parsed for ids. Every id comes from the workbook's IdAllocator, so two headers that slug alike get a gensym suffix. The saved file is the new version. Most of this falls out of step 3, when creation sites allocate ids. This bead is the import-specific evidence, plus whatever the wizard's preview or naming steps show.\n\nImport is other agents' ground (improvise-8jk6.3/.4/.5/.6 and improvise-lpzu), so coordinate and merge.\n\n## Acceptance Criteria\n- Tests first, through `improvise import --no-wizard`:\n - headers `Region(code)`, `Size(cm)` and `Time Period`/`Time_Period` import as displays with ids `regioncode`, `sizecm`, `time_period` and `time_period-\u003cn\u003e`;\n - the saved file pipe-quotes `|Region(code)|(regioncode)`;\n - the saved file reloads with every cell still drawn (improvise-80rm).\n- Rival: parse a trailing `(token)` as an id, and the first test fails.\n","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T04:50:38Z","created_by":"agent-adr007","updated_at":"2026-09-25T04:50:38Z","dependencies":[{"issue_id":"improvise-cq1q.10","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T04:50:38Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.10","depends_on_id":"improvise-cq1q.5","type":"blocks","created_at":"2026-09-25T04:51:03Z","created_by":"agent-adr007","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-cq1q.9","title":"ADR 007 step 8: clause 12 — a formula edit captures its display-to-id mapping when the editor opens","description":"ADR 007 clause 12: a formula edit maps its display names to ids when the editor opens, following the OpenedCell pattern. It is blocked by the user question in improvise-hexb item 2 and improvise-m56i item 6: which side holds the mapping, what ReplaceFormula.raw carries on the wire, and how an ambiguous typed name resolves. The TUI and the browser must reach the same answer.\n\n## Acceptance Criteria\n- The required regression test, as corrected by improvise-hexb item 1:\n 1. Open the editor on `Profit = Revenue - Cost`.\n 2. From another client, rename `Revenue` to `Sales`, and rename a different item to `Revenue`.\n 3. Commit `Revenue - Cost * 1`.\n 4. The stored formula references the original item's id, and the commit is not refused.\n- Two rivals, each seen to fail: resolving at commit time (binds to the new Revenue), and refusing on rename.\n- A newly typed name that does not resolve is refused, and the refusal names it.\n- A captured id that is gone at commit gets Rejection::Gone.\n","notes":"Updated for the user's decision of 2026-09-25 (answer 32; PR #326): the CLIENT holds the capture, as it holds OpenedCell, and the resolver lives in improvise-protocol so the TUI and the browser share it. ReplaceFormula.raw and AddFormula.raw carry only id-form text, and the host never resolves a display name. It only parses ids, and one that is gone is Rejection::Gone. Typed names new to the edit resolve against the client's id-bearing projection, displays first and then ids (answer 31); an ambiguous display is refused with the candidates' ids listed. The regression test edits a non-name token (Revenue - Cost -\u003e Revenue - Cost * 1), per hexb item 1. The dependency on improvise-hexb is satisfied: it is answered.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T04:50:26Z","created_by":"agent-adr007","updated_at":"2026-09-25T18:59:40Z","dependencies":[{"issue_id":"improvise-cq1q.9","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T04:50:26Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.9","depends_on_id":"improvise-cq1q.6","type":"blocks","created_at":"2026-09-25T04:51:00Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.9","depends_on_id":"improvise-cq1q.8","type":"blocks","created_at":"2026-09-25T04:51:01Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.9","depends_on_id":"improvise-hexb","type":"blocks","created_at":"2026-09-25T04:51:02Z","created_by":"agent-adr007","metadata":"{}"}],"dependency_count":3,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-cq1q.9","title":"ADR 007 step 8: clause 12 — a formula edit captures its display-to-id mapping when the editor opens","description":"ADR 007 clause 12: a formula edit maps its display names to ids when the editor opens, following the OpenedCell pattern. It is blocked by the user question in improvise-hexb item 2 and improvise-m56i item 6: which side holds the mapping, what ReplaceFormula.raw carries on the wire, and how an ambiguous typed name resolves. The TUI and the browser must reach the same answer.\n\n## Acceptance Criteria\n- The required regression test, as corrected by improvise-hexb item 1:\n 1. Open the editor on `Profit = Revenue - Cost`.\n 2. From another client, rename `Revenue` to `Sales`, and rename a different item to `Revenue`.\n 3. Commit `Revenue - Cost * 1`.\n 4. The stored formula references the original item's id, and the commit is not refused.\n- Two rivals, each seen to fail: resolving at commit time (binds to the new Revenue), and refusing on rename.\n- A newly typed name that does not resolve is refused, and the refusal names it.\n- A captured id that is gone at commit gets Rejection::Gone.\n","notes":"Updated for the user's decision of 2026-09-25 (answer 32; PR #326): the CLIENT holds the capture, as it holds OpenedCell, and the resolver lives in improvise-protocol so the TUI and the browser share it. ReplaceFormula.raw and AddFormula.raw carry only id-form text, and the host never resolves a display name. It only parses ids, and one that is gone is Rejection::Gone. Typed names new to the edit resolve against the client's id-bearing projection, displays first and then ids (answer 31); an ambiguous display is refused with the candidates' ids listed. The regression test edits a non-name token (Revenue - Cost -\u003e Revenue - Cost * 1), per hexb item 1. The dependency on improvise-hexb is satisfied: it is answered.\nAdded 2026-10-02 (PR #326): clause 11 now lists this step as step 7 (hexb item 3), after steps 5 (projections) and 6 (vocabulary), and the regression test is the corrected one that edits a non-name token (Revenue - Cost -\u003e Revenue - Cost * 1). No other change.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T04:50:26Z","created_by":"agent-adr007","updated_at":"2026-10-02T22:52:56Z","dependencies":[{"issue_id":"improvise-cq1q.9","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T04:50:26Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.9","depends_on_id":"improvise-cq1q.6","type":"blocks","created_at":"2026-09-25T04:51:00Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.9","depends_on_id":"improvise-cq1q.8","type":"blocks","created_at":"2026-09-25T04:51:01Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.9","depends_on_id":"improvise-hexb","type":"blocks","created_at":"2026-09-25T04:51:02Z","created_by":"agent-adr007","metadata":"{}"}],"dependency_count":3,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-cq1q.8","title":"ADR 007 step 7: wire operands by id, create by display, Rejection::Gone, ids_named and broken_chain's id arm","description":"ADR 007 clauses 6 and 7: the wire vocabulary by id; migration step 6.\n- Every ModelCommand operand that names an existing thing is an id. A create op takes a display and its parent's id: AddItem { category: CategoryId, display }, AddItemInGroup, AddFormula { target_category, raw }.\n- CreateView { display, switch: true } replaces the [CreateView, SwitchView] compound.\n- RenameRecordCoordinate { record, category: CategoryId, item: ItemRef::{Existing(ItemId), New(display)} }, and the category in CellId::Coordinate becomes a CategoryId.\n- The older variants move to ids one whole variant at a time: SetAxis, SetPageSelection, HideItem, SetCell/ClearCell { view }, and the rest.\n- Rejection::Gone(Named) comes with an exhaustive ids_named feeding Checked::against, and an id arm in broken_chain (improvise-gkyo). Rejection loses Copy. Remove CheckedBatch's created-record exemption, which no longer has a producer (improvise-p003 item 5).\n- The host assigns every id.\n\nThis may be split per variant group if it is too wide for one PR.\n\n## Acceptance Criteria\n- Tests first:\n - [RemoveFormula(f), ReplaceFormula(f)] is refused whole;\n - a command naming a removed id gets Rejection::Gone with the id's text;\n - an AddItem racing a remove-and-re-add of its category lands nowhere (Gone), not on a namesake. The rival, a parent addressed by display, fails it;\n - create-and-switch-view is one command and switches to the id the host assigned.\n- vocabulary_is_complete and vocabulary_is_composed still hold.\n","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T04:50:15Z","created_by":"agent-adr007","updated_at":"2026-09-25T04:50:15Z","dependencies":[{"issue_id":"improvise-cq1q.8","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T04:50:15Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.8","depends_on_id":"improvise-cq1q.4","type":"blocks","created_at":"2026-09-25T06:41:56Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.8","depends_on_id":"improvise-cq1q.7","type":"blocks","created_at":"2026-09-25T04:50:59Z","created_by":"agent-adr007","metadata":"{}"}],"dependency_count":2,"dependent_count":2,"comment_count":0} {"_type":"issue","id":"improvise-cq1q.7","title":"ADR 007 step 6: projections carry id and display; ViewState keyed by id","description":"ADR 007 clause 6, projections and ViewState; migration step 5.\n- The projections carry id and display together:\n - CategoryRow { id, display, items: Vec\u003c(ItemHandle, String)\u003e, axis }, with ItemHandle::{Item(ItemId), FormulaTarget(FormulaId)} (improvise-i0n7);\n - FormulaCache rows { id, stored, display };\n - ViewSummary { id, display, .. }.\n- AxisFrame labels stay display strings.\n- ViewState keys by id: cursors, expanded_cats, view_back_stack/view_forward_stack, and active_view (improvise-p003).\n\n## Acceptance Criteria\n- Tests first: after a display rename of a category, that client's expanded_cats still holds the category, and its cursors and back history still name the view after a view rename. The rival, name keys, fails both.\n- The panel's formula row shows `Profit (profit) = Revenue - Cost` and shows ids where a display is ambiguous (answer 19). Use a fixture with two items displayed `Revenue` in different categories.\n- view_state_survives_a_json_round_trip still holds.\n","notes":"Reordered for improvise-cq1q.11 (agent-adr007, 2026-09-25). This step now lands BEFORE the re-key (cq1q.4) and depends only on cq1q.3. The projections gain an id field beside the display (CategoryRow, FormulaCache, ViewSummary). Clients compose every wire operand from that id field, and ViewState keys its cursors, expanded_cats, view history and active_view by it. Until cq1q.4 lands, id == the key == the name, so nothing changes behaviour. After cq1q.4 the key becomes the slug, and the browser's commands still name real keys. That closes the gap without an interim display-\u003eid resolver in model_effects. Added acceptance criterion: a test-only document whose key differs from its display (the cq1q.3 hook) composes SetAxis/HideItem from the panel with the KEY, and the host applies it. The rival, composing from the display, fails.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T04:50:03Z","created_by":"agent-adr007","updated_at":"2026-09-25T06:42:11Z","dependencies":[{"issue_id":"improvise-cq1q.7","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T04:50:03Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.7","depends_on_id":"improvise-cq1q.3","type":"blocks","created_at":"2026-09-25T06:41:46Z","created_by":"agent-adr007","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} -{"_type":"issue","id":"improvise-cq1q.6","title":"ADR 007 step 5: formula identity — FormulaId becomes one id, formula text over ids, lift kb1d","description":"ADR 007 clauses 1 and 5, and the km71 section: formula identity.\n- FormulaId's private fields become one id. Signatures do not change, which is why km71 made it opaque.\n- A formula's target display lives on the Formula. Where it is stored depends on the user's answer to improvise-m56i item 2.\n- Formula text is stored over ids (Formula::raw is no longer verbatim) and quoted through quote::quote_name (for example `|2024|` and `|sum|`).\n- WHERE/IS filter values are item ids in the stored text (the ADR's reading of answer 3; improvise-m56i item 5).\n- A target change becomes a display rename, so kb1d's refusal outside _Measure is lifted.\n\nBlocked by the user's answers to improvise-m56i items 2 and 6.\n\n## Acceptance Criteria\n- Tests first: renaming an item that a formula references does not change the formula's stored text or its FormulaId. The rival, a FormulaId still derived from (target, target_category), fails it.\n- Retargeting a formula outside _Measure succeeds. The test is the kb1d refusal inverted.\n- An id that begins with a digit or is a keyword round-trips through formula text.\n","notes":"Updated for the user's decisions of 2026-09-25 (answer 30; PR #326). This step implements: (1) FormulaId becomes one id, allocated separately from the target. (2) For a target outside _Measure, add_formula keeps creating the real item (its own ItemId), and the formula line names that target by the ITEM's id. For a _Measure target, Formula carries the target's display, and a formula cell's CellKey carries the FormulaId text under _measure. (3) Formula text is stored over ids, and a WHERE/IS filter value is an item id. The exact formula-line spelling waits for Q23 on PR #326; option (a) is recommended there. Added acceptance criteria: a formula targeting CustomCat item Tax has item id tax and a distinct formula id (tax-\u003cn\u003e). Renaming the item's display changes neither id, and the formula line still names tax.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T04:49:52Z","created_by":"agent-adr007","updated_at":"2026-09-25T18:59:39Z","dependencies":[{"issue_id":"improvise-cq1q.6","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T04:49:52Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.6","depends_on_id":"improvise-cq1q.4","type":"blocks","created_at":"2026-09-25T04:50:55Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.6","depends_on_id":"improvise-m56i","type":"blocks","created_at":"2026-09-25T04:50:56Z","created_by":"agent-adr007","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0} -{"_type":"issue","id":"improvise-cq1q.1","title":"ADR 007: text fixes from the review tickets (no decision changes)","description":"Text-only fixes to context/adr/007-stable-identities.md. They clarify the ADR and change no decision the user made. This is an ADR PR, so it needs the user's explicit approval before it merges (AGENTS.md, \"ADR pull requests\").\n\nFold in:\n- improvise-le4p. In the new-version arm of view_entry, try axis_line (id_ref ~ \": \" ~ axis keyword) before format_line. The id `format` then reads as an axis line, and no number format is spelled as an axis keyword. id_ref needs no quoting arm. Required test: a category displayed `Format` on a row axis round-trips.\n- improvise-p003:\n 1. The virtual views get the reserved ids `_records` and `_drill`, following answer 23 (\"no non-virtual id can start with _\").\n 2. _Index/_Dim synthetic coordinates are record positions, not items. They are outside the id namespace and are never persisted. This is the ADR's own reading; flag it as such.\n 3. ViewState::active_view and Workbook::active_view hold a ViewId.\n 4. The collapsed set is (CategoryId, group name) while groups have no id (answer 9), and `collapsed:` writes the category id then the group name.\n 5. Under clause 6, CheckedBatch's created-record exemption has nothing left that produces it. It is removed in the wire step.\n- improvise-i0n7, what remains: on an ItemHandle::FormulaTarget row, remove means RemoveFormula, and rename means a display rename of the formula target.\n- improvise-hexb items 1, 3 and 4:\n - The regression test edits a non-name token (`Revenue - Cost` -\u003e `Revenue - Cost * 1`) instead of committing unedited text.\n - Clause 11 lists clause 12's capture step.\n - Fix the drift in \"the only reader is one test\" and \"on the idempotent path\" (this is also improvise-8vs2 item 4).\n - The kb.pl fact decision('improvise-sboz', a_formula_edit_maps_display_names_to_ids_when_the_editor_opens, ...) restates the vacuous test. It must be replaced in the same change, and only the orchestrator writes kb.pl (scripts/kb replace).\n- improvise-8tk5, improvise-80rm, improvise-gkyo and improvise-rxz9 are already answered by revision 8 (answers 13 and 15; clauses 5, 6 and 7). Add one sentence on merges: two branches that add different things under one slug produce a duplicate id, and the merged file's load is refused (answers 11 and 29).\n\nOut of scope: improvise-m56i items 2 and 6, and improvise-hexb item 2. Those are user questions.\n\nAcceptance: `./scripts/bd-audit-ids` passes, no clause's decision changes, and the user approves on the PR.\n","status":"in_progress","priority":2,"issue_type":"task","assignee":"agent-adr007","owner":"el-github@elangley.org","created_at":"2026-09-25T04:48:44Z","created_by":"agent-adr007","updated_at":"2026-09-25T17:52:26Z","started_at":"2026-09-25T17:52:26Z","lease_expires_at":"2026-09-25T17:57:26Z","heartbeat_at":"2026-09-25T17:52:26Z","dependencies":[{"issue_id":"improvise-cq1q.1","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T04:48:44Z","created_by":"agent-adr007","metadata":"{}"}],"comments":[{"id":"bb182e07-65db-5fa1-84f0-1473d61d7fa8","issue_id":"improvise-cq1q.1","author":"Edward Langley","text":"From the adversarial review of PR #318 (head 689480bf), which is Claude as an agent in the owner's session. Here are two readings that clause 4 leaves implicit and PR #318 had to choose. Consider flagging them in the ADR as its own readings, as p003 item 2 does.\n\n1. **\"Space\" means U+0020 only.** A tab, a no-break space (U+00A0) and an ideographic space (U+3000) are stripped, not turned into `_`. So `Time\\tPeriod` → `timeperiod`, which collides with `TimePeriod` rather than `Time Period`. The reasoning is in context/code/core.md §19. A tab-separated import header is the realistic case.\n2. **A slug made only of `-` is a legal bare id.** Displays `-` and `---` slug to `-` and `---`, and they are not treated as empty. The id_token alphabet admits them, and an `.improv` line such as `-: row`, or an item line beginning `- -`, is legal but unreadable. Step 5's \"if nothing is left\" treats only the empty string as empty.\n","created_at":"2026-09-25T06:19:59Z"}],"dependency_count":0,"dependent_count":1,"comment_count":1} +{"_type":"issue","id":"improvise-cq1q.6","title":"ADR 007 step 5: formula identity — FormulaId becomes one id, formula text over ids, lift kb1d","description":"ADR 007 clauses 1 and 5, and the km71 section: formula identity.\n- FormulaId's private fields become one id. Signatures do not change, which is why km71 made it opaque.\n- A formula's target display lives on the Formula. Where it is stored depends on the user's answer to improvise-m56i item 2.\n- Formula text is stored over ids (Formula::raw is no longer verbatim) and quoted through quote::quote_name (for example `|2024|` and `|sum|`).\n- WHERE/IS filter values are item ids in the stored text (the ADR's reading of answer 3; improvise-m56i item 5).\n- A target change becomes a display rename, so kb1d's refusal outside _Measure is lifted.\n\nBlocked by the user's answers to improvise-m56i items 2 and 6.\n\n## Acceptance Criteria\n- Tests first: renaming an item that a formula references does not change the formula's stored text or its FormulaId. The rival, a FormulaId still derived from (target, target_category), fails it.\n- Retargeting a formula outside _Measure succeeds. The test is the kb1d refusal inverted.\n- An id that begins with a digit or is a keyword round-trips through formula text.\n","notes":"Updated for the user's decisions of 2026-09-25 (answer 30; PR #326). This step implements: (1) FormulaId becomes one id, allocated separately from the target. (2) For a target outside _Measure, add_formula keeps creating the real item (its own ItemId), and the formula line names that target by the ITEM's id. For a _Measure target, Formula carries the target's display, and a formula cell's CellKey carries the FormulaId text under _measure. (3) Formula text is stored over ids, and a WHERE/IS filter value is an item id. The exact formula-line spelling waits for Q23 on PR #326; option (a) is recommended there. Added acceptance criteria: a formula targeting CustomCat item Tax has item id tax and a distinct formula id (tax-\u003cn\u003e). Renaming the item's display changes neither id, and the formula line still names tax.\nAdded 2026-10-02 (PR #326, option (a) of Q23, which awaits the user): an expression and a WHERE/IS value name a formula target outside _Measure by its ITEM id and a _Measure target by its FORMULA id (ItemHandle). The writer's pipe-quoting of a display that looks like Name(x) also covers a formula-line target. Acceptance criterion: a _Measure target displayed 'Margin(%)' round-trips through save and load.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T04:49:52Z","created_by":"agent-adr007","updated_at":"2026-10-02T22:52:48Z","dependencies":[{"issue_id":"improvise-cq1q.6","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T04:49:52Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.6","depends_on_id":"improvise-cq1q.4","type":"blocks","created_at":"2026-09-25T04:50:55Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.6","depends_on_id":"improvise-m56i","type":"blocks","created_at":"2026-09-25T04:50:56Z","created_by":"agent-adr007","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"improvise-cq1q.1","title":"ADR 007: text fixes from the review tickets (no decision changes)","description":"Text-only fixes to context/adr/007-stable-identities.md. They clarify the ADR and change no decision the user made. This is an ADR PR, so it needs the user's explicit approval before it merges (AGENTS.md, \"ADR pull requests\").\n\nFold in:\n- improvise-le4p. In the new-version arm of view_entry, try axis_line (id_ref ~ \": \" ~ axis keyword) before format_line. The id `format` then reads as an axis line, and no number format is spelled as an axis keyword. id_ref needs no quoting arm. Required test: a category displayed `Format` on a row axis round-trips.\n- improvise-p003:\n 1. The virtual views get the reserved ids `_records` and `_drill`, following answer 23 (\"no non-virtual id can start with _\").\n 2. _Index/_Dim synthetic coordinates are record positions, not items. They are outside the id namespace and are never persisted. This is the ADR's own reading; flag it as such.\n 3. ViewState::active_view and Workbook::active_view hold a ViewId.\n 4. The collapsed set is (CategoryId, group name) while groups have no id (answer 9), and `collapsed:` writes the category id then the group name.\n 5. Under clause 6, CheckedBatch's created-record exemption has nothing left that produces it. It is removed in the wire step.\n- improvise-i0n7, what remains: on an ItemHandle::FormulaTarget row, remove means RemoveFormula, and rename means a display rename of the formula target.\n- improvise-hexb items 1, 3 and 4:\n - The regression test edits a non-name token (`Revenue - Cost` -\u003e `Revenue - Cost * 1`) instead of committing unedited text.\n - Clause 11 lists clause 12's capture step.\n - Fix the drift in \"the only reader is one test\" and \"on the idempotent path\" (this is also improvise-8vs2 item 4).\n - The kb.pl fact decision('improvise-sboz', a_formula_edit_maps_display_names_to_ids_when_the_editor_opens, ...) restates the vacuous test. It must be replaced in the same change, and only the orchestrator writes kb.pl (scripts/kb replace).\n- improvise-8tk5, improvise-80rm, improvise-gkyo and improvise-rxz9 are already answered by revision 8 (answers 13 and 15; clauses 5, 6 and 7). Add one sentence on merges: two branches that add different things under one slug produce a duplicate id, and the merged file's load is refused (answers 11 and 29).\n\nOut of scope: improvise-m56i items 2 and 6, and improvise-hexb item 2. Those are user questions.\n\nAcceptance: `./scripts/bd-audit-ids` passes, no clause's decision changes, and the user approves on the PR.\n","status":"in_progress","priority":2,"issue_type":"task","assignee":"agent-adr007","owner":"el-github@elangley.org","created_at":"2026-09-25T04:48:44Z","created_by":"agent-adr007","updated_at":"2026-09-25T17:52:26Z","started_at":"2026-09-25T17:52:26Z","dependencies":[{"issue_id":"improvise-cq1q.1","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T04:48:44Z","created_by":"agent-adr007","metadata":"{}"}],"comments":[{"id":"bb182e07-65db-5fa1-84f0-1473d61d7fa8","issue_id":"improvise-cq1q.1","author":"Edward Langley","text":"From the adversarial review of PR #318 (head 689480bf), which is Claude as an agent in the owner's session. Here are two readings that clause 4 leaves implicit and PR #318 had to choose. Consider flagging them in the ADR as its own readings, as p003 item 2 does.\n\n1. **\"Space\" means U+0020 only.** A tab, a no-break space (U+00A0) and an ideographic space (U+3000) are stripped, not turned into `_`. So `Time\\tPeriod` → `timeperiod`, which collides with `TimePeriod` rather than `Time Period`. The reasoning is in context/code/core.md §19. A tab-separated import header is the realistic case.\n2. **A slug made only of `-` is a legal bare id.** Displays `-` and `---` slug to `-` and `---`, and they are not treated as empty. The id_token alphabet admits them, and an `.improv` line such as `-: row`, or an item line beginning `- -`, is legal but unreadable. Step 5's \"if nothing is left\" treats only the empty string as empty.\n","created_at":"2026-09-25T06:19:59Z"}],"dependency_count":0,"dependent_count":1,"comment_count":1} {"_type":"issue","id":"improvise-nn4s.2","title":"Faces step 2: draw.rs face_style; axis_fg, minibuf_fg, field_kind_fg and mode_style become callers and disappear","description":"ADR 004 clause 8 step 2. One face_style(Face)-\u003eStyle in the TUI. AxisColor and Axis::color leave improvise-core (clause 6: face_of(Axis) in protocol). mode_style decomposes into the five mode faces plus the text-entry chain. Carries clause 8's one declared exception: minibuffer.category-add becomes green (user, Q1).","status":"closed","priority":2,"issue_type":"task","assignee":"agent-faces","owner":"el-github@elangley.org","created_at":"2026-09-25T04:48:26Z","created_by":"agent-faces","updated_at":"2026-09-25T17:10:21Z","started_at":"2026-09-25T04:48:48Z","closed_at":"2026-09-25T17:10:21Z","close_reason":"Landed in PR #319 (merged 4ee5d4b1); post-merge review found the code sound (issuecomment-5836381716); gaps filed as nn4s.8-.12","dependencies":[{"issue_id":"improvise-nn4s.2","depends_on_id":"improvise-nn4s","type":"parent-child","created_at":"2026-09-25T04:48:26Z","created_by":"agent-faces","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-nn4s.1","title":"Faces step 1: Face, Theme, resolve and the default theme in improvise-protocol","description":"ADR 004 clause 8 step 1. Face vocabulary as semantic data in improvise-protocol (clause 6), derived from domain enums where one exists (clause 2); Colour = Default|Named|Indexed|Rgb (clause 4); six portable attributes (clause 5); inheritance resolved once by Theme::resolve(Face) (clause 3); serde JSON shape with face names as keys (clause 9). The default theme contains no Rgb. Faces land only where a migrated call site needs them; later steps add the rest.","status":"closed","priority":2,"issue_type":"task","assignee":"agent-faces","owner":"el-github@elangley.org","created_at":"2026-09-25T04:48:21Z","created_by":"agent-faces","updated_at":"2026-09-25T17:10:19Z","started_at":"2026-09-25T04:48:47Z","closed_at":"2026-09-25T17:10:19Z","close_reason":"Landed in PR #319 (merged 4ee5d4b1); post-merge review found the code sound (issuecomment-5836381716); gaps filed as nn4s.8-.12","dependencies":[{"issue_id":"improvise-nn4s.1","depends_on_id":"improvise-nn4s","type":"parent-child","created_at":"2026-09-25T04:48:21Z","created_by":"agent-faces","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-31oe.8","title":"create-view silently replaces an existing view of the same name and exits 0","description":"Found by the PR #313 review (issuecomment-5826870397): Workbook::create_view is an IndexMap::insert, so 'create-view X' when a view X exists overwrites it -- its axes, page selections, hidden items and collapse state -- with no message, and headless 'improvise cmd/script' exits 0. The repo-stats pipeline's interim create-view replaced a per-file view that folder import (#314) had built. Refuse (naming the view) unless an explicit replace is asked for; tests first in both the TUI path and headless. Related: improvise-31oe.2/31oe.3 (refusals for unknown names, in progress).","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T04:43:03Z","created_by":"Edward Langley","updated_at":"2026-09-25T04:43:03Z","labels":["headless","view"],"dependencies":[{"issue_id":"improvise-31oe.8","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T04:43:03Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-felo.8","title":"Semi-additive measures: a different summary per category","description":"USER 2026-09-25, 'yes, file these'. One summary per measure cannot express a semi-additive measure: holdings value is SUMMED across Account but takes LAST across Date; a stock price is AVG or LAST across Date but meaningless summed across Ticker. Add per-category overrides to a measure's aggregation, e.g. in '## Aggregation': '- Value: sum; Date: last' (grammar first). Decide the fold semantics when several categories are folded at once (apply per-category functions in a defined order -- e.g. the overriding categories innermost -- since sum-of-last and last-of-sum differ), how a collapsed group of the overriding category uses it, and totals. Depends on the ordered last/first ticket for the canonical examples. Interacts with ADR 009's incremental aggregates.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-25T04:30:51Z","created_by":"Edward Langley","updated_at":"2026-09-25T04:30:51Z","labels":["core","formula"],"dependencies":[{"issue_id":"improvise-felo.8","depends_on_id":"improvise-felo","type":"parent-child","created_at":"2026-09-25T04:30:51Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-felo.7","title":"Ordered summary functions: last and first (closing/opening value)","description":"USER 2026-09-25: 'we probably should also be able to specify a summary function. e.g. stock price doesn't make sense to add' -- and on the proposal: 'yes, file these'. Today a measure's summary is one of sum/avg/min/max/count (AggFunc, improvise-formula ast.rs; per measure in Model::measure_agg, '## Aggregation' section '- Price: avg'), applied to every fold incl. collapsed groups. A stock price wants LAST (closing) or FIRST (opening) over a period. Those need an ORDER over the folded values; the natural one is the item order of the category being folded (e.g. months within a year of a date hierarchy, improvise-8jk6.3/felo.6). Decide: which category orders the fold when several are folded at once (the time-like one? an explicit per-measure 'along' category?), ties, and what last/first mean when the ordering category is on an axis rather than folded. Grammar first (agg_kind in improv.pest). Interacts with ADR 009 (PR #309): its integrated per-group state is an unordered multiset -- last/first need ordered state; raise it there. Test with a stock-price fixture.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-25T04:30:50Z","created_by":"Edward Langley","updated_at":"2026-09-25T04:30:50Z","labels":["core","formula"],"dependencies":[{"issue_id":"improvise-felo.7","depends_on_id":"improvise-felo","type":"parent-child","created_at":"2026-09-25T04:30:50Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-31oe.3","title":"The command language does not read |pipe quoting|: create-view |By area| makes a view named |By, and extra arguments are silently ignored","description":"The `:` command language, used by `improvise cmd` and `improvise script`, does not read `|pipe quoting|`, though `.improv` files and formulas both use it for names (improvise_formula::quote). It also silently ignores extra arguments. Found by the repo-stats pipeline, PR #313 (improvise-1xfb.5.1).\n\nRepro:\n\n improvise cmd 'create-view |By area|' -f m.improv\n grep '## View' m.improv\n\nWhat happens: a view named `|By` is created, and `area|` is dropped without a word.\n\nWhat should happen:\n- A pipe-quoted argument is one argument with the pipes removed and the escapes applied. The rules are the ones `.improv` uses, and the code should come from the one shared home rather than a new copy.\n- A command given more arguments than it takes refuses with an argument error, and the headless run exits non-zero.\n","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-cmdlang","owner":"el-github@elangley.org","created_at":"2026-09-25T04:29:56Z","created_by":"agent-gapfile","updated_at":"2026-09-25T04:33:11Z","started_at":"2026-09-25T04:33:11Z","lease_expires_at":"2026-09-25T04:38:11Z","heartbeat_at":"2026-09-25T04:33:11Z","labels":["area:cli","headless"],"dependencies":[{"issue_id":"improvise-31oe.3","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T04:29:56Z","created_by":"agent-gapfile","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-31oe.2","title":"set-axis and set-page with an unknown category, axis or item are silent no-ops that exit 0 headless","description":"In `improvise cmd` and `improvise script`, `set-axis` and `set-page` with an unknown category, axis or item do nothing, print nothing and exit 0. A typo in a script therefore goes unnoticed. Found by the repo-stats pipeline, PR #313 (improvise-1xfb.5.1).\n\nRepro, on any model without a category called `Regoin`:\n\n improvise cmd 'set-axis Regoin row' -f m.improv; echo \"exit=$?\"\n improvise cmd 'set-page Region Nowhere' -f m.improv; echo \"exit=$?\"\n improvise cmd 'set-axis Region sideways' -f m.improv; echo \"exit=$?\"\n\nWhat happens: all three exit 0 with no message, and nothing changes.\n\nWhat should happen: each one refuses with a message that names what it could not find, and the headless run exits non-zero. This is the same path other refusals take since improvise-udt2 and improvise-j6x8. An unparseable axis name is an argument error. An unknown category or item is a refusal.\n","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-cmdlang","owner":"el-github@elangley.org","created_at":"2026-09-25T04:29:55Z","created_by":"agent-gapfile","updated_at":"2026-09-25T04:33:10Z","started_at":"2026-09-25T04:33:10Z","lease_expires_at":"2026-09-25T04:38:10Z","heartbeat_at":"2026-09-25T04:33:10Z","labels":["area:cli","headless"],"dependencies":[{"issue_id":"improvise-31oe.2","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T04:29:55Z","created_by":"agent-gapfile","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-8jk6.6","title":"An import that skipped records, even all of them, exits 0 and saves an empty model","description":"An import that skipped records exits 0 and saves the model, even when it skipped every record and the model is empty. Found by the repo-stats pipeline, PR #313 (improvise-1xfb.5.1).\n\nPR #314 (improvise-8jk6.2) removes one cause, the skip on a missing category value. Other skips remain. For example, a JSON array element that is not an object is skipped.\n\nRepro:\n\n echo '[1, 2, \"three\"]' \u003e r.json\n improvise import r.json --no-wizard -o r.improv; echo \"exit=$?\"\n\nWhat happens: the file is written with no data, and the exit status is 0.\n\nWhat should happen: the user must hear about skipped records. At minimum, print a count on stderr. An import that kept no records at all should exit non-zero rather than save an empty model as a success. Whether a partial skip is a warning or an error, and whether a flag makes it strict, is a decision for the fix.\n","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-headless-import","owner":"el-github@elangley.org","created_at":"2026-09-25T04:29:53Z","created_by":"agent-gapfile","updated_at":"2026-09-25T04:33:06Z","started_at":"2026-09-25T04:33:06Z","lease_expires_at":"2026-09-25T04:38:06Z","heartbeat_at":"2026-09-25T04:33:06Z","labels":["headless","import"],"dependencies":[{"issue_id":"improvise-8jk6.6","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T04:29:53Z","created_by":"agent-gapfile","metadata":"{}"}],"comments":[{"id":"4ba3a96b-9421-522a-b648-0299ee89fbba","issue_id":"improvise-8jk6.6","author":"Edward Langley","text":"USER DECISIONS 2026-09-25 (PR #321): (1) a folder of only header-only CSVs: 'Refuse (as #321 does)' -- an import that keeps no record is refused, folder or not; (2) partial skip: 'Add a --strict flag' -- default stays exit 0 with a counted warning, --strict makes any skip non-zero; (3) JSON 1e3 forced to Category: '1000.0' (keep serde_json's rendering, as #321 does).","created_at":"2026-09-25T06:20:01Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} -{"_type":"issue","id":"improvise-8jk6.5","title":"import --formula silently drops a formula it cannot parse and exits 0","description":"`improvise import --formula` silently drops a formula it cannot parse. The import still saves and exits 0, so a script cannot tell that the formula is missing. Found by the repo-stats pipeline, PR #313 (improvise-1xfb.5.1).\n\nRepro:\n\n printf 'a,x\\nk,1\\n' \u003e f.csv\n improvise import f.csv --no-wizard --measure x --formula 'Y = x +' -o f.improv; echo \"exit=$?\"\n grep -A3 'Formulas' f.improv\n\nWhat happens: no formula is written, nothing is reported, and the exit status is 0.\n\nWhat should happen: a formula that does not parse is an error the user asked for by name. Report it with the parse error on stderr and exit non-zero, the same way a headless refusal exits non-zero since improvise-udt2 and improvise-j6x8. Whether to also refuse to write the output file is a decision for the fix. Refusing is the conservative default.\n","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-headless-import","owner":"el-github@elangley.org","created_at":"2026-09-25T04:29:52Z","created_by":"agent-gapfile","updated_at":"2026-09-25T04:33:05Z","started_at":"2026-09-25T04:33:05Z","lease_expires_at":"2026-09-25T04:38:05Z","heartbeat_at":"2026-09-25T04:33:05Z","labels":["headless","import"],"dependencies":[{"issue_id":"improvise-8jk6.5","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T04:29:52Z","created_by":"agent-gapfile","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-8jk6.4","title":"import --category still parses a forced category as a number: 00565796 becomes 565796, 220944e2 becomes 22094400.0","description":"A column the user forced to Category with `import --category` is still parsed as a number, so its text is lost. Found by the repo-stats pipeline, PR #313 (improvise-1xfb.5.1): commit `00565796` became item `565796`, and `220944e2` was read as scientific notation and became `22094400.0`.\n\nRepro:\n\n printf 'commit,lines\\n00565796,3\\n220944e2,5\\n' \u003e c.csv\n improvise import c.csv --no-wizard --category commit --measure lines -o c.improv\n grep -A2 'Category: commit' c.improv\n\nWhat happens: the items are `565796` and `22094400.0`.\n\nWhat should happen: forcing a field to Category means its values are item names, and item names are text. The items must be `00565796` and `220944e2`, verbatim. Numeric parsing is for measures, and should not run on a forced category.\n","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-headless-import","owner":"el-github@elangley.org","created_at":"2026-09-25T04:29:50Z","created_by":"agent-gapfile","updated_at":"2026-09-25T04:33:00Z","started_at":"2026-09-25T04:33:00Z","lease_expires_at":"2026-09-25T04:38:00Z","heartbeat_at":"2026-09-25T04:33:00Z","labels":["headless","import"],"dependencies":[{"issue_id":"improvise-8jk6.4","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T04:29:50Z","created_by":"agent-gapfile","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-8jk6.3","title":"Import a date column as ONE hierarchical category (Year \u003e Quarter \u003e Month), crosstab via formulas","description":"USER 2026-09-25, verbatim: 'I think dates should usually be one category and we can use formulas to crosstab'. Today a date column becomes up to three FLAT categories (Date_Year '2024', Date_Month '2024-01', Date_Quarter '2024-Q1'; analyzer.rs:81-93), never hierarchical -- groups predate date extraction (eae00522 vs a73fe160) but the import has never created a group. Change the default: one category named after the column, whose items are the finest chosen component (e.g. months) grouped into quarters grouped into years ('\u003e Q1-2024[2024]'), so it costs one of the 24 category slots instead of three and collapses naturally. Crosstabs (year x month) are done with formulas, not by splitting the category -- verify the formula language can do that today (a WHERE/IS naming a GROUP rather than an item: does it cover the group's members?) and file the gap if not. Keep the flat split available as a wizard choice. Blocked by the group-parent ticket. Decide item naming (a month item must stay unique across years: '2024-01', not 'Jan'), and headless flag parity (--time / --extract).","status":"in_progress","priority":2,"issue_type":"feature","assignee":"agent-hier","owner":"el-github@elangley.org","created_at":"2026-09-25T04:03:02Z","created_by":"Edward Langley","updated_at":"2026-09-25T05:01:29Z","started_at":"2026-09-25T05:01:29Z","lease_expires_at":"2026-09-25T05:06:29Z","heartbeat_at":"2026-09-25T05:01:29Z","labels":["import"],"dependencies":[{"issue_id":"improvise-8jk6.3","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T04:03:02Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-8jk6.3","depends_on_id":"improvise-felo.6","type":"blocks","created_at":"2026-09-25T04:03:03Z","created_by":"Edward Langley","metadata":"{}"}],"comments":[{"id":"5aff5014-0515-5078-8dc1-b5ef15cceeb3","issue_id":"improvise-8jk6.3","author":"Edward Langley","text":"Per the user (2026-09-25, on improvise-felo.6): hierarchies start closed unless a view opts in to a depth -- so an imported date category shows years folded by default.","created_at":"2026-09-25T04:04:41Z"}],"dependency_count":1,"dependent_count":0,"comment_count":1} +{"_type":"issue","id":"improvise-31oe.3","title":"The command language does not read |pipe quoting|: create-view |By area| makes a view named |By, and extra arguments are silently ignored","description":"The `:` command language, used by `improvise cmd` and `improvise script`, does not read `|pipe quoting|`, though `.improv` files and formulas both use it for names (improvise_formula::quote). It also silently ignores extra arguments. Found by the repo-stats pipeline, PR #313 (improvise-1xfb.5.1).\n\nRepro:\n\n improvise cmd 'create-view |By area|' -f m.improv\n grep '## View' m.improv\n\nWhat happens: a view named `|By` is created, and `area|` is dropped without a word.\n\nWhat should happen:\n- A pipe-quoted argument is one argument with the pipes removed and the escapes applied. The rules are the ones `.improv` uses, and the code should come from the one shared home rather than a new copy.\n- A command given more arguments than it takes refuses with an argument error, and the headless run exits non-zero.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-cmdlang","owner":"el-github@elangley.org","created_at":"2026-09-25T04:29:56Z","created_by":"agent-gapfile","updated_at":"2026-10-03T05:31:00Z","started_at":"2026-09-25T04:33:11Z","closed_at":"2026-10-03T05:31:00Z","close_reason":"merged: #316 (66931785) and #333 (8da5a310)","labels":["area:cli","headless"],"dependencies":[{"issue_id":"improvise-31oe.3","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T04:29:56Z","created_by":"agent-gapfile","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-31oe.2","title":"set-axis and set-page with an unknown category, axis or item are silent no-ops that exit 0 headless","description":"In `improvise cmd` and `improvise script`, `set-axis` and `set-page` with an unknown category, axis or item do nothing, print nothing and exit 0. A typo in a script therefore goes unnoticed. Found by the repo-stats pipeline, PR #313 (improvise-1xfb.5.1).\n\nRepro, on any model without a category called `Regoin`:\n\n improvise cmd 'set-axis Regoin row' -f m.improv; echo \"exit=$?\"\n improvise cmd 'set-page Region Nowhere' -f m.improv; echo \"exit=$?\"\n improvise cmd 'set-axis Region sideways' -f m.improv; echo \"exit=$?\"\n\nWhat happens: all three exit 0 with no message, and nothing changes.\n\nWhat should happen: each one refuses with a message that names what it could not find, and the headless run exits non-zero. This is the same path other refusals take since improvise-udt2 and improvise-j6x8. An unparseable axis name is an argument error. An unknown category or item is a refusal.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-cmdlang","owner":"el-github@elangley.org","created_at":"2026-09-25T04:29:55Z","created_by":"agent-gapfile","updated_at":"2026-10-03T05:31:00Z","started_at":"2026-09-25T04:33:10Z","closed_at":"2026-10-03T05:31:00Z","close_reason":"merged: #316 (66931785) and #333 (8da5a310)","labels":["area:cli","headless"],"dependencies":[{"issue_id":"improvise-31oe.2","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T04:29:55Z","created_by":"agent-gapfile","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-8jk6.6","title":"An import that skipped records, even all of them, exits 0 and saves an empty model","description":"An import that skipped records exits 0 and saves the model, even when it skipped every record and the model is empty. Found by the repo-stats pipeline, PR #313 (improvise-1xfb.5.1).\n\nPR #314 (improvise-8jk6.2) removes one cause, the skip on a missing category value. Other skips remain. For example, a JSON array element that is not an object is skipped.\n\nRepro:\n\n echo '[1, 2, \"three\"]' \u003e r.json\n improvise import r.json --no-wizard -o r.improv; echo \"exit=$?\"\n\nWhat happens: the file is written with no data, and the exit status is 0.\n\nWhat should happen: the user must hear about skipped records. At minimum, print a count on stderr. An import that kept no records at all should exit non-zero rather than save an empty model as a success. Whether a partial skip is a warning or an error, and whether a flag makes it strict, is a decision for the fix.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-headless-import","owner":"el-github@elangley.org","created_at":"2026-09-25T04:29:53Z","created_by":"agent-gapfile","updated_at":"2026-10-03T06:06:38Z","started_at":"2026-09-25T04:33:06Z","closed_at":"2026-10-03T06:06:38Z","close_reason":"merged: #321 (7e879702)","labels":["headless","import"],"dependencies":[{"issue_id":"improvise-8jk6.6","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T04:29:53Z","created_by":"agent-gapfile","metadata":"{}"}],"comments":[{"id":"4ba3a96b-9421-522a-b648-0299ee89fbba","issue_id":"improvise-8jk6.6","author":"Edward Langley","text":"USER DECISIONS 2026-09-25 (PR #321): (1) a folder of only header-only CSVs: 'Refuse (as #321 does)' -- an import that keeps no record is refused, folder or not; (2) partial skip: 'Add a --strict flag' -- default stays exit 0 with a counted warning, --strict makes any skip non-zero; (3) JSON 1e3 forced to Category: '1000.0' (keep serde_json's rendering, as #321 does).","created_at":"2026-09-25T06:20:01Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} +{"_type":"issue","id":"improvise-8jk6.5","title":"import --formula silently drops a formula it cannot parse and exits 0","description":"`improvise import --formula` silently drops a formula it cannot parse. The import still saves and exits 0, so a script cannot tell that the formula is missing. Found by the repo-stats pipeline, PR #313 (improvise-1xfb.5.1).\n\nRepro:\n\n printf 'a,x\\nk,1\\n' \u003e f.csv\n improvise import f.csv --no-wizard --measure x --formula 'Y = x +' -o f.improv; echo \"exit=$?\"\n grep -A3 'Formulas' f.improv\n\nWhat happens: no formula is written, nothing is reported, and the exit status is 0.\n\nWhat should happen: a formula that does not parse is an error the user asked for by name. Report it with the parse error on stderr and exit non-zero, the same way a headless refusal exits non-zero since improvise-udt2 and improvise-j6x8. Whether to also refuse to write the output file is a decision for the fix. Refusing is the conservative default.\n","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-headless-import","owner":"el-github@elangley.org","created_at":"2026-09-25T04:29:52Z","created_by":"agent-gapfile","updated_at":"2026-09-25T04:33:05Z","started_at":"2026-09-25T04:33:05Z","labels":["headless","import"],"dependencies":[{"issue_id":"improvise-8jk6.5","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T04:29:52Z","created_by":"agent-gapfile","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-8jk6.4","title":"import --category still parses a forced category as a number: 00565796 becomes 565796, 220944e2 becomes 22094400.0","description":"A column the user forced to Category with `import --category` is still parsed as a number, so its text is lost. Found by the repo-stats pipeline, PR #313 (improvise-1xfb.5.1): commit `00565796` became item `565796`, and `220944e2` was read as scientific notation and became `22094400.0`.\n\nRepro:\n\n printf 'commit,lines\\n00565796,3\\n220944e2,5\\n' \u003e c.csv\n improvise import c.csv --no-wizard --category commit --measure lines -o c.improv\n grep -A2 'Category: commit' c.improv\n\nWhat happens: the items are `565796` and `22094400.0`.\n\nWhat should happen: forcing a field to Category means its values are item names, and item names are text. The items must be `00565796` and `220944e2`, verbatim. Numeric parsing is for measures, and should not run on a forced category.\n","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-headless-import","owner":"el-github@elangley.org","created_at":"2026-09-25T04:29:50Z","created_by":"agent-gapfile","updated_at":"2026-09-25T04:33:00Z","started_at":"2026-09-25T04:33:00Z","labels":["headless","import"],"dependencies":[{"issue_id":"improvise-8jk6.4","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T04:29:50Z","created_by":"agent-gapfile","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-8jk6.3","title":"Import a date column as ONE hierarchical category (Year \u003e Quarter \u003e Month), crosstab via formulas","description":"USER 2026-09-25, verbatim: 'I think dates should usually be one category and we can use formulas to crosstab'. Today a date column becomes up to three FLAT categories (Date_Year '2024', Date_Month '2024-01', Date_Quarter '2024-Q1'; analyzer.rs:81-93), never hierarchical -- groups predate date extraction (eae00522 vs a73fe160) but the import has never created a group. Change the default: one category named after the column, whose items are the finest chosen component (e.g. months) grouped into quarters grouped into years ('\u003e Q1-2024[2024]'), so it costs one of the 24 category slots instead of three and collapses naturally. Crosstabs (year x month) are done with formulas, not by splitting the category -- verify the formula language can do that today (a WHERE/IS naming a GROUP rather than an item: does it cover the group's members?) and file the gap if not. Keep the flat split available as a wizard choice. Blocked by the group-parent ticket. Decide item naming (a month item must stay unique across years: '2024-01', not 'Jan'), and headless flag parity (--time / --extract).","status":"closed","priority":2,"issue_type":"feature","assignee":"agent-hier","owner":"el-github@elangley.org","created_at":"2026-09-25T04:03:02Z","created_by":"Edward Langley","updated_at":"2026-10-03T05:32:43Z","started_at":"2026-09-25T05:01:29Z","closed_at":"2026-10-03T05:32:43Z","close_reason":"merged: #322 (d571abbd); d0n6 fixed by the spelling_clash refusal (wizard step + build)","labels":["import"],"dependencies":[{"issue_id":"improvise-8jk6.3","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T04:03:02Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-8jk6.3","depends_on_id":"improvise-felo.6","type":"blocks","created_at":"2026-09-25T04:03:03Z","created_by":"Edward Langley","metadata":"{}"}],"comments":[{"id":"5aff5014-0515-5078-8dc1-b5ef15cceeb3","issue_id":"improvise-8jk6.3","author":"Edward Langley","text":"Per the user (2026-09-25, on improvise-felo.6): hierarchies start closed unless a view opts in to a depth -- so an imported date category shows years folded by default.","created_at":"2026-09-25T04:04:41Z"}],"dependency_count":1,"dependent_count":0,"comment_count":1} {"_type":"issue","id":"improvise-felo.6","title":"Honour group parents: nested groups within one category","description":"Group::parent (model/category.rs:34) is parsed ('\u003e Q1[2025]', improv.pest group_hierarchy), written (format.rs:210), round-tripped and hashed (app.rs:195), but NOTHING in layout, the grid frame, collapse or aggregation reads it -- so Q1 is never drawn inside 2025. Make the within-category hierarchy real: expand_category emits nested group runs (reusing the GroupHeader{level}/GroupEnd{level} machinery that already nests runs across categories, core.md 'Group runs nest'); AxisFrame/FrameGroup carry the depth so the TUI and the browser draw the same headers (design-principles section 5); collapsing a group at any level folds everything under it into one row whose value is the fold over all covered leaf items (evaluate_covering must expand a group to its leaves through child groups); z toggles the innermost group at the cursor; hidden items and prune_empty behave at every level. Decide: cycles and dangling parents in a file (refuse on load vs ignore), and whether an item may sit directly in a parent group alongside child groups. Tests first, including a 3-level Year\u003eQuarter\u003eMonth fixture. Prerequisite for importing dates as one hierarchical category. Interacts with ADR 008 (operator shape includes group membership).","status":"closed","priority":2,"issue_type":"feature","assignee":"agent-hier","owner":"el-github@elangley.org","created_at":"2026-09-25T04:03:00Z","created_by":"Edward Langley","updated_at":"2026-09-25T06:39:32Z","started_at":"2026-09-25T04:05:54Z","closed_at":"2026-09-25T06:39:32Z","close_reason":"Landed in PR #317; reviewed PASS at 44718e8a","labels":["browser","core","tui","view"],"dependencies":[{"issue_id":"improvise-felo.6","depends_on_id":"improvise-felo","type":"parent-child","created_at":"2026-09-25T04:03:00Z","created_by":"Edward Langley","metadata":"{}"}],"comments":[{"id":"ff4b12b2-f571-55e3-aa28-96e355d35faf","issue_id":"improvise-felo.6","author":"Edward Langley","text":"USER 2026-09-25, verbatim: 'I think hierarchies should start closed unless we somehow opt into a specific one opened to a certain depth (view config maybe?)'. So: groups start COLLAPSED by default; a view opts in to opening a category's hierarchy to a given depth (e.g. a per-category depth in View, persisted as a new view line such as 'expand: Date 2'), with per-group toggles (z) on top. Today View::collapsed_groups (view/types.rs:40) records exceptions to an EXPANDED default and .improv writes 'collapsed:' lines -- this inverts the default, so decide: (a) state = depth + a set of per-group overrides in both directions (opened-beyond-depth, closed-within-depth); (b) migration of existing files, whose 'collapsed:' lines assume groups default open -- a file with groups and no 'collapsed:' line would suddenly render folded; options: keep old files' meaning by version (the file-version literal selects the regime, as ADR 007 does), or accept the change. The migration choice is the user's; ask on the PR. Also affects the import default (improvise-8jk6.3: an imported date hierarchy opens closed).","created_at":"2026-09-25T04:04:39Z"},{"id":"72c7ea45-210e-576a-b25a-448b7a41b26b","issue_id":"improvise-felo.6","author":"Edward Langley","text":"USER 2026-09-25, verbatim: 'I think, in most cases, the closed by default start is preferable because it makes the grid more dense.' (The reason for the closed default.) Bears on the migration question: it argues for old files also opening closed rather than version-gating the old open default -- pending the user's explicit answer.","created_at":"2026-09-25T04:06:05Z"},{"id":"29921739-1f03-5c2d-b0f7-9636a94fd0bf","issue_id":"improvise-felo.6","author":"Edward Langley","text":"USER DECISION 2026-09-25, verbatim: 'Everything opens closed, including old files.' No version gating: old files open closed too; their 'collapsed:' lines agree with the default.","created_at":"2026-09-25T04:06:39Z"},{"id":"73d76560-7ebf-52f8-bb02-ab7a856c2041","issue_id":"improvise-felo.6","author":"Edward Langley","text":"USER 2026-09-25, verbatim: 'Yes, and we can add an expanded: line if it's not in the grammar. Since it's an additive change (and there's no real users), I'm not too concerned about strict compatibility so no version bump needed.' So: per-group opens persist as an 'expanded:' view line (mirroring 'collapsed:'), added to improv.pest; no file-version bump.","created_at":"2026-09-25T04:17:52Z"},{"id":"7a483598-1078-5d71-ba40-93444af3b91b","issue_id":"improvise-felo.6","author":"Edward Langley","text":"USER 2026-09-25, verbatim: 'I think we'll need a depth for expanded, something like expanded(2): ...'. So the expanded line carries an optional depth: 'expanded(N): \u003ctarget\u003e'. Proposed semantics for the implementer to confirm in the PR: target 'Cat' opens the category's top N levels; target 'Cat/Group' opens that group and N-1 levels beneath it; bare 'expanded:' means N=1; collapsed: and z still override individual groups.","created_at":"2026-09-25T04:20:23Z"},{"id":"93ce9859-0231-52b7-a781-86560834865e","issue_id":"improvise-felo.6","author":"Edward Langley","text":"USER 2026-09-25, verbatim: 'expanded+collapsed in the same group is an error'. A view naming the same Cat/Group in both an expanded(...) line and a collapsed line is refused on load (naming it). Only the SAME group is an error; a collapsed group inside a region opened by an ancestor's or the category's expanded(N) is a legitimate override.","created_at":"2026-09-25T04:22:51Z"}],"dependency_count":0,"dependent_count":1,"comment_count":6} {"_type":"issue","id":"improvise-31oe.1","title":"Auto-complete system (commands first)","description":"USER 2026-09-25, verbatim: 'We need a bead for an auto-complete system: commands are highest priority'. One completion engine in the shared layer (design-principles section 5: a front end displays state, it does not reimplement behaviour), used by the TUI and the browser alike: given a prompt kind and the text so far, return candidates. Sources, in priority order: (1) command names on the ':' command line -- from the registry, whose names come off prototypes (gotcha 15), never a hand-kept list; (2) command arguments -- mode names (parse_mode_name), panel names, axis keywords, category/item/view names from the model, file paths via Storage::list (not std::fs; the browser host must reuse it); (3) name completion in the text-entry minibuffers (category-add, item-add, formula edit refs with pipe quoting). Open design questions for the children: where the engine lives (improvise-command vs improvise-protocol, given the browser needs it and improvise-y11g puts the ':' line in the browser); key binding (Tab conflicts?); cycling vs a candidate list (a which-key-style popup exists in src/ui/which_key.rs); how candidates reach a thin client (projection vs client-side from a shared table).","status":"open","priority":2,"issue_type":"epic","owner":"el-github@elangley.org","created_at":"2026-09-25T03:41:47Z","created_by":"Edward Langley","updated_at":"2026-09-25T03:41:47Z","labels":["command","protocol","ux"],"dependencies":[{"issue_id":"improvise-31oe.1","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T03:41:47Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-1xfb.5.1","title":"scripts/repo-stats: monitor churn by area, prose-to-code ratio, doc growth and change coupling","description":"USER 2026-09-25: 'add some tooling to monitor these stats' -- the stats the user ran by hand with code-maat on 2026-09-25 (coupling, age, abs-churn) plus the orchestrator's area split (since 09-19 on gh/main: Rust src +57k/-24k, context/test-evidence +25k/-1.4k, context/code +18k/-1.5k, site +5.6k/-0). Build a repo-owned script (no java/code-maat dependency; python or the repo's existing script language, run under nix develop) that reports over a window (default: last 7 days, on a named ref, default gh/main, no-merges): (1) added/deleted lines by area (rust src, rust tests, context/code, context/test-evidence, other context, repo-map, kb.pl, agent docs/skills, scripts/CI, web+harness, generated); (2) the prose-to-code ratio and each area's deletion ratio; (3) current size of the watched docs (lines of each context/*.md, repo-map gotcha count and longest gotcha, test-evidence entry count); (4) top-N change-coupled file pairs (co-change degree and revisions, code-maat's definition, min-revs threshold), flagging pairs that cross crate boundaries and pairs among the agent-rule docs (a rule stated in several places). Output: human-readable text plus --json. Add a CI step that runs it on pushes to main and writes the report to GITHUB_STEP_SUMMARY -- report only, never fails the build (budgets are a separate decision under the compaction epic). Give it a test in the repo's scripts/*-test.sh style against a fixture repo, wired into checkers.sh. Document it in context/code/tooling.md.","status":"in_progress","priority":2,"issue_type":"task","assignee":"agent-stats","owner":"el-github@elangley.org","created_at":"2026-09-25T03:21:11Z","created_by":"Edward Langley","updated_at":"2026-09-25T03:22:03Z","started_at":"2026-09-25T03:22:03Z","lease_expires_at":"2026-09-25T03:27:03Z","heartbeat_at":"2026-09-25T03:22:03Z","labels":["docs","tooling"],"dependencies":[{"issue_id":"improvise-1xfb.5.1","depends_on_id":"improvise-1xfb.5","type":"parent-child","created_at":"2026-09-25T03:21:11Z","created_by":"Edward Langley","metadata":"{}"}],"comments":[{"id":"84080845-4c53-5f4a-8ecf-2c5c468f5815","issue_id":"improvise-1xfb.5.1","author":"Edward Langley","text":"USER 2026-09-25 pointed at https://github.com/adamtornhill/code-maat/releases/download/v1.0.4/code-maat-1.0.4-standalone.jar -- so USE code-maat for the analyses it provides (coupling, age, abs-churn, soc, etc.) rather than reimplementing them. Pin the jar in nix (fetchurl with its hash) and add a JRE to the dev and ci shells; the script prepares the git log in code-maat's git2 format itself (scoped to a ref, --no-merges, a window) and adds only what code-maat does not do: the by-area split, prose-to-code and deletion ratios, doc sizes, and flags on the coupling rows (crosses a crate boundary; agent-rule docs).","created_at":"2026-09-25T03:21:25Z"},{"id":"11ba9c08-a926-5fec-8589-3c873ba4a4b9","issue_id":"improvise-1xfb.5.1","author":"Edward Langley","text":"USER 2026-09-25, verbatim: 'The stat tooling could use improvise as its scripting engine.' So: churn/ratio analysis becomes an .improv model built headlessly (improvise import --no-wizard + cmd/script for formulas and views), with the .improv file as the artifact; code-maat stays for coupling/age/soc. Gaps in improvise's headless surface found along the way are findings to file, not silent workarounds.","created_at":"2026-09-25T03:28:35Z"},{"id":"41996941-7d1c-5bb6-a00d-4412929a1734","issue_id":"improvise-1xfb.5.1","author":"Edward Langley","text":"USER 2026-09-25, verbatim: 'code-maat generates CSV files in a directory that we process with improvise and use this to notice features and regressions at the command/model layer (which is its own first-class product, separate from the UIs)'. Depends on folder import (improvise-lpzu).","created_at":"2026-09-25T03:30:03Z"}],"dependency_count":0,"dependent_count":0,"comment_count":3} +{"_type":"issue","id":"improvise-1xfb.5.1","title":"scripts/repo-stats: monitor churn by area, prose-to-code ratio, doc growth and change coupling","description":"USER 2026-09-25: 'add some tooling to monitor these stats' -- the stats the user ran by hand with code-maat on 2026-09-25 (coupling, age, abs-churn) plus the orchestrator's area split (since 09-19 on gh/main: Rust src +57k/-24k, context/test-evidence +25k/-1.4k, context/code +18k/-1.5k, site +5.6k/-0). Build a repo-owned script (no java/code-maat dependency; python or the repo's existing script language, run under nix develop) that reports over a window (default: last 7 days, on a named ref, default gh/main, no-merges): (1) added/deleted lines by area (rust src, rust tests, context/code, context/test-evidence, other context, repo-map, kb.pl, agent docs/skills, scripts/CI, web+harness, generated); (2) the prose-to-code ratio and each area's deletion ratio; (3) current size of the watched docs (lines of each context/*.md, repo-map gotcha count and longest gotcha, test-evidence entry count); (4) top-N change-coupled file pairs (co-change degree and revisions, code-maat's definition, min-revs threshold), flagging pairs that cross crate boundaries and pairs among the agent-rule docs (a rule stated in several places). Output: human-readable text plus --json. Add a CI step that runs it on pushes to main and writes the report to GITHUB_STEP_SUMMARY -- report only, never fails the build (budgets are a separate decision under the compaction epic). Give it a test in the repo's scripts/*-test.sh style against a fixture repo, wired into checkers.sh. Document it in context/code/tooling.md.","status":"in_progress","priority":2,"issue_type":"task","assignee":"agent-stats","owner":"el-github@elangley.org","created_at":"2026-09-25T03:21:11Z","created_by":"Edward Langley","updated_at":"2026-09-25T03:22:03Z","started_at":"2026-09-25T03:22:03Z","labels":["docs","tooling"],"dependencies":[{"issue_id":"improvise-1xfb.5.1","depends_on_id":"improvise-1xfb.5","type":"parent-child","created_at":"2026-09-25T03:21:11Z","created_by":"Edward Langley","metadata":"{}"}],"comments":[{"id":"84080845-4c53-5f4a-8ecf-2c5c468f5815","issue_id":"improvise-1xfb.5.1","author":"Edward Langley","text":"USER 2026-09-25 pointed at https://github.com/adamtornhill/code-maat/releases/download/v1.0.4/code-maat-1.0.4-standalone.jar -- so USE code-maat for the analyses it provides (coupling, age, abs-churn, soc, etc.) rather than reimplementing them. Pin the jar in nix (fetchurl with its hash) and add a JRE to the dev and ci shells; the script prepares the git log in code-maat's git2 format itself (scoped to a ref, --no-merges, a window) and adds only what code-maat does not do: the by-area split, prose-to-code and deletion ratios, doc sizes, and flags on the coupling rows (crosses a crate boundary; agent-rule docs).","created_at":"2026-09-25T03:21:25Z"},{"id":"11ba9c08-a926-5fec-8589-3c873ba4a4b9","issue_id":"improvise-1xfb.5.1","author":"Edward Langley","text":"USER 2026-09-25, verbatim: 'The stat tooling could use improvise as its scripting engine.' So: churn/ratio analysis becomes an .improv model built headlessly (improvise import --no-wizard + cmd/script for formulas and views), with the .improv file as the artifact; code-maat stays for coupling/age/soc. Gaps in improvise's headless surface found along the way are findings to file, not silent workarounds.","created_at":"2026-09-25T03:28:35Z"},{"id":"41996941-7d1c-5bb6-a00d-4412929a1734","issue_id":"improvise-1xfb.5.1","author":"Edward Langley","text":"USER 2026-09-25, verbatim: 'code-maat generates CSV files in a directory that we process with improvise and use this to notice features and regressions at the command/model layer (which is its own first-class product, separate from the UIs)'. Depends on folder import (improvise-lpzu).","created_at":"2026-09-25T03:30:03Z"}],"dependency_count":0,"dependent_count":0,"comment_count":3} {"_type":"issue","id":"improvise-1xfb.5","title":"Doc compaction: context/ states current rules, history lives in git","description":"USER 2026-09-25: 'doc compaction is a good idea'. Since 2026-09-19 context/code + context/test-evidence grew +42k/-2.8k lines (about 0.7 lines of prose per line of Rust source added) and almost never shrink; the repo-map gotchas run to pages and many narrate their own corrections ('this paragraph said X until...'). Every change pays to read and patch them, and today's reviews found stale copies each time a rule was stated in several places (code-maat: agent-rules/AGENTS.md/wave SKILL/code-review SKILL change together 39-48%). Goal: each doc states the CURRENT rule and its reason once; superseded narration goes (git and the ticket keep it); a rule stated in several files gets one home and pointers. Keep test-evidence entries keyed by test name (check-comment-runs --check-evidence) but compact them to what broke, the injected fault, and why the test can fail. Consider a size budget enforced by a checker so growth does not return. Sequence per area to avoid conflicting with in-flight PRs; repo-map.md is orchestrator-only.","status":"open","priority":2,"issue_type":"epic","owner":"el-github@elangley.org","created_at":"2026-09-25T03:20:21Z","created_by":"Edward Langley","updated_at":"2026-09-25T03:20:21Z","labels":["docs","tooling"],"dependencies":[{"issue_id":"improvise-1xfb.5","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T03:20:21Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-1xfb.4","title":"Stop tracking site/: it is web/assemble.sh output","description":"USER 2026-09-25: 'remove site/' (and 'keep TAGS for now'). site/ is the directory web/assemble.sh assembles (landing page + scripts/build-doc-site.mjs output); 27 generated files are tracked on gh/main, grew +5.6k/-9 lines since 09-19, and drift stale (the category-cap review found site/docs/** still saying 12). Remove site/ from the index (git rm -r --cached), add it to .gitignore, and confirm CI's Pages job builds it from source rather than reading the tracked copy; check nothing (harness specs, pages.spec.js, landing.spec.js, doc-site tests, release scripts) reads the committed site/. Do not touch TAGS.","status":"closed","priority":2,"issue_type":"task","assignee":"agent-1xfb4","owner":"el-github@elangley.org","created_at":"2026-09-25T03:20:19Z","created_by":"Edward Langley","updated_at":"2026-09-25T03:52:30Z","started_at":"2026-09-25T03:20:45Z","closed_at":"2026-09-25T03:52:30Z","close_reason":"Landed in PR #312; reviewed PASS at 67634767","labels":["tooling"],"dependencies":[{"issue_id":"improvise-1xfb.4","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T03:20:19Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-felo.5","title":"An acyclic formula chain deeper than MAX_EVAL_DEPTH evaluates to Error(\"circular\")","description":"Found by the ADR 009 agent (PR #309, question 4), 2026-09-25: recompute_formulas iterates the fixed point a bounded number of passes (MAX_EVAL_DEPTH), and a chain of 16 formulas each depending on the previous one -- no cycle at all -- has not stabilized when the bound is hit, so it reads Error(\"circular\"). Gotcha 9 and core.md section 3 describe the bound as the circular-reference detector; that conflates 'deep' with 'cyclic'. A topological evaluator (ADR 009) would give the right answer and disagree with eager, so the differential test would fail on correct code. Tests first: a 17-link acyclic chain evaluates to its value; a 2-cycle still gives Error(\"circular\"). Fix shape to decide: detect cycles in the formula dependency graph and bound only those, or evaluate acyclic formulas in dependency order.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T03:19:21Z","created_by":"Edward Langley","updated_at":"2026-09-25T03:19:21Z","labels":["core","formula"],"dependencies":[{"issue_id":"improvise-felo.5","depends_on_id":"improvise-felo","type":"parent-child","created_at":"2026-09-25T03:19:21Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-1xfb.3","title":"Agents set the git author name to include their BEADS_ACTOR","description":"USER, 2026-09-25, verbatim: 'agents should override git author name, I think to include an agent identifier (matching BEADS_ACTOR)'. Today every agent commit is authored as the repository owner, so history cannot say which agent wrote what. Proposed default (confirm on the PR): author name 'Claude (\u003cBEADS_ACTOR\u003e)', author email unchanged (the owner's configured email, so GitHub still links it), committer untouched; the Co-Authored-By trailer stays. Mechanism must survive a shell that does not persist env between commands and must NOT change shared git config: worktrees share .git/config, so 'git config user.name' in one worktree renames every agent. Candidates: 'git -c user.name=... commit' per commit, GIT_AUTHOR_NAME per command, or a tiny scripts/agent-commit wrapper reading BEADS_ACTOR (refusing when unset). Put the rule in .agents/skills/wave/agent-rules.md next to 'Claim first: export BEADS_ACTOR', and in the brief checklist in wave/SKILL.md. Related: improvise-xfsb (agents posting to GitHub under their own identity, deferred).","status":"closed","priority":2,"issue_type":"task","assignee":"agent-1xfb3","owner":"el-github@elangley.org","created_at":"2026-09-25T03:15:45Z","created_by":"Edward Langley","updated_at":"2026-09-25T03:54:49Z","started_at":"2026-09-25T03:16:08Z","closed_at":"2026-09-25T03:54:49Z","close_reason":"Landed in PR #310; reviewed PASS at 0539fb37","labels":["agents","tooling"],"dependencies":[{"issue_id":"improvise-1xfb.3","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T03:15:45Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-felo.4","title":"ADR 009: incremental evaluation — an in-house DBSP-core crate, pluggable","description":"USER DECISION 2026-09-25, verbatim: 'I think doing our own implementation of the core of dbsp as a crate we can plugin is probably dieal [sic] here. Write the ADR and Update ADR 008 to assume the decisions in that one'. Prompted by the user pointing at https://github.com/brurucy/dbsp-from-scratch (a teaching implementation of the DBSP paper: Z-sets, streams, delay/differentiate/integrate, lifted/incremental join, distinct, recursion by nested streams) as a way to evaluate formulas so data can stream. Scope: an ADR (next free number, 009) specifying a workspace crate implementing the DBSP core (not depending on Feldera's dbsp), and how improvise plugs it in: cell writes as Z-set deltas; incremental group-by aggregates (SUM/COUNT linear, AVG derived, MIN/MAX and the text-unanimity rule via integrated per-group multisets); per-row occupancy counts for prune_empty; formula dependencies updated in topological order with the circular check kept (DBSP's least-fixed-point recursion is not our circular-error semantics); f64 non-associativity vs ADR 005 exact decimals (improvise-l2b8); ADR 003 live-backed sources as delta streams; wasm32 cleanliness. Then ADR 008 (PR #308) is revised to assume ADR 009's decisions (notably its question 6, totals).","status":"in_progress","priority":2,"issue_type":"decision","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-25T03:03:27Z","created_by":"Edward Langley","updated_at":"2026-09-25T03:04:04Z","started_at":"2026-09-25T03:04:04Z","lease_expires_at":"2026-09-25T03:09:04Z","heartbeat_at":"2026-09-25T03:04:04Z","labels":["adr","core","formula"],"dependencies":[{"issue_id":"improvise-felo.4","depends_on_id":"improvise-felo","type":"parent-child","created_at":"2026-09-25T03:03:27Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-felo.3","title":"Virtualize the grid: no limit on grid size, realize only what is visible","description":"layout.rs:935 cross_product and layout.rs:690 prune_empty matrix. The category cap never bounded either; a user who puts many categories on Row or Column builds an arbitrarily large grid. Decide whether a guard (refuse, warn, or lazy layout) is wanted. Found by the improvise-felo.1 coupling inventory (MAX_CATEGORIES 12 -\u003e 24, PR #307; context/code/core.md section 9). Reachable at 12 too; more likely at 24.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"felo3-adr","owner":"el-github@elangley.org","created_at":"2026-09-25T02:30:30Z","created_by":"Edward Langley","updated_at":"2026-09-25T02:33:29Z","started_at":"2026-09-25T02:33:29Z","lease_expires_at":"2026-09-25T02:38:29Z","heartbeat_at":"2026-09-25T02:33:29Z","labels":["core","perf"],"dependencies":[{"issue_id":"improvise-felo.3","depends_on_id":"improvise-felo","type":"parent-child","created_at":"2026-09-25T02:30:30Z","created_by":"Edward Langley","metadata":"{}"}],"comments":[{"id":"2ba6da4a-78fa-593e-84f9-6372c7f1afe8","issue_id":"improvise-felo.3","author":"Edward Langley","text":"USER DIRECTION 2026-09-25, verbatim: 'Ideally, there should be no limit on the size of the grid, we should just virtualize it so we only have to realize what's visible.' So the answer to 'refuse, warn, or lazy layout' is: lazy -- no guard, no size limit. Today GridLayout eagerly builds cross_product per axis (layout.rs:935) and prune_empty builds a rows x cols matrix (layout.rs:690). Known hard parts to design, not assume away: (1) prune_empty needs to know whether a row/column is empty across the WHOLE grid, not just the window; (2) column totals are sums over every row, including ones never scrolled to (gotcha 44 -- must stay projected, not client-derived); (3) row/column COUNT and index-\u003etuple mapping without materializing (mixed-radix indexing over item lists works when nothing is pruned or collapsed; groups, collapse and hidden items break pure arithmetic); (4) records mode rows (see improvise-sk32); (5) GridCache is already viewport-indexed for the browser, so the protocol side may need little change. Likely warrants an ADR.","created_at":"2026-09-25T02:31:59Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} +{"_type":"issue","id":"improvise-felo.4","title":"ADR 009: incremental evaluation — an in-house DBSP-core crate, pluggable","description":"USER DECISION 2026-09-25, verbatim: 'I think doing our own implementation of the core of dbsp as a crate we can plugin is probably dieal [sic] here. Write the ADR and Update ADR 008 to assume the decisions in that one'. Prompted by the user pointing at https://github.com/brurucy/dbsp-from-scratch (a teaching implementation of the DBSP paper: Z-sets, streams, delay/differentiate/integrate, lifted/incremental join, distinct, recursion by nested streams) as a way to evaluate formulas so data can stream. Scope: an ADR (next free number, 009) specifying a workspace crate implementing the DBSP core (not depending on Feldera's dbsp), and how improvise plugs it in: cell writes as Z-set deltas; incremental group-by aggregates (SUM/COUNT linear, AVG derived, MIN/MAX and the text-unanimity rule via integrated per-group multisets); per-row occupancy counts for prune_empty; formula dependencies updated in topological order with the circular check kept (DBSP's least-fixed-point recursion is not our circular-error semantics); f64 non-associativity vs ADR 005 exact decimals (improvise-l2b8); ADR 003 live-backed sources as delta streams; wasm32 cleanliness. Then ADR 008 (PR #308) is revised to assume ADR 009's decisions (notably its question 6, totals).","status":"in_progress","priority":2,"issue_type":"decision","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-25T03:03:27Z","created_by":"Edward Langley","updated_at":"2026-09-25T03:04:04Z","started_at":"2026-09-25T03:04:04Z","labels":["adr","core","formula"],"dependencies":[{"issue_id":"improvise-felo.4","depends_on_id":"improvise-felo","type":"parent-child","created_at":"2026-09-25T03:03:27Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-felo.3","title":"Virtualize the grid: no limit on grid size, realize only what is visible","description":"layout.rs:935 cross_product and layout.rs:690 prune_empty matrix. The category cap never bounded either; a user who puts many categories on Row or Column builds an arbitrarily large grid. Decide whether a guard (refuse, warn, or lazy layout) is wanted. Found by the improvise-felo.1 coupling inventory (MAX_CATEGORIES 12 -\u003e 24, PR #307; context/code/core.md section 9). Reachable at 12 too; more likely at 24.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"felo3-adr","owner":"el-github@elangley.org","created_at":"2026-09-25T02:30:30Z","created_by":"Edward Langley","updated_at":"2026-09-25T02:33:29Z","started_at":"2026-09-25T02:33:29Z","labels":["core","perf"],"dependencies":[{"issue_id":"improvise-felo.3","depends_on_id":"improvise-felo","type":"parent-child","created_at":"2026-09-25T02:30:30Z","created_by":"Edward Langley","metadata":"{}"}],"comments":[{"id":"2ba6da4a-78fa-593e-84f9-6372c7f1afe8","issue_id":"improvise-felo.3","author":"Edward Langley","text":"USER DIRECTION 2026-09-25, verbatim: 'Ideally, there should be no limit on the size of the grid, we should just virtualize it so we only have to realize what's visible.' So the answer to 'refuse, warn, or lazy layout' is: lazy -- no guard, no size limit. Today GridLayout eagerly builds cross_product per axis (layout.rs:935) and prune_empty builds a rows x cols matrix (layout.rs:690). Known hard parts to design, not assume away: (1) prune_empty needs to know whether a row/column is empty across the WHOLE grid, not just the window; (2) column totals are sums over every row, including ones never scrolled to (gotcha 44 -- must stay projected, not client-derived); (3) row/column COUNT and index-\u003etuple mapping without materializing (mixed-radix indexing over item lists works when nothing is pruned or collapsed; groups, collapse and hidden items break pure arithmetic); (4) records mode rows (see improvise-sk32); (5) GridCache is already viewport-indexed for the browser, so the protocol side may need little change. Likely warrants an ADR.","created_at":"2026-09-25T02:31:59Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} {"_type":"issue","id":"improvise-8jk6.1","title":"Import wizard field list does not scroll, so the over-cap case cannot show every field it asks the user to fix","description":"import_wizard_ui.rs:136 stops at the popup bottom (popup at most 30 rows, line 39), unlike the steps that use the scrolling list PR #304 added. About 22 fields fit; a CSV with more columns hides fields, including the ones the over-cap precondition tells the user to Skip. Use the same scrolling/Panel machinery. Found by the improvise-felo.1 coupling inventory (MAX_CATEGORIES 12 -\u003e 24, PR #307; context/code/core.md section 9). Reachable at 12 too; more likely at 24.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T02:30:28Z","created_by":"Edward Langley","updated_at":"2026-09-25T02:30:28Z","labels":["import","tui","ux"],"dependencies":[{"issue_id":"improvise-8jk6.1","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T02:30:28Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-gtn7.8","title":"TUI side panels do not scroll: items past the panel height are undrawn and the cursor can move onto them","description":"src/ui/panel.rs:77-81: the category/formula/view panels draw from the top and stop at the panel height; the cursor keeps moving into undrawn rows. The category panel lists every category plus expanded items, so 24 categories overflow a normal terminal. Scroll to keep the cursor visible, with more-above/below markers; test at a small height. Related: improvise-n8vh (long names overflow the border), improvise-j7ij. Found by the improvise-felo.1 coupling inventory (MAX_CATEGORIES 12 -\u003e 24, PR #307; context/code/core.md section 9). Reachable at 12 too; more likely at 24.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T02:30:27Z","created_by":"Edward Langley","updated_at":"2026-09-25T02:30:27Z","labels":["tui","ux"],"dependencies":[{"issue_id":"improvise-gtn7.8","depends_on_id":"improvise-gtn7","type":"parent-child","created_at":"2026-09-25T02:30:27Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-gtn7.7","title":"TUI page bar is one unclipped line: with many Page categories most selectors are cut off with no marker","description":"grid.rs:452-465 draws the page bar as a single line; default seating (view/types.rs:127) puts every category after the second on Page, so a 24-category import shows 22 page selectors on one line, clips most silently, and opens on one slice. Wrap or scroll it (user rule messages-wrap-not-truncate applies to anything the user must read), with a visible marker. Found by the improvise-felo.1 coupling inventory (MAX_CATEGORIES 12 -\u003e 24, PR #307; context/code/core.md section 9). Reachable at 12 too; more likely at 24.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T02:30:26Z","created_by":"Edward Langley","updated_at":"2026-09-25T02:30:26Z","labels":["tui","ux"],"dependencies":[{"issue_id":"improvise-gtn7.7","depends_on_id":"improvise-gtn7","type":"parent-child","created_at":"2026-09-25T02:30:26Z","created_by":"Edward Langley","metadata":"{}"}],"comments":[{"id":"aa6e2320-d872-5de9-b1fa-97727d3c7e0b","issue_id":"improvise-gtn7.7","author":"Edward Langley","text":"Also (PR #307 review): the page bar's set_string is not clipped to inner.width, so it can overwrite the right border too.","created_at":"2026-09-25T02:40:25Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} {"_type":"issue","id":"improvise-gtn7.6","title":"TUI visible_rows ignores column header levels, the group row and the page bar, so the cursor scrolls below the drawn grid","description":"app.rs:969 sets visible_rows = term_height - 8, a fixed chrome allowance; grid.rs:128 draws one line per column level. With several categories on Column the vertical scroll counts rows that are never drawn -- the vertical twin of improvise-gtn7.1 (a scroll must be measured where it is drawn). Derive the grid height from one function the draw also uses; test: with N column levels, moving down keeps the selected row drawn. Found by the improvise-felo.1 coupling inventory (MAX_CATEGORIES 12 -\u003e 24, PR #307; context/code/core.md section 9). Reachable at 12 too; more likely at 24.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T02:30:25Z","created_by":"Edward Langley","updated_at":"2026-09-25T02:30:25Z","labels":["tui","ux"],"dependencies":[{"issue_id":"improvise-gtn7.6","depends_on_id":"improvise-gtn7","type":"parent-child","created_at":"2026-09-25T02:30:25Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-gtn7.5","title":"TUI row header grows with every Row category until the data area is zero","description":"src/ui/grid.rs:94,102 and ui/viewport.rs:154-161: the row header is the SUM of per-level widths (4..24 cells each), so enough categories on Row shrink the data area to nothing. Needs a cap/compaction rule for the header and a test that some data column always draws. Found by the improvise-felo.1 coupling inventory (MAX_CATEGORIES 12 -\u003e 24, PR #307; context/code/core.md section 9). Reachable at 12 too; more likely at 24.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T02:30:23Z","created_by":"Edward Langley","updated_at":"2026-09-25T02:30:23Z","labels":["tui","ux"],"dependencies":[{"issue_id":"improvise-gtn7.5","depends_on_id":"improvise-gtn7","type":"parent-child","created_at":"2026-09-25T02:30:23Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-1xfb.2","title":"agent-rules: default to a new conventional commit; amend HEAD only for small unpushed fixes","description":"USER, 2026-09-25, verbatim: 'As a general rule, you shuold just add a new conventional commit. Small adjustments can ammend HEAD, but when in doubt don't.' PR #305 (merged 6427312d) wrote rule 8 and 'Commit early' as allowing an agent to 'amend, reorder or fold' unpushed commits and to 'tidy WIPs before the first push'. That is broader than the user now states: reordering and folding are out; the default is a new commit; amending is limited to HEAD, for a small adjustment, and only while unpushed; when in doubt, a new commit. Also: commit messages follow Conventional Commits (type(scope): subject) -- the recent history is mixed (docs:/ci:/chore:/test: alongside 'improvise-7mro: ...' and 'pr-reviews: ...'), so state the convention and how a ticket id is carried (e.g. in the scope or a trailer). Update agent-rules rule 8 + 'Commit early', AGENTS.md's top line, context/cloud-agents.md, the code-review skill summary line.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-25T02:19:18Z","created_by":"Edward Langley","updated_at":"2026-09-25T02:45:04Z","started_at":"2026-09-25T02:19:41Z","closed_at":"2026-09-25T02:45:04Z","close_reason":"Landed in PR #306; reviewed PASS at 94260d58, Codex P2/P3 answered","labels":["agents","tooling"],"dependencies":[{"issue_id":"improvise-1xfb.2","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T02:19:18Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-sk32","title":"Records-mode layout build is quadratic in records: RecordRow::group scans rows linearly per cell","description":"Found while measuring improvise-felo.1 (crates/improvise-core/examples/category_width_bench.rs, Apple M4 Max, release). GridLayout::with_frozen_records in records mode: 2,500 records 79-82 ms, 5,000 records 302-321 ms, i.e. ~4x for 2x records, at BOTH 12 and 24 categories (width barely moves it). Cause: view/layout.rs RecordRow::group finds a cell's row with rows.iter().position(|r| r.coords == coords) -- a linear scan per cell, so O(cells x records). Drawing the built layout is linear (3 ms / 7 ms). Fix shape: index rows by coords (HashMap\u003cCellKey, usize\u003e) while keeping first-appearance order, which gotcha 18 / core.md section 8 require; a test must pin the order is unchanged. Not caused or worsened by the category cap.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T02:14:46Z","created_by":"wave-felo1","updated_at":"2026-09-25T02:14:46Z","labels":["core"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-1xfb.1","title":"agent-rules: no squash, rebase or reset — reconcile diverging history only by merge","description":"USER RULE 2026-09-25, verbatim: 'generally there should be no squashing or rebasing in git, just merge to reconcile diverging histories.' Prompted by the improvise-cgqt agent squashing with 'git reset --soft gh/main' after #303 merged mid-session, which staged a commit that reverted #303 (caught by numstat before push) -- the same accident as improvise-diho/#196. Rule 7 of .agents/skills/wave/agent-rules.md currently treats 'reset' as a normal operation and only pins the SHA; the fix is to forbid the operation, not to make it safer. Change agent-rules (and AGENTS.md where it restates git policy): no git rebase, no squash (reset --soft, merge --squash, rebase -i fixup), no amending or force-pushing a pushed commit; take newer main with 'git merge \u003cpinned gh/main SHA\u003e'; WIP commits stay as they are. Keep rule 7's SHA pinning for merge-base and diff. Review the recovery recipes in AGENTS.md that use cherry-pick (a PR built on the wrong base; a fix pushed after merge) and say whether they stay as the one sanctioned exception or become merges. Also: bound any test loop that walks a state machine until a step.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-25T01:47:05Z","created_by":"Edward Langley","updated_at":"2026-09-25T02:15:34Z","started_at":"2026-09-25T01:48:18Z","closed_at":"2026-09-25T02:15:34Z","close_reason":"Landed in PR #305 (merge 6427312d), reviewed PASS at 9b9b1efa","labels":["agents","tooling"],"dependencies":[{"issue_id":"improvise-1xfb.1","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T01:47:05Z","created_by":"Edward Langley","metadata":"{}"}],"comments":[{"id":"8347c6b5-9936-5254-83d7-d4af2f05b388","issue_id":"improvise-1xfb.1","author":"Edward Langley","text":"USER, 2026-09-25, verbatim: 'Git operations that preserve history should generally be preserved, because the merge structure of git history is a causal record.' This is the rule's reason; state it in agent-rules. It bears on the cherry-pick recipes: a cherry-pick loses the causal link, so prefer a merge wherever one does the job.","created_at":"2026-09-25T01:49:26Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} {"_type":"issue","id":"improvise-gtn7.3","title":"The TUI status line truncates import warnings (and every long status) to one row","description":"Per the user's rule (bd memory messages-wrap-not-truncate, 2026-09-25): a message the user needs to read wraps, never truncates. AcceptWizardModel and ImportJsonHeadless join every import warning into status_msg ('Imported with warnings: a; b; c'), and draw.rs::draw_status / fill_line cut the status line to one row -- the same truncation PR #304 fixed inside the wizard, one screen later. Any long refusal/error on the status line has the same problem. Shape to decide: a status area that grows to fit (bounded, with a way to see the rest), or long messages routed to a readable pane. Test: a status message wider than the terminal renders every word. Found by the improvise-cgqt agent.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T01:46:56Z","created_by":"Edward Langley","updated_at":"2026-09-25T01:46:56Z","labels":["tui","ux"],"dependencies":[{"issue_id":"improvise-gtn7.3","depends_on_id":"improvise-gtn7","type":"parent-child","created_at":"2026-09-25T01:46:56Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-gtn7.2","title":"TUI grid cursor scrolls off screen across columns of mixed width","description":"Found while fixing improvise-gtn7.1. With NO side panel open, a row of ten narrow columns followed by wide ones: moving right from column 9 selects columns 10..14 while the grid draws none of them (col_offset 1,3,6,9,12). Cause: App::cmd_context computes visible_cols once, at the OLD col_offset, and navigation.rs::viewport_effects then applies that count at the NEW offset (nc - (vc-1)); seven narrow columns fit from offset 0, but not seven wide ones from offset 1. A single count cannot be right across mixed widths. Fix shape: the new col_offset must be the least offset from which columns offset..=nc fit (widths + data width), the same shape as the tile bar's first_tile. Constraint: improvise-protocol resolve.rs::scrolled_to mirrors viewport_effects line for line over a column-COUNT window and tests/lockstep.rs holds host and client in agreement, so a width-aware host scroll must decide what the browser's window means first. Probe: draw.rs content_tests shape, 1 row x 16 cols (A0..A9, then 28-wide names), 80x24, panels closed.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T01:28:54Z","created_by":"agent-gtn7-1","updated_at":"2026-09-25T01:28:54Z","labels":["tui","ux"],"dependencies":[{"issue_id":"improvise-gtn7.2","depends_on_id":"improvise-gtn7","type":"parent-child","created_at":"2026-09-25T01:28:54Z","created_by":"agent-gtn7-1","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -269,14 +294,14 @@ {"_type":"issue","id":"improvise-obtu","title":"The aggregate-addressing seam: a view-naming command resolved against that view's layout","description":"The remaining improvise-gd28 work, now that the blocker is correctly diagnosed (see improvise-gd28 and gotcha 44).\n\nAn AggregateHash is layout-scoped and cannot be otherwise, so App::resolve_aggregate answers only for the host's active view -- which since improvise-jytm is not the client's. A browser holding CellId::Aggregate(h) therefore cannot get it resolved.\n\nTHE SHAPE, and it is not a new mechanism: let the command NAME ITS VIEW, exactly as ModelCommand::SetAxis { view: String, .. } already does, and resolve the surrogate against a layout derived for that name rather than against app.layout. grid_in already derives a layout for a session on a view the host is not on, so the machinery exists.\n\nExplicitly NOT the alternative: projecting the coordinates to the client. That is precisely what ADR 002 exists to prevent -- an opaque surrogate stops being opaque and the client starts holding document coordinates.\n\nExplicitly NOT needing a SessionId: the mutation a drill entry performs is addressed to NO ONE session -- it configures the shared _Drill view exactly as CreateView/SetAxis already do unaddressed. Only the RESOLUTION of its operand is session-scoped. That asymmetry is the whole finding.\n\nAlso not needed: improvise-fdk1. edit_or_drill declines records-mode editing out loud before reaching a cell, so a browser cannot edit inside a drill grid at all, and the user's \"staging scheme should be identical\" ruling is kept by the browser getting no edits rather than by a second scheme.\n\nNote the contention issue filed separately: _Drill is one view in Workbook::views, so two clients drilling contend.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-23T01:36:28Z","created_by":"Edward Langley","updated_at":"2026-09-23T01:36:28Z","dependencies":[{"issue_id":"improvise-obtu","depends_on_id":"improvise-pmt0","type":"parent-child","created_at":"2026-09-25T01:05:04Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-t0ma","title":"records_steps is guarded by one incidental test","description":"records_steps (improvise-core/src/view/layout.rs) is guarded by exactly ONE test in the whole workspace, and that test is incidental: injecting is_index_or_dim() -\u003e is_measure() into records_steps failed only save_with_unfinished_record_is_readable, which is a persistence test that happens to enter records mode. Measured during PR #140.\n\nThat is thin for the spine GridLayout auto-detects records mode by (_Index on Row, _Dim on Column). A rival that got the spine wrong in a way that persistence does not observe would ship.\n\nWanted: a step-equivalence test asserting records_steps(model) produces the spine plus exactly one Axis::None step per non-index/dim category, and drill_steps likewise. It must be a test OF THE STEPS, not of the resulting effects -- the effects are cmd/grid.rs's translation and are already covered; the point of the split is that the steps are the model-side answer a browser will also consume.\n\nInject the is_measure() rival and show it fails the new test. Asked for by the ChatGPT Codex review on PR #140.","status":"closed","priority":2,"issue_type":"task","assignee":"agent-collapse-fu","owner":"el-github@elangley.org","created_at":"2026-09-23T01:36:26Z","created_by":"Edward Langley","updated_at":"2026-09-23T06:07:58Z","started_at":"2026-09-23T04:38:25Z","closed_at":"2026-09-23T06:07:58Z","close_reason":"Fixed in PR #191, merged after adversarial review at 31fd2f5.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-up8g","title":"Viewport extent is an external input on BOTH front ends, and it changes which record an edit writes","description":"The viewport extent is an EXTERNAL INPUT that reaches the document, and it is not a browser problem -- the TUI has exactly the same hole and always did.\n\nrun_headless_commands reads crossterm::terminal::size() into app.term_{width,height}, and CmdContext::{visible_rows, visible_cols} derive from it. Measured: term 80x24 -\u003e visible_rows=16; term 200x60 -\u003e visible_rows=52.\n\nAND IT REACHES THE DOCUMENT. Probe: a 40-row single-category grid, identical command sequence, one PageDown then clear-cell at the cursor:\n term 80x24 -\u003e selected=(12,0) -\u003e cleared Row=R12\n term 200x60 -\u003e selected=(39,0) -\u003e cleared Row=R39\nSame commands, DIFFERENT RECORD DELETED.\n\nSo Client::set_viewport is not a browser-introduced external input; it is the browser's SPELLING of an input the TUI has had all along. Gotcha 47's \"not derivable\" is true of BOTH front ends. Any ADR that treats extent as a browser problem will produce a log that replays in a browser and not in a terminal.\n\nTHE INTERACTION THAT MAKES THIS URGENT: today this hazard is INVISIBLE in `improvise script`, because the script path discards the cursor entirely (improvise-6uva). Fixing 6uva makes this live. The extent must be in the log -- or pinned by the replay harness -- IN THE SAME CHANGE that restores context-aware script parsing, or the repair will look like it works and then be flaky.\n\nThis is the cheapest of the five external inputs to close: it is one pair of integers and it already has a wire spelling (ClientFrame::Viewport). Make it a logged command (set-extent \u003crows\u003e \u003ccols\u003e) emitted on resize. The TUI has no such command today -- that is the only new vocabulary needed, and gotcha 42 already argues the extent is deployment-independent.\n\nA regression test must drive TWO sizes and assert the edit lands on the same record. One size cannot see this.\n\nFound by the improvise-ilvl replay audit.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-cli","owner":"el-github@elangley.org","created_at":"2026-09-23T01:24:54Z","created_by":"Edward Langley","updated_at":"2026-09-23T04:42:36Z","started_at":"2026-09-23T04:12:59Z","closed_at":"2026-09-23T04:42:36Z","close_reason":"PR #185 MERGED (confirmed at 8d51ad7), adversarially reviewed (merge); CI 13/0. up8g: headless runs pin the extent to 80x24 (HEADLESS_EXTENT), so a script writes the same record whatever terminal ran it. The reviewer restored the terminal-size read and the test failed with R13 vs R40, both in a non-tty shell and under a real 50x150 pty. c5ip/udt2: the exit code reads whether the request was REFUSED (App::refuse sets a per-batch batch_refused flag, in AppSnapshot) rather than whether a chain ABORTED, so a success that aborts exits 0 and a refusal that narrates exits 1. That was the basis for improvise-cz8d, which reworks #181 on top of it. Follow-up: improvise-0v8l (23 of 27 refusal conversions are not pinned by a test, AddCategory most importantly).","comments":[{"id":"43b16194-af3a-50b5-8265-105b872d2cde","issue_id":"improvise-up8g","author":"agent-review-cmd","text":"NOW LIVE, confirmed by a post-merge review of PR #151 (improvise-6uva, merged as 57be9144). #151 restored context-aware script parsing without logging or pinning the extent in the same change, which is exactly what this ticket asked for.\n\nProbe: a document with one category Region and 60 items, run under a pty through `script -q /dev/null` with `stty rows N`:\n improvise cmd 'page-scroll 1 . hide-selected-row-item' -f doc.improv\n- gh/main 40b8913, 24 rows: hidden: Region/R13\n- gh/main 40b8913, 50 rows: hidden: Region/R32\n- pre-#151 f7573bff, 50 rows: hidden: Region/R01. The cursor was always reset to the origin, so the result did not depend on the terminal.\nThe same script run against the same document writes a different document depending on the height of the terminal that ran it. With no tty (CI, cron), crossterm::terminal::size() fails and the app keeps 80x24, so CI and an interactive shell also disagree.\n","created_at":"2026-09-23T03:36:23Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} -{"_type":"issue","id":"improvise-ilvl","title":"ADR 006: everything is a command, and a recorded sequence replays the whole system","description":"USER RULING 2026-09-22: \"No action anywhere should happen without firing a\ncommand: like with emacs, everything is a command. But I should be able to\nreproduce any state of the whole system by spinning up a fresh copy of an\napplication and replaying a recorded sequence of commands.\"\n\nTWO CLAUSES, and the second is much stronger than the first. \"Everything is a\ncommand\" is a discipline. \"Replay reproduces any state\" is a TESTABLE PROPERTY,\nand it is the one that makes the discipline enforceable rather than aspirational.\n\nWHAT THE TREE ALREADY GIVES US, measured:\n - The Cmd/Effect pipeline IS the Emacs shape: a named command in a registry,\n resolved against a read-only CmdContext, returning effects that are the only\n mutation site. Nothing about that needs inventing.\n - `improvise script \u003cfile\u003e` ALREADY REPLAYS a command sequence into a fresh\n process (src/main.rs::run_headless_script), and PR #131 made it and\n `improvise cmd` one function so they cannot diverge. So half the machinery\n exists and is shipped.\n - Determinism work is largely done and was done for this reason without\n knowing it: gotcha 18 (DataStore iteration order, analyze_records first-seen\n order) and gotcha 37b (a gp fit records seed AND FitConfig, because the same\n seed under a different config lands on a different tree).\n - THERE IS NO RECORDER. parse.rs parses a script; nothing writes one.\n\nSO THE WORK IS (a) the recording side, and (b) closing the paths that bypass the\ncommand layer.\n\nTHE HARD QUESTION, WHICH THE ADR MUST ANSWER FIRST: WHICH VOCABULARY IS RECORDED?\nThere are THREE command-ish layers and they are not interchangeable:\n 1. `Cmd` -- a NAME plus args, resolved against a CmdContext. The Emacs\n analogue, and what `improvise script` already replays.\n 2. `Effect` -- the resolved mutation, the only thing that touches App.\n 3. `Command`/`ModelCommand` -- the WIRE vocabulary, derived from the effects.\n\nGotcha 38 draws the line that matters: A KEYMAP LOOKUP CANNOT RETURN A `Command`,\nbecause a binding names an INTENT resolved against a CmdContext (\"move-cursor\ndown\") while a Command is the RESOLVED MUTATION (\"SetSelected{row,col}\").\nimprovise-gxi's AC 4 asked for something that split forbids.\n\nThat cuts both ways for replay:\n - Recording layer 1 is small, human-readable and editable, and replays only if\n the CONTEXT evolves identically -- which is the Emacs model and is fine from\n a known start state.\n - Recording layer 2 or 3 is robust to context drift but is not \"commands\" in\n the user's sense, and a log of SetSelected{3,4} is not something a person\n reads or edits.\nThe ADR should probably choose layer 1 and say what that costs.\n\nWHAT BREAKS REPLAY TODAY -- enumerate before designing:\n - ANY ACTION THAT DOES NOT GO THROUGH A COMMAND. Audit for it: the browser's\n click surfaces compose Commands (Click::PageItem does), but viewport/resize,\n the ResizeObserver, and the OPFS persist trigger are observations rather than\n commands. Decide whether an observation must become a command or is outside\n the log by construction.\n - EXTERNAL INPUT. LoadModel(path) and the import wizard read files; replay\n reproduces the state only given the same inputs. Does the log reference them,\n or must it capture them?\n - TIME AND RANDOMNESS. A gp fit already records seed + config; anything else\n that samples a clock or an RNG has to.\n - MULTI-CLIENT. \"The state of the whole SYSTEM\" with several sessions means the\n log has to carry WHO acted, which is ADR 001's addressed-command question\n from a new direction -- and gotcha 29 says the model is shared and the UI is\n not, so a replay reproducing every client's UI needs per-session logs.\n\nWHAT THE PROPERTY BUYS, and why it is worth the ADR: a replay harness is the\nstrongest possible regression test for this codebase -- record a session, replay\nit, diff the resulting document. It would have caught the whole class this repo\nkeeps rediscovering (a test that passes while the thing it names is broken),\nbecause a replay diff cannot be vacuous.\n\nDO NOT START BY WRITING A RECORDER. Start by auditing what currently bypasses the\ncommand layer, because that list decides whether the property is reachable at all.","notes":"TWO REFINEMENTS FROM THE USER, 2026-09-22, and the second changes what this bead\nis:\n\n \"(Ignoring nondeterminism like rng seeding)\"\n \"This was how the app used to work, but maybe the web refactor broke it\"\n\nSO RNG/CLOCK DETERMINISM IS OUT OF SCOPE. Strike that bullet from the enumeration\nabove; a gp fit's seed+config recording (gotcha 37b) is a separate concern and\nalready handled.\n\nAND THIS IS ARCHAEOLOGY, NOT DESIGN. The property existed. The first task is to\nfind WHEN and WHERE it stopped holding, not to invent a mechanism. That reframes\nthe whole bead: start with `git log` and a bisect-shaped question, not with a\nrecorder.\n\nWHAT THE WEB WORK INTRODUCED THAT IS A CANDIDATE, found in a first pass:\n\nThe browser refactor deliberately replaced several hand-kept tables with\nOBSERVATIONS, and each is a state change that happens WITHOUT A COMMAND FIRING:\n - App::document_generation += 1 (app.rs:444, bumped by install_workbook)\n - App::cursor_epoch += 1 (app.rs:653, on cursor invalidation)\n - App::layout_version += 1 (app.rs:945, per LayoutGeometry effect)\n - view_frame / view_frames (app.rs:73-124) -- the cursor is invalidated\n by OBSERVING the grid's frame (gotcha 24, improvise-ni6k), which is why no\n effect calls reset_cursor any more\n - model_state.dirty raised in apply_effects off EffectKind (gotcha 45), which\n is why MarkDirty was DELETED as an effect\n\nEACH OF THOSE WAS A DELIBERATE MOVE AND THE ARGUMENT FOR IT IS GOOD: a table of\n\"which effects renumber the grid\" is a second description free to disagree with\nthe first, so the repo chose to observe instead. Gotchas 24, 39 and 45 all say\n\"observed, not predicted\" in as many words.\n\nSO THERE IS A REAL TENSION TO RESOLVE IN THE ADR, and it is the interesting part:\n\"everything is a command\" and \"observe rather than predict\" pull in opposite\ndirections.\n\nBUT PROBABLY NOT A CONTRADICTION, and the ADR should test this claim first\nbecause it may dissolve the whole problem: AN OBSERVATION IS A PURE FUNCTION OF\nSTATE. If replaying the commands reproduces the state, every observation\nre-derives identically and nothing is lost. Observations are DERIVED, not\nprimitive, so they do not need to be in the log -- they need to be REPRODUCIBLE\nFROM it. That is a weaker and much cheaper requirement.\n\nWHAT WOULD GENUINELY BREAK REPLAY is an action whose result is NOT determined by\nthe command sequence. The candidates to audit, in order:\n - VIEWPORT EXTENT. Client::set_viewport records what the PAGE announced, off\n the browser window size and a ResizeObserver. That is external input, not a\n command, and gotcha 47 says it is \"not derivable\". A replay on a different\n window size gets a different extent -- and therefore a different projected\n grid.\n - EXTERNAL FILES. LoadModel(path) and the import wizard read the filesystem;\n replay reproduces state only given the same inputs.\n - MULTI-CLIENT ORDERING. \"The state of the whole system\" with several sessions\n needs the log to carry WHO acted and in what order -- ADR 001's addressed-\n command question from a new direction.\n\nFIRST CONCRETE STEP: `improvise script` already replays a command sequence into a\nfresh process (src/main.rs::run_headless_script, one function with\nrun_headless_commands since PR #131). So the check is cheap -- take a document,\ndrive it with a script, and see whether the result is reproducible. If it is, the\nTUI path still holds the property and the regression is browser-side only, which\nnarrows the search to the list above.\nAUDIT COMPLETE 2026-09-22 (read-only, base a9cad15). Baseline measured on the auditing host: 1699 passed / 0 failed / 7 ignored over 38 binaries, longest single binary 0.70s.\n\nTHE USER'S HYPOTHESIS IS WRONG FOR THE MAIN DEFECT. Replay is broken, but not by the web refactor: see improvise-6uva, broken by 35946af on 2026-04-04, five months before the first wasm-client commit.\n\nTWO DEFECTS FOUND, FILED SEPARATELY: improvise-6uva (script cannot move the cursor) and improvise-up8g (viewport extent is external on both front ends). THE SECOND IS MASKED BY THE FIRST -- fix 6uva and up8g becomes live, so they must be closed together or the repair will look like it works and then be flaky.\n\nTHE TICKET'S STATED SAFETY ARGUMENT IS FALSE, and the false version is dangerous. The ticket claims \"an observation is a pure function of state\". IT IS NOT -- it is a pure function of the TRAJECTORY. Measured, two routes to an IDENTICAL document:\n no commands -\u003e layout_version 0, cursor_epoch 0, dirty false\n set-axis Product column, then set-axis Product row -\u003e layout_version 2, cursor_epoch 2, dirty true\nThe weaker true claim (\"reproducible from the log\") is all replay needs. But the stronger false one is exactly what you would cite to justify SNAPSHOT-PLUS-SUFFIX LOG COMPACTION, and that would be wrong, silently, in the direction that looks like it works: Basis puts document_generation and layout_version ON THE WIRE and clients compare them for equality, so a compacted replay is not basis-compatible with any client holding a claim. model_state.dirty is the same shape and is the worker's OPFS persist trigger (gotcha 45), so a replay reaching the same document by a different route persists a different number of times.\n\nGOTCHA 40 IS STALE, and the truth is stronger than the doc. It says layout_version bumps \"per ModelChange::LayoutGeometry effect\". It actually bumps from an OBSERVED view_shapes diff (observe_reshape, app.rs:938), so three idempotent set-axis calls bump nothing and batching two real changes into one line or two gives the same 2. The observed form is what the ADR should lean on. Correction applied to context/repo-map.md.\n\nDETERMINISM OTHERWISE CHECKS OUT: no clock or RNG is reachable from any command or effect except App::last_autosave (Instant::now(), debounce only, writes to a separate autosave_path and never the document), and fit-measure takes its seed as an explicit argument with a fixed default. One genuine clause-1 violation that costs replay nothing: autosave_if_needed writes a file on a wall-clock timer with NO COMMAND FIRING, so \"everything is a command\" is literally false there.\n\nTHE FIVE GENUINE EXTERNAL INPUTS, ranked by cost to close:\n1. Viewport extent (improvise-up8g) -- cheapest, one pair of integers, already has a wire spelling.\n2. Document identity at startup (get_initial_workbook, -f \u003cpath\u003e) -- cheap: the log header names the starting document by content hash.\n3. External files mid-session (LoadModel, ExportCsv, SaveAs, ImportJsonHeadless, StartImportWizard) -- moderate. Storage is ALREADY the seam (gotcha 2), so a Storage that replays recorded bytes is a clean mechanism needing no effect change.\n4. Import-wizard interaction -- moderate/hard: the keys go through a command but the wizard state is session-local residue with no Projection. Replayable in principle; nothing tests it.\n5. Multi-client ordering and authorship -- HARDEST, and partly a design question. apply_model's basis check means a differently-interleaved replay can REJECT a batch the original accepted, so the log must carry SessionId AND the arrival order across sessions.\n\nWHICH VOCABULARY TO RECORD -- the ADR's central question. The auditor's reading, offered for the ADR to accept or reject: the user's ruling has two clauses wanting different layers. Clause 1 (\"everything is a command, like Emacs\") is a LAYER-1 discipline about what a user can type, bind and read. Clause 2 (\"replay reproduces any state\") is a LOG property, and LAYER 3 (Command/ModelCommand) is strictly better at it -- already Serialize/Deserialize, already carries a Basis, already has one door and one interpreter, already transactional via ClientFrame::Commands, and ALREADY WHAT THE BROWSER EMITS. Note a layer-1 log CANNOT BE PRODUCED BY THE BROWSER AS BUILT: gotcha 38 says a keymap lookup cannot return a Command, so the browser resolves keys to layer 3 and never constructs a layer-1 name.\nThe two are not in conflict if the log records layer 3 and layer 1 keeps its status as the human surface, with ExecuteCommand being just another producer of layer-3 traffic. WHAT THAT COSTS is closing layer 3's three DELIBERATE holes -- ApplyAndClearDrill and AcceptWizardModel off the wire (gotcha 38), ClientFrame::Save a frame not a command (gotcha 45), SwitchView carrying no basis -- each of which has a written argument that would have to be REOPENED. That is the real price and belongs in the ADR rather than being discovered later.\nLayer 2 (Effect) declined: 58 types, no text syntax, no parser, no Serialize, and a log of effects cannot be edited by a person, which is most of what the user asked for.\n\nNOT VERIFIED: the browser half was not driven (no Playwright, no wasm build) -- everything about Client::set_viewport is read, not run, though the TUI analogue WAS run and is the same mechanism. Multi-client interleaving was not exercised. The import wizard was not replayed. No bisect between 35946af and HEAD for ADDITIONAL regressions -- a second archaeology pass after 6uva is fixed is cheap and is where to look next. Only two terminal sizes probed.","status":"in_progress","priority":2,"issue_type":"task","assignee":"agent-adr006","owner":"el-github@elangley.org","created_at":"2026-09-23T00:55:57Z","created_by":"Edward Langley","updated_at":"2026-09-25T04:45:22Z","started_at":"2026-09-25T04:45:22Z","lease_expires_at":"2026-09-25T04:50:22Z","heartbeat_at":"2026-09-25T04:45:22Z","dependencies":[{"issue_id":"improvise-ilvl","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T01:05:08Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-ilvl","title":"ADR 006: everything is a command, and a recorded sequence replays the whole system","description":"USER RULING 2026-09-22: \"No action anywhere should happen without firing a\ncommand: like with emacs, everything is a command. But I should be able to\nreproduce any state of the whole system by spinning up a fresh copy of an\napplication and replaying a recorded sequence of commands.\"\n\nTWO CLAUSES, and the second is much stronger than the first. \"Everything is a\ncommand\" is a discipline. \"Replay reproduces any state\" is a TESTABLE PROPERTY,\nand it is the one that makes the discipline enforceable rather than aspirational.\n\nWHAT THE TREE ALREADY GIVES US, measured:\n - The Cmd/Effect pipeline IS the Emacs shape: a named command in a registry,\n resolved against a read-only CmdContext, returning effects that are the only\n mutation site. Nothing about that needs inventing.\n - `improvise script \u003cfile\u003e` ALREADY REPLAYS a command sequence into a fresh\n process (src/main.rs::run_headless_script), and PR #131 made it and\n `improvise cmd` one function so they cannot diverge. So half the machinery\n exists and is shipped.\n - Determinism work is largely done and was done for this reason without\n knowing it: gotcha 18 (DataStore iteration order, analyze_records first-seen\n order) and gotcha 37b (a gp fit records seed AND FitConfig, because the same\n seed under a different config lands on a different tree).\n - THERE IS NO RECORDER. parse.rs parses a script; nothing writes one.\n\nSO THE WORK IS (a) the recording side, and (b) closing the paths that bypass the\ncommand layer.\n\nTHE HARD QUESTION, WHICH THE ADR MUST ANSWER FIRST: WHICH VOCABULARY IS RECORDED?\nThere are THREE command-ish layers and they are not interchangeable:\n 1. `Cmd` -- a NAME plus args, resolved against a CmdContext. The Emacs\n analogue, and what `improvise script` already replays.\n 2. `Effect` -- the resolved mutation, the only thing that touches App.\n 3. `Command`/`ModelCommand` -- the WIRE vocabulary, derived from the effects.\n\nGotcha 38 draws the line that matters: A KEYMAP LOOKUP CANNOT RETURN A `Command`,\nbecause a binding names an INTENT resolved against a CmdContext (\"move-cursor\ndown\") while a Command is the RESOLVED MUTATION (\"SetSelected{row,col}\").\nimprovise-gxi's AC 4 asked for something that split forbids.\n\nThat cuts both ways for replay:\n - Recording layer 1 is small, human-readable and editable, and replays only if\n the CONTEXT evolves identically -- which is the Emacs model and is fine from\n a known start state.\n - Recording layer 2 or 3 is robust to context drift but is not \"commands\" in\n the user's sense, and a log of SetSelected{3,4} is not something a person\n reads or edits.\nThe ADR should probably choose layer 1 and say what that costs.\n\nWHAT BREAKS REPLAY TODAY -- enumerate before designing:\n - ANY ACTION THAT DOES NOT GO THROUGH A COMMAND. Audit for it: the browser's\n click surfaces compose Commands (Click::PageItem does), but viewport/resize,\n the ResizeObserver, and the OPFS persist trigger are observations rather than\n commands. Decide whether an observation must become a command or is outside\n the log by construction.\n - EXTERNAL INPUT. LoadModel(path) and the import wizard read files; replay\n reproduces the state only given the same inputs. Does the log reference them,\n or must it capture them?\n - TIME AND RANDOMNESS. A gp fit already records seed + config; anything else\n that samples a clock or an RNG has to.\n - MULTI-CLIENT. \"The state of the whole SYSTEM\" with several sessions means the\n log has to carry WHO acted, which is ADR 001's addressed-command question\n from a new direction -- and gotcha 29 says the model is shared and the UI is\n not, so a replay reproducing every client's UI needs per-session logs.\n\nWHAT THE PROPERTY BUYS, and why it is worth the ADR: a replay harness is the\nstrongest possible regression test for this codebase -- record a session, replay\nit, diff the resulting document. It would have caught the whole class this repo\nkeeps rediscovering (a test that passes while the thing it names is broken),\nbecause a replay diff cannot be vacuous.\n\nDO NOT START BY WRITING A RECORDER. Start by auditing what currently bypasses the\ncommand layer, because that list decides whether the property is reachable at all.","notes":"TWO REFINEMENTS FROM THE USER, 2026-09-22, and the second changes what this bead\nis:\n\n \"(Ignoring nondeterminism like rng seeding)\"\n \"This was how the app used to work, but maybe the web refactor broke it\"\n\nSO RNG/CLOCK DETERMINISM IS OUT OF SCOPE. Strike that bullet from the enumeration\nabove; a gp fit's seed+config recording (gotcha 37b) is a separate concern and\nalready handled.\n\nAND THIS IS ARCHAEOLOGY, NOT DESIGN. The property existed. The first task is to\nfind WHEN and WHERE it stopped holding, not to invent a mechanism. That reframes\nthe whole bead: start with `git log` and a bisect-shaped question, not with a\nrecorder.\n\nWHAT THE WEB WORK INTRODUCED THAT IS A CANDIDATE, found in a first pass:\n\nThe browser refactor deliberately replaced several hand-kept tables with\nOBSERVATIONS, and each is a state change that happens WITHOUT A COMMAND FIRING:\n - App::document_generation += 1 (app.rs:444, bumped by install_workbook)\n - App::cursor_epoch += 1 (app.rs:653, on cursor invalidation)\n - App::layout_version += 1 (app.rs:945, per LayoutGeometry effect)\n - view_frame / view_frames (app.rs:73-124) -- the cursor is invalidated\n by OBSERVING the grid's frame (gotcha 24, improvise-ni6k), which is why no\n effect calls reset_cursor any more\n - model_state.dirty raised in apply_effects off EffectKind (gotcha 45), which\n is why MarkDirty was DELETED as an effect\n\nEACH OF THOSE WAS A DELIBERATE MOVE AND THE ARGUMENT FOR IT IS GOOD: a table of\n\"which effects renumber the grid\" is a second description free to disagree with\nthe first, so the repo chose to observe instead. Gotchas 24, 39 and 45 all say\n\"observed, not predicted\" in as many words.\n\nSO THERE IS A REAL TENSION TO RESOLVE IN THE ADR, and it is the interesting part:\n\"everything is a command\" and \"observe rather than predict\" pull in opposite\ndirections.\n\nBUT PROBABLY NOT A CONTRADICTION, and the ADR should test this claim first\nbecause it may dissolve the whole problem: AN OBSERVATION IS A PURE FUNCTION OF\nSTATE. If replaying the commands reproduces the state, every observation\nre-derives identically and nothing is lost. Observations are DERIVED, not\nprimitive, so they do not need to be in the log -- they need to be REPRODUCIBLE\nFROM it. That is a weaker and much cheaper requirement.\n\nWHAT WOULD GENUINELY BREAK REPLAY is an action whose result is NOT determined by\nthe command sequence. The candidates to audit, in order:\n - VIEWPORT EXTENT. Client::set_viewport records what the PAGE announced, off\n the browser window size and a ResizeObserver. That is external input, not a\n command, and gotcha 47 says it is \"not derivable\". A replay on a different\n window size gets a different extent -- and therefore a different projected\n grid.\n - EXTERNAL FILES. LoadModel(path) and the import wizard read the filesystem;\n replay reproduces state only given the same inputs.\n - MULTI-CLIENT ORDERING. \"The state of the whole system\" with several sessions\n needs the log to carry WHO acted and in what order -- ADR 001's addressed-\n command question from a new direction.\n\nFIRST CONCRETE STEP: `improvise script` already replays a command sequence into a\nfresh process (src/main.rs::run_headless_script, one function with\nrun_headless_commands since PR #131). So the check is cheap -- take a document,\ndrive it with a script, and see whether the result is reproducible. If it is, the\nTUI path still holds the property and the regression is browser-side only, which\nnarrows the search to the list above.\nAUDIT COMPLETE 2026-09-22 (read-only, base a9cad15). Baseline measured on the auditing host: 1699 passed / 0 failed / 7 ignored over 38 binaries, longest single binary 0.70s.\n\nTHE USER'S HYPOTHESIS IS WRONG FOR THE MAIN DEFECT. Replay is broken, but not by the web refactor: see improvise-6uva, broken by 35946af on 2026-04-04, five months before the first wasm-client commit.\n\nTWO DEFECTS FOUND, FILED SEPARATELY: improvise-6uva (script cannot move the cursor) and improvise-up8g (viewport extent is external on both front ends). THE SECOND IS MASKED BY THE FIRST -- fix 6uva and up8g becomes live, so they must be closed together or the repair will look like it works and then be flaky.\n\nTHE TICKET'S STATED SAFETY ARGUMENT IS FALSE, and the false version is dangerous. The ticket claims \"an observation is a pure function of state\". IT IS NOT -- it is a pure function of the TRAJECTORY. Measured, two routes to an IDENTICAL document:\n no commands -\u003e layout_version 0, cursor_epoch 0, dirty false\n set-axis Product column, then set-axis Product row -\u003e layout_version 2, cursor_epoch 2, dirty true\nThe weaker true claim (\"reproducible from the log\") is all replay needs. But the stronger false one is exactly what you would cite to justify SNAPSHOT-PLUS-SUFFIX LOG COMPACTION, and that would be wrong, silently, in the direction that looks like it works: Basis puts document_generation and layout_version ON THE WIRE and clients compare them for equality, so a compacted replay is not basis-compatible with any client holding a claim. model_state.dirty is the same shape and is the worker's OPFS persist trigger (gotcha 45), so a replay reaching the same document by a different route persists a different number of times.\n\nGOTCHA 40 IS STALE, and the truth is stronger than the doc. It says layout_version bumps \"per ModelChange::LayoutGeometry effect\". It actually bumps from an OBSERVED view_shapes diff (observe_reshape, app.rs:938), so three idempotent set-axis calls bump nothing and batching two real changes into one line or two gives the same 2. The observed form is what the ADR should lean on. Correction applied to context/repo-map.md.\n\nDETERMINISM OTHERWISE CHECKS OUT: no clock or RNG is reachable from any command or effect except App::last_autosave (Instant::now(), debounce only, writes to a separate autosave_path and never the document), and fit-measure takes its seed as an explicit argument with a fixed default. One genuine clause-1 violation that costs replay nothing: autosave_if_needed writes a file on a wall-clock timer with NO COMMAND FIRING, so \"everything is a command\" is literally false there.\n\nTHE FIVE GENUINE EXTERNAL INPUTS, ranked by cost to close:\n1. Viewport extent (improvise-up8g) -- cheapest, one pair of integers, already has a wire spelling.\n2. Document identity at startup (get_initial_workbook, -f \u003cpath\u003e) -- cheap: the log header names the starting document by content hash.\n3. External files mid-session (LoadModel, ExportCsv, SaveAs, ImportJsonHeadless, StartImportWizard) -- moderate. Storage is ALREADY the seam (gotcha 2), so a Storage that replays recorded bytes is a clean mechanism needing no effect change.\n4. Import-wizard interaction -- moderate/hard: the keys go through a command but the wizard state is session-local residue with no Projection. Replayable in principle; nothing tests it.\n5. Multi-client ordering and authorship -- HARDEST, and partly a design question. apply_model's basis check means a differently-interleaved replay can REJECT a batch the original accepted, so the log must carry SessionId AND the arrival order across sessions.\n\nWHICH VOCABULARY TO RECORD -- the ADR's central question. The auditor's reading, offered for the ADR to accept or reject: the user's ruling has two clauses wanting different layers. Clause 1 (\"everything is a command, like Emacs\") is a LAYER-1 discipline about what a user can type, bind and read. Clause 2 (\"replay reproduces any state\") is a LOG property, and LAYER 3 (Command/ModelCommand) is strictly better at it -- already Serialize/Deserialize, already carries a Basis, already has one door and one interpreter, already transactional via ClientFrame::Commands, and ALREADY WHAT THE BROWSER EMITS. Note a layer-1 log CANNOT BE PRODUCED BY THE BROWSER AS BUILT: gotcha 38 says a keymap lookup cannot return a Command, so the browser resolves keys to layer 3 and never constructs a layer-1 name.\nThe two are not in conflict if the log records layer 3 and layer 1 keeps its status as the human surface, with ExecuteCommand being just another producer of layer-3 traffic. WHAT THAT COSTS is closing layer 3's three DELIBERATE holes -- ApplyAndClearDrill and AcceptWizardModel off the wire (gotcha 38), ClientFrame::Save a frame not a command (gotcha 45), SwitchView carrying no basis -- each of which has a written argument that would have to be REOPENED. That is the real price and belongs in the ADR rather than being discovered later.\nLayer 2 (Effect) declined: 58 types, no text syntax, no parser, no Serialize, and a log of effects cannot be edited by a person, which is most of what the user asked for.\n\nNOT VERIFIED: the browser half was not driven (no Playwright, no wasm build) -- everything about Client::set_viewport is read, not run, though the TUI analogue WAS run and is the same mechanism. Multi-client interleaving was not exercised. The import wizard was not replayed. No bisect between 35946af and HEAD for ADDITIONAL regressions -- a second archaeology pass after 6uva is fixed is cheap and is where to look next. Only two terminal sizes probed.","status":"in_progress","priority":2,"issue_type":"task","assignee":"agent-adr006","owner":"el-github@elangley.org","created_at":"2026-09-23T00:55:57Z","created_by":"Edward Langley","updated_at":"2026-09-25T04:45:22Z","started_at":"2026-09-25T04:45:22Z","dependencies":[{"issue_id":"improvise-ilvl","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T01:05:08Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-f9qf","title":"Pin the shell: macOS bash 3.2 hid two bugs that failed 17/19 in CI on the same commit","description":"USER RULING 2026-09-22: \"Bash really should be gotten from nix: macOS bash is old\nand deprecated, or just use zsh for scripts. You can use a nix shebang line\n(nix-shell manpage has details here).\"\n\nWHY, MEASURED RATHER THAN ASSERTED. improvise-xgra (PR #133) shipped two bugs that\nwere invisible on the machine that wrote them:\n - `local n=\"$1\" name=\"$2\" f=\"...$n...\"` is NOT left-to-right on bash 5 -- every\n name is created before the assignments run, so $n is unbound under set -u.\n - macOS ships bash 3.2 (2007, GPLv2, frozen), where it works.\nRESULT: scripts/pr-reviews-test.sh passed 19/19 LOCALLY AND 2/19 IN CI ON THE SAME\nCOMMIT. Both bugs surfaced as exit 2 with no output, because a dead command\nsubstitution hands jq an empty --argjson and jq answers with a USAGE error.\n\nTHE INVENTORY, as of 22ab58f: 16 scripts, with 11 `#!/usr/bin/env bash`,\n4 `#!/bin/sh` and 1 `#!/usr/bin/env sh`. So the bash/sh split is already\ninconsistent, and the sh ones are running as macOS bash-in-sh-mode locally and\ndash in CI -- a SECOND undetected platform difference of the same kind.\n\nTHE TWO OPTIONS THE USER NAMED:\n a. A NIX SHEBANG, e.g.\n #!/usr/bin/env nix-shell\n #!nix-shell -i bash -p bash\n which pins ONE bash for every developer and CI alike. Cost: nix-shell\n startup on every invocation, which matters for scripts CI runs per job and\n for anything in a tight loop -- MEASURE IT before committing, and say what it\n costs on a cold and a warm store.\n b. ZSH, which macOS ships current and which is the login shell there. No startup\n cost, but it is a different language in the places that bite (word splitting,\n array indexing from 1, `setopt` needed for sh-compatible globbing), so\n PORTING IS NOT A SHEBANG CHANGE -- every script would need reading.\n\nDECIDE ONE AND APPLY IT UNIFORMLY. A per-script choice reintroduces exactly the\ninconsistency above.\n\nCHECK BEFORE CHOOSING, because it may decide it:\n - Does CI have nix on every job that runs a script? The kb job uses\n `nix shell nixpkgs#trealla`, so nix is present there; confirm for build, test\n and browser.\n - Does the git merge driver (scripts/beads-merge-driver.sh) tolerate a\n nix-shell startup? It runs inside git's merge machinery.\n - Does .envrc / direnv already put a nix bash on PATH? If so, `#!/usr/bin/env\n bash` may already be correct INSIDE the dev shell and wrong only outside it --\n which would make the fix \"run scripts through direnv\" rather than a shebang\n change, and would be cheaper than either option.\n\nWHATEVER LANDS, THE GUARD IS THE POINT: the bug this closes was invisible to a\nlocal run, so the fix must be verified by something that RUNS BOTH SHELLS, or by\npinning so there is only one. A checker wired into CI in the same change is the\nminimum -- improvise-xgra's lesson exactly.","notes":"FIXED in PR #137 (merge a9cad15).\n\nTHE ENVIRONMENT, NOT THE INTERPRETER, WAS THE LOAD-BEARING HALF -- and this\nticket, the originating commit, and the agent's own first hypothesis all blamed\nthe interpreter.\n\nDIRENV DOES NOT SOLVE IT, and the reason is the finding. .envrc puts nix bash\n5.3.15 on PATH, which IS CI's semantics. But pkgs.mkShell ALSO EXPORTS ~30\nGENERICALLY-NAMED LOWERCASE VARIABLES THE RUNNER DOES NOT HAVE -- name, out,\noutputs, system, shell, builder, stdenv, phases, patches, buildInputs, doCheck,\nstrictDeps and more. A script reading one by accident under set -u is SILENT IN\nTHE DEV SHELL AND FATAL IN CI.\n\nThat is exactly what happened. pr-reviews' broken third clause read $name, and in\nthe dev shell $name is \"nix-shell-env\". Reproduced at 051f541^, one machine, one\nsitting:\n macOS /bin/bash 3.2 19 passed, 0 failed\n direnv exec . (nix bash 5.3) 19 passed, 0 failed\n the same, with `name` unset 2 passed, 17 failed \u003c- matches CI exactly\nTWO DIFFERENT REASONS FOR GREEN, ONE OF THEM THE CORRECT INTERPRETER.\n\nNote also: on CI it is $name that is unbound, NOT $n -- `fetch` declares `local\nn`, so dynamic scoping keeps n bound. A fix aimed only at the shell would have\nshipped.\n\nVERIFIED BY THE ORCHESTRATOR, and my first check was confounded: `nix shell\nnixpkgs#bash` appeared to set `name` too, until I re-ran with `env -u name` and\nit came back UNSET while `direnv exec .` still gave nix-shell-env. My check had\ninherited the variable from my own direnv-loaded shell -- it answered \"does name\nend up set\" where the question is \"does nix shell set it\". The agent's control\nwas right and mine was not.\n\nTHE NIX SHEBANG WAS DECLINED ON AVAILABILITY, NOT SPEED. Measured warm, 20\ninvocations: ambient `bash -c true` 5.1ms; `nix shell nixpkgs#bash --command`\n61.8ms; `nix-shell -p bash --run` (THE MANPAGE FORM) 675.2ms, 132x. 675ms inside\ngit's merge machinery for beads-merge-driver.sh is bad -- but the decider is that\nNIX IS ON ONLY THE kb JOB. build, test, wasm, js, harness-lint, browser and\npages-deploy have none, so a nix shebang needs install-nix-action added to three\nmore jobs, paid every run, to reach a bash those runners already ship. zsh\ndeclined too: not a shebang change, sixteen scripts to read, and no zsh on the\nrunner either.\n\nWHAT LANDED: scripts/checkers.sh, the ONE entry point for every check_the_checker,\ncalled verbatim by CI and by the local gate. It pins a bash \u003e= 5 (ambient first,\nnix only when the ambient one is older, exit 3 when neither), SCRUBS THE\nENVIRONMENT TO AN ALLOWLIST, and refuses any shebang in scripts/ that is not\n#!/usr/bin/env bash. CI logs `checkers: bash 5.2 at /usr/bin/bash` -- ambient, no\nnix, zero startup.\n\nTHE INJECTED FAULT, which is the proof: restoring the multi-assignment `local`\ngives 22 passed under the OLD local gate, 22 passed under direnv, and\n2 passed / 20 failed / exit 1 under checkers.sh.\n\nTHE OLD GATE WAS ITSELF THE BUG. `for t in scripts/*-test.sh; do sh \"$t\"; done`\nOVERRIDES EVERY SHEBANG with /bin/sh, so on macOS it ran every suite under bash\n3.2 in sh-mode regardless of what the file said. The gate in my brief was the\ndisease. Seven other sites ran a repo script through an explicit `sh`, and one of\nthem is dist-workspace.toml -- which a first-line shebang gate STRUCTURALLY CANNOT\nSEE. The shebang rule closes declarations, never invocations.\n\nUNRUN, STATED: scripts/build-web-release.sh runs only under dist in\nbuild-global-artifacts, which no branch push exercises, so its new shebang and two\nchanged lines are mechanical and untested.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T22:31:17Z","created_by":"Edward Langley","updated_at":"2026-09-22T23:16:23Z","closed_at":"2026-09-22T23:16:23Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-clso","title":"Ctrl-S saves and also opens the browser's own save dialog","description":"Found by the improvise-jz6w agent (PR #134) as a consequence of its own fix, and\ncorrectly NOT fixed there because every available fix crosses a doctrine line.\n\nNOT A REGRESSION -- verified by the orchestrator. web/app.js:786 calls\npreventDefault only when `!e.ctrlKey \u0026\u0026 !e.altKey`, with the deliberate comment\n\"Ctrl/Alt combinations are left alone: they are the browser's\". So Ctrl-S ALWAYS\nopened Chrome's \"Save page as\" dialog; before improvise-jz6w it did that and\nnothing else. Now it saves the document AND opens the dialog. The save is\ncorrect; the dialog is the wart, and it is newly VISIBLE rather than newly\nintroduced.\n\nWHY IT WAS NOT FIXED IN #134, and this is the useful part:\n - Parsing the frame in app.js gives the page PROTOCOL KNOWLEDGE\n (design-principles section 5).\n - A `e.key === \"s\" \u0026\u0026 e.ctrlKey` test puts a KEYBINDING FACT in the page.\n - Widening the wasm surface so Rust can say \"this key is claimed exclusively\"\n is a new decision the ticket did not authorise.\n\nTHE HONEST SHAPE: the DECISION -- \"is this key exclusively claimed by the app?\" --\nbelongs in Rust as a third thing a resolver already knows, beside Local and\nRequest. That is a natural follow-up to the KeyOutcome variant #134 added, not a\npatch to app.js.\n\nWHATEVER LANDS, THE ASSERTION GOES IN harness/tests/shell.spec.js (gotcha 48: a\nchange to web/*.js belongs in that spec directory). Note harness/node_modules does\nnot exist in a fresh worktree, so `npx playwright test` fails with an error about\nthe PACKAGE rather than the harness -- run npm install in harness/ first.\n\nCHECK BEFORE BUILDING: whether any other Ctrl chord is now in the same position.\nThe preventDefault rule is blanket, so the answer is \"every Ctrl binding the\nresolver claims\", and today that is one.","notes":"SECOND INSTANCE, AND IT IS WORSE: Ctrl-W. Found by the improvise-htl6 agent while hunting for a free key; verified by the orchestrator at keymap.rs:611 --\n\n normal.bind_args(KeyCode::Char('w'), ctrl, \"enter-mode\", vec![\"save-prompt\".into()])\n\nwith the comment \"Ctrl-S saves to the known path; Ctrl-W asks for one\" (improvise-lae).\n\nIN A BROWSER, Ctrl-W CLOSES THE TAB. Same mechanism as the Ctrl-S wart -- web/app.js calls preventDefault only when !e.ctrlKey \u0026\u0026 !e.altKey -- but the consequence is not a spurious dialog the user dismisses, it is THE DOCUMENT'S TAB CLOSING. Whatever the save prompt does is invisible, because the surface it would draw on is gone.\n\nTHE GENERALISATION, which is why this belongs here rather than in its own ticket: THE KEYMAP IS SHARED BETWEEN THE TWO FRONT ENDS (improvise-bpq2, gotcha 47), so a Ctrl- chord chosen for a terminal is one the browser must live with, and the browser has a reserved set the terminal does not. Ctrl-R (reload), Ctrl-S (save page), Ctrl-W (close tab), Ctrl-T, Ctrl-N are all browser-owned. So the fix for this ticket should be stated over the CLASS -- what happens to a browser-reserved chord in a shared keymap -- rather than per key, or a third instance arrives the next time somebody needs a free Ctrl- binding.\n\nNote the constraint that makes this hard is already recorded above: every available fix crosses a doctrine line, because preventDefault-ing selectively needs the page to know which chords the app binds, which is protocol knowledge (design-principles section 5). The shared keymap is where that knowledge already lives, which is a hint at where the answer is.\n\nALSO WORTH RECORDING: Ctrl-R is NOT bound today, and the htl6 agent deliberately did not take it for view-forward for exactly this reason -- it chose ',' instead. So this ticket has already changed one decision.\nANALYSIS 2026-09-22 by agent-helpkeys2 (NOT fixed; this needs a ruling). Read at gh/main 4d71c01.\n\nTHE DECISION IS ALREADY IN RUST. web/app.js calls preventDefault when on_key_event returns a frame, and a frame exists only when resolve() claimed the key (Local/Request/Upstream; Ignored gives none). The ONLY keybinding knowledge in the page is the extra rule `if (!e.ctrlKey \u0026\u0026 !e.altKey)` at app.js:786. So the obvious fix is to DELETE page knowledge, not add it: preventDefault whenever a frame comes back, whatever the modifiers. That fixes Ctrl-S (and Ctrl-d/Ctrl-u, which today scroll the grid AND whatever the browser does) with no protocol knowledge in the page. It is not safe to ship alone, for the reason below.\n\nTHE CLASS HAS THREE MEMBERS, not one, and they want different answers:\n\n1. PREVENTABLE chords the app binds on purpose (Ctrl-S, Ctrl-d, Ctrl-u; Ctrl-R/Ctrl-P/Ctrl-F if ever bound). Answer: the page honours the claim, per above. Rust already decides.\n\n2. UNPREVENTABLE chords (Ctrl-W, Ctrl-T, Ctrl-N, Ctrl-Shift-T/N/W, Ctrl-Tab; Ctrl-Q on Linux Chrome). Believed, NOT measured here: Chromium does not deliver these to a normal tab's keydown at all, and preventDefault cannot stop them. So no page code fixes Ctrl-W. Its consequence is also narrower than the note above suggests: `enter-mode save-prompt` is a mode `declined` refuses in the browser anyway, it is advertised nowhere the browser paints (not in advertised(), not in help pages), and the worker persists every accepted command to OPFS, so a closed tab loses nothing. The rule this member needs is a KEYMAP rule: a command bound only to a browser-reserved chord is unreachable in a browser, so it must have a second key or be one the browser declines. That is checkable in resolve.rs's tests over the shared keymap, given a named list of reserved chords (a fact about browsers, living beside BrowserKey).\n\n3. ACCIDENTAL claims through the AnyChar fallback. Keymap::lookup_local retries a modified Char without its modifiers and then falls to AnyChar, so in every text-entry mode Ctrl-C, Ctrl-V, Ctrl-R, Ctrl-L TYPE A LETTER — pinned on purpose by resolve::tests::a_modified_character_now_types_because_the_fallback_is_the_shared_one (Ctrl-x types 'x'). Today, in a browser minibuffer, Ctrl-V types 'v' AND pastes; Ctrl-R types 'r' AND reloads. Applying fix 1 alone would make it type 'v' and SUPPRESS the paste — strictly worse. So fix 1 is blocked on a ruling about member 3.\n\nTHE RULING NEEDED (the user's, not an agent's): should a Ctrl/Alt-modified character reach AnyChar at all? Options:\n (A) No: lookup_local stops retrying Ctrl/Alt-modified Chars as bare/AnyChar. Shared table, so the TUI changes too (Ctrl-x in the TUI minibuffer stops typing x — probably correct there as well). Risk: AltGr on Windows arrives as Ctrl+Alt, so international characters (e.g. @ on a German layout) would stop typing in the browser; BrowserKey would need to treat ctrl\u0026\u0026alt as AltGr and drop both. Then fix 1 is safe and the page loses its modifier rule.\n (B) Browser-only: BrowserKey (wasm-client/keys.rs) drops Ctrl-modified printable characters before resolve, the way it already drops Meta. No shared-table change; the TUI keeps today's behaviour; the page's rule moves into Rust where it is testable. Then fix 1 is safe.\n (C) Keep typing modified chars, keep the page rule, and fix only Ctrl-S by making Request outcomes claim regardless of modifiers. Smallest, but it is per-key again, which is what this ticket asks not to do.\nRecommended by this agent: (B) + fix 1 + the member-2 keymap guard; (A) is cleaner but changes the TUI and needs an AltGr decision.\n\nWhatever lands, the assertion belongs in harness/tests/shell.spec.js (gotcha 48) and needs `npm install` in harness/ first.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T20:59:53Z","created_by":"Edward Langley","updated_at":"2026-09-23T04:22:55Z","labels":["browser"],"dependencies":[{"issue_id":"improvise-clso","depends_on_id":"improvise-fhj0","type":"parent-child","created_at":"2026-09-25T01:05:37Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-udt2","title":"A status message carries no severity, so a headless refusal cannot set a non-zero exit","description":"Left open deliberately by improvise-q914/j6x8 (PR for fix/cli-honesty). The\nreasoning is context/code/cli.md section 3; this is the ticket it points at.\n\nimprovise cmd now PRINTS everything a command reports, to stderr, so a refusal\nis no longer indistinguishable from a success. What it still cannot do is put\na refusal in the EXIT CODE. Exit 1 means a parse error or an aborted batch;\na refusal that only writes a status exits 0.\n\nWHY IT WAS NOT CLOSED THERE. ViewState::status_msg is a bare String. Both\nclassifiers available in the tree are disproved by code in the tree:\n\n - \"a refusal is a batch that aborted\" -- only three effects set\n abort_effects (gotcha 21). effect::AddItem writes Workbook::add_item's\n Err straight to the status and does NOT abort, and that is q914's own\n worked example of a refusal. This classifier calls a refusal a success.\n\n - \"a refusal is anything that spoke\" -- cmd/fit.rs sets a status on\n SUCCESS, carrying the seed that reproduces the fit. effect::Save does\n too. This classifier calls a deliverable a failure.\n\nSo the CLI cannot guess, and guessing would be the adjacent-check defect\ndesign-principles section 7 names.\n\nWHAT WOULD CLOSE IT. A severity beside status_msg -- the smallest honest\nshape is probably an enum on the same write (Report | Refusal), set at the\n~60 sites that assign status_msg. That is a ViewState change, so it is a wire\nchange (gotcha 25): it must derive Serialize/Deserialize, it reaches the\nbrowser client's Snapshot, and the TUI can then colour the status line by it,\nwhich is arguably the bigger win. Scope it as a protocol ticket, not a CLI one.\n\nACCEPTANCE\n- A refusal that only writes a status exits non-zero from improvise cmd.\n- effect::Save on success and cmd/fit.rs on success still exit 0 -- these are\n the two rivals; a fix that fails either is the wrong fix.\n- tests/headless_cli.rs::a_command_that_succeeds_while_reporting_still_exits_zero\n must still pass unchanged.\n- README's CLI contract section is updated; it currently states the hole.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-cli","owner":"el-github@elangley.org","created_at":"2026-09-22T15:20:31Z","created_by":"Edward Langley","updated_at":"2026-09-23T04:42:38Z","started_at":"2026-09-23T04:13:05Z","closed_at":"2026-09-23T04:42:38Z","close_reason":"PR #185 MERGED (confirmed at 8d51ad7), adversarially reviewed (merge); CI 13/0. up8g: headless runs pin the extent to 80x24 (HEADLESS_EXTENT), so a script writes the same record whatever terminal ran it. The reviewer restored the terminal-size read and the test failed with R13 vs R40, both in a non-tty shell and under a real 50x150 pty. c5ip/udt2: the exit code reads whether the request was REFUSED (App::refuse sets a per-batch batch_refused flag, in AppSnapshot) rather than whether a chain ABORTED, so a success that aborts exits 0 and a refusal that narrates exits 1. That was the basis for improvise-cz8d, which reworks #181 on top of it. Follow-up: improvise-0v8l (23 of 27 refusal conversions are not pinned by a test, AddCategory most importantly).","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-j6x8","title":"improvise cmd discards every status message and exits 0, so a refusal is indistinguishable from success","description":"Found by the improvise-pv3g agent, while checking whether that P1's own fix was\nobservable through the binary the P1 was demonstrated with. It is not.\n\nrun_headless_commands (src/main.rs:331) applies effects and NEVER PRINTS\napp.view_state.status_msg. Measured on clean fixtures:\n\n document outcome stdout/stderr exit\n Score 1000/4000 fit succeeds, formula stored nothing 0\n Score 1e20/4e20 fit refused, nothing stored nothing 0\n\nSo a refused command is INDISTINGUISHABLE FROM A SUCCESSFUL ONE. The user gets\nsilence and exit 0 whether the command worked, refused, or did nothing at all.\nexit_code is set to 1 only for PARSE errors, so a refused command cannot signal\nfailure through the exit status either.\n\nWHY THIS MATTERS MORE THAN IT LOOKS: improvise-pv3g's acceptance asked for an\nactionable refusal naming the cause (\"too large for the 32-bit arithmetic ...\nscale the measure and fit that\"). That wording was written, tested and is\ncorrect -- and reaches NOBODY through `improvise cmd`. The TUI renders the status\nline, so the criterion is genuinely met there; the headless path is where it\nevaporates. This is the repo's characteristic defect one layer out: the fix is\nright and the surface that would show it is adjacent.\n\nWHY NO TEST SEES IT: a_refusal_names_what_went_wrong_and_stores_nothing asserts\nthe EFFECT's status. The effect is correct. Nothing asserts that the headless\nbinary emits it.\n\nA SECOND, SMALLER TRAP in the same path, worth fixing or documenting: `improvise\ncmd -f \u003cfile\u003e` REWRITES THE DOCUMENT EVEN WHEN THE COMMAND CHANGED NOTHING,\nexpanding 1e20 to 100000000000000000000. A fixture edited by string-replacing a\nvalue after one run silently becomes a no-op. It cost the reporting agent two\nmisleading test results before it was caught.\n\nWHAT TO DECIDE, not assume:\n - Which stream? A status message is not an error; stdout is probably right,\n with stderr for a refusal. Say which and why.\n - Should a refusal set a non-zero exit? It is the difference between `improvise\n cmd ... \u0026\u0026 next-step` working and silently proceeding on a no-op. Note this\n is a CLI contract change, so it belongs in README's CLI section too.\n - Is silence ever right? A script running a hundred commands may not want a\n hundred lines. If so the answer is a flag, and the DEFAULT should be the\n honest one.\n - `improvise script` shares this path -- check whether it has the same hole.\n\nNOTE improvise-q914 is adjacent (\"improvise cmd advertises JSON it does not\naccept, and swallows every effect error at exit 0\") and may subsume or be\nsubsumed by this. READ IT IN FULL FIRST and merge the two rather than filing a\nthird view of one problem.","notes":"FIXED in PR #131 (merge 9d2bdd3) as one fix with improvise-q914 -- see that ticket for the full reasoning, including why the stdout/stderr split I proposed in the brief cannot be built honestly and why the exit code is narrower than q914's AC. improvise script shares the hole exactly, being the same function. The per-command dirty accumulation also closes the no-op-rewrite trap noted here.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T06:26:41Z","created_by":"Edward Langley","updated_at":"2026-09-22T15:38:58Z","closed_at":"2026-09-22T15:38:58Z","close_reason":"Closed","dependencies":[{"issue_id":"improvise-j6x8","depends_on_id":"improvise-q914","type":"blocks","created_at":"2026-09-22T06:26:52Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-n6t8","title":"Header layout must be reserved, not fitted: the mode pill, the hint and the status all reflow","description":"USER 2026-09-22: \"the top bar jumps around a lot because the layout isn't fixed\nenough: The messages like 'the command line is not available in the browser yet'\nshould be toasts that do not cause relayout and should disappear eventually\n(maybe where the minibuffer is?)\"\n\nHOW IT WORKS TODAY, read before filing:\n - web/shell.js::composeStatus joins TWO independent facts into #status with\n \" . \": `shell` (the transport -- \"in-page worker\") and `model`\n (RenderModel::status, which is ViewState::status_msg). They are composed\n rather than overwritten deliberately, so a repaint cannot silently erase a\n connection error (gotcha 46).\n - #hint also carries variable-width content, one line per mode, off\n help::hint_line.\n - Both live in the flex \u003cheader\u003e. So the bar reflows when EITHER a status\n message arrives or the MODE changes -- the second is worth naming because it\n reflows even with no message at all.\n\nTHE TRAP IN THE OBVIOUS FIX, and it is the thing to get right:\nViewState::status_msg is STATE, not an event. web/app.js's repaint() calls\nshell.setModel(model.status) unconditionally, and repaint() runs on every\nkeydown. So a toast dismissed on a timer RESURRECTS on the next keypress, because\nnothing cleared the underlying string -- and Rust only replaces status_msg when\nsome later command sets one. A naive setTimeout(hide) produces a message that\nflickers back whenever the user does anything, which is worse than a message that\nsimply stays.\n\nSo dismissal must be keyed to MESSAGE IDENTITY (\"I have already shown this\nstring\") rather than to a timer alone, or the same string arriving genuinely\ntwice must be distinguishable from a repaint of the old one. Decide which, and\nsay so -- if two identical messages in a row should both be shown, identity alone\nis not enough and something must carry a sequence.\n\nSCOPE -- PRESENTATION ONLY, and that is a deliberate limit. Do NOT split\nstatus_msg into transient-vs-persistent in ViewState. That is a protocol change\nwith a portability argument attached (gotcha 30), the TUI shows status_msg in its\nbottom bar where it costs no layout at all, and nothing here forecloses it later.\nThe browser's problem is where it draws the string, not what the string is.\n\nTHREE THINGS THE FIX OWES:\n 1. THE HEADER MUST STOP REFLOWING ON ITS OWN, independently of toasts. #hint\n changes per mode, so a fixed/reserved layout is needed regardless. Reserving\n space is the fix; making the text shorter is not.\n 2. THE TOAST MUST NOT TAKE PART IN LAYOUT AT ALL -- out of flow, so it cannot\n push anything.\n 3. THE CONNECTION-ERROR FACT MUST SURVIVE. composeStatus exists because a\n repaint used to erase it. A toast that auto-hides must not take a transport\n failure with it: \"disconnected\" is not transient and must stay visible.\n That is the mirror of the bug gotcha 46 records.\n\nPLACEMENT: the user suggests near the minibuffer. PR #120 put the minibuffer as a\nraised bordered card over the grid, so that region is consistent -- but DECIDE\nWHAT HAPPENS WHEN BOTH ARE PRESENT. A user typing in the minibuffer who triggers\na status message must not have the prompt covered or displaced. Say what you\nchose.\n\nTESTABLE, and therefore required: harness/tests/ can assert that the header's\nbounding box does not change when a status message arrives and when the mode\nchanges (the two reflow causes above), and that the toast overlaps rather than\ndisplaces. gotcha 48: a change to web/*.js belongs in that spec directory. The\ncolour and the animation are not testable there and should be argued instead.\n\nSEQUENCING: branch ui/page-menu is in flight and owns web/app.js, web/shell.js\nand web/style.css. START THIS AFTER IT LANDS, or expect a conflict in all three.","notes":"USER 2026-09-22, widening this ticket: \"the other display annoyance is that the\nnormal/categories/etc. pill changes sizes constantly: it should have a fixed\nallocated size and not move the layout around. As a general rule, there shouldn't\nbe motion of layout elements I expect to be fixed.\"\n\nSO THIS TICKET IS NOW ABOUT HEADER LAYOUT STABILITY GENERALLY, and the toast is\none instance of it. THE RULE IS THE DELIVERABLE, not three separate patches.\n\nTHREE KNOWN MOVERS, all in the flex \u003cheader\u003e:\n 1. THE MODE PILL. Its label comes from AppMode::label() -- NORMAL, CATEGORIES,\n FORMULA, INSERT, RECORDS, ... -- and the pill is sized by its text, so every\n mode change resizes it and shifts everything after it.\n 2. #hint. One line per mode off help::hint_line, so it reflows on the same\n event as the pill, compounding it.\n 3. #status. Composed from the transport fact and ViewState::status_msg (see\n the original description).\n\nTHE OBVIOUS FIX IS THE FRAGILE ONE, AND IT WILL BE CHOSEN BY DEFAULT: a\nhardcoded min-width in ch, sized by eye to today's longest label. That silently\nresumes the jitter the day an AppMode variant with a longer label is added.\nAppMode's per-variant facts are exhaustive-match methods, so the COMPILER makes\nyou fill in label() for a new variant (repo-map, AppMode) -- but nothing anywhere\nchecks that the CSS still fits it. The gap between \"the compiler caught the Rust\"\nand \"the pixel is wrong\" is exactly where this regresses.\n\nSO THE GUARD IS THE POINT, and it is genuinely testable: a Playwright spec that\nwalks every reachable mode and asserts the header's bounding box -- and the left\nedge of the element AFTER the pill -- do not change. That catches a new long\nlabel, not just today's set. Prove it discriminates by injecting a mode label\nlonger than the reserved width and watching it fail. Without that injection this\nis a test that passes because nothing moved anyway.\n\nNote which modes a browser can actually reach: improvise-6acs and improvise-y11g\nmean some text-entry modes are not reachable there yet, so a spec that walks\n\"every mode\" may be walking four. Say how many it really covered -- an\noverstated scope is worse than a narrow one honestly described.\n\nTHE GENERAL RULE TO WRITE DOWN, since the user stated it as one: an element whose\nCONTENT varies but whose POSITION the reader treats as fixed must have its space\nRESERVED, not fitted. That covers the pill, the hint, the status, the document\ntitle and the view chip, and it is the reason a toast must be out of flow rather\nthan merely short.\nFIXED in PR #127 (merge d46ec59).\n\nTHE TICKET'S FRAMING WAS WRONG ABOUT WHICH ELEMENT COMPOUNDED, and the spec found\nit rather than the author. The ticket (and the user's report) named the mode pill.\nOn its first run the new spec failed on the header's HEIGHT: #status was\nflex: 0 1 auto beside a flex: 1 1 auto hint, so a longer hint took the shrink out\nof #status, which WRAPPED TO TWO AND THEN THREE LINES -- 52.5px to 71.0px. THE\nOLD HEADER WAS TWO LINES TALL AT NORMAL WITH NO MESSAGE AT ALL.\n\nSo reserving the pill alone would have left the bar moving and the PR would have\nshipped looking correct. Fixed with flex: 1 1 0 on the hint (zero basis, absorbs\nthe remainder, contributes nothing to overflow) and nowrap + flex: 0 0 auto on\n#status.\n\nTHE RESERVE IS A FLOOR, NOT A CAP, and that is the load-bearing choice: a width\nor max-width absorbs an over-long value inside a box that never grows, so every\nbounding-box measurement stays identical and the regression is INVISIBLE. min-width\nlets an over-long mode label grow the box, which is the only way the guard can see\nit. Proved by injection: removing the 21ch reserve fails \"the bar moved in\nCATEGORIES\" AND the 16-char check-the-checker.\n\nDISMISSAL IS KEYED ON WHAT WAS LAST ACCEPTED, not on a timer, because\nViewState::status_msg is STATE re-offered by every repaint. Consequence, stated\nrather than hidden: TWO IDENTICAL MESSAGES IN A ROW SHOW ONCE. That is forced --\nnothing beside status_msg carries a sequence, model.revision moves for a cursor\nkey as readily as for a re-issued command, and of the two readings of an ambiguous\noffer only one avoids reintroducing the reported bug. A sequence number is the\nprotocol change the scope forbade.\n\nGOTCHA 46 IS NOW STRUCTURAL RATHER THAN CONVENTIONAL. composeStatus is DELETED:\nthe transport fact and the document fact go to two ELEMENTS, not one string joined\nwith a separator, so a repaint reaches onModel and CANNOT ADDRESS onShell AT ALL.\nThe join's unit test was REPLACED, not dropped -- \"a repaint's model status cannot\nreach the transport's sink at all\", verified present by the orchestrator. A\nseparation with no guard is how one regresses into a single sink.\n\nNINE MODES WALKED, not the four I guessed: NORMAL, CATEGORIES, FORMULAS, VIEWS,\nTILES, HELP, INSERT, NEW CATEGORY, FORMULA. Excluded with reasons:\nSavePrompt/ExportPrompt/CommandMode refused by resolve::stranding, records mode\ndeclines out loud, ImportWizard has no browser entry, Quit is not a screen. Each\niteration asserts #mode BEFORE measuring, so a key that failed to reach its mode\ncannot make the comparison vacuous.\n\nMINIBUFFER AND TOAST COEXIST: #bottom-dock is position: fixed and anchored at its\nBOTTOM, stacking upward. The minibuffer's box is byte-identical with and without a\ntoast. A top-anchored stack, or an offset from the minibuffer's height, would both\nhave moved the prompt.\n\nTHE TOAST KEEPS ITS TEXT WHEN IT HIDES, deliberately: the element is the record of\nwhat was last said, and drill.spec.js's discrimination between two refusals reads\nit -- an emptied node would satisfy not.toContainText(\"aggregate\") VACUOUSLY the\nmoment the timer fired.\n\nNOT VERIFIED: prefers-color-scheme is exercised nowhere (dark was screenshotted by\nhand; pre-existing, now stated in clients.md). Colour, contrast, spacing and the\ngrouped/multi-level header DOM remain argued. Text ALIGNMENT inside a fixed box is\ninvisible to a bounding-box guard -- honest scope, not a gap. Firefox and WebKit\ndo not run header.spec.js (Chromium-only, the config's existing rule).","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T06:09:15Z","created_by":"Edward Langley","updated_at":"2026-09-22T07:45:56Z","closed_at":"2026-09-22T07:45:56Z","close_reason":"Closed","labels":["browser"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-rw6l","title":"Page bar: clicking a page category should open a menu of its items, with a down-arrow affordance","description":"USER 2026-09-22, pointing at the page bar (the \"Quarter = Q1 (1/4)\" chip) on the\ndeployed page: \"The page items here (e.g. Quarter) should open a menu when clicked\nso you can pick a different option. Also have a down-arrow indicating there's a\nmenu.\"\n\nTHE GOOD NEWS, VERIFIED BEFORE FILING: almost everything needed already exists.\nThis is NOT a protocol change and needs no new derivation.\n\n - improvise_core::view::layout::PageAxis ALREADY carries\n `items: Option\u003cVec\u003cString\u003e\u003e` -- Some exactly for Axis::Page, None for a\n Filter axis, which constrains without being navigable (gotcha 27). So the\n ITEM NAMES ARE ALREADY PROJECTED: GridCache::page_coords is Vec\u003cPageAxis\u003e.\n - ModelCommand::SetPageSelection { .. } ALREADY EXISTS on the wire\n (improvise-protocol/src/reduce.rs:127), and is in records_named's match at\n :185. So the mutation half is done.\n\nTHE ONE GAP is that the browser THROWS THE NAMES AWAY. wasm-client/src/render.rs\nRenderPageAxis (:157) keeps `cat`, `selected` and `cycle: Option\u003cRenderPageCycle\u003e`,\nand RenderPageCycle (:168) is `{ at: usize, count: usize }` -- a POSITION and a\nCOUNT. That was enough for improvise-p1ym, which asked \"can [ and ] cycle\"; it is\nnot enough for \"can the user pick\", which needs the names.\n\nSo: widen RenderPageAxis to carry the item names it is already handed. That is a\npass-through, not a second derivation -- the names come from the projection, and\nnothing in the browser may compute them (design-principles section 5).\n\nTHEN THE UI, which is the actual work:\n - a down-arrow affordance on a page chip that HAS a menu. Note the affordance\n must follow `items.is_some()`, NOT the presence of a page bar entry: a Filter\n axis appears in page_coords and must NOT get an arrow, because there is\n nothing to pick. Getting that wrong offers a menu that cannot do anything.\n - click opens a menu of the items; choosing one composes\n Command::Model(SetPageSelection{..}).\n - the currently selected item should be marked in the menu.\n\nEVENT HANDLING: the user has already ruled on the shape (improvise-b8ot) -- one\ndelegated handler per surface on a stable root, not one listener per node, and\nIDs/classes/data attributes as needed to disambiguate. A menu built with per-node\nlisteners would be new work in exactly the style b8ot exists to remove. Read b8ot\nbefore starting, and prefer landing this in the shape b8ot wants rather than\nadding another per-node listener to migrate later.\n\nKEYBOARD: the menu must be operable without a mouse, and must not swallow keys\nthe grid needs. web/app.js's window keydown handler returns early when `editing`\nis true; a menu is a third mode of that kind and needs the same treatment or it\nwill eat hjkl. Say which you chose.\n\nNOT IN SCOPE, adjacent, filed separately or noted:\n - the TILE bar above it (the \"Quarter Pag\" chips) is a different widget. Its\n three-character labels come from Axis::short_label(), which exists so the\n TUI's tile bar has fixed-width columns; in a browser that reads as a\n truncation bug. Axis::plain_label() already says \"Page\". That is a separate\n change and NOT this ticket.","notes":"FIXED in PR #123 (merge 7470b61).\n\nTHE DESIGN DECISION WORTH KEEPING: `items` is relayed INSIDE `cycle`, not beside\nit. One predicate, not two. A Filter axis has no cycle, so it gets no arrow, no\nmenu, no items, is painted as a \u003cspan\u003e rather than a \u003cbutton\u003e, and\nClick::PageItem composes nothing if a click reaches it -- FOUR answers off\nPageAxis::cycle, the one home gotcha 27 already named. `items.is_some()` appears\nnowhere. That is stronger than the guard I asked for in the brief, which was a\ntest that a Filter axis gets no arrow: here the wrong state is not constructible.\n\nEvery test carries a Filter axis beside the Page one, and the pass-through fault\n(items: Vec::new()) is caught by\nrender::tests::a_navigable_page_axis_relays_the_items_it_may_be_fixed_to.\n\nKEYBOARD: the menu is the third state of the kind `editing` already was --\napp.js's window keydown returns early into shell.js::menuKey. Same mechanism, not\na new one, because the menu hangs over the grid and hjkl reaching through would\nscroll the thing it points at out from under it. menuKey answers EVERY key with\n`ignore` as the default (swallowed, never passed on), and count: 0 closes rather\nthan wrapping modulo zero.\n\nCODEX FOUND A REAL BUG and it was correct: the pageMenu branch originally sat\nABOVE app.js's metaKey guard, so with the menu open Cmd-R was answered `ignore`\nand then preventDefault'ed -- reload blocked -- and Ctrl-j/Alt-Home moved the\nmenu, two lines below a comment saying Ctrl/Alt stay the browser's. Fixed by\nordering plus one bail. VERIFIED BY THE ORCHESTRATOR on the merged branch: the\nmetaKey guard is above the branch, and pageMenuKeydown returns on\nctrlKey || altKey before claiming anything.\n\nBOUNDARY CROSSING, flagged and accepted: resolve.rs gained Click::PageItem and\nlib.rs a click_page_item. Unavoidable -- the ticket asks that choosing an item\nCOMPOSES SetPageSelection, the page holds no Basis, and design-principles\nsection 5 forbids it deriving one. No wire change: Command, ModelCommand and the\nframe pair are untouched. Indices, matching every existing Click variant, so the\npage never spells a category or item name.\n\nNOT VERIFIED: Firefox and WebKit run none of pages.spec.js (the config is\nChromium-only outside opfs/shell). The menu's position: fixed placement off\ngetBoundingClientRect is the part that could plausibly differ by engine.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T06:07:10Z","created_by":"Edward Langley","updated_at":"2026-09-22T06:41:45Z","closed_at":"2026-09-22T06:41:45Z","close_reason":"Closed","labels":["browser"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-nn4s","title":"ADR 004: an Emacs-like face system, so a theme is UI-agnostic data the front ends apply","description":"USER DIRECTION 2026-09-22: \"I think we want to iterate towards having a way to\ntheme the app like emacs does: faces, etc. described in a UI-agnostic format that\nthe different frontends can apply (there's probably a swift version down the road\nand maybe Qt, etc.)\"\n\nNote \"iterate towards\". This is a DIRECTION, not a feature request, and the first\ndeliverable is an ADR (004 is free) rather than code.\n\nWHERE THE REPO ACTUALLY IS TODAY, measured:\n - AxisColor { Row, Column, Page, Filter, Inactive } in improvise-core/view/axis.rs\n -- names ROLES. This is the pattern working.\n - MinibufColor { Green, Cyan, Yellow } in improvise-protocol/lib.rs -- names\n COLOURS. draw.rs::minibuf_fg is an IDENTITY MAP (Green -\u003e Color::Green). Its\n doc comment says \"semantic palette only\" and the type makes that false: there\n is nothing to theme. The roles exist at the call sites (green is editing,\n yellow is the prompt/destructive family, cyan is search) and were spelled as\n colours.\n - 124 `Color::` literals across 10 TUI files (draw.rs, grid.rs, every panel,\n help, the wizard). No theme, palette or color_scheme concept anywhere.\n - PR #120 just built a CSS custom-property palette for the browser. So the two\n front ends are AT A FORK: either colour roles become shared vocabulary each\n end maps to its own palette, or the web palette and the TUI's 124 literals\n drift permanently and one document looks unrelated in two clients.\n\nSEVEN QUESTIONS THE ADR MUST ANSWER. They are listed because each is a place this\ngets decided by accident otherwise.\n\n1. THE TERMINAL-PALETTE TENSION, WHICH IS THE HARD ONE. A face's colour may want\n to be \"the terminal's green\" rather than an RGB value. Today Color::Green means\n whatever the USER'S TERMINAL calls green, so their terminal theme applies. A\n format carrying only RGB would LOSE that -- a regression, not a gap, and one\n that would be discovered late and be expensive to undo. Emacs answers it with\n named colours plus tty approximation. Decide whether a face attribute can be\n a NAMED terminal colour, an RGB value, or either, and what a browser does with\n \"terminal green\".\n\n2. INHERITANCE is what makes a face system tractable rather than 124 entries.\n Emacs derives most faces from a few. Decide whether faces inherit, and if so\n whether the resolution happens in shared code (once, correctly) or per front\n end (three times, differently).\n\n3. A THEME IS CLIENT-LOCAL. Gotcha 29 / the user's own ruling: the model is shared\n and the UI is not. So a theme must NOT be a Command, must NOT live in\n ViewState, and must NOT be projected -- otherwise one client's theme reaches\n another client's screen, which is the exact bug improvise-g0f is about. State\n this explicitly in the ADR; it is cheap now and a protocol change later.\n\n4. WHERE THE VOCABULARY LIVES. AxisColor is in improvise-core, which is supposed\n to have no UI; MinibufColor is in improvise-protocol. That inconsistency\n predates this and the ADR should resolve it rather than inherit it.\n\n5. WHICH ATTRIBUTES ARE PORTABLE. Foreground, background, weight, slant,\n underline map onto ratatui Modifier, CSS, Swift and Qt. Box, shadow and\n rounded corners do not reach a terminal. Decide the minimum common set, and\n decide what a front end does with an attribute it cannot render -- Emacs\n ignores it, which is probably right and should be SAID.\n\n6. THE FORMAT. A Swift or Qt front end means the theme is DATA, not Rust -- so it\n needs a file format. TOML, JSON, or a small pest grammar in this repo's usual\n style. It is NOT part of .improv: a theme is configuration, not document data,\n and putting it there would make a document dictate a reader's colours.\n\n7. HOW IT ARRIVES WITHOUT A FLAG DAY. A big-bang refactor of 124 literals is how\n this dies. The ADR needs a migration path where the vocabulary lands first and\n the literals move opportunistically.\n\nTHE INCREMENTAL STEPS THAT FORECLOSE NOTHING, cheapest first:\n a. Rename MinibufColor's variants to roles. Pure vocabulary, no behaviour, and\n it converts \"semantic palette\" from a false doc comment into a true type.\n b. Name PR #120's CSS custom properties after the same roles, so the web\n palette is already a theme in the making rather than a second vocabulary.\n c. Let the 124 literals migrate as files are touched for other reasons.\n\nNOT IN SCOPE YET: a theme picker, user-supplied theme files, or live reloading.\nThose follow the vocabulary; none of them is the hard part.","status":"in_progress","priority":2,"issue_type":"task","assignee":"agent-faces","owner":"el-github@elangley.org","created_at":"2026-09-22T05:44:55Z","created_by":"Edward Langley","updated_at":"2026-09-25T04:45:30Z","started_at":"2026-09-25T04:45:30Z","lease_expires_at":"2026-09-25T04:50:30Z","heartbeat_at":"2026-09-25T04:45:30Z","dependencies":[{"issue_id":"improvise-nn4s","depends_on_id":"improvise-gtn7","type":"parent-child","created_at":"2026-09-25T01:05:33Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-nn4s","title":"ADR 004: an Emacs-like face system, so a theme is UI-agnostic data the front ends apply","description":"USER DIRECTION 2026-09-22: \"I think we want to iterate towards having a way to\ntheme the app like emacs does: faces, etc. described in a UI-agnostic format that\nthe different frontends can apply (there's probably a swift version down the road\nand maybe Qt, etc.)\"\n\nNote \"iterate towards\". This is a DIRECTION, not a feature request, and the first\ndeliverable is an ADR (004 is free) rather than code.\n\nWHERE THE REPO ACTUALLY IS TODAY, measured:\n - AxisColor { Row, Column, Page, Filter, Inactive } in improvise-core/view/axis.rs\n -- names ROLES. This is the pattern working.\n - MinibufColor { Green, Cyan, Yellow } in improvise-protocol/lib.rs -- names\n COLOURS. draw.rs::minibuf_fg is an IDENTITY MAP (Green -\u003e Color::Green). Its\n doc comment says \"semantic palette only\" and the type makes that false: there\n is nothing to theme. The roles exist at the call sites (green is editing,\n yellow is the prompt/destructive family, cyan is search) and were spelled as\n colours.\n - 124 `Color::` literals across 10 TUI files (draw.rs, grid.rs, every panel,\n help, the wizard). No theme, palette or color_scheme concept anywhere.\n - PR #120 just built a CSS custom-property palette for the browser. So the two\n front ends are AT A FORK: either colour roles become shared vocabulary each\n end maps to its own palette, or the web palette and the TUI's 124 literals\n drift permanently and one document looks unrelated in two clients.\n\nSEVEN QUESTIONS THE ADR MUST ANSWER. They are listed because each is a place this\ngets decided by accident otherwise.\n\n1. THE TERMINAL-PALETTE TENSION, WHICH IS THE HARD ONE. A face's colour may want\n to be \"the terminal's green\" rather than an RGB value. Today Color::Green means\n whatever the USER'S TERMINAL calls green, so their terminal theme applies. A\n format carrying only RGB would LOSE that -- a regression, not a gap, and one\n that would be discovered late and be expensive to undo. Emacs answers it with\n named colours plus tty approximation. Decide whether a face attribute can be\n a NAMED terminal colour, an RGB value, or either, and what a browser does with\n \"terminal green\".\n\n2. INHERITANCE is what makes a face system tractable rather than 124 entries.\n Emacs derives most faces from a few. Decide whether faces inherit, and if so\n whether the resolution happens in shared code (once, correctly) or per front\n end (three times, differently).\n\n3. A THEME IS CLIENT-LOCAL. Gotcha 29 / the user's own ruling: the model is shared\n and the UI is not. So a theme must NOT be a Command, must NOT live in\n ViewState, and must NOT be projected -- otherwise one client's theme reaches\n another client's screen, which is the exact bug improvise-g0f is about. State\n this explicitly in the ADR; it is cheap now and a protocol change later.\n\n4. WHERE THE VOCABULARY LIVES. AxisColor is in improvise-core, which is supposed\n to have no UI; MinibufColor is in improvise-protocol. That inconsistency\n predates this and the ADR should resolve it rather than inherit it.\n\n5. WHICH ATTRIBUTES ARE PORTABLE. Foreground, background, weight, slant,\n underline map onto ratatui Modifier, CSS, Swift and Qt. Box, shadow and\n rounded corners do not reach a terminal. Decide the minimum common set, and\n decide what a front end does with an attribute it cannot render -- Emacs\n ignores it, which is probably right and should be SAID.\n\n6. THE FORMAT. A Swift or Qt front end means the theme is DATA, not Rust -- so it\n needs a file format. TOML, JSON, or a small pest grammar in this repo's usual\n style. It is NOT part of .improv: a theme is configuration, not document data,\n and putting it there would make a document dictate a reader's colours.\n\n7. HOW IT ARRIVES WITHOUT A FLAG DAY. A big-bang refactor of 124 literals is how\n this dies. The ADR needs a migration path where the vocabulary lands first and\n the literals move opportunistically.\n\nTHE INCREMENTAL STEPS THAT FORECLOSE NOTHING, cheapest first:\n a. Rename MinibufColor's variants to roles. Pure vocabulary, no behaviour, and\n it converts \"semantic palette\" from a false doc comment into a true type.\n b. Name PR #120's CSS custom properties after the same roles, so the web\n palette is already a theme in the making rather than a second vocabulary.\n c. Let the 124 literals migrate as files are touched for other reasons.\n\nNOT IN SCOPE YET: a theme picker, user-supplied theme files, or live reloading.\nThose follow the vocabulary; none of them is the hard part.","status":"in_progress","priority":2,"issue_type":"task","assignee":"agent-faces","owner":"el-github@elangley.org","created_at":"2026-09-22T05:44:55Z","created_by":"Edward Langley","updated_at":"2026-09-25T04:45:30Z","started_at":"2026-09-25T04:45:30Z","dependencies":[{"issue_id":"improvise-nn4s","depends_on_id":"improvise-gtn7","type":"parent-child","created_at":"2026-09-25T01:05:33Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-oujb","title":"A release should carry the web app too: today a tag captures only the native binaries","description":"USER RULING 2026-09-22, asked whether the release workflow builds a package for the\nweb version and told it does not: \"Attach the site to releases too.\"\n\nWHAT IS TRUE TODAY. .github/workflows/release.yml has ZERO references to wasm, web,\npkg, pages or assemble -- verified by grep. It builds four native targets\n(aarch64/x86_64 x macOS/Linux) plus a shell installer, per dist-workspace.toml.\nThe web app ships by a completely separate route: ci.yml's pages-deploy, gated on\n`github.event_name == 'push' \u0026\u0026 github.ref == 'refs/heads/main'`. Continuous\ndeployment from the default branch, not a release artifact.\n\nFOUR CONSEQUENCES, none of them written down anywhere:\n - A TAG DOES NOT DEPLOY THE SITE. pages-deploy fires only on a push to main, so\n tagging leaves the site wherever main last was. Tag from main's tip and they\n coincide BY ACCIDENT; tag an older commit and they diverge silently.\n - There is no web version of a release to download, self-host or roll back to.\n - THE PAGE NAMES NO VERSION AT ALL. web/assemble.sh stamps the cache-bust token\n with $GITHUB_SHA, but nothing in web/index.html shows a version or commit, so\n a visitor cannot tell what they are running and neither can a bug report.\n - A binary user and a site user are running different code, potentially months\n apart: v0.1.0-rc2's binaries are from 2026-04-11 and the site is from whatever\n merged most recently. README's Installation section does not say so.\n\nTHE ASK: a tagged release also carries the assembled site as an artifact, so a\nrelease has a self-hostable web bundle and a rollback target.\n\nFIVE TRAPS, each of which is how this gets done wrong:\n\n1. release.yml IS AUTOGENERATED BY cargo-dist and says so on line 1. A hand edit\n is clobbered by the next `dist init`. Use dist's own supported mechanism for\n extra artifacts (see its `extra-artifacts` / custom build configuration in\n dist-workspace.toml) so the addition SURVIVES REGENERATION. If no supported\n mechanism fits, say so explicitly and argue for the hand edit rather than\n making it silently -- and leave a comment in dist-workspace.toml so the next\n `dist init` has a warning.\n2. DO NOT WRITE A SECOND WAY TO BUILD THE BUNDLE. ci.yml's `build` job carries a\n long comment explaining that everything downstream DOWNLOADS from one\n wasm-pack run precisely so \"the harness runs against the tree Pages publishes\"\n is true by construction. A release job that builds its own bundle a different\n way reintroduces exactly what that comment prevents. Use web/build.sh and\n web/assemble.sh, the same scripts a developer runs.\n3. THE CACHE-BUST STAMP TAKES AN ARGUMENT AND THE RIGHT ONE DIFFERS. CI passes\n $GITHUB_SHA; a local run defaults to `dev`. A release should almost certainly\n pass the TAG. Decide, and note scripts/assemble-stamp.sh verifies each stamped\n reference independently -- check it still passes with whatever you choose.\n4. THE RELEASE JOB NEEDS A TOOLCHAIN IT DOES NOT HAVE: wasm32-unknown-unknown,\n a PINNED wasm-pack (ci.yml pins v0.15.0 and explains that pinning is what makes\n a local measurement evidence about a CI run), and node for the prose site.\n Reuse those pins rather than picking fresh ones.\n5. wasm-opt REJECTS LLVM's memory.copy WITHOUT --enable-bulk-memory, and the\n worker bundle ALSO needs --enable-nontrapping-float-to-int because it carries\n the whole App and so chrono's date arithmetic (gotcha 45). Those live in the\n crates' Cargo.toml metadata; do not re-specify them.\n\nALSO IN SCOPE, because it is the cheap half of the same problem: GIVE THE PAGE A\nVISIBLE VERSION OR COMMIT. A site that cannot say what it is makes every bug\nreport about it unanswerable. Small, and worth more than the tarball.\n\nDECIDE AND STATE, do not default: whether tagging should ALSO deploy Pages, or\nwhether the site keeps tracking main and a release merely captures a copy. The\nuser chose \"attach the site to releases\", which does not by itself answer this.\nBoth are defensible; supporting both by accident is not.\n\nNOT IN SCOPE: bumping a version or cutting a tag (improvise-3tj / improvise-l36,\nboth need the user), and RELEASING.md, which improvise-gx5d owns on another branch.","notes":"USER RULING 2026-09-22, settling the open decision this ticket flagged: \"I like\nPages showing the latest main for now.\"\n\nSO: pages-deploy is UNCHANGED. It keeps its `github.event_name == 'push' \u0026\u0026\ngithub.ref == 'refs/heads/main'` gate; a tag does not deploy the site. A release\nmerely CAPTURES a copy of the assembled site as an artifact.\n\nNote the words \"for now\". This is a current preference, not a permanent\narchitecture decision, and whoever reads it later should be able to tell the\ndifference. Do not record it as settled forever.\n\nTHREE THINGS THAT FOLLOW, and they change the emphasis of the work:\n\n1. THE TWO LINEAGES DIVERGING IS NOW DELIBERATE, so it gets DOCUMENTED rather than\n fixed. A binary user runs whatever was tagged; a site visitor runs latest main.\n One sentence in README's Installation section.\n\n2. THE VERSION STAMP IS THE MOST VALUABLE PART OF THIS TICKET, not the tarball.\n With the lineages deliberately different, a page that cannot say what it is\n makes every bug report about it unanswerable. web/index.html names no version\n at all today. If only one thing lands from this bead, it is the stamp.\n\n3. THE ARTIFACT'S PURPOSE IS NARROWER THAN IT SOUNDS. With Pages tracking main,\n the release tarball is NOT what anyone visits -- it is a self-hostable copy and\n a rollback target. Saying \"the web version of v0.1.0\" invites a reader to\n assume the tarball is what is deployed, which is false and costs an afternoon.\n State the narrow claim.\n\nThe cache-bust token question narrows too: since pages-deploy is untouched, the\nrelease capture is the only new caller of web/assemble.sh, so the tag is the\nobvious token. Check scripts/assemble-stamp.sh still passes with it.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T05:31:13Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:31:44Z","dependencies":[{"issue_id":"improvise-oujb","depends_on_id":"improvise-0s6","type":"parent-child","created_at":"2026-09-25T01:04:33Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-gx5d","title":"The tag-push release workflow: diagnosis, a preflight guard, and the missing procedure","description":"USER 2026-09-22: \"we need to fix the tag push release workflow\" ... then, on being\nshown the diagnosis: \"do nothing for now, just make sure the workflow will work\"\nand yes to a preflight guard.\n\nTHE WORKFLOW WAS NEVER BROKEN, and the evidence is stronger than a local check.\nv0.1.0-rc1 and v0.1.0-rc2 (2026-04-11) BOTH have full published GitHub Releases:\ntarballs for all four targets, their .sha256 files, dist-manifest.json,\nsource.tar.gz and improvise-installer.sh. So the workflow has run end to end and\nproduced everything it is supposed to produce.\n\nWHAT ACTUALLY HAPPENED: v0.1.0-rc3 was pushed to gh, origin and gitea while every\ncrate still said 0.1.0-rc2. dist matches the tag against the ROOT PACKAGE version\n(dist-workspace.toml says members = [\"cargo:.\"], so only the root package counts\nand gp-tree sitting at 0.1.0 is not a finding). It refused with \"This workspace\ndoesn't have anything for dist to Release!\" and named the right answer itself, and\nthe four jobs behind `plan` skipped.\n\nReproduced both directions locally: `dist plan --tag=v0.1.0-rc2` gets past the\ncheck; `--tag=v0.1.0-rc3` gives the CI error verbatim.\n\nA CORRECTION WORTH KEEPING, because it nearly became a false fact in context/: I\nfirst concluded rc1/rc2 had never triggered the workflow, inferring it from an\nempty `gh run list`. GitHub Actions logs AGE OUT AT 90 DAYS and those tags are\nfive months old. An absent run and a discarded run look identical from the run\nlist, which is the same shape as doctrine(a_missing_loose_ref_is_not_a_deleted_branch).\nCheck `gh release view \u003ctag\u003e --json assets` -- the artifacts outlive the logs.\n\nTHE REAL ROOT CAUSE IS DOCUMENTATION. There is no release procedure anywhere:\n - .github/workflows/release.yml is cargo-dist's autogenerated file -- a\n mechanism, not a procedure.\n - README section Installation describes the current state and is accurate.\n - context/plan.md Phase 3 has one-time SETUP steps from the launch plan,\n including \"test the workflow by tagging v0.1.0-rc1 ... Delete the rc tag\n afterward\" -- which was not done; all three rc tags are still on all three\n remotes.\nNothing anywhere says BUMP THE VERSION BEFORE YOU TAG.\n\nIN FLIGHT on branch fix/release-preflight: scripts/release-preflight.sh (refuses a\ndirty tree, compares tag to root version, runs `dist plan` as a second opinion and\nsays when it could not), its check_the_checker, a CI step in the `test` job, the\nreasoning in context/code/tooling.md, and a short RELEASING procedure.\n\nNOTE THE TEST LESSON, which is the transferable part: the preflight's first test\nasserted only \"did it refuse\", and every negative case still passed with the tag\ncomparison replaced by `if false;` -- because the script refuses a DIRTY WORKING\nTREE first and this repo's tree is almost always dirty from bd's auto-export. Four\nof five checks were answering \"is the tree dirty\" while claiming to answer \"does\nit catch a bad tag\". It now runs against a clean throwaway checkout AND asserts the\nrefusal names the version mismatch.\n\nNO VERSION IS BUMPED AND NO TAG IS CUT. Cutting v0.1.0 is improvise-3tj and\npublishing is improvise-l36; both need the user.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T05:26:50Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:26:50Z","dependencies":[{"issue_id":"improvise-gx5d","depends_on_id":"improvise-0s6","type":"parent-child","created_at":"2026-09-25T01:07:40Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"improvise-owep","title":"Path traversal: a connected client can make the server read any file it can read (LoadModel)","description":"USER RULING 2026-09-22: \"We'll rely on sufficient containerization, but we should\nprevent path traversal attacks mainly: the server should have a documents\ndirectory and make sure the path canonicalizes to that directory.\"\n\nTHIS IS LIVE TODAY, NOT A FUTURE CONCERN. Verified by the orchestrator while\nscoping the ruling:\n\n - ModelCommand::LoadModel(PathBuf) is ON THE WIRE (protocol/src/reduce.rs:159).\n - effect::LoadModel (command/src/ui/effect.rs:914) calls\n persistence::load_from(\u0026*app.storage, \u0026self.0) with the CLIENT'S path.\n - PhysicalFs (io/src/storage.rs:94) has NO root and NO confinement:\n File::open(path) and File::create(path) on whatever arrives.\n - improvise-ws-server builds its App with App::new(workbook, path), which\n supplies that unconfined PhysicalFs.\n\nSo ANY CONNECTED WEBSOCKET CLIENT CAN ASK THE HOST TO READ ANY FILE THE SERVER\nPROCESS CAN READ. A .improv-shaped file loads wholesale and is then projected\nback to the client. A file that does NOT parse is still a leak risk: the error\ngoes into view_state.status_msg as \"Load error: {e}\", and pest errors quote the\noffending input.\n\nWRITE IS NOT YET CLIENT-REACHABLE -- Save/SaveAs are ViewOnly and off the wire --\nBUT IT IS ABOUT TO BE. improvise-y11g (the : command line, a core feature per the\nuser) reaches `:w \u003cpath\u003e`, and improvise-2mqz adds the export/download frames.\nLanding either without this is how arbitrary file WRITE ships.\n\nPAIR WITH improvise-kc7n: the Origin check answers what PAGE is driving a client,\nnever WHO is connecting, and improvise-kpnr (authentication) is open. Confinement\nis the control that does not depend on either.\n\nWHERE IT BELONGS: THE BACKEND, NOT THE CALL SITES. Gotcha 2 -- every document read\nor write goes through App::storage, and the host picks the backend. So this is a\nROOTED PhysicalFs (e.g. PhysicalFs::rooted(dir)), and every caller is covered by\nconstruction rather than by each remembering to check. That is\ndoctrine(an_invariant_is_made_unrepresentable_not_validated), the same answer\nprune_axis_items and gp-tree's Row\u003cT\u003e got.\n\nCRITICALLY, THIS IS A PROPERTY OF THE SERVER, NOT OF Storage. A TUI user may open\nany file they own; confining App::new would be WRONG. The server constructs the\nrooted backend; the TUI keeps the unrooted one. Gotcha 2 already says the host\npicks, so this fits without a trait change.\n\nSIX IMPLEMENTATION TRAPS, each of which is the standard way this fix is got wrong:\n\n1. std::fs::canonicalize REQUIRES THE PATH TO EXIST. A write to a NEW document\n fails it. Canonicalize the PARENT and join the final component -- and reject a\n final component that is itself `..` or contains a separator.\n2. REJECT, DO NOT SANITIZE. Stripping or collapsing `..` is the classic broken\n fix; `....//` and percent/unicode variants defeat it. Canonicalize, then\n compare.\n3. COMPARE COMPONENT-WISE, NOT AS STRINGS. `/srv/docs-evil`.starts_with(\"/srv/docs\")\n is TRUE as a string and false as a path. Use Path::starts_with, which is\n component-wise, and canonicalize the ROOT once at construction (a relative or\n symlinked root defeats the comparison otherwise).\n4. SYMLINKS: canonicalize resolves them, so a link inside the directory pointing\n out IS caught -- but only because the check runs on the resolved path. Do not\n \"optimise\" it into a lexical normalisation.\n5. TOCTOU IS A STATED RESIDUAL, NOT A SOLVED PROBLEM. Between the check and the\n open, a component can be replaced. openat2(RESOLVE_BENEATH) is the real answer\n and portable Rust does not have it. The user has ruled that containerization\n carries this; SAY SO in the code's context/ prose rather than implying the\n check is total.\n6. ABSOLUTE PATHS INSIDE THE ROOT must be allowed or the feature is unusable, and\n absolute paths outside it refused -- so the rule is \"canonicalizes under the\n root\", never \"is relative\".\n\nWHAT THE SERVER'S CLI GAINS: a --documents-dir (name it as you like), replacing or\nconstraining the current `files: Vec\u003cPathBuf\u003e` list. Decide and state whether the\nexisting multi-file form survives as \"these files, which must be under the root\"\nor is replaced by \"this directory\". Both are defensible; silently supporting both\nis not.\n\nTESTS -- and the check_the_checker discipline applies, because a confinement test\nthat passes against an unconfined backend is worse than none:\n - ../ escapes, absolute escapes, a symlink inside the root pointing out, a root\n that is itself a symlink, and the `/srv/docs-evil` string-prefix case.\n - A NEW file inside the root must be WRITABLE -- the case trap 1 breaks.\n - Drive it through ModelCommand::LoadModel over Sessions, not just against\n PhysicalFs directly: the vulnerability is reachable from the wire and the test\n should be too.\n - INJECT THE FAULT: point the rooted backend's root at `/` and watch the escape\n tests fail. A confinement test that cannot fail is the characteristic defect\n of this repo.\n - Consider whether io/src/storage.rs::contract::storage_contract should gain a\n confinement clause. Note OpfsFs is confined by the browser already and\n MemoryFs is a map, so this may be a PhysicalFs property rather than a contract\n one -- decide explicitly and write down which.","notes":"DEFERRED BY USER RULING 2026-09-22: \"The server isn't running anywhere yet so this\ncan be deferred.\"\n\nThat is an EXPOSURE judgement, not a correctness one. Everything in the analysis\nabove stands: the path really is client-supplied, PhysicalFs really is unrooted,\nand the read really would succeed. What is absent is a deployment -- nobody is\nrunning improvise-ws-server, so there is no process for a client to reach and no\nfilesystem to read. Priority dropped P0 -\u003e P2 and the bead set BLOCKED so it stays\noff `bd ready`, NOT closed.\n\nTHE DEFERRAL IS CONDITIONAL, AND THE CONDITION IS ALREADY WRITTEN DOWN AS A\nDEPENDENCY. improvise-y11g (the : command line) and improvise-2mqz (SaveAs /\nExportCsv) are both `blocked-by` this ticket, and that edge is now MORE load-\nbearing rather than less: it is what stops the exposure arriving before the fix.\nDo not remove those edges to unblock either ticket. If one of them becomes urgent,\nthis one comes with it.\n\nTHREE THINGS THAT REOPEN IT, so the condition is testable rather than remembered:\n 1. improvise-ws-server being deployed or demoed anywhere reachable, including a\n tunnel for a Show HN.\n 2. improvise-y11g or improvise-2mqz landing, either of which makes a\n client-supplied path reach a WRITE.\n 3. Any new ModelCommand or frame that carries a path.\n\nWHAT DOES NOT REOPEN IT: the browser worker deployment. Its Storage is OpfsFs,\nconfined by the browser to the origin's private filesystem, and the document is\nthe user's own. This ticket is about improvise-ws-server only.\n\nNOTE FOR WHOEVER PICKS IT UP: the analysis above was written against a live\nreading of the tree at d17ad0f. Re-verify rather than trust it -- a deferred\nsecurity finding whose premises have quietly changed is worse than an open one,\nbecause the priority says it was judged and the judgement may no longer hold.","status":"blocked","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T05:24:28Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:25:03Z","labels":["protocol"],"dependencies":[{"issue_id":"improvise-owep","depends_on_id":"improvise-tr08","type":"parent-child","created_at":"2026-09-25T01:04:25Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":2,"comment_count":0} @@ -315,7 +340,7 @@ {"_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.\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-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"],"comments":[{"id":"58260e16-2ba7-5aec-96be-56fd944a012c","issue_id":"improvise-jkxr","author":"external-grok-bot","text":"PR #100 exclusions note vacuous parse_duplicate already filed as this bead; not re-filed. — external Grok Bot from cloud adversarial review PR #100 for Ed Langley","created_at":"2026-09-21T20:49:20Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} {"_type":"issue","id":"improvise-12ha","title":"Multi-tab browser editing: a SharedWorker cannot host OpfsFs (measured), so pick a shape","description":"CONTEXT: improvise-i0v9 is the bug (a second tab is dead and silent). This is the\ncapability ticket. The user ruled that read-only second tabs are acceptable \"as\nlong as that's clear\", then asked whether the worker should be shared between\ntabs. The answer was measured rather than recalled.\n\nTHE DECISIVE MEASUREMENT, all three engines:\n\n chromium SharedWorkerGlobalScope, isWorkerGlobalScope true, getDirectory ok,\n getFileHandle ok, hasCreateSyncAccessHandle FALSE\n \"TypeError: fh.createSyncAccessHandle is not a function\"\n firefox identical\n webkit getDirectory itself fails (no OPFS at all -- the gap opfs.spec.js\n already skips on)\n\ncreateSyncAccessHandle is spec'd [Exposed=DedicatedWorker] and both engines\nimplement that faithfully. NOT a flag, NOT transient, NOT permissions. The real\nOpfsFs was then run unmodified inside a module SharedWorker and failed at exactly\nthat call.\n\nSO: a SharedWorker CANNOT host OpfsFs as written. Gotcha 28's premise -- the\nhandle's read/write/truncate/flush ARE blocking, which is what let Storage stay\nsynchronous -- is a DEDICATED-WORKER-ONLY premise.\n\nTwo things cut the other way and are worth keeping:\n - SharedWorker itself is in fine shape: present in all three, MODULE shared\n workers work in all three, and two tabs demonstrably share ONE instance (same\n instance id, connections: 2, both served).\n - WebKit's SharedWorker is healthy; its OPFS is what is missing. WebKit is no\n worse off under a shared-worker design than it is today.\n\nTHREE WAYS OUT, ALL PROBED:\n\nA. SharedWorker host with an ASYNC storage seam. createWritable() IS\n [Exposed=Window,Worker] and works in a shared worker (measured, chromium and\n firefox); reads need no handle at all (getFile().text()). But this is\n precisely what gotcha 28 exists to avoid: Storage::read/write are sync,\n App::storage is a Box\u003cdyn Storage\u003e, effect::Save calls it synchronously\n inside Effect::apply inside App::apply_effects -- the one mutation site. An\n async fn in Effect::apply is a DIFFERENT TRAIT, and it reaches the native TUI\n too. Load-bearing, not plumbing.\n NARROWER VARIANT worth costing first: keep Storage sync over an IN-MEMORY\n document, flushed asynchronously from the message loop -- the shape\n commit_deletes() already has. Storage never becomes async; a fifth awaited\n method persist() joins the four. UNCOSTED: what holds a document in memory\n across a LoadModel, and what a crashed tab loses that a sync handle would not.\n\nB. Nested dedicated worker under a SharedWorker. DEAD. Firefox allows it and the\n nested worker gets a real working sync handle with OpfsFs unchanged; Chromium\n and WebKit do not implement nested workers in shared workers at all\n (\"ReferenceError: Worker is not defined\"). The one shape that would have cost\n nothing works in exactly one engine.\n\nC. Keep the dedicated worker, add a RELAY owning no storage -- a SharedWorker or\n BroadcastChannel moving ClientFrame/ServerFrame JSON between follower tabs and\n the one tab whose dedicated worker holds the handle.\n improvise_protocol::frame is already THE one pair for all deployments\n (improvise-xks6), and the argument recorded there is explicitly\n transport-agnostic. A third transport is the case that consolidation was made\n for.\n - probably free: Worker::on_frame's \"one client by construction\". Gotcha 45\n already says the Addressed filter is honoured against a future SharedWorker.\n Session ids, fan-out and per-session mirrors need nothing new.\n - LOAD-BEARING: leader election and failover. The handle IS released when the\n leading tab closes, but bindings.rs::boot has NO RETRY PATH, so a follower\n cannot be promoted without a reload -- the same \"dead until you reload\"\n through a different door.\n - LOAD-BEARING: the relay must serialize frames onto one tail promise.\n web/worker.js had this exact race and web/serial.js exists because of it\n (gotcha 48). A relay forwarding async reintroduces it ACROSS TABS, where\n gotcha 33 answers an overtaken update by DROPPING it.\n - REVISIT: persist-on-dirty. Storage is whole-document (gotcha 41), so N\n editing tabs is N whole-file rewrites through one handle.\n\nRECOMMENDED ORDER (agent's read, not a ruling): C first, A's narrow variant\nsecond, read-only as the fallback it now is. C's real price -- leader failover --\nis THE SAME MISSING RETRY PATH the read-only fallback needs anyway, so that work\nis not wasted either way.\n\nDO NOT treat this as small because Sessions is ready. That readiness is real and\nis the good news; the no is entirely on the storage side.\n\nUNTESTED: Safari proper. Playwright's WebKit has no OPFS in any context so it is\nunaskable here. Safari 17+ ships OPFS; whether createSyncAccessHandle is\ndedicated-only there too is an EXPECTATION, not a measurement.\nUNPROBED: whether BroadcastChannel beats a SharedWorker relay for C. It is in all\nthree engines and needs no new worker, possibly making C cheaper -- but it fans\nout to everyone, so the Addressed filter would be doing real work rather than\nbeing honoured defensively. One probe settles it.\n","notes":"NEXT CHEAPEST PROBE: whether BroadcastChannel beats a SharedWorker relay for option C. It is in all three engines and needs no new worker. The catch is that it fans out to everyone, so the Addressed filter would be doing real work rather than being honoured defensively (gotcha 45).","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-21T20:40:31Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:42:20Z","labels":["architecture","browser","opfs"],"dependencies":[{"issue_id":"improvise-12ha","depends_on_id":"improvise-i0v9","type":"discovered-from","created_at":"2026-09-21T21:31:54Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-12ha","depends_on_id":"improvise-tm6","type":"parent-child","created_at":"2026-09-25T01:04:35Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-7gip","title":"[bug] OpfsFs::open's dedicated-worker guard checks WorkerGlobalScope, which a SharedWorker satisfies","description":"MEASURED in all three engines at 1f28868.\n\nOpfsFs::open() guards with:\n\n .dyn_into::\u003cweb_sys::WorkerGlobalScope\u003e()\n .map_err(|_| anyhow!(\"OPFS sync access handles require a dedicated Web Worker\"))?\n\n`self instanceof WorkerGlobalScope` is TRUE in a SharedWorkerGlobalScope. So a\nshared worker sails past this check and fails much later as a raw TypeError deep\nin the wasm glue:\n\n [firefox] workerError \"TypeError: arg0.createSyncAccessHandle is not a\n function\" at opfs/pkg/opfs_contract.js:93\n\nThe message names the right requirement; the type cannot check it.\ndyn_into::\u003cweb_sys::DedicatedWorkerGlobalScope\u003e() would.\n\nThis is doctrine(an_invariant_is_made_unrepresentable_not_validated) HALF DONE:\na guard was reached for, and the type chosen is one level too wide, so the\ninvariant is validated-and-wrong rather than unrepresentable.\n\nopfs.rs is wasm32-only with no Rust tests (0t in the inventory), so there is no\ncargo test that can fail first. The instrument that CAN fail first is harness/ --\nthe probe above is exactly the reproduction, and harness/opfs-contract already\nboots the real bundle in a real worker.\n","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:40:31Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:58:57Z","closed_at":"2026-09-21T21:58:57Z","close_reason":"Fixed in the 2026-09-21 wave, integrated into main at 1574 passed / 0 failed / 7 ignored. Playwright 42 passed / 0 failed / 4 skipped (WebKit, no OPFS) -- up from 37, so the count MOVED. All gates green.\n\nGuard is now DedicatedWorkerGlobalScope.\n\nTHE ONE-LINER DOES NOT COMPILE: the type is web-sys feature-gated, so improvise-io/Cargo.toml needed a line too. That is why it looked free and was not.\n\nNOT VERIFIED: that a SharedWorker is actually refused -- the harness has no SharedWorker page. What is asserted is only that a DEDICATED worker still satisfies the narrow type, i.e. the fix did not break the shipped path. Stated explicitly in the evidence rather than implied. improvise-12ha owns the SharedWorker question.","labels":["browser","bug","opfs"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-7xyx","title":"FrameGroup::cat is dead in both front ends and its doc comment promises an echo nobody composes","description":"FrameGroup::cat is read by NEITHER front end -- dead in both. Its doc comment\nsays it is \"echoed back in ToggleGroup\", and resolve.rs has NO ToggleGroup arm\nat all, so the echo it promises cannot happen from a browser.\n\nBy design-principles.md section 6 this is the expensive class: a reason that\nlives only in a comment, where nothing checks it against the code. The comment\nis a promise about a client that does not exist.\n\nTwo outcomes are acceptable: compose ToggleGroup in resolve.rs and make the\ncomment true (z is on the silent list anyway, so this would close part of that),\nor delete the field and the comment together. What is not acceptable is leaving\na doc comment describing a protocol interaction no code performs.\n\nAlso noticed in the same pass: AxisFrame::cats is PARTIAL on both ends -- the\ncount is used, the category NAMES are drawn by neither, so the grid's corner\ncell is blank in both surfaces. Possibly intended; worth a decision either way.\n","status":"closed","priority":2,"issue_type":"task","assignee":"agent-viewhist","owner":"el-github@elangley.org","created_at":"2026-09-21T20:39:58Z","created_by":"Edward Langley","updated_at":"2026-09-23T05:28:40Z","started_at":"2026-09-23T04:38:11Z","closed_at":"2026-09-23T05:28:40Z","close_reason":"Fixed in PR #196, merged after adversarial review at 6b3dd8c. Follow-ups: improvise-w1bu (test gap), improvise-pakc (totals identity).","labels":["docs","protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -331,8 +356,8 @@ {"_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":"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-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":"{}"},{"issue_id":"improvise-9cma","depends_on_id":"improvise-vep8","type":"relates-to","created_at":"2026-09-21T20:49:18Z","created_by":"external-grok-bot","metadata":"{}"}],"comments":[{"id":"6539ce12-7d51-5df7-9337-80a196a9954f","issue_id":"improvise-9cma","author":"external-grok-bot","text":"PR #100 P3 F25 filed as improvise-vep8: watchdog arms after await transport.ready, so hang-before-open never times out. Related but distinct from invalid ?doc= blank UI. — external Grok Bot from cloud adversarial review PR #100 for Ed Langley","created_at":"2026-09-21T20:49:19Z"}],"dependency_count":1,"dependent_count":0,"comment_count":1} +{"_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":"{}"}],"comments":[{"id":"01ac8a18-0851-5a6e-96ba-c7e986dad170","issue_id":"improvise-iobc","author":"external-grok-bot","text":"PR #100 F21 (Worker seed fetch errors silently swallowed) is a duplicate of this bead — same seedBytes() catch/!res.ok → undefined path in web/worker.js. Do not re-file. — external Grok Bot from cloud adversarial review PR #100 for Ed Langley","created_at":"2026-09-21T20:48:20Z"}],"dependency_count":1,"dependent_count":0,"comment_count":1} {"_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} {"_type":"issue","id":"improvise-l1f5","title":"A blank numeric CSV field makes the entire measure a Label","description":"Report-only bug hunt at HEAD 70025e2, 2026-09-21. No implementation changes.\n\nLocation: crates/improvise-io/src/import/analyzer.rs:129-132\n\nCSV Region,Revenue with East,10 and West,\u003cblank\u003e yields a Revenue proposal of Label instead of Measure. parse_csv_field correctly maps blank to Null, but all_numeric tests every value including Null. Default import therefore stops importing all numeric Revenue cells; they become label coordinates instead. Infer from present values, define all-null behavior explicitly, and test both blank CSV and JSON null. Duplicate checks: blank, numeric, null, import. improvise-ych concerns missing CATEGORY values, a separate stage.\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 blank_measure_still_infers_numeric() {\n use improvise_io::import::{wizard::ImportPipeline,analyzer::FieldKind};\n let (records,_)=parse_csv_bytes_with_warnings(b\"Region,Revenue\\nEast,10\\nWest,\\n\").unwrap();\n let p=ImportPipeline::new(serde_json::Value::Array(records));\n let proposal=p.proposals.iter().find(|p|p.field==\"Revenue\").unwrap();\n assert_eq!(proposal.kind,FieldKind::Measure);\n}\n```\nRun cargo test blank_measure_still_infers_numeric -- --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","notes":"Mechanism, carried over from improvise-dhw5 (closed as a duplicate, filed independently by an external Grok Bot review the same day): analyze_records' all_numeric EXCLUDES Value::Null, so a column that is 99% numeric with a single null is classified Label rather than Measure. parse_csv_field correctly maps blank to Null; the misclassification is entirely in the analyzer. Consequence beyond the proposal being wrong: the numbers then import as stringified item names.\n\nNote this interacts with gotcha 18 -- analyze_records was already changed once (improvise-7yx) to use first-seen order rather than a HashSet. Same function, different defect.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:31:27Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:58:03Z","closed_at":"2026-09-21T21:58:03Z","close_reason":"Fixed in the 2026-09-21 wave, integrated into main at 1574 passed / 0 failed / 7 ignored, with fmt, clippy, --check-evidence and the 8-crate wasm gate green. Every guard was fault-injected and watched failing before being believed.\n\nall_numeric AND all_string now run over the non-null values, with an !is_empty() guard so an all-null column does not satisfy a vacuous all() and falls through to Label. BOTH bindings fixed, not just the numeric one the ticket named: identical expression, identical defect, and a blank in a Region column made the whole category a Label.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-m4rv","title":"Loading a document invents a Default view the file does not contain, and saving writes it back","description":"Found by the UX audit. DEMONSTRATED; orchestrator re-verified.\n\nweb/demo.improv declares exactly ONE view, `Plan`. Parsing it produces TWO:\n\n $ cargo run -q --example pretty-print \u003c web/demo.improv | grep '^## View'\n ## View: Default \u003c- not in the file\n ## View: Plan\n\n $ grep -c '^## View' web/demo.improv\n 1\n\n`Default` comes out auto-laid-out (Product: row, Quarter: column, _Measure: page) because Workbook::new seeds a Default view and on_category_added assigns each parsed category into every existing view. examples/demo.improv is unaffected only because its view IS named Default.\n\n## Two consequences, the second worse\n1. THE SHIPPED DEMO'S VIEW PANEL SHOWS A PHANTOM ROW, AND IT IS INDEX 0. The panel cursor starts on `Default`, so the first V+Enter a visitor presses takes them OFF the curated view onto an auto-arrangement. harness/tests/contract.spec.js:71 already encodes this (toHaveCount(2), cursor on Default) as expected behaviour — so the test pins the bug.\n\n2. OPEN-AND-SAVE REWRITES THE USER'S DOCUMENT. A round trip with no edits adds the whole `## View: Default` block permanently. Demonstrated with `improvise cmd 'set-format ,.0'`.\n\n## Acceptance Criteria\n- parse_md drops a `Default` view that the parsed text never named and that holds no non-default configuration — the same class of rule as the virtual-view drop already in persistence.\n- Update contract.spec.js, which currently asserts the phantom.\n- A test: parse(format(x)) names exactly the views x named.\n\n## Adjacent, same experience, probably a different owner\nThe same round trip REORDERS the ## Data block — |Cold Brew| moves from the middle to the end. That is DataStore ordering (gotcha 18 territory) and lands in the same \"save produced a different file\" complaint. Worth checking whether it is the same insertion-order guarantee that gotcha 18 says persistence relies on.\n","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:30:19Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:58:14Z","closed_at":"2026-09-21T21:58:14Z","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\ndrop_unnamed_default drops Default only when the file named at least one view and none was Default. The name is lifted to view::DEFAULT_VIEW so parser and Workbook::new cannot drift.\n\nUNPREDICTED: the phantom was LOAD-BEARING in a test that had nothing to do with it. contract.spec.js's click test needs two view rows, and the invented view was silently supplying one. web/demo.improv gained a real second view (Season). Generalises: when removing something spurious, grep for tests that need N of something, not only tests that name the thing.","comments":[{"id":"a2b602bb-1f71-5d7b-9712-7e42113db440","issue_id":"improvise-m4rv","author":"Edward Langley","text":"Independently reproduced in this report-only hunt at 70025e2: create Custom, delete Default, parse_md(format_md(wb)); expected [Custom], actual [Default, Custom]. A failing standalone Rust regression and exact source location are retained in improvise-izx2. That ticket was filed at 20:27 before this concurrently-created report existed; consolidating here because this report adds shipped-demo and harness impact.","created_at":"2026-09-21T20:32:02Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} @@ -360,7 +385,7 @@ {"_type":"issue","id":"improvise-5ubs","title":"A cyclic-successor all() can silently omit a variant while its doc says it cannot","description":"Found by the adversarial review of PR #78, which probed it rather than reading the doc comment.\n\nAppMode::all() and Rejection::all() walk a total, cyclic after(). The claim in both doc comments is that a new variant cannot be omitted, because after() has no None arm so the only way to answer for a new variant is to NAME ANOTHER ONE.\n\nThat is half true and the half it gets wrong is the half that matters. ANSWERING after() FOR a new variant is forced by the compiler. POINTING AN EXISTING ARM AT IT IS NOT. A probe variant whose after() arm returns some existing variant, with nothing in the cycle pointing back to it, is simply never walked: all().len() came back 17, not 18, with the suite green.\n\nSo the consolidation replaced five grep-visible lists with one derivation whose totality is asserted in prose and checked by nothing. THE REVIEWER'S META-POINT IS THE REAL TICKET: five disagreeing AppMode lists were visible to grep; all() under-enumerating is visible to nothing. A consolidation owes a test that the surviving derivation is TOTAL, not merely that it agrees with what it replaced.\n\nWHAT IS STILL BETTER THAN BEFORE, so this is not a revert: the compiler now forces a human to look, and two of the five lists it replaced were ALREADY WRONG (both missing SavePrompt). This is a gap in a guarantee, not a regression.\n\nOPTIONS, roughly in order of cost:\n- A test that walks the cycle and asserts every variant is visited, driven by something independent of after() -- which needs an independent enumeration, and that is the problem restated.\n- A derive macro or a dependency (strum). Rejected during PR #78 only because adding a workspace dep mid-wave collides in Cargo.toml; on its own it is the honest answer and cheap.\n- Accept and document the residual precisely, replacing both doc comments, which currently overclaim.\n\nWHATEVER IS CHOSEN, FIX THE DOC COMMENTS FIRST: they presently say the omission is impossible, and a reader checking this property will believe them. An overclaiming comment is worse than none, because it stops the next person looking.","notes":"A FOURTH INSTANCE, inside the PR that found this ticket. The adversarial review of PR #78 added a fourteenth `pub struct` declaring `EffectKind::Model(ModelChange::LayoutGeometry)`, pointed at by nothing, and the whole suite stayed green.\n\nThe site is layout_geometry_effects_actually_change_document_config in ui/effect.rs. Its comment said \"a fourteenth declaring LayoutGeometry must come here and say so\". That is false for exactly the reason this ticket names: adding is compiler-forced only where an EXISTING arm must point at the new thing, and here nothing must. `assert_eq!(cases.len(), 13)` is a count, not a totality check -- it catches a case being deleted from the list, never one being absent from it.\n\nCorrected in #78 to say what it is rather than what it is not; the mechanism is unchanged and the cursor consolidation does not depend on it. Filed here rather than fixed because the honest fix is whatever 5ubs settles on, and a fourth hand-rolled attempt in a fourth place is the disease.\n\nWHAT MAKES THIS ONE WORTH RECORDING: it is the same defect as AppMode::all(), in the PR whose own consolidation work surfaced AppMode::all(). A list that is checked for LENGTH reads exactly like a list that is checked for COMPLETENESS, and both are written as an assert_eq over a number.\n","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T04:01:27Z","created_by":"claude-agent-protocol","updated_at":"2026-09-21T12:39:36Z","closed_at":"2026-09-21T12:39:36Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-349m","title":"format_md/parse_md is not idempotent when a .improv file has two Category sections with the same name","description":"format -\u003e parse -\u003e format differs, the two sides differing only in the order of '\u003e' group lines. DETERMINISTIC -- 8 failures in 8 runs on unmodified main (00f7678) -- so it is NOT gotcha 18's HashSet shape.\n\nCause: the file carries two '## Category: ||' sections with the SAME name. The second merges into the first and its group lines are appended in a position the second parse does not reproduce.\n\nReproducer, from the proptest's own minimal input:\n\n let file = \"v2025-04-09\\n# |\\\\a|\\n## View: a\\n_-: row\\ncollapsed: ||/A--\\n\\\n ## Category: ||\\n- |\\\\a\\\\a\\\\a|,|\\\\a\\\\a|,|\\\\a|,|\\\\a|\\n\u003e _---[|\\\\a|]\\n\u003e _[|\\\\a|]\\n\\\n ## Category: ||\\n- ||[||]\\n\u003e ||[||]\\n\";\n let m1 = parse_md(file).unwrap();\n let printed = format_md(\u0026m1);\n assert_eq!(printed, format_md(\u0026parse_md(\u0026printed).unwrap())); // fails\n\nWHY IT LOOKS FLAKY AND IS NOT: persistence::parse::grammar_prop_tests::generated_file_roundtrips goes red only when the grammar-walking generator happens to emit a duplicate section header, which is rare. It was observed once during PR #70's CI, the rerun passed, and the honest move was to take proptest's minimal input and try it deterministically rather than rerun and move on. That is what turned a 'flake' into a filed bug.\n\nFound by the improvise-pdvm/75ld agent on PR #70; not caused by that PR. bd search for idempotent, roundtrip, duplicate category, format_md and group order returned nothing, so it was not already filed.\n\nWorth deciding as part of the fix: is a duplicate '## Category:' header legal input at all? If it is not, the parser should refuse it and the generator should stop emitting it; if it is, the merge must produce a canonical group order. The round-trip property cannot hold without one of those answers.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T23:42:59Z","created_by":"orchestrator","updated_at":"2026-09-21T04:42:54Z","closed_at":"2026-09-21T04:42:54Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-v5pc","title":"restore the browser assertion that ? opens help","description":"36ade35 moved the shell suite to node and deleted harness/tests/widgets.spec.js, taking the Playwright test '? opens help, and its rows are the shared ones' with it. Nothing replaced it: no node test and no spec asserts that the help overlay is drawn, that its active tab is the shared module's page title, or that l pages it. The evidence entry in context/test-evidence/clients.md survived the deletion and went on promising the coverage for a whole release -- the improvise-5ray cost, now caught by --check-evidence, and the heading is demoted to prose rather than deleted so its two injections (dropping the paintHelp call; removing the mode != Help guard in help_rows) survive for whoever restores it. improvise-ktv1's acceptance -- a browser shows help -- is currently unasserted end to end.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T23:25:28Z","created_by":"claude-agent-scripts","updated_at":"2026-09-21T00:06:02Z","closed_at":"2026-09-21T00:06:02Z","close_reason":"Duplicate of improvise-78v6, already fixed and merged in PR #72. harness/tests/help.spec.js on main carries test(\"? opens help, and its rows carry the keys and the words, not empty frames\") plus a second test that reads an advertised key out of the DOM, presses it, and asserts the selection moved.\n\nWorth noting HOW this duplicate happened, because the filing agent did nothing wrong: it searched bd before filing, as AGENTS.md requires, and found nothing -- because the fix was in a sibling agent's UNMERGED PR at the time. A parallel wave makes in-flight work invisible to bd search. The cheap addition to the wave brief is to check open PRs, not only the tracker, before filing something a sibling unit might own.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-xfsb","title":"Agents should post to GitHub under their own identity, not the owner's","description":"Today every agent comment, review and PR goes out as @fiddlerwoaroof, because gh uses his credentials. The interim is a convention -- open each comment with a line saying a model wrote it (doctrine(an_agent_posting_to_github_says_so), AGENTS.md) -- and a convention is something an agent can forget. A separate identity makes attribution structural instead.\n\nTwo routes:\n\n1. GitHub App installed on the repo. Agents mint an installation token and export GH_TOKEN. Comments render as \u003cname\u003e[bot], the same shape as copilot-pull-request-reviewer[bot]. Scoped permissions, no seat consumed, revocable independently of the owner's account, and the bot suffix is not spoofable by a human account.\n\n2. Machine user with its own PAT as GH_TOKEN. Simpler to set up, consumes a seat on some plans, and is indistinguishable from a person at a glance -- which is most of what we are trying to fix.\n\nPrefer 1. Needs the user: create the App, grant contents+pull_requests+issues write, install it, and put the app id and private key where agents can reach them.\n\nWorth checking while doing it: whether the bot can be requested as a PR reviewer, which would let review agents post as reviews rather than as comments.\n\nOnce it lands, the attribution line becomes belt-and-braces rather than the mechanism, and the doctrine should be amended to say so rather than deleted -- a reader seeing an unattributed comment from the owner's account still needs to know it might be an agent.","notes":"DEFERRED by the user 2026-09-22. Recording the reasoning, because the status was\nset with none and the INTERIM IS LOAD-BEARING WHILE THIS SITS.\n\nWHY IT IS A FINE THING TO DEFER: route 1 needs the user to create a GitHub App,\ngrant it contents+pull_requests+issues write, install it, and put the app id and\nprivate key where agents can reach them. That is credential work only he can do,\nand nothing in the codebase is blocked on it.\n\nWHAT MUST STAY IN FORCE MEANWHILE, and this is the part that would be quietly\nlost: EVERY agent comment, review, PR body and issue still opens with a line\nnaming the model and that it is an agent posting in his session\n(doctrine(an_agent_posting_to_github_says_so), AGENTS.md). While this ticket is\ndeferred that convention is THE MECHANISM, not belt-and-braces -- an\nunattributed comment reads as the owner's own words to everyone who sees it.\nDeferring the structural fix therefore RAISES the cost of forgetting the\nconvention rather than lowering it.\n\nMeasured this session: the convention held across four merged PRs and every\nthread comment. It is not currently failing; it is simply the kind of thing that\nfails silently and only once someone notices.\n\nWHAT REOPENS IT:\n 1. The user creating the App -- the only real blocker.\n 2. Any instance of the convention actually being forgotten on a public\n artefact. One is enough: the failure mode is a human reading a model's\n words as his own, and it is not detectable after the fact by anything\n automated.\n 3. A second person gaining write access to the repo, which makes \"who said\n this\" ambiguous in a way the attribution line alone does not resolve.\n\nNOT A REASON TO REOPEN: more agents, or more PRs. Volume does not change the\nargument either way.\n\nNote the ticket's own closing paragraph still applies and should not be lost: when\nthis lands, the attribution doctrine is AMENDED rather than deleted, because a\nreader seeing an unattributed comment from the owner's account still needs to know\nit might be an agent.","status":"in_progress","priority":2,"issue_type":"task","assignee":"Ed","owner":"el-github@elangley.org","created_at":"2026-09-20T23:04:19Z","created_by":"orchestrator","updated_at":"2026-09-25T07:19:43Z","started_at":"2026-09-25T07:19:43Z","lease_expires_at":"2026-09-25T07:24:43Z","heartbeat_at":"2026-09-25T07:19:43Z","dependencies":[{"issue_id":"improvise-xfsb","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T01:05:51Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-xfsb","title":"Agents should post to GitHub under their own identity, not the owner's","description":"Today every agent comment, review and PR goes out as @fiddlerwoaroof, because gh uses his credentials. The interim is a convention -- open each comment with a line saying a model wrote it (doctrine(an_agent_posting_to_github_says_so), AGENTS.md) -- and a convention is something an agent can forget. A separate identity makes attribution structural instead.\n\nTwo routes:\n\n1. GitHub App installed on the repo. Agents mint an installation token and export GH_TOKEN. Comments render as \u003cname\u003e[bot], the same shape as copilot-pull-request-reviewer[bot]. Scoped permissions, no seat consumed, revocable independently of the owner's account, and the bot suffix is not spoofable by a human account.\n\n2. Machine user with its own PAT as GH_TOKEN. Simpler to set up, consumes a seat on some plans, and is indistinguishable from a person at a glance -- which is most of what we are trying to fix.\n\nPrefer 1. Needs the user: create the App, grant contents+pull_requests+issues write, install it, and put the app id and private key where agents can reach them.\n\nWorth checking while doing it: whether the bot can be requested as a PR reviewer, which would let review agents post as reviews rather than as comments.\n\nOnce it lands, the attribution line becomes belt-and-braces rather than the mechanism, and the doctrine should be amended to say so rather than deleted -- a reader seeing an unattributed comment from the owner's account still needs to know it might be an agent.","notes":"DEFERRED by the user 2026-09-22. Recording the reasoning, because the status was\nset with none and the INTERIM IS LOAD-BEARING WHILE THIS SITS.\n\nWHY IT IS A FINE THING TO DEFER: route 1 needs the user to create a GitHub App,\ngrant it contents+pull_requests+issues write, install it, and put the app id and\nprivate key where agents can reach them. That is credential work only he can do,\nand nothing in the codebase is blocked on it.\n\nWHAT MUST STAY IN FORCE MEANWHILE, and this is the part that would be quietly\nlost: EVERY agent comment, review, PR body and issue still opens with a line\nnaming the model and that it is an agent posting in his session\n(doctrine(an_agent_posting_to_github_says_so), AGENTS.md). While this ticket is\ndeferred that convention is THE MECHANISM, not belt-and-braces -- an\nunattributed comment reads as the owner's own words to everyone who sees it.\nDeferring the structural fix therefore RAISES the cost of forgetting the\nconvention rather than lowering it.\n\nMeasured this session: the convention held across four merged PRs and every\nthread comment. It is not currently failing; it is simply the kind of thing that\nfails silently and only once someone notices.\n\nWHAT REOPENS IT:\n 1. The user creating the App -- the only real blocker.\n 2. Any instance of the convention actually being forgotten on a public\n artefact. One is enough: the failure mode is a human reading a model's\n words as his own, and it is not detectable after the fact by anything\n automated.\n 3. A second person gaining write access to the repo, which makes \"who said\n this\" ambiguous in a way the attribution line alone does not resolve.\n\nNOT A REASON TO REOPEN: more agents, or more PRs. Volume does not change the\nargument either way.\n\nNote the ticket's own closing paragraph still applies and should not be lost: when\nthis lands, the attribution doctrine is AMENDED rather than deleted, because a\nreader seeing an unattributed comment from the owner's account still needs to know\nit might be an agent.","status":"in_progress","priority":2,"issue_type":"task","assignee":"Ed","owner":"el-github@elangley.org","created_at":"2026-09-20T23:04:19Z","created_by":"orchestrator","updated_at":"2026-09-25T07:19:43Z","started_at":"2026-09-25T07:19:43Z","dependencies":[{"issue_id":"improvise-xfsb","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T01:05:51Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-84wp","title":"Doctrine: a check that looks like the answer, answering a neighbouring question","description":"Ready-to-apply prose for `context/design-principles.md` §7. Filed rather than committed because PR #60 head `ec03f95` is byte-identical (tree `ec5d531...`) to a tree the reviewer fully tested, and a further commit would destroy that property and require a fresh harness run while `main` was moving several times an hour. The artefact is worth more preserved than rushed.\n\n---\n\n### A check that looks like the answer, answering a neighbouring question\n\nThe characteristic defect in this repo is not a wrong line; it is a check that is *adjacent* to the thing it names. Three instances landed in one campaign (PRs #59/#60), in three different layers, which is why it is worth stating as a shape rather than three gotchas:\n\n- **A vacuous assertion.** `set_panel_open_and_cursor` asserted `category_panel_open` was true after an effect set it true -- once the launch default supplied `true`, the effect could be replaced by a **no-op** and 1391 tests passed. It answered *is the flag true* where it meant *did the effect set it*.\n- **A vacuous repair of it.** The first fix folded `[open:false, open:true]` and asserted the final state -- which a reducer that folds nothing also satisfies. It answered *does the text mention the other direction* where it meant *does the writer move the flag both ways*. **Reading the diff is what failed**, and it failed because the vacuous version contained `open: false`.\n- **A clean auto-merge.** `git merge-tree` reporting no conflict answers *can git combine these texts*, where the question is *does the combination do what both sides meant*. Two PRs edited the same four files with no conflict and neither side CI-tested together.\n\nEach has the same remedy shape: **name the question you actually mean, then find an input that separates it from its neighbour.**\n\n- For an assertion: **assert the direction the default cannot supply.** Closing the panel discriminates; opening it proves nothing.\n- For a repair: inject a **one-directional** fault (`if open { set true }` -- can raise, never lower). A vacuous repair cannot see it, because a test that only asserts the `true` direction is satisfied by a writer that only ever writes `true`.\n- For a merge: **materialise it and run the gates**, and check that a count which should MOVE actually moved. Playwright going 16 -\u003e 17 is what showed the other side survived; \"no conflict\" and an unchanged total would both have been consistent with losing it.\n\nThis generalises the existing `doctrine(fold_tests_must_test_order_not_the_axiom)` (asserting `fold(prefix)+fold(rest) == fold(whole)` is the definition of a left fold, so it answers a neighbouring question too). What the campaign adds is that the pattern recurs in **test repairs** and in **merge hygiene**, not only in production code and not only in fold tests.\n\n---\n\nAcceptance: the section is in §7; `doctrine(fold_tests_must_test_order_not_the_axiom)` cross-references it or is subsumed by it. Worth considering a `kb.pl` doctrine clause, which is orchestrator-only.\n","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T21:38:23Z","created_by":"wave3-webui","updated_at":"2026-09-21T04:49:32Z","closed_at":"2026-09-21T04:49:32Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-qsmp","title":"App::cmd_context's panel-open propagator is untested: C cannot open a closed panel and nothing fails","description":"`App::cmd_context` (crates/improvise-command/src/ui/app.rs) propagates `self.view_state.category_panel_open` into the `CmdContext`. **Hardcode it to `true` and the entire workspace passes** — 1394 tests, nothing fails.\n\nThat matters because `registry.rs` reads it in three places for `toggle-panel-and-focus`s open-vs-close decision and the panel focus/cycle commands. With it stuck true, pressing `C` on a CLOSED category panel decides to *close* it rather than open it — the panel becomes unopenable by its own key — and no test notices.\n\n**Why it is invisible:** `cmd/mod.rs`s `test_helpers` sets `category_panel_open` explicitly on the context it builds, so no command test ever exercises the real propagator. The tests construct the value they then assert on.\n\n**This is PRE-EXISTING and was not caused by the launch-default ruling (PR #60).** That distinction was established by control rather than assumed: with the default inverted, this mutation fails *exactly the same two tests as inverting the default alone* and nothing more. So it was unguarded before the ruling too. The remedy is therefore \"a test at all\", not the precondition-stating fixture that fixed the two genuinely-disarmed tests (`set_panel_open_and_cursor`, `panel_commands_address_the_named_panel_only`).\n\n**The method that found it is worth more than the finding** and is written up in `context/test-evidence/clients.md` under \"How to know a launch default has stopped disarming things\": mutate every site that writes, propagates or reads the value; sites where nothing fails are suspects; then re-run each suspect with the default inverted to separate *vacuous* (fix the fixture) from *unguarded* (file it). Grep cannot do this — it finds sites that NAME the flag, not tests that merely start from a default-built state.\n\nAcceptance: a command-layer test that drives the real `App::cmd_context` rather than a hand-built context, such that `toggle-panel-and-focus` on a closed panel opens it; proved by hardcoding the propagator and watching it fail.\n\nSibling note: `improvise-wasm-client/src/render.rs` has the same propagator shape for `RenderModel`. Hardcoding THAT one true is improvise-wsaw reintroduced one layer up, and Rust sees nothing — only the Chromium harness catches it, at `contract.spec.js`s `toBeHidden`. Consequence worth keeping: a Rust-only run of the `test` CI job cannot see a reintroduced wsaw, which is the sharpest argument on file for the browser job being able to fail the build.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"claude-agent-cmd","owner":"el-github@elangley.org","created_at":"2026-09-20T21:23:53Z","created_by":"wave3-webui","updated_at":"2026-09-21T04:43:50Z","started_at":"2026-09-21T03:27:55Z","closed_at":"2026-09-21T04:43:50Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-5ray","title":"check-evidence ignores #### headings and every browser-test entry uses them","description":"Found by review of PR #65, 2026-09-20 (finding 4). Sibling of improvise-lyyc\nand improvise-r0qa, and the sharpest of the three because this one has already\nlet a false claim stand for a release.\n\nscripts/check-comment-runs.py --check-evidence collects headings with\n`s.startswith(\"### \")` and builds its `defined` set from rust_files(roots) only.\nTwo consequences compound:\n\n 1. A `####` heading is invisible to it — `\"### \"` does not match `\"#### \"`.\n 2. Even if it matched, `defined` holds only Rust `fn` names, so a JavaScript\n test name could never resolve.\n\ncontext/test-evidence/clients.md uses `#### \"quoted name\"` for every browser\ntest precisely BECAUSE of (2) — a `###` heading naming a Playwright test would\nbe reported as an orphan. So the convention that keeps the checker quiet is the\nsame convention that makes it blind.\n\nDEMONSTRATED: appending\n\n #### \"a test that does not exist anywhere\"\n\nto context/test-evidence/clients.md leaves `--check-evidence` exiting 0.\n\nWHAT IT COST: PR #61 deleted `a socket that opened and then dropped reads as\ndisconnected, and stays that way`. Its `####` heading stayed. The gate passed\nfor the whole release while the file promised a guard that was not there —\nexactly the \"dangling explanation\" design-principles §7 says the checker exists\nto prevent.\n\nFIX SHAPE: collect `###`/`####` alike, and extend `defined` with JS test names\n(`test(\"…\"` in harness/tests/*.spec.js and web/tests/*.test.js). Note the\nchecker's own --self-test must grow a case, or this fix is unguarded in exactly\nthe way the thing it fixes was.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"claude-agent-scripts","owner":"el-github@elangley.org","created_at":"2026-09-20T21:22:55Z","created_by":"wave3-jstests","updated_at":"2026-09-20T23:35:11Z","started_at":"2026-09-20T23:22:05Z","closed_at":"2026-09-20T23:35:11Z","close_reason":"fixed in PR #69, each proved by injection","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -384,7 +409,7 @@ {"_type":"issue","id":"improvise-8qlr","title":"Column-group run coalescing is derived twice, and the agreement test does not pin the TUI arm","description":"Found by the post-wave code consistency review, CONFIRMED BY INJECTION.\n\nColumn-group RUN COALESCING is derived in two places:\n * src/ui/grid.rs:189-192, coalescing on `group_name != prev_group`\n * crates/improvise-wasm-client/src/render.rs:339-364 (`col_groups`), coalescing by `label` alone — with a comment saying \"which is what src/ui/grid.rs does ... and the two must agree\"\n\nA comment is what keeps them in step. AxisFrame already owns `groups()`; a `group_runs()` there would make this one rule instead of two.\n\n## Steps to Reproduce\n\n1. In src/ui/grid.rs, force the TUI to emit the group label on EVERY column (`match (true, col_group)`) — a visibly different grid.\n2. `cargo test -p improvise` stays GREEN.\n\n`tests/grid_frame_agreement.rs::the_two_front_ends_draw_the_same_column_header` compares the BROWSER's spans against a hard-coded `vec![(Some(\"Q1\"), 6)]` but only checks the TUI's group line with `group_line.contains(\"▼ Q1\")`. That `contains` is the unpinned arm.\n\n## Note\n\nThe rest of that test file is exemplary — it drives each front end the way it is really driven and records its injected-fault evidence. This is its one weak assertion, and it is weak in the direction that matters: the side the migration could most easily have broken.\n","acceptance_criteria":"The run rule lives once, on AxisFrame (a group_runs() beside groups()), and both front ends call it. the_two_front_ends_draw_the_same_column_header compares the TUI's spans EXACTLY rather than with contains(), so forcing the TUI to emit a group label on every column fails it — verify by injecting that fault.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-render-frame","owner":"el-github@elangley.org","created_at":"2026-09-20T07:49:43Z","created_by":"Edward Langley","updated_at":"2026-09-21T03:46:31Z","started_at":"2026-09-21T03:39:55Z","closed_at":"2026-09-21T03:46:31Z","close_reason":"Fixed on PR #77 (fix/render-frame-wave, head 8f3a976). Each guard proved by injecting the fault it names; GROUPED_FRAME and harness/tests/grouped.spec.js re-pinned deliberately.","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-8uj8","title":"every_rejection_message_says_the_edit_did_not_land covers five of seven variants","description":"Found by the post-wave code consistency review, CONFIRMED BY INJECTION.\n\ncrates/improvise-wasm-client/src/socket.rs:273-288. The test is named `every_rejection_message_says_the_edit_did_not_land` and its doc says the fact \"is asserted over all of them rather than one at a time\". It drives a HAND-WRITTEN LIST of Rejection variants, and that list predates PR #26, which added `NoDocumentPath` and `SaveFailed`.\n\n## Steps to Reproduce\n\n1. Add `NoDocumentPath` and `SaveFailed` to the test's list.\n2. It FAILS: \"... which does not say the edit did not land\".\n\nSo two variants were silently exempted from a rule the test claims is universal, rather than argued out of it.\n\n## The nuance that makes this a re-scope rather than a message fix\n\nThe messages themselves are defensible — a save failure did not fail to APPLY anything, so \"the edit did not land\" is the wrong sentence for it. But then the TEST'S CLAIM is what is wrong, and a test asserting something false about its own subject is worse than no test.\n\ndoctrine(a_hand_enumerated_mirror_needs_an_exhaustive_match) names this exactly.\n","acceptance_criteria":"The test is driven by an EXHAUSTIVE match over Rejection so a new variant cannot slip, and its claim matches what it checks — either every variant really does say the edit did not land, or the test is re-scoped to the subset for which that sentence is correct and says which and why. Adding a variant to Rejection fails the build until someone answers for it.","status":"closed","priority":2,"issue_type":"bug","assignee":"claude-agent-protocol","owner":"el-github@elangley.org","created_at":"2026-09-20T07:49:42Z","created_by":"Edward Langley","updated_at":"2026-09-21T03:47:00Z","started_at":"2026-09-21T03:28:16Z","closed_at":"2026-09-21T03:47:00Z","close_reason":"Closed","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-75ld","title":"A refused or no-op AddCategory still bumps layout_version and raises dirty","description":"Found by the post-wave code consistency review, CONFIRMED BY EXECUTION against a real App.\n\nApp::apply_effects (crates/improvise-command/src/ui/app.rs:637-650) runs `effect.apply(); dirty.union(of(kind)); dirty = true; layout_version += 1;` and ONLY THEN checks abort_effects. So the aborting effect itself is counted.\n\n## Steps to Reproduce\n\n1. Fill a document to MAX_CATEGORIES (12), then add a 13th via `:add-cat X` or the `+cat` records column. The category is NOT created — `AddCategory` now sets abort_effects on refusal (PR #27) — yet `layout_version` goes 12 -\u003e 13 and `model_state.dirty` -\u003e true.\n2. Or simply `:add-cat Region` twice. `Model::add_category` returns Ok for a duplicate (types.rs:177), so this does not even abort: a pure no-op bumps both.\n\n## Consequences\n\n * EVERY OTHER CONNECTED CLIENT'S NEXT Command::Model IS REFUSED with Rejection::LayoutMoved, for a keystroke that changed nothing.\n * The browser worker's `if app.model_state.dirty { persist() }` (worker.rs:178) rewrites the whole OPFS document — and Storage is whole-document (gotcha 41).\n\n## Why this is a contradiction and not just an over-approximation\n\nThe `dirty` half IS argued as acceptable over-approximation in the comment at app.rs:617-622. The `layout_version` half is NOT — the same comment block says \"an aborted batch must not claim the effects it never ran (gotcha 21)\", which is exactly what it does. The code and its own stated rule disagree, four lines apart.\n\n## Note before fixing\n\nMoving the two bumps below the abort check closes the MAX_CATEGORIES arm but NOT the duplicate arm, because a duplicate AddCategory does not abort at all. The reachable, silent case is the duplicate. Consider bumping on observed change instead — `apply_effects` already fingerprints records for the CellData tier (gotcha 40), so \"did anything actually change\" is machinery that exists.\n","acceptance_criteria":"A refused AddCategory (MAX_CATEGORIES) and a duplicate AddCategory both leave layout_version and model_state.dirty untouched. Note the duplicate arm does not abort, so moving the bumps below the abort check is not sufficient on its own — the reachable silent case is the duplicate. A test drives both arms through App::apply_effects and asserts neither counter moved; injecting the old order fails it. State in the commit what the rule now is: a counter is bumped on an observed change, not on a declared intent.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T07:49:41Z","created_by":"Edward Langley","updated_at":"2026-09-20T23:59:38Z","closed_at":"2026-09-20T23:59:38Z","close_reason":"Fixed in PR #70 (14757a7), reviewed and merged. layout_version and dirty moved from declared EffectKind to observed change; adopt_cursor_epoch narrowed to the reshaped views with an is-empty fail-safe; Panel-\u003efield consolidated onto ViewState accessors.","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-yqpd","title":"Transactional rollback: a frame of mutations applies whole or not at all (ADR 001 clause 4)","description":"Clause 4 of the transaction contract the user specified 2026-09-20. Clauses 1-3 landed in PR #26; this is the half that was deliberately deferred with a costed design note.\n\nTHE CONTRACT, in the user's own words: \"a sequence of commands sent to the server should assume that it's played in order from start to end and that each command sees the state after the previous command runs\", as \"a single transaction from a given base\". Four clauses:\n\n 1. A frame states ONE basis, describing the base the client composed against. [DONE, PR #26]\n 2. Checked ONCE, before anything is applied. [DONE, PR #26]\n 3. Applied IN ORDER, each command seeing what its predecessor left. [DONE, PR #26]\n 4. ALL OR NOTHING -- a failure leaves the document exactly as the base described. [THIS BEAD]\n\nWHERE THE MECHANISM BELONGS: App::apply_effects, NOT Host::commands. A boundary drawn around a sequence of COMMANDS gets redrawn the day one command becomes a sequence -- and the user has said a command could eventually be \"run script\", which makes an unbounded number of mutations internally. So arity is not a property of a variant (doctrine(arity_is_not_a_property_of_a_variant)): a classifier can honestly say WHETHER a command changes the document, never HOW MANY TIMES. Design at the one mutation site and let both the frame case and the future script case use it.\n\nTHE CORE PROBLEM IN ONE LINE: abort_effects stops a batch and UNDOES NOTHING. That is an abort, not a rollback (gotcha 21), and clause 4 needs the latter. Two live examples sit in main today: a failed Save, and AddCategory refusing on MAX_CATEGORIES (PR #27).\n\nTHE DESIGN NOTE FROM PR #26, whose arguments should not be re-derived:\n * SNAPSHOT AND RESTORE, not speculative validation and not inverse effects. Speculation fails a concrete divergence argument: AcceptWizardModel calls take_built(), which CONSUMES the operand a real run would need. Inverse effects degenerate -- the general inverse of a document-replacing effect IS a snapshot -- so they do not avoid the clone, they multiply the surface by ~50.\n * THE FINGERPRINTS CANNOT BE AN UNDO LOG. The per-record XOR (gotcha 40) is a change DETECTOR, not invertible, and covers CellData only.\n * THE BOUNDARY IS THE DOCUMENT AND ITS TIERS, NOT ALL App STATE. AddCategory writes its refusal into view_state.status_msg and THEN aborts, so rolling that back would undo the explanation along with the damage.\n * WHY ROLLBACK IS SOUND AT ALL, structurally rather than by a flag: the three storage-WRITING effects (Save, SaveAs, ExportCsv) are all ViewOnly and unreachable from ModelCommand; LoadModel and ImportJsonHeadless only read. That is testable, and it becomes an acceptance criterion for any future RunScript -- a script language that can run :w breaks it.\n\nTWO ACCEPTANCE CRITERIA THAT CAME OUT OF THE P1 THE REVIEWER FOUND, and they are not obvious:\n * THE CHECKS MUST STAY BEFORE ANY APPLICATION. A frame that writes ONE RECORD TWICE is legitimate and likely -- two measures on one record IS one record -- and it works only because the second write is compared to the record's PRE-FRAME counter rather than the one its own sibling just bumped. Proved by injection: moving the record check into the apply loop fails that arm. `a_later_write_in_a_chain_still_answers_for_its_own_record` arm 3 is the guard.\n * CLAUSE 4'S ROLLBACK IS NOT THE RECORD TIER. They fail on DISJOINT frames, which is exactly why the P1 could not be deferred into this bead: rollback never fires on a frame where every command SUCCEEDS, and the lost update was a wrong answer fully applied.\n\nTHE TRADE NOBODY HAD NAMED, and the reason arm 3 exists: INTERLEAVING THE RECORD CHECK WITH THE APPLIES WOULD CLOSE THIS BEAD'S SILENCE FOR FREE. [RemoveItem(North), SetCell(North)] under interleaving is refused RecordGone instead of silently half-applying. So check-before-apply is not a free win -- it BUYS the legitimate double-write and SELLS the loud failure on a broken chain. Clause 4 must pay that back some other way, and arm 3 is what stops someone taking the trade without noticing they made it. Confirmed by two independent injections (the author's and the reviewer's, which differ in detail and agree on the direction).\n\nTHE BIGGEST AND LEAST OBVIOUS COST: ROLLING BACK THE App IS NOT ENOUGH. Host::command projects PER COMMAND, and apply_switch/apply_model adopt mirrors inline, so by the time a later command fails every session's mirror and believed grid cache have already moved. Deferring projection and relay to commit is the right fix and is also a bandwidth win -- but it changes Applied and restructures project.rs, so it is a merge-order problem as well as a size one.\n\nPROTOCOL MOVE THAT BELONGS HERE: clause 1 says the FRAME states one basis, but the wire still carries a Basis inside each Command::Model -- right when one command was one transaction. Later commands' Basis fields are currently IGNORED. Moving it to ClientFrame::Commands makes \"one basis per frame\" unrepresentable-otherwise instead of a convention.\n\nALSO FOLD IN: the `answered`/`frame_basis` classifier is a matches! whose failure direction is safe (a missed variant over-checks, never under-checks) -- make it exhaustive when the classifier returns, for the reason mutates_the_document was made exhaustive before it was deleted.\n\nESTIMATED COST from the design note: ~350-450 lines of production and as much again in tests, across app.rs, reduce.rs, project.rs, host.rs, improvise-protocol and an ADR section.\n\nINVERT `a_frame_whose_later_mutation_fails_is_not_yet_undone` when this lands. It currently characterizes the hole; all three of its assertions flip.\n","acceptance_criteria":"A ClientFrame::Commands whose later command fails leaves the document exactly as its stated basis described. The mechanism sits at or near App::apply_effects, not Host::commands, so a future multi-mutation command (RunScript) uses the same machinery. The checks stay BEFORE any application — a frame writing one record twice is legitimate and refuses itself otherwise, which a_later_write_in_a_chain_still_answers_for_its_own_record arm 3 guards. Rolling back the App is not enough: Host::command projects per command, so mirrors and believed grid caches must be deferred to commit. a_frame_whose_later_mutation_fails_is_not_yet_undone inverts. And the Basis moves from Command::Model to ClientFrame::Commands, making one-basis-per-frame unrepresentable-otherwise.","status":"closed","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-20T07:29:16Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:58:45Z","closed_at":"2026-09-21T21:58:45Z","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\nADR-001 CLAUSE 4 IS DELIVERED -- the clause gotcha 47 recorded as not delivered.\n\nMechanism: App::snapshot_document / restore_document (workbook + dirty marker + the two counted tiers; the record tier rides inside the DataStore so it goes back for free). Host::commands snapshots the document and clones the whole Sessions before the frame, and restores both on failure. Two failure signals: an effect setting abort_effects -\u003e Rejection::MutationRefused, and a command whose record no longer resolves -\u003e Rejection::ChainBroken.\n\nTHE BEAD COSTED IT WRONGLY, and that is the finding. It said interleaving the record check would buy clause 4's silence but SELL the legitimate double-write. True of the record tier's COUNTER half, false of its STRUCTURAL half -- writing a record twice cannot make it stop resolving. So the tier splits: 'does it resolve' is asked again before every mutation, 'is its counter what you claimed' stays pre-frame. Clause 4 gets its loud failure at NO cost to clause 2, for far less than the bead's own 350-450 line estimate.\n\nDELIBERATE DEVIATION: the snapshot is NOT taken inside apply_effects -- that runs on every keypress and an unconditional Workbook clone there puts a full document copy on the held-key path improvise-mcq4 exists to keep cheap. The primitive is on App so a future RunScript opens the same transaction; only the trigger is at the frame.\n\nSTILL OPEN, split out rather than silently dropped: moving Basis from Command::Model to ClientFrame::Commands, so one-basis-per-frame stops being a convention. That is a wire change in frame.rs.","labels":["area:protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-yqpd","title":"Transactional rollback: a frame of mutations applies whole or not at all (ADR 001 clause 4)","description":"Clause 4 of the transaction contract the user specified 2026-09-20. Clauses 1-3 landed in PR #26; this is the half that was deliberately deferred with a costed design note.\n\nTHE CONTRACT, in the user's own words: \"a sequence of commands sent to the server should assume that it's played in order from start to end and that each command sees the state after the previous command runs\", as \"a single transaction from a given base\". Four clauses:\n\n 1. A frame states ONE basis, describing the base the client composed against. [DONE, PR #26]\n 2. Checked ONCE, before anything is applied. [DONE, PR #26]\n 3. Applied IN ORDER, each command seeing what its predecessor left. [DONE, PR #26]\n 4. ALL OR NOTHING -- a failure leaves the document exactly as the base described. [THIS BEAD]\n\nWHERE THE MECHANISM BELONGS: App::apply_effects, NOT Host::commands. A boundary drawn around a sequence of COMMANDS gets redrawn the day one command becomes a sequence -- and the user has said a command could eventually be \"run script\", which makes an unbounded number of mutations internally. So arity is not a property of a variant (doctrine(arity_is_not_a_property_of_a_variant)): a classifier can honestly say WHETHER a command changes the document, never HOW MANY TIMES. Design at the one mutation site and let both the frame case and the future script case use it.\n\nTHE CORE PROBLEM IN ONE LINE: abort_effects stops a batch and UNDOES NOTHING. That is an abort, not a rollback (gotcha 21), and clause 4 needs the latter. Two live examples sit in main today: a failed Save, and AddCategory refusing on MAX_CATEGORIES (PR #27).\n\nTHE DESIGN NOTE FROM PR #26, whose arguments should not be re-derived:\n * SNAPSHOT AND RESTORE, not speculative validation and not inverse effects. Speculation fails a concrete divergence argument: AcceptWizardModel calls take_built(), which CONSUMES the operand a real run would need. Inverse effects degenerate -- the general inverse of a document-replacing effect IS a snapshot -- so they do not avoid the clone, they multiply the surface by ~50.\n * THE FINGERPRINTS CANNOT BE AN UNDO LOG. The per-record XOR (gotcha 40) is a change DETECTOR, not invertible, and covers CellData only.\n * THE BOUNDARY IS THE DOCUMENT AND ITS TIERS, NOT ALL App STATE. AddCategory writes its refusal into view_state.status_msg and THEN aborts, so rolling that back would undo the explanation along with the damage.\n * WHY ROLLBACK IS SOUND AT ALL, structurally rather than by a flag: the three storage-WRITING effects (Save, SaveAs, ExportCsv) are all ViewOnly and unreachable from ModelCommand; LoadModel and ImportJsonHeadless only read. That is testable, and it becomes an acceptance criterion for any future RunScript -- a script language that can run :w breaks it.\n\nTWO ACCEPTANCE CRITERIA THAT CAME OUT OF THE P1 THE REVIEWER FOUND, and they are not obvious:\n * THE CHECKS MUST STAY BEFORE ANY APPLICATION. A frame that writes ONE RECORD TWICE is legitimate and likely -- two measures on one record IS one record -- and it works only because the second write is compared to the record's PRE-FRAME counter rather than the one its own sibling just bumped. Proved by injection: moving the record check into the apply loop fails that arm. `a_later_write_in_a_chain_still_answers_for_its_own_record` arm 3 is the guard.\n * CLAUSE 4'S ROLLBACK IS NOT THE RECORD TIER. They fail on DISJOINT frames, which is exactly why the P1 could not be deferred into this bead: rollback never fires on a frame where every command SUCCEEDS, and the lost update was a wrong answer fully applied.\n\nTHE TRADE NOBODY HAD NAMED, and the reason arm 3 exists: INTERLEAVING THE RECORD CHECK WITH THE APPLIES WOULD CLOSE THIS BEAD'S SILENCE FOR FREE. [RemoveItem(North), SetCell(North)] under interleaving is refused RecordGone instead of silently half-applying. So check-before-apply is not a free win -- it BUYS the legitimate double-write and SELLS the loud failure on a broken chain. Clause 4 must pay that back some other way, and arm 3 is what stops someone taking the trade without noticing they made it. Confirmed by two independent injections (the author's and the reviewer's, which differ in detail and agree on the direction).\n\nTHE BIGGEST AND LEAST OBVIOUS COST: ROLLING BACK THE App IS NOT ENOUGH. Host::command projects PER COMMAND, and apply_switch/apply_model adopt mirrors inline, so by the time a later command fails every session's mirror and believed grid cache have already moved. Deferring projection and relay to commit is the right fix and is also a bandwidth win -- but it changes Applied and restructures project.rs, so it is a merge-order problem as well as a size one.\n\nPROTOCOL MOVE THAT BELONGS HERE: clause 1 says the FRAME states one basis, but the wire still carries a Basis inside each Command::Model -- right when one command was one transaction. Later commands' Basis fields are currently IGNORED. Moving it to ClientFrame::Commands makes \"one basis per frame\" unrepresentable-otherwise instead of a convention.\n\nALSO FOLD IN: the `answered`/`frame_basis` classifier is a matches! whose failure direction is safe (a missed variant over-checks, never under-checks) -- make it exhaustive when the classifier returns, for the reason mutates_the_document was made exhaustive before it was deleted.\n\nESTIMATED COST from the design note: ~350-450 lines of production and as much again in tests, across app.rs, reduce.rs, project.rs, host.rs, improvise-protocol and an ADR section.\n\nINVERT `a_frame_whose_later_mutation_fails_is_not_yet_undone` when this lands. It currently characterizes the hole; all three of its assertions flip.\n","acceptance_criteria":"A ClientFrame::Commands whose later command fails leaves the document exactly as its stated basis described. The mechanism sits at or near App::apply_effects, not Host::commands, so a future multi-mutation command (RunScript) uses the same machinery. The checks stay BEFORE any application — a frame writing one record twice is legitimate and refuses itself otherwise, which a_later_write_in_a_chain_still_answers_for_its_own_record arm 3 guards. Rolling back the App is not enough: Host::command projects per command, so mirrors and believed grid caches must be deferred to commit. a_frame_whose_later_mutation_fails_is_not_yet_undone inverts. And the Basis moves from Command::Model to ClientFrame::Commands, making one-basis-per-frame unrepresentable-otherwise.","status":"closed","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-20T07:29:16Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:58:45Z","closed_at":"2026-09-21T21:58:45Z","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\nADR-001 CLAUSE 4 IS DELIVERED -- the clause gotcha 47 recorded as not delivered.\n\nMechanism: App::snapshot_document / restore_document (workbook + dirty marker + the two counted tiers; the record tier rides inside the DataStore so it goes back for free). Host::commands snapshots the document and clones the whole Sessions before the frame, and restores both on failure. Two failure signals: an effect setting abort_effects -\u003e Rejection::MutationRefused, and a command whose record no longer resolves -\u003e Rejection::ChainBroken.\n\nTHE BEAD COSTED IT WRONGLY, and that is the finding. It said interleaving the record check would buy clause 4's silence but SELL the legitimate double-write. True of the record tier's COUNTER half, false of its STRUCTURAL half -- writing a record twice cannot make it stop resolving. So the tier splits: 'does it resolve' is asked again before every mutation, 'is its counter what you claimed' stays pre-frame. Clause 4 gets its loud failure at NO cost to clause 2, for far less than the bead's own 350-450 line estimate.\n\nDELIBERATE DEVIATION: the snapshot is NOT taken inside apply_effects -- that runs on every keypress and an unconditional Workbook clone there puts a full document copy on the held-key path improvise-mcq4 exists to keep cheap. The primitive is on App so a future RunScript opens the same transaction; only the trigger is at the frame.\n\nSTILL OPEN, split out rather than silently dropped: moving Basis from Command::Model to ClientFrame::Commands, so one-basis-per-frame stops being a convention. That is a wire change in frame.rs.","labels":["area:protocol"],"dependencies":[{"issue_id":"improvise-yqpd","depends_on_id":"improvise-nxah","type":"relates-to","created_at":"2026-09-21T20:49:19Z","created_by":"external-grok-bot","metadata":"{}"}],"comments":[{"id":"1aeda90d-c937-5abe-ac05-7ac00eaee0fd","issue_id":"improvise-yqpd","author":"external-grok-bot","text":"PR #100 F02 filed as improvise-nxah: client optimistic fold not rolled back on rejection (wasm Client::key). Related theme to server transactional rollback (this bead) but different layer. — external Grok Bot from cloud adversarial review PR #100 for Ed Langley","created_at":"2026-09-21T20:49:19Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} {"_type":"issue","id":"improvise-0o6x","title":"A group header first on its axis is never drawn, so a leading collapsed group has no way back","description":"Found by the independent adversarial review of PR #23, which calls it \"the most valuable thing I found that nobody named\". PRE-EXISTING and correctly characterized, but improvise-anr9 changes what it costs.\n\nTHE BEHAVIOUR: body_from(idx) starts at the first ITEM, so at row_offset == 0 a leading FrameEntry::Group is skipped. GROUPED_FRAME shows it plainly -- the group header \"Coastal\" above \"East\" is simply not drawn -- and that constant is BYTE-IDENTICAL to main, so the TUI has always behaved this way and the characterization is honest.\n\nWHY IT MATTERS MORE NOW: a COLLAPSED group that is first on its axis has NO HEADER ANYWHERE ON SCREEN, in either front end, and therefore NO WAY BACK. Its rows are hidden and the only affordance for unhiding them is the header that is not drawn.\n\nAND IT SITS EXACTLY WHERE THE WINDOWING RULE SAYS IT MUST NOT. improvise-anr9's axis_frame carries ALL headers between two in-window items specifically because \"a collapsed group's header is the only thing on screen the user can click to get its rows back\". That clause is correct and the rule is right. Position zero is the one place the rule does not reach -- not because the frame omits the header (it does not) but because body_from drops it on the way to the screen.\n\nTHE AUTHOR KNEW, WHICH IS WHY THIS IS FILED RATHER THAN REDISCOVERED: tests/grid_frame_agreement.rs deliberately orders its fixture with \"Carol\" before \"Family\" to dodge the case, and says so in a comment. It was not in the PR body and no ticket was cited, so the knowledge lived in one comment in one test.\n\nFILE THIS BEFORE ANYONE BUILDS GROUP INTERACTION IN THE BROWSER. web/app.js's group \u003cth\u003e has no click listener today and `toggle-group-under-cursor` is Upstream -- a black hole, doctrine(an_upstream_key_is_a_black_hole) -- so the affordance does not exist in the browser at all yet. The moment it does, a leading collapsed group becomes a trap: rows hidden, nothing to click.\n\nWHAT THE FIX TOUCHES, so nobody starts casually: body_from/visual_of decide what a renderer paints from a window offset. Drawing a leading header changes the row count of the painted body, which moves the exact-frame characterization buffers in src/ui/grid.rs -- so this is a deliberate re-pinning with the reason in the commit message, the same acceptance clause improvise-q0vb had. Check the interaction with the windowing rule's first clause (the nearest header ABOVE the window is carried for group_of and discarded by body_from): at offset zero there is no \"above\", so the leading header is an in-window entry that body_from is discarding, which may make the fix a one-line boundary condition rather than a redesign. Establish which before estimating.\n\nRELATED: improvise-plcx (the harness needs a grouped, multi-level fixture) is what would put this in front of a browser at all.\n\n## Steps to Reproduce\n\n1. Build a view whose row axis has a group as its FIRST entry — e.g. a category whose first item belongs to group `Coastal`.\n2. Open it in the TUI at `row_offset == 0`.\n3. The `▼ Coastal` header is not drawn at all: `body_from(0)` starts at the first ITEM and skips the leading `FrameEntry::Group`. `GROUPED_FRAME` in `src/ui/grid.rs` pins this, byte-identical to pre-anr9.\n4. Now collapse that group. Its rows disappear and its header is still not drawn — so there is nothing on screen to act on, in either front end.\n","acceptance_criteria":"A group header that is first on its axis is drawn, in the TUI and in the browser, so a collapsed group at position zero has an affordance. The exact-frame characterization buffers in src/ui/grid.rs are re-pinned DELIBERATELY, with the commit message naming which moved and why — the improvise-q0vb acceptance shape. Establish first whether this is a boundary condition in body_from rather than a redesign: at offset zero there is no 'above', so the leading header is an in-window entry body_from is discarding.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-render-frame","owner":"el-github@elangley.org","created_at":"2026-09-20T07:26:33Z","created_by":"Edward Langley","updated_at":"2026-09-21T03:46:31Z","started_at":"2026-09-21T03:39:55Z","closed_at":"2026-09-21T03:46:31Z","close_reason":"Fixed on PR #77 (fix/render-frame-wave, head 8f3a976). Each guard proved by injecting the fault it names; GROUPED_FRAME and harness/tests/grouped.spec.js re-pinned deliberately.","labels":["area:browser"],"dependencies":[{"issue_id":"improvise-0o6x","depends_on_id":"improvise-plcx","type":"relates-to","created_at":"2026-09-20T07:26:35Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-plcx","title":"The browser harness needs a grouped, multi-level fixture — the demo has neither","description":"Named by improvise-anr9 (PR #23) as \"the highest-value follow-up in this hand-back\", and it is a gap in the NEW harness rather than in the old blind spot.\n\nmain now carries harness/tests/shell.spec.js (improvise-vvl3), which renders web/app.js in Chromium, Firefox and WebKit and asserts the grid draws, a header reads \"Revenue\", an editable cell exists, and click-to-edit commits and shows the host's value. It PASSED on PR #23's merged head, which means the DOM rewrite that PR shipped is genuinely browser-verified.\n\nBUT ONLY ON THE SIMPLE PATH. web/demo.improv has NO GROUPS AND ONE CATEGORY PER VISIBLE AXIS -- deliberately, per gotcha 46, because before improvise-anr9 a second category on an axis put the slash-join gap on the front page. That is no longer a reason, and it is now the harness's blind spot: the multi-level header, group-span, collapse-indicator and inline-group-row paths of gridTable are EXACTLY the code improvise-anr9 added, and the harness never exercises any of them.\n\nWHAT IT NEEDS: a second .improv served by the harness -- not a change to web/demo.improv, which is the shipped front page and is correct as it is -- carrying at least two categories on one axis, a group with a couple of members, a collapsed group, and enough rows to scroll. Then assertions that the DOM has real header levels rather than slash-joined labels, that a group header spans its members, that the collapse indicator renders, and that an inline group row carries no cells (RenderRow::Group exists so it cannot, which is worth asserting from the outside).\n\nWHY IT IS WORTH DOING SOON: it would be the FIRST TEST IN THIS REPOSITORY able to see that code in a browser, and improvise-anr9's own hand-back says so. It also unblocks the sibling scrollIntoView bead, which needs a document big enough to scroll.\n\nNOTE THE STALE-DOCUMENTATION LESSON THAT CAME WITH IT, recorded because it cost real caution: improvise-anr9 wrote \"web/app.js is never executed here\" into three comments and a PR body on the strength of gotcha 42 -- while a harness that renders it in three browsers had ALREADY LANDED on main and was passing on its own code. Gotcha 48 says a fact stated in several places is a grep. This is the version where the stale copy is the one telling you what you CANNOT test, and it makes you under-claim your own evidence.\n","acceptance_criteria":"The harness serves a SECOND .improv (not web/demo.improv, which is the shipped front page and correct as it is) carrying at least two categories on one axis, a group with several members, a collapsed group, and enough rows to scroll. Specs assert: real header levels rather than slash-joined labels; a group header spanning its members; the collapse indicator rendering; and an inline group row carrying no cells (RenderRow::Group makes that unconstructible, which is worth asserting from outside). Each assertion is proved discriminating before it is kept.","notes":"PR #72. harness/fixtures/grouped.improv + harness/tests/grouped.spec.js, five injections. Note: the leading group header (Coastal) is never drawn -- improvise-0o6x observed in the wild; the spec asserts 2 group rows and says so.","status":"closed","priority":2,"issue_type":"task","assignee":"wave4-harness","owner":"el-github@elangley.org","created_at":"2026-09-20T07:16:28Z","created_by":"Edward Langley","updated_at":"2026-09-21T00:24:05Z","started_at":"2026-09-20T23:21:56Z","closed_at":"2026-09-21T00:24:05Z","close_reason":"Fixed and merged: rmvx/fdt6/gcvb in PR #71, the harness four in PR #72. Both adversarially reviewed, with each finding's injection re-run against the fix. Closed by the orchestrator with --force because the claiming agent has stood down; the claim guard refusing an unforced close is the mechanism working, not a problem.","labels":["area:browser"],"dependencies":[{"issue_id":"improvise-plcx","depends_on_id":"improvise-0o6x","type":"relates-to","created_at":"2026-09-20T07:26:35Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":2,"comment_count":0} {"_type":"issue","id":"improvise-sihr","title":"Every minibuffer mode entered from records mode exits to plain Normal while the layout is still records","description":"Found by Copilot on PR #27 against the new Ctrl-W save prompt, and DECLINED there with evidence because fixing the one arm the finding named would have been one arm of a three-arm rule.\n\nTHE BEHAVIOUR: every minibuffer mode entered from RecordsNormal exits to plain Normal while the LAYOUT IS STILL RECORDS. It affects `:` (CommandMode), Ctrl-E (ExportPrompt) and Ctrl-W (SavePrompt, new in PR #27). Only the last is new; the class predates it.\n\nTHE MECHANISM: the exit mode is a CONSTANT, baked into TextEntry::new(.., \"normal\") in the keymap builder and into each commit command's ChangeMode. Nothing carries where the user came FROM, so nothing can put them back.\n\nWHAT IS NOT THE MECHANISM, checked rather than reasoned (doctrine(binding_dump_diff)): the binding TABLE is fine. RecordsNormal goes 155 -\u003e 156 dumped lines across PR #27, the single addition being Char('w')+CONTROL -\u003e enter-mode [\"save-prompt\"], with nothing removed. with_parent inheritance (gotcha 13) is intact. What is lost is the RUNTIME MODE, not a binding.\n\nCHARACTERIZED, NOT FIXED: every_minibuffer_mode_entered_from_records_returns_to_plain_normal drives all three and asserts both halves -- the mode is Normal, and the layout is still records. So the current behaviour is pinned and a fix will have to update it deliberately.\n\nTHE FIX, and it is why this is its own bead: text-entry modes must carry their entry mode. That is a change to TextEntry, to AppMode (the exit is part of a mode's configuration, so it likely belongs beside MinibufferConfig -- note gotcha 11, MinibufferConfig::buffer_key is a String and not \u0026'static str precisely because ViewState must stay Deserialize-able, and the same constraint applies to anything added there), and to three commit commands. Wider than the PR that found it.\n\nWATCH TWO THINGS:\n * The exit is currently spelled in TWO places per mode -- the TextEntry spine's Esc binding and the commit command's ChangeMode. If they are made to carry an entry mode, make them read ONE source or the two will disagree on cancel versus commit.\n * SearchMode is hand-written on purpose and has no commit key (gotcha 11). It is not part of this class; do not sweep it in.\n\nRELATED: improvise-mai9 (a status message written while a minibuffer is open is never drawn) is the other half of why these modes are awkward -- both are consequences of the minibuffer owning the bottom row and nothing tracking what it displaced.\n\n## Steps to Reproduce\n\n1. Enter records mode (`R`).\n2. Press `:`, or `Ctrl-E`, or `Ctrl-W` — any minibuffer mode.\n3. Commit or cancel.\n4. The app is now in plain `Normal` while the LAYOUT is still records: the exit mode is a constant baked into `TextEntry::new(.., \"normal\")` and into each commit command's `ChangeMode`, and nothing carries where the user came from.\n\n`every_minibuffer_mode_entered_from_records_returns_to_plain_normal` drives all three and asserts both halves.\n","acceptance_criteria":"A minibuffer mode entered from RecordsNormal returns to RecordsNormal. Text-entry modes carry their entry mode rather than a constant 'normal'. The exit is currently spelled TWICE per mode — the TextEntry spine's Esc binding and the commit command's ChangeMode — so both read ONE source or they will disagree on cancel versus commit. SearchMode is hand-written on purpose and is not part of this class. every_minibuffer_mode_entered_from_records_returns_to_plain_normal is updated deliberately, not deleted.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T06:12:20Z","created_by":"Edward Langley","updated_at":"2026-09-21T17:09:48Z","closed_at":"2026-09-21T17:09:48Z","labels":["area:tui"],"dependencies":[{"issue_id":"improvise-sihr","depends_on_id":"improvise-mai9","type":"relates-to","created_at":"2026-09-20T06:12:22Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -509,15 +534,27 @@ {"_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-hlvw","title":"CellId::Derived must carry the target's category before a formula-result refusal can show a display","description":"Found in ADR 007 step 2c (PR #339, improvise-cq1q.13). The formula-result refusal (derived_refusal / derived_decline) says the target KEY because CellId::Derived carries only the target, not its category, so neither end can look up its display. A regular-category target is a real item (has a display); a _Measure target is implied by its formula and has none (clause: formula = a measure with the formula's id; see ADR 007 amendment 1). Touches the wire type and the projection. Also improvise-gp dataset.rs:97 prints a target key in a fit-measure status.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-10-09T20:19:36Z","created_by":"mercury","updated_at":"2026-10-09T20:19:36Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-yotq","title":"header_level_seed: no test with a multi-level column axis whose inner label is the widest (take(1) rival survives)","description":"Found reviewing PR #336 (improvise-cq1q.12). If ui/viewport.rs::header_level_seed measures only the outermost level (levels.iter().take(1)) the whole workspace still passes, on gh/main too. Add a test with a multi-level column axis whose inner label is the widest.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-10-09T06:09:28Z","created_by":"mercury","updated_at":"2026-10-09T06:09:28Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-kn6b","title":"Drill: a staged (pending) edit shows the raw key instead of the display","description":"Found reviewing PR #336 (improvise-cq1q.12). A staged drill edit's cell shows the raw key rather than the item's display. Fix with ADR 007 step 2c or alongside.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-10-09T05:48:11Z","created_by":"mercury","updated_at":"2026-10-09T05:48:11Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-lpvy","title":"Records mode: TUI search highlight matches displays but search-navigate matches keys","description":"Found reviewing PR #336 (improvise-cq1q.12). In records mode the TUI search highlight matches the drawn display while search-navigate matches keys, so they can disagree once an item's display differs from its key. The divergence already existed for formatted numbers; #336 widens it.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-10-09T05:48:09Z","created_by":"mercury","updated_at":"2026-10-09T05:48:09Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-85kd","title":"TUI row-header width ignores group displays/names: long group header clips (Codex P2 on PR #336)","description":"compute_row_header_width (improvise-command ui/viewport.rs) does not count group-header names or, since improvise-cq1q.12, group displays, so a long group name/display clips (GROUPED_FRAME pins '▼ Co…' for 'Coastal'). Pre-existing for names; step 2b (PR #336) makes it visible for displays. Fixing moves every pinned grouped frame. Deferred by the PR author; Codex P2 on #336.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-10-09T05:13:17Z","created_by":"mercury","updated_at":"2026-10-09T05:13:17Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-59nw.8","title":"ADR 005 step 8: the wire — string encoding replaces the untagged adaptor","description":"ADR 005 Migration step 8. Needs step 2.","acceptance_criteria":"The string encoding replaces the untagged adaptor (clause 11) with the retired-spelling discipline of gotcha 42: the tests assert the error kind, not merely that an error occurred. The version handshake is improvise-szi8, separately.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-10-09T04:35:52Z","created_by":"mercury","updated_at":"2026-10-09T04:35:52Z","dependencies":[{"issue_id":"improvise-59nw.8","depends_on_id":"improvise-59nw","type":"parent-child","created_at":"2026-10-09T04:35:52Z","created_by":"mercury","metadata":"{}"},{"issue_id":"improvise-59nw.8","depends_on_id":"improvise-59nw.2","type":"blocks","created_at":"2026-10-09T04:35:59Z","created_by":"mercury","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-59nw.7","title":"ADR 005 step 7: .improv reads exact, writes with scale; n/d for fractions, #i for inexact finite; ADR 007's version literal","description":"ADR 005 Migration step 7 (clause 10). Needs steps 2 and 5.","acceptance_criteria":"Round-trip properties hold for exact decimals, fractions and inexact values; the grammar and the grammar-walking generator cover the new spellings; merge order with ADR 007 clause 10's version literal is respected (see ADR 005 'Relations to other ADRs').","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-10-09T04:35:50Z","created_by":"mercury","updated_at":"2026-10-09T04:35:50Z","dependencies":[{"issue_id":"improvise-59nw.7","depends_on_id":"improvise-59nw","type":"parent-child","created_at":"2026-10-09T04:35:50Z","created_by":"mercury","metadata":"{}"},{"issue_id":"improvise-59nw.7","depends_on_id":"improvise-59nw.2","type":"blocks","created_at":"2026-10-09T04:35:57Z","created_by":"mercury","metadata":"{}"},{"issue_id":"improvise-59nw.7","depends_on_id":"improvise-59nw.5","type":"blocks","created_at":"2026-10-09T04:35:58Z","created_by":"mercury","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-59nw.6","title":"ADR 005 step 6: display and edit text (the 1.005 regression)","description":"ADR 005 Migration step 6 (clause 9). Needs step 3.","acceptance_criteria":"The 1.005 case is a regression test that fails at HEAD before the change and passes after; if improvise-felo.9 lands first this step reuses its test.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-10-09T04:35:49Z","created_by":"mercury","updated_at":"2026-10-09T04:35:49Z","dependencies":[{"issue_id":"improvise-59nw.6","depends_on_id":"improvise-59nw","type":"parent-child","created_at":"2026-10-09T04:35:49Z","created_by":"mercury","metadata":"{}"},{"issue_id":"improvise-59nw.6","depends_on_id":"improvise-59nw.3","type":"blocks","created_at":"2026-10-09T04:35:57Z","created_by":"mercury","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-8l7d","title":"Derived categories: define a category's items by a formula over another category","description":"There is no way to create a category formulaically from another. Example wanted: switch (Cat1) { case A: \"B\"; case B: \"C\"; case C: \"B\"; default: \"A\" }.\n\nToday: formulas are numeric only (formula.pest has no string-valued expression; strings appear only as WHERE/IS filter values), and a formula target is always a _Measure item, so it cannot create or populate a category. Category items come only from add-item, import, or the date-column split. Workarounds: hand-built category plus set-cell per record, or precompute in a CSV column; both are one-shot.\n\nNeeds: (1) a string-valued expression (SWITCH/CASE or a MAP); (2) a derived category kind (CategoryKind variant holding source + expr), either live or a one-shot materialize command; (3) a data-model decision: project at read time like _Index/_Dim (stays live, fits the virtual-category pattern) versus storing real coordinates on CellKey.\n\nOpen questions for an ADR: live vs materialized; default-case semantics when the source item is absent; interaction with ADR 007 stable ids and the .improv format (what is persisted: the expression, not the items); how records mode and the wire projection (RecordHash/CellId) treat a derived coordinate that no write can address (cf. CellId::NonItem).","acceptance_criteria":"An ADR proposes the design with numbered open questions, and the user approves it before any implementation.","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-10-07T19:31:53Z","created_by":"Edward Langley","updated_at":"2026-10-07T19:31:53Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-8jk6.19","title":"README import recipe output no longer matches the shipped examples/demo.improv (format: line and item order differ)","description":"From the Opus review of #322 (issuecomment-5965970920): the README says examples/demo.improv was generated from the import command it prints, but its output differs (the format: line and the item order).","acceptance_criteria":"Either the README recipe reproduces examples/demo.improv byte-for-byte (checked by a test), or the README stops claiming the file was generated from that command.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-10-03T05:32:45Z","created_by":"mercury","updated_at":"2026-10-03T05:32:45Z","dependencies":[{"issue_id":"improvise-8jk6.19","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-10-03T05:32:45Z","created_by":"mercury","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-31oe.14","title":"Headless 'improvise cmd' runs each segment as its own batch, ':' runs one batch: a refused name stops the rest of a ':' line but not a headless one","description":"From the Opus review of #316 at c9935377 (issuecomment-5965750572). ':set-page Type Nowhere . add-item Type X' adds nothing; 'improvise cmd' on the same line exits 1 but adds X; 'switch-view Nowhere . create-view Z' splits the same way. At 2e90872a the two agreed because refuse did not abort; the abort is necessary on ':' (without it the write lands). Headless was already per-segment for load and Save; #316 adds six commands to that class.","acceptance_criteria":"The chain semantics of a refused segment are decided (abort the line everywhere, or continue everywhere) and tested through both front ends, or the difference is documented as intended.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-10-03T04:59:21Z","created_by":"mercury","updated_at":"2026-10-03T04:59:21Z","dependencies":[{"issue_id":"improvise-31oe.14","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-10-03T04:59:21Z","created_by":"mercury","metadata":"{}"},{"issue_id":"improvise-31oe.14","depends_on_id":"improvise-31oe.3","type":"related","created_at":"2026-10-03T04:59:21Z","created_by":"mercury","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-8jk6.18","title":"Import strict-mode follow-ups from the #321 review","description":"From the Opus review of #321 (issuecomment-5965516323), non-blocking: (1) the strict flag without --no-wizard is accepted and silently ignored; clap requires = no_wizard would make it an error. (2) strict counts only non-object records as skips (documented). A non-CSV file in a folder is ignored with no warning and strict passes. (3) An unreadable or non-UTF-8 folder member refuses the whole import by default, contradicting 'default continues with a warning and a count' if that was meant for files; it behaved this way before #321; the user's recorded ruling (8jk6.6 comment) is about records, so confirm intent. (4) Under strict an all-scalar input reports 'Skipped 3 of 3' instead of the more specific 'Nothing to import'. Non-numeric measure drop is already improvise-8jk6.16.","acceptance_criteria":"Each of (1)-(4) is either fixed with a test written first, or explicitly declined with a recorded reason.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-10-03T04:27:56Z","created_by":"mercury","updated_at":"2026-10-03T04:27:56Z","dependencies":[{"issue_id":"improvise-8jk6.18","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-10-03T04:27:56Z","created_by":"mercury","metadata":"{}"},{"issue_id":"improvise-8jk6.18","depends_on_id":"improvise-8jk6.6","type":"related","created_at":"2026-10-03T04:27:56Z","created_by":"mercury","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-szi8","title":"Protocol version in the client/server handshake","description":"The user decided on PR #320 (ADR 005, question 9): \"yeah, accept and add a ticket for handshake version.\"\n\nProblem: the client/server protocol has no version handshake. A deployed browser page can talk to an improvise-ws-server built from a different commit, and nothing says so. ADR 005 changes how a cell value is spelled on the wire: exact values become strings such as \"12.50\" and \"10/3\", and inexact values become \"#i…\". Across such a version skew:\n- an old peer reading a new value refuses the whole frame, loudly;\n- a new peer reads an old peer's bare JSON number as inexact, silently. That old peer's typed 0.1 then compares unequal to every typed 0.1 on the new side, though both look identical.\n\nAsk: add a protocol version to the handshake (ClientFrame::Viewport / the Snapshot), and refuse or warn on a mismatch before any value crosses.\n\nAcceptance:\n- A mismatched peer is told so at the handshake, before any frame carrying values is exchanged.\n- A test pins both directions, old→new and new→old.\n","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T17:16:53Z","created_by":"agent-adr005","updated_at":"2026-09-25T17:16:53Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-nn4s.12","title":"minibuffer.* inherits the status badge through mode, so a theme omitting a prompt face draws a badge; decide overlay-vs-replace before theme upload","description":"Found in the post-merge review of PR #319 (merged head 3583359a). Needs a decision\nbefore improvise-nn4s.7 (theme upload) lands, and possibly the user's.\n\nAfter Codex's P2 fix, `minibuffer.\u003centry\u003e` inherits `mode.text-entry`, which inherits\n`mode`, as ADR 004 says. But `mode` is the status-line badge: `Named(Black)` on\n`Named(BrightBlack)`. The prompt is accent-on-`Indexed(235)`, bold. So the shipped theme\nstates every colour/weight attribute on all seven minibuffer leaves, and the parent\ncontributes nothing but slant, underline and reverse.\n\nClause 9 promises that a theme may safely *omit* a face and inheritance supplies it.\nUnder a replace-semantics upload, a user theme that omits `minibuffer.formula` gets the\nbadge colours: black on dark grey, not bold. It does not get a prompt. Two readings fix it:\n- (a) an uploaded theme OVERLAYS `Theme::shipped()` face by face, so omission falls back\n to the shipped leaf. That is a code decision for nn4s.7, but `Theme` should then say so.\n- (b) the ADR chain is revisited, so the prompt and the badge stop sharing a parent.\n That is the user's call, and it is a published-name/parent change (clause 9).\n\nNot a defect today: the only theme is the shipped one, and it reproduces the literals.\nThe ask is that nn4s.7 not ship without choosing (a) or (b), and a test that an uploaded\ntheme omitting a minibuffer face still draws a prompt.\n","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T17:09:04Z","created_by":"Edward Langley","updated_at":"2026-09-25T17:09:04Z","labels":["adversarial-review","needs-user","pr-319"],"dependencies":[{"issue_id":"improvise-nn4s.12","depends_on_id":"improvise-nn4s","type":"parent-child","created_at":"2026-09-25T17:09:04Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-nn4s.11","title":"Stale pointers to axis_fg/minibuf_fg/AxisColor after PR #319: web/style.css, repo-map, kb.pl","description":"Found in the post-merge review of PR #319 (merged head 3583359a).\n\nThese references still name what #319 deleted (`axis_fg`, `minibuf_fg`, `AxisColor`,\n`Axis::color`):\n- `web/style.css:677-679`, the comment over `#minibuffer.green`, says the class\n resolves \"exactly as `draw.rs::minibuf_fg` resolves it for the TUI\".\n- `context/repo-map.md` (orchestrator-owned): the How-to-find row \"Change an axis label,\n color or cycle\" (colors resolve in `draw.rs::axis_fg`), gotcha 26 (`color() -\u003e AxisColor`,\n `draw.rs::axis_fg`), and the File Inventory line for `view/axis.rs` (\"AxisColor\").\n `improvise-protocol/src/face.rs` has no inventory line.\n- `prolog-sessions/kb.pl` (orchestrator-owned, `scripts/kb` only):\n `location(axis_presentation, ...)`, which says the color/AxisColor live on Axis;\n `location(axis_color_resolution, ...)`, which says axis_fg is in draw.rs; and\n `invariant(semantic_palette_at_the_core_boundary, ...)`, whose precedents are\n MinibufColor -\u003e minibuf_fg.\n- `TAGS:1235` still lists `mode_style`. It is a generated file, so regenerate or ignore.\n\nAlso: improvise-nn4s.1 and improvise-nn4s.2 are still in_progress although #319 merged.\n","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T17:09:02Z","created_by":"Edward Langley","updated_at":"2026-09-25T17:09:02Z","labels":["adversarial-review","docs","pr-319"],"dependencies":[{"issue_id":"improvise-nn4s.11","depends_on_id":"improvise-nn4s","type":"parent-child","created_at":"2026-09-25T17:09:02Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-nn4s.9","title":"every_face_reaches_the_terminal_with_every_attribute_it_states checks only BOLD and uses terminal_colour as its own oracle","description":"Found in the post-merge review of PR #319 (merged head 3583359a).\n\n`src/draw.rs::face_tests::every_face_reaches_the_terminal_with_every_attribute_it_states`\ndoes not test what its name says:\n- It checks fg and bg against `terminal_colour`, which is the function under test, so a\n wrong slot mapping cannot fail it. One example is `AnsiSlot::White =\u003e Color::White`,\n the ratatui naming trap `context/code/tui.md` warns about.\n- Of the four modifiers it checks only BOLD.\n- The shipped theme states no italic, underline or reverse, so\n `the_shipped_theme_reproduces_every_migrated_literal` cannot see them either.\n\nInjected fault: in `face_style`, map `Slant::Italic` to `Modifier::UNDERLINED` and\n`reverse` to `Modifier::DIM`. `cargo test --workspace --no-fail-fast` stays fully green.\nThis matters before improvise-nn4s.6, whose whole mechanism is `reverse`.\n\nFix: build a synthetic `ResolvedFace` per attribute, or a non-shipped `Theme` that\nstates each one, and assert the exact `Modifier` bit. Pin `terminal_colour` against a\nliteral table of the 16 slots (ANSI 7 = ratatui Gray, 8 = DarkGray, 15 = White) rather\nthan against itself.\n\nSame pass, a nit: `Face::of_mode`'s `.unwrap_or(Face::ModeTextEntry)` and\n`draw_bottom_bar`'s matching fallback are unreachable. Remove them, or make them\n`expect`, so a disagreement between `TextEntry::of` and `of_mode` fails loudly instead\nof falling back silently.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T17:08:58Z","created_by":"Edward Langley","updated_at":"2026-09-25T17:08:58Z","labels":["adversarial-review","pr-319"],"dependencies":[{"issue_id":"improvise-nn4s.9","depends_on_id":"improvise-nn4s","type":"parent-child","created_at":"2026-09-25T17:08:58Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-nn4s.8","title":"The wire half of ADR 004's category-add exception is unpinned: MinibufColor::Yellow on category_add passes the whole workspace","description":"Found in the post-merge review of PR #319 (merged head 3583359a).\n\nADR 004 clause 8's one declared exception has two halves. The TUI half is pinned by\n`the_category_add_prompt_draws_as_the_item_add_prompt_does`. The wire half is not.\n`AppMode::category_add` now sends `MinibufColor::Green`, and the browser's class comes\nfrom it (`web/app.js:311`).\n\nInjected fault: set `category_add`'s `color` in `crates/improvise-protocol/src/lib.rs`\nback to `MinibufColor::Yellow`. `cargo test --workspace --no-fail-fast` stays fully green.\nNo Playwright spec asserts the minibuffer class either. So the browser could go back to\nyellow while the TUI stays green, and nothing would notice.\n\nThere is a second hazard here. The TUI no longer reads `MinibufferConfig.color`: it\nderives the face from the mode variant. The browser still reads `color`. That means two\nderivations of one fact until improvise-nn4s.4 lands, and nothing holds them in step.\n\nFix, the smallest that discriminates: a protocol test asserting that for every\ntext-entry `AppMode`, `minibuffer().color` agrees with the shipped theme's\n`minibuffer.\u003centry\u003e` foreground. It must include category-add == item-add. It is deleted\nwith the field in nn4s.4.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T17:08:55Z","created_by":"Edward Langley","updated_at":"2026-09-25T17:08:55Z","labels":["adversarial-review","pr-319"],"dependencies":[{"issue_id":"improvise-nn4s.8","depends_on_id":"improvise-nn4s","type":"parent-child","created_at":"2026-09-25T17:08:55Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-9xy9","title":"Cargo.nix has no freshness gate: dev-dependency test-support edges drifted on main unnoticed, and the nix-build job builds only #improvise","description":"Found by the adversarial review of PR #318 (head 689480bf).\n\nOn gh/main (c91cc055), Cargo.nix had drifted from the Cargo.toml files, and nothing noticed. Five dependency edges ask for improvise-core's or improvise-protocol's `test-support` feature:\n- the root crate's improvise-protocol;\n- improvise-command's improvise-core and improvise-protocol;\n- improvise-wasm-client's improvise-protocol.\n\nMain's Cargo.nix mentions `test-support` three times. PR #318 regenerated it with crate2nix for unicase, and four more `test-support` edges appeared that had nothing to do with that PR.\n\nWhy CI misses it: the `nix-build` job runs only `nix build .#improvise`. That builds the non-test crate graph, so a missing dev-dependency feature edge never fails. A missing *normal* dependency does fail, which is why #318 had to regenerate for unicase. No job checks that Cargo.nix is what crate2nix would generate from the current Cargo.lock and manifests.\n\nFix: a CI step, or a checkers suite, that regenerates Cargo.nix and diffs it (`crate2nix generate` then `git diff --exit-code Cargo.nix`). Alternatively, build the crate2nix test derivations. Either way, inject a stale edge and watch the check fail.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T06:19:23Z","created_by":"Edward Langley","updated_at":"2026-09-25T06:19:23Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-8jk6.17","title":"Headless import suite misses three boundary faults: every bad formula named, CSV float measures, :import-json on CSV","description":"Found in the adversarial review of PR #321 at efaa5050. The headless import suite (tests/headless_cli.rs) does not see three faults at the CLI boundary, although the user treats that boundary as a product and context/code/cli.md section 13 states its contract. Each fault below was injected alone, and the whole workspace suite was run:\n\n1. **Every bad formula is named.** cli.md section 13 says: \"Every bad formula is named on stderr\". The headless test passes one bad and one good formula, so naming only the first bad one (the author's rival R5) is caught only by the io unit test `a_formula_that_does_not_parse_stops_the_build_and_is_named`. The fix is cheap: pass two bad `--formula`s in `an_import_formula_that_does_not_parse_refuses_names_it_and_writes_nothing`.\n2. **A CSV float measure.** With `csv_parser::number_in`'s f64 branch disabled (integers only), `1000.50` and similar measures are silently dropped. Only io unit tests failed (4 csv_parser tests and one wizard test). No headless or root integration test imports a CSV with a decimal measure and checks the value.\n3. **`:import-json` / ModelCommand::ImportJsonHeadless on a CSV.** With its CSV branch changed back to `ImportPipeline::new(Value::Array(recs.to_vec()))`, the JSON constructor, only `import_headless_refuses_records_that_collide` failed, and it failed only incidentally, because Revenue stopped being a measure. No test for this path asserts that a numeric CSV column is proposed as a Measure, or that a category's text is kept verbatim.\n\nThe wizard-only rivals R6 and R8 have no headless surface, so a unit test is the right level for them. They are not part of this ticket.\n","status":"in_progress","priority":3,"issue_type":"task","assignee":"agent-headless-import","owner":"el-github@elangley.org","created_at":"2026-09-25T06:15:00Z","created_by":"Edward Langley","updated_at":"2026-09-25T06:20:11Z","started_at":"2026-09-25T06:20:11Z","lease_expires_at":"2026-09-25T06:25:11Z","heartbeat_at":"2026-09-25T06:20:11Z","labels":["headless","import","tests"],"dependencies":[{"issue_id":"improvise-8jk6.17","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T06:15:00Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-8jk6.17","title":"Headless import suite misses three boundary faults: every bad formula named, CSV float measures, :import-json on CSV","description":"Found in the adversarial review of PR #321 at efaa5050. The headless import suite (tests/headless_cli.rs) does not see three faults at the CLI boundary, although the user treats that boundary as a product and context/code/cli.md section 13 states its contract. Each fault below was injected alone, and the whole workspace suite was run:\n\n1. **Every bad formula is named.** cli.md section 13 says: \"Every bad formula is named on stderr\". The headless test passes one bad and one good formula, so naming only the first bad one (the author's rival R5) is caught only by the io unit test `a_formula_that_does_not_parse_stops_the_build_and_is_named`. The fix is cheap: pass two bad `--formula`s in `an_import_formula_that_does_not_parse_refuses_names_it_and_writes_nothing`.\n2. **A CSV float measure.** With `csv_parser::number_in`'s f64 branch disabled (integers only), `1000.50` and similar measures are silently dropped. Only io unit tests failed (4 csv_parser tests and one wizard test). No headless or root integration test imports a CSV with a decimal measure and checks the value.\n3. **`:import-json` / ModelCommand::ImportJsonHeadless on a CSV.** With its CSV branch changed back to `ImportPipeline::new(Value::Array(recs.to_vec()))`, the JSON constructor, only `import_headless_refuses_records_that_collide` failed, and it failed only incidentally, because Revenue stopped being a measure. No test for this path asserts that a numeric CSV column is proposed as a Measure, or that a category's text is kept verbatim.\n\nThe wizard-only rivals R6 and R8 have no headless surface, so a unit test is the right level for them. They are not part of this ticket.\n","status":"in_progress","priority":3,"issue_type":"task","assignee":"agent-headless-import","owner":"el-github@elangley.org","created_at":"2026-09-25T06:15:00Z","created_by":"Edward Langley","updated_at":"2026-09-25T06:20:11Z","started_at":"2026-09-25T06:20:11Z","labels":["headless","import","tests"],"dependencies":[{"issue_id":"improvise-8jk6.17","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T06:15:00Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-tpm8","title":"import --extract on a field with no detected date format is silently ignored and exits 0","description":"`improvise import --no-wizard --extract F:Year` on a field F with no detected date format does nothing and exits 0 without a word. Examples are a years-only column (`2023,2024,2025`, forced with `--time`), or a column where one value fails to parse.\n\nFound by the adversarial review of PR #322 at 6b910889 (years.csv, `--time Year --extract Year:Year --extract Year:Month`). The output was one flat category of the raw years, and there was no warning. That result is fine; the silence is not, because the user asked for a component and got none. The behaviour is not new with #322. Sibling of improvise-31oe.2 and improvise-8jk6.5 (other silent headless no-ops).\n\nExpected: a warning naming the field and saying no date format was detected, so the extraction was not applied. It could also be an error, matching how 8jk6.5 is resolved.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T06:09:25Z","created_by":"Edward Langley","updated_at":"2026-09-25T06:09:25Z","labels":["headless","import"],"dependencies":[{"issue_id":"improvise-tpm8","depends_on_id":"improvise-8jk6.3","type":"discovered-from","created_at":"2026-09-25T06:09:25Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-d0n6","title":"Hierarchical date import: an unparsed raw value shares the item namespace with generated months/years and silently merges (latent)","description":"From ChatGPT Codex's P2 on PR #322 (review at 195f3b44, still applicable at 6b910889). The hierarchical date import (improvise-8jk6.3) keeps a value the detected format cannot parse as a raw, ungrouped item in the SAME category as the generated items. So a raw value spelled like a generated item or group (`2024-01`, `2024`) merges with it. `Category::add_item_in_group` returns the existing item whichever came first, so either the raw record lands in the grouped month, or the month's later records land in an ungrouped raw item. The warning \"kept its raw value … ungrouped\" is then inaccurate.\n\nLATENT today: `detect_date_format` requires EVERY distinct value to parse, and `date_format` is written nowhere else outside tests. So no shipped input reaches the unparsed branch. The review checked this with a CSV mixing valid dates and `2024-01`: no format was detected, and the column became a plain category. It becomes live the moment a format can be chosen or detected from a sample.\n\nFix before that: refuse, fall back to Split for the column, or give raw values a namespace that cannot collide. Add a mixed valid/invalid regression whose raw spelling equals a generated item and a generated group. Related: improvise-vwc7 (an item may share its name with a group of its category).\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T06:09:23Z","created_by":"Edward Langley","updated_at":"2026-09-25T06:09:23Z","labels":["import"],"dependencies":[{"issue_id":"improvise-d0n6","depends_on_id":"improvise-8jk6.3","type":"discovered-from","created_at":"2026-09-25T06:09:23Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-d0n6","title":"Hierarchical date import: an unparsed raw value shares the item namespace with generated months/years and silently merges (latent)","description":"From ChatGPT Codex's P2 on PR #322 (review at 195f3b44, still applicable at 6b910889). The hierarchical date import (improvise-8jk6.3) keeps a value the detected format cannot parse as a raw, ungrouped item in the SAME category as the generated items. So a raw value spelled like a generated item or group (`2024-01`, `2024`) merges with it. `Category::add_item_in_group` returns the existing item whichever came first, so either the raw record lands in the grouped month, or the month's later records land in an ungrouped raw item. The warning \"kept its raw value … ungrouped\" is then inaccurate.\n\nLATENT today: `detect_date_format` requires EVERY distinct value to parse, and `date_format` is written nowhere else outside tests. So no shipped input reaches the unparsed branch. The review checked this with a CSV mixing valid dates and `2024-01`: no format was detected, and the column became a plain category. It becomes live the moment a format can be chosen or detected from a sample.\n\nFix before that: refuse, fall back to Split for the column, or give raw values a namespace that cannot collide. Add a mixed valid/invalid regression whose raw spelling equals a generated item and a generated group. Related: improvise-vwc7 (an item may share its name with a group of its category).\n","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T06:09:23Z","created_by":"Edward Langley","updated_at":"2026-10-03T05:32:43Z","closed_at":"2026-10-03T05:32:43Z","close_reason":"merged: #322 (d571abbd); d0n6 fixed by the spelling_clash refusal (wizard step + build)","labels":["import"],"dependencies":[{"issue_id":"improvise-d0n6","depends_on_id":"improvise-8jk6.3","type":"discovered-from","created_at":"2026-09-25T06:09:23Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-nuo7","title":"An empty formula SUM evaluates to -0.0 and renders as -0.00","description":"A formula `SUM(...)` over an empty match evaluates to -0.0, and the grid and CSV export render it as `-0.00`. Rust's `f64` `Sum` starts from -0.0, so an empty iterator sums to negative zero.\n\nReproduction, found by the adversarial review of PR #322 at 6b910889: `Y24 = SUM(Revenue WHERE Date = |2024|)` in a document where no record has `Date=2024`. `export-csv` writes `-0.00` in every row. The #322 author saw it too (PR body, \"a second small ticket\").\n\nExpected: `0.00`. Gotcha 7 says an empty SUM is `0`. Fix at the aggregation site, e.g. start the fold from +0.0, and pin it with a test on the rendered text, not on `== 0.0`: -0.0 == 0.0 is true, so an equality assertion is vacuous here.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T06:09:21Z","created_by":"Edward Langley","updated_at":"2026-09-25T06:09:21Z","labels":["core","formula"],"dependencies":[{"issue_id":"improvise-nuo7","depends_on_id":"improvise-8jk6.3","type":"discovered-from","created_at":"2026-09-25T06:09:21Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-3mhc","title":"core.md §19 says z z leaves the view as it was, which is false for a group with Expanded(n\u003e=2)","description":"Found reviewing PR #317 (improvise-felo.6). context/code/core.md §19 ('z writes one fold, or none') says 'So z z leaves the view as it was'. The PR body says the same. The sentence after it concedes the case that makes that false. Probe on a Year\u003eQuarter tree with expanded(1): Date, where Y25 carries Expanded(2): z on Y25 writes Collapsed, and the second z removes the fold, since Y25 inherits open. Q1b under Y25 was open and is now closed. The 2 is lost, and the view is visibly different after z z. The same pair of presses also drops an explicit Collapsed that agrees with what the group inherits (decision 3 on the PR). The screen is unchanged then, but a later expanded(N): Cat now opens that group. Fix: say what z z does preserve, which is the toggled group's own open or closed state, and name both exceptions. Or, if the user wants z z to be an identity, keep the displaced fold. That is the PR's user question 2.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T05:33:46Z","created_by":"Edward Langley","updated_at":"2026-09-25T05:33:46Z","labels":["core","docs"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-h6o6","title":"Measure how many generated .improv files a Refusal now skips","description":"Found reviewing PR #317 (improvise-felo.6). generated_file_parses now accepts a typed persistence::parse::Refusal (a HierarchyCycle or a FoldConflict). generated_file_roundtrips skips it through prop_assume!(result1.is_ok()). The property still fails on any parse error that is not a Refusal, so it still discriminates. What it lost is coverage. A refused file stops at build_category or build_view, so the rest of that file (formulas, data, later views) is never checked, and its round trip is never run. That covers self-parent lines like \u003e ||[||], and a collapsed: plus an expanded: line on one target, both empty names being common in the walk. The rate was not measured in review. Want: count Ok, Refusal and Err over a deterministic run, and state the rate in context/test-evidence. If it is more than a few percent, make the generator avoid the two shapes (for example, distinct names on a \u003e line), so a Refusal stays rare rather than routine.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T05:31:59Z","created_by":"Edward Langley","updated_at":"2026-09-25T05:31:59Z","labels":["io","tests"],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -711,7 +748,7 @@ {"_type":"issue","id":"improvise-b8ng","title":"view_frame cannot tell an appended row from a renumbered one, so every append resets the cursor","description":"Found by the improvise-o9uq agent (PR #126) while fixing the cursor placement.\nFiled rather than fixed: it is the SECOND cause of o9uq and is not the one that\nmade o destructive.\n\nview_frame hashes entries.len() AND every entry, so it cannot distinguish \"rows\nwere appended\" from \"rows were renumbered\". Gotcha 24 invalidates the cursor by\nOBSERVING the frame, which is deliberately total over EffectKind::Model(_) and is\nthe reason there is no hand-kept table of geometry effects -- so every append\nthrows the cursor to the origin, although nothing above the new row moved.\n\nWHY IT IS NOT URGENT NOW: improvise-o9uq closed the destructive path by having\nadd-record-row place its own cursor AFTER the SetCell, so the invalidation is\nimmediately overwritten by a correct placement. The remaining cost is any OTHER\nappend-shaped change whose command does not place a cursor.\n\nWHY IT IS DELICATE, and the reason this is a ticket rather than a patch: the\nobservation's totality is the property gotcha 24 was rewritten to get. A frame\nhash that can tell append from renumber is a SMARTER observation, and a smarter\nobservation is one more thing that can be subtly wrong in a direction nothing\ntests -- gotcha 24 already records that doing only half of the invalidation is\nSTRICTLY WORSE THAN DOING NOTHING, because the diff then believes every client\nholds origin-positioned content. Do not optimise this without a test that\ndistinguishes the two cases by construction.\n\nNote the adjacent gap already filed: improvise-9c9o (a geometry effect\nimmediately after a mid-batch SwitchView skips the check entirely).","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T07:40:55Z","created_by":"Edward Langley","updated_at":"2026-09-22T07:40:55Z","dependencies":[{"issue_id":"improvise-b8ng","depends_on_id":"improvise-pmt0","type":"parent-child","created_at":"2026-09-25T01:05:07Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-n8vh","title":"The category and view panels never truncate, so a long name overwrites the panel border","description":"The category and view panels never truncate a row, so a name longer than the\npanel overwrites its right border.\n\nFound while fixing improvise-8l0c, which was the *wrong* truncation in\n`src/ui/formula_panel.rs`. These two are a different bug: no truncation at all.\n\n`src/ui/category_panel.rs::render_item` writes `format!(\"{indicator} {name} ({item_count})\")`\nstraight through `Buffer::set_string`, and `src/ui/view_panel.rs::render_item`\ndoes the same with `format!(\"{prefix}{}\", summary.name)`. `set_string` clips at\nthe *buffer* edge, not at the panel's `inner` rect, so a long category or view\nname draws over the block's right border character and, in a narrow terminal,\nruns to the screen edge. No panic.\n\n8l0c left the tool in place: `src/ui/text.rs::truncate` is the shared\ndisplay-width-safe truncator and is what these two want, with the axis tag /\naxis summary suppressed rather than pushed off. Both files already measure the\ntag's offset in cells since 8l0c, so the arithmetic is there.\n\nEvidence to write: `context/test-evidence/tui.md` already carries the\nobservation under the 8l0c section, keyed by\n`ui::category_panel::tests::the_axis_tag_sits_beside_a_multibyte_category_name`.\nA regression test should assert the border column still holds the border glyph.\n\nFiled by Claude (Opus 5), an agent in Edward Langley's session.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T06:28:47Z","created_by":"Edward Langley","updated_at":"2026-09-22T06:28:47Z","dependencies":[{"issue_id":"improvise-n8vh","depends_on_id":"improvise-gtn7","type":"parent-child","created_at":"2026-09-25T01:05:31Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-dw33","title":"Truncate the build stamp to 8 characters -- but only when it is a SHA, never a tag","description":"USER 2026-09-22: \"the build number in the header should be truncated to like 8\ndigits, `ee0f57d227e8d9601a7353b23704de78df9a75a8` is way too much text\"\n\nWHERE IT IS: web/index.html:43 carries\n`\u003cspan title=\"Build this page was assembled from\" id=\"build-version\"\u003eunstamped\u003c/span\u003e`,\nand web/assemble.sh:88 substitutes the token into it, gated by\nscripts/assemble-stamp.sh against the pattern `id=\"build-version\"\u003e${token}\u003c`.\n\n8 IS ALSO THE REPO'S OWN CONVENTION: scripts/pr-reviews prints `.commit.oid[0:8]`,\nand the orchestrator's prose uses 8 throughout. So this is consistency, not taste.\n\nTHE TRAP, AND IT IS THE WHOLE TICKET: THE TOKEN IS NOT ALWAYS A SHA. Pages passes\n$GITHUB_SHA; a RELEASE passes the tag (improvise-oujb). Truncating blindly turns\n`v0.1.0-rc2` into `v0.1.0-r`, which is not a shorter version -- it is a WRONG one,\nand it is wrong in the one build a user is most likely to quote in a bug report.\n\nSo truncate ONLY when the token is a 40-hex SHA. That test already exists in this\nrepo: scripts/build-doc-site.mjs:21 is\n`const ref = /^[0-9a-f]{40}$/.test(token) ? token : \"main\";`\n-- SHARE the rule rather than writing a third copy of it, or at minimum point at\nthat line so the two cannot drift. Two answers to \"is this token a SHA\" is the\ndisease design-principles section 5 names.\n\nKEEP THE FULL VALUE REACHABLE. The 40-char SHA renders today only because CSS\n`text-overflow` clamps it, and the #121 agent measured that selecting the text\nstill copies the WHOLE SHA. Truncating the text loses that unless the full value\ngoes somewhere -- the `title` attribute already exists and is the obvious home,\nor a `data-` attribute. A build id you cannot copy in full is a build id that\ncannot be pasted into an issue.\n\nTHE GATE MUST MATCH WHAT IS DISPLAYED. assemble.sh:88 passes `${token}` as the\nexpected pattern; if the displayed text is truncated, that argument becomes the\ntruncated form. Getting it wrong REFUSES THE ASSEMBLY rather than shipping an\nunstamped page, so it fails safe -- but it fails loudly during a release, so get\nit right rather than relying on that. scripts/assemble-stamp.sh verifies each of\nits five pairs independently (improvise-x4wv), so a wrong pattern names itself.\n\nTEST IT AT BOTH SHAPES. scripts/assemble-stamp-test.sh is the check_the_checker\nand already runs 9 checks; a tag token and a SHA token are two cases and only one\nof them is the interesting one. Inject a missed substitution at each shape.\n\nSEQUENCING: branch ui/page-menu is in flight. It should not touch\nweb/index.html:43 or web/assemble.sh, but confirm before starting.","notes":"CLOSED BY USER RULING 2026-09-22, as already answered by PR #121.\n\nThe user proposed the CSS form -- \"We could also just do a max-width there, plus\nellipsis text-truncation in css\" -- and on being shown that it is already live,\nruled: leave it.\n\nWHAT IS ALREADY ON MAIN (web/style.css:92, landed with improvise-oujb / PR #121):\n #build-version { flex: none; max-width: 14ch; overflow: hidden;\n text-overflow: ellipsis; white-space: nowrap; ... }\nwith a comment stating the property that makes it the right choice: A TAG FITS\nWHOLE, and a 40-char SHA truncates to a prefix long enough to name a build while\nselecting the text still copies the whole thing. Measured by the #121 agent in\nChromium: tag renders whole (scrollWidth 69 = clientWidth 69), SHA renders 13\nchars + ellipsis, header does not overflow at either.\n\nWHY THIS IS THE BETTER ANSWER AND NOT JUST THE CHEAPER ONE: clamping by WIDTH\ncannot mangle a tag, where truncating by CHARACTER COUNT turns v0.1.0-rc2 into\nv0.1.0-r -- a wrong version rather than a short one, in exactly the build a user\nwould quote. The CSS form sidesteps the whole SHA-vs-tag test this ticket was\nfiled to handle.\n\nTHE COST, ACCEPTED AND RECORDED so it is not rediscovered as a defect: the shown\nprefix is VIEWPORT-DEPENDENT, so two people may quote different prefixes of one\nSHA, and neither matches the 8-character form scripts/pr-reviews and the rest of\nthis repo's tooling uses. If that ever bites -- a bug report quoting a prefix\nnobody can match -- the fix is build-side truncation gated on\n/^[0-9a-f]{40}$/ (the test already exists at scripts/build-doc-site.mjs:21),\nwith the gate pattern in web/assemble.sh:88 changed to match the displayed form.\nReopen rather than re-derive.\n\nNOTE the user was most likely looking at a Pages build predating the #121 merge;\nthe deploy for it went out with the push of ee0f57d.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T06:15:12Z","created_by":"Edward Langley","updated_at":"2026-09-22T06:16:24Z","closed_at":"2026-09-22T06:16:24Z","close_reason":"Closed","labels":["browser"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-l2b8","title":"ADR 005: exact decimal arithmetic for formulas, on Scheme numeric-tower promotion rules","description":"USER 2026-09-22: \"Formulas should default to decimal math (ideally following\nScheme's numeric tower rules for promotion).\"\n\nSEPARATE FROM THE SYMPTOM THAT PROMPTED IT. The \"useless numbers\" the user saw\nare a FORMATTING defect (improvise-e0ex): number_format defaults to \",.0\", zero\ndecimal places, so a 0.66 ratio draws as \"1\". Exact arithmetic would not change\nthat by one character. This ticket is the numerics change on its own merits, and\nthose merits are real -- which is why it is filed rather than folded in.\n\nWHY IT IS WORTH DOING INDEPENDENTLY:\n - A spreadsheet that answers 0.1 + 0.2 with 0.30000000000000004 is wrong in the\n way users notice and never forgive. CellValue::Number(f64) does.\n - gotcha 18 records a REAL BUG this would have prevented: f64 addition is not\n associative, so SUM/AVG drifted in the last bits when DataStore iteration\n order changed. Min/Max/Count were unaffected because they are order-free.\n EXACT RATIONALS ARE ASSOCIATIVE -- the whole class disappears rather than\n being fixed by pinning iteration order.\n - gotcha 17's FLOAT_EQ_EPSILON (1e-10, shared by =, != and the div/0 guard in\n both evaluators) exists BECAUSE of binary floats. Exact arithmetic makes\n equality exact and the epsilon unnecessary -- six uses, one constant, gone.\n\nWHAT THE TOWER MEANS HERE. Scheme's rule: exact integer -\u003e exact rational -\u003e\ninexact real, with operations preserving exactness where they can, and `/` on two\nexacts yielding an exact rational rather than a float. So Profit / Revenue is an\nexact ratio, rounded only at display. Decide explicitly what makes a value\nINEXACT and whether improvise has any such operation today (POW with a\nnon-integer exponent is the obvious candidate; there are no transcendentals).\n\nSEVEN THINGS TO ANSWER BEFORE WRITING CODE:\n 1. WHAT IS THE STORED TYPE? CellValue::Number(f64) is in improvise-core and\n reaches everything. A new arm, a replaced arm, or a wrapper -- each has a\n different blast radius, and CellValue is Serialize/Deserialize and crosses\n the wire.\n 2. DENOMINATOR GROWTH. Summing thousands of exact rationals with unrelated\n denominators is how exact arithmetic becomes slow and memory-hungry. Say\n what bounds it. Measure at the 18,000-cell and 90,000-cell sizes gotcha 24\n already uses, so the numbers are comparable.\n 3. THE WASM BUNDLE. improvise-core is a browser-bundle crate. A bignum\n dependency is bundle weight on every page load; check with `cargo tree\n --edges normal` per crate (the default output includes DEV edges and reads\n like a leak -- see the Wasm section of repo-map).\n 4. .improv ROUND-TRIP. The format stores numbers as TEXT, so exact decimals\n round-trip BETTER than f64 does today. That is a point in favour, but the\n grammar and the round-trip tests must be re-read: \"1/3\" and \"0.333...\" are\n not the same value and the format has to say which it holds.\n 5. gp-tree IS NOT IN SCOPE. It fits over f32 feature matrices and its\n tests/scoring_known_answers.rs pins f32 BIT PATTERNS plus an RNG-call-order\n witness (gotcha 37c). It must keep binary floats; the seam is\n improvise-gp::dataset, and what crosses it needs stating.\n 6. WHAT DOES A THIN CLIENT SEE? CellDisplay carries text today, so a browser\n may be unaffected -- confirm rather than assume, because if a number crosses\n as a number, its type is a protocol change.\n 7. MIGRATION. Every existing .improv must load unchanged. Exactness is a\n property the current format does not record, so decide what an existing\n decimal literal BECOMES on load, and whether that is a behaviour change to\n existing documents.\n\nTHIS IS AN ADR, NOT A TICKET, and should be one before any code -- the type\nchoice reaches improvise-core, the wire, the grammar and the browser bundle at\nonce. Note ADR 004 is being drafted for the face system, so this is 005.\n\nDO NOT START BY REPLACING f64. Start by writing down what exactness means in a\npivot table, where most cells are user-entered decimals and most operations are\nSUM.","status":"in_progress","priority":3,"issue_type":"task","assignee":"agent-adr005","owner":"el-github@elangley.org","created_at":"2026-09-22T06:11:47Z","created_by":"Edward Langley","updated_at":"2026-09-25T04:45:31Z","started_at":"2026-09-25T04:45:31Z","lease_expires_at":"2026-09-25T04:50:31Z","heartbeat_at":"2026-09-25T04:45:31Z","dependencies":[{"issue_id":"improvise-l2b8","depends_on_id":"improvise-felo","type":"parent-child","created_at":"2026-09-25T01:05:18Z","created_by":"Edward Langley","metadata":"{}"}],"comments":[{"id":"da77dcfd-15a1-5256-9e46-bb12f61582d8","issue_id":"improvise-l2b8","author":"Edward Langley","text":"Evidence from the ADR 009 agent (PR #309, examples/retraction_drift.rs, M4 Max): TODAY's eager f64 SUM over 18k cells is up to 5.8e-10 from the exact decimal sum, above FLOAT_EQ_EPSILON (1e-10) at 7 of 8 checkpoints, so IF(SUM(..) = typed value) can already be wrong. And incrementally maintained SUM/AVG (ADR 009) cannot be made bit-equal to eager in f64 at all, so ADR 009 gates maintained Sum/Avg on this ADR.","created_at":"2026-09-25T03:19:21Z"},{"id":"68db5de9-d990-5a6a-8e9d-d49017a3bc8e","issue_id":"improvise-l2b8","author":"Edward Langley","text":"USER 2026-09-25 (asked 'whether division degrading into floats for 10/3 is ideal'), verbatim: 'I think (d): exact fractions with a size limit. should be added.' -\u003e ADR 005 question 2 gains option (d): bounded exact rationals (decimals are the power-of-10-denominator subset), demoting to f64 only on overflow; to be measured.","created_at":"2026-09-25T06:11:02Z"},{"id":"bd0edc40-db14-5aa1-9ffe-8d013e732958","issue_id":"improvise-l2b8","author":"Edward Langley","text":"USER 2026-09-25, verbatim: 'I think ADR 005 should implement the math operations in a separate crate'. The numeric tower (Num, arithmetic, contagion, overflow detection, conversion, exact comparison, the exact-sum accumulator, display rounding, exact parsing) becomes its own problem-agnostic workspace crate that improvise-core depends on.","created_at":"2026-09-25T06:39:37Z"}],"dependency_count":0,"dependent_count":0,"comment_count":3} +{"_type":"issue","id":"improvise-l2b8","title":"ADR 005: exact decimal arithmetic for formulas, on Scheme numeric-tower promotion rules","description":"USER 2026-09-22: \"Formulas should default to decimal math (ideally following\nScheme's numeric tower rules for promotion).\"\n\nSEPARATE FROM THE SYMPTOM THAT PROMPTED IT. The \"useless numbers\" the user saw\nare a FORMATTING defect (improvise-e0ex): number_format defaults to \",.0\", zero\ndecimal places, so a 0.66 ratio draws as \"1\". Exact arithmetic would not change\nthat by one character. This ticket is the numerics change on its own merits, and\nthose merits are real -- which is why it is filed rather than folded in.\n\nWHY IT IS WORTH DOING INDEPENDENTLY:\n - A spreadsheet that answers 0.1 + 0.2 with 0.30000000000000004 is wrong in the\n way users notice and never forgive. CellValue::Number(f64) does.\n - gotcha 18 records a REAL BUG this would have prevented: f64 addition is not\n associative, so SUM/AVG drifted in the last bits when DataStore iteration\n order changed. Min/Max/Count were unaffected because they are order-free.\n EXACT RATIONALS ARE ASSOCIATIVE -- the whole class disappears rather than\n being fixed by pinning iteration order.\n - gotcha 17's FLOAT_EQ_EPSILON (1e-10, shared by =, != and the div/0 guard in\n both evaluators) exists BECAUSE of binary floats. Exact arithmetic makes\n equality exact and the epsilon unnecessary -- six uses, one constant, gone.\n\nWHAT THE TOWER MEANS HERE. Scheme's rule: exact integer -\u003e exact rational -\u003e\ninexact real, with operations preserving exactness where they can, and `/` on two\nexacts yielding an exact rational rather than a float. So Profit / Revenue is an\nexact ratio, rounded only at display. Decide explicitly what makes a value\nINEXACT and whether improvise has any such operation today (POW with a\nnon-integer exponent is the obvious candidate; there are no transcendentals).\n\nSEVEN THINGS TO ANSWER BEFORE WRITING CODE:\n 1. WHAT IS THE STORED TYPE? CellValue::Number(f64) is in improvise-core and\n reaches everything. A new arm, a replaced arm, or a wrapper -- each has a\n different blast radius, and CellValue is Serialize/Deserialize and crosses\n the wire.\n 2. DENOMINATOR GROWTH. Summing thousands of exact rationals with unrelated\n denominators is how exact arithmetic becomes slow and memory-hungry. Say\n what bounds it. Measure at the 18,000-cell and 90,000-cell sizes gotcha 24\n already uses, so the numbers are comparable.\n 3. THE WASM BUNDLE. improvise-core is a browser-bundle crate. A bignum\n dependency is bundle weight on every page load; check with `cargo tree\n --edges normal` per crate (the default output includes DEV edges and reads\n like a leak -- see the Wasm section of repo-map).\n 4. .improv ROUND-TRIP. The format stores numbers as TEXT, so exact decimals\n round-trip BETTER than f64 does today. That is a point in favour, but the\n grammar and the round-trip tests must be re-read: \"1/3\" and \"0.333...\" are\n not the same value and the format has to say which it holds.\n 5. gp-tree IS NOT IN SCOPE. It fits over f32 feature matrices and its\n tests/scoring_known_answers.rs pins f32 BIT PATTERNS plus an RNG-call-order\n witness (gotcha 37c). It must keep binary floats; the seam is\n improvise-gp::dataset, and what crosses it needs stating.\n 6. WHAT DOES A THIN CLIENT SEE? CellDisplay carries text today, so a browser\n may be unaffected -- confirm rather than assume, because if a number crosses\n as a number, its type is a protocol change.\n 7. MIGRATION. Every existing .improv must load unchanged. Exactness is a\n property the current format does not record, so decide what an existing\n decimal literal BECOMES on load, and whether that is a behaviour change to\n existing documents.\n\nTHIS IS AN ADR, NOT A TICKET, and should be one before any code -- the type\nchoice reaches improvise-core, the wire, the grammar and the browser bundle at\nonce. Note ADR 004 is being drafted for the face system, so this is 005.\n\nDO NOT START BY REPLACING f64. Start by writing down what exactness means in a\npivot table, where most cells are user-entered decimals and most operations are\nSUM.","status":"closed","priority":3,"issue_type":"task","assignee":"agent-adr005","owner":"el-github@elangley.org","created_at":"2026-09-22T06:11:47Z","created_by":"Edward Langley","updated_at":"2026-10-09T04:36:00Z","started_at":"2026-09-25T04:45:31Z","closed_at":"2026-10-09T04:36:00Z","close_reason":"ADR 005 accepted ('approved', PR #320 comments 6074205802 and 6074312058) and merged f0fb6880 on 2026-10-09; implementation tracked in epic improvise-59nw","dependencies":[{"issue_id":"improvise-l2b8","depends_on_id":"improvise-felo","type":"parent-child","created_at":"2026-09-25T01:05:18Z","created_by":"Edward Langley","metadata":"{}"}],"comments":[{"id":"da77dcfd-15a1-5256-9e46-bb12f61582d8","issue_id":"improvise-l2b8","author":"Edward Langley","text":"Evidence from the ADR 009 agent (PR #309, examples/retraction_drift.rs, M4 Max): TODAY's eager f64 SUM over 18k cells is up to 5.8e-10 from the exact decimal sum, above FLOAT_EQ_EPSILON (1e-10) at 7 of 8 checkpoints, so IF(SUM(..) = typed value) can already be wrong. And incrementally maintained SUM/AVG (ADR 009) cannot be made bit-equal to eager in f64 at all, so ADR 009 gates maintained Sum/Avg on this ADR.","created_at":"2026-09-25T03:19:21Z"},{"id":"68db5de9-d990-5a6a-8e9d-d49017a3bc8e","issue_id":"improvise-l2b8","author":"Edward Langley","text":"USER 2026-09-25 (asked 'whether division degrading into floats for 10/3 is ideal'), verbatim: 'I think (d): exact fractions with a size limit. should be added.' -\u003e ADR 005 question 2 gains option (d): bounded exact rationals (decimals are the power-of-10-denominator subset), demoting to f64 only on overflow; to be measured.","created_at":"2026-09-25T06:11:02Z"},{"id":"bd0edc40-db14-5aa1-9ffe-8d013e732958","issue_id":"improvise-l2b8","author":"Edward Langley","text":"USER 2026-09-25, verbatim: 'I think ADR 005 should implement the math operations in a separate crate'. The numeric tower (Num, arithmetic, contagion, overflow detection, conversion, exact comparison, the exact-sum accumulator, display rounding, exact parsing) becomes its own problem-agnostic workspace crate that improvise-core depends on.","created_at":"2026-09-25T06:39:37Z"}],"dependency_count":0,"dependent_count":0,"comment_count":3} {"_type":"issue","id":"improvise-wiew","title":"[ and ] should probably be in the global keymap: decide whether 'global' means every mode or every grid mode","description":"USER 2026-09-22: \"Also [/] probably should be in the global keymap.\"\n\nNote the \"probably\" -- judgement is invited, and there is a real question below.\n\nWHERE THEY ARE NOW, verified: keymap.rs:615-616 binds ']' -\u003e page-next and\n'[' -\u003e page-prev on NORMAL. global_keymap() (:392) currently holds '?', ':' and\nthe panel toggles.\n\nMECHANICALLY THIS IS SAFE AND CHEAP. Both are unmodified Char keys bound as\nBinding::Cmd, so they satisfy the_global_binds_only_unmodified_char_keys, the\nguard PR #112 added. And TEXT ENTRY IS NOT AFFECTED: the lookup order is\nexact(key,mods) -\u003e Char(NONE) -\u003e AnyChar -\u003e Any -\u003e parent, so a text-entry mode's\nAnyChar shadows the global BEFORE the fallthrough is reached. Typing '[' into a\ncategory name still types '['. That shadowing is already covered by #112's tests.\n\nNO EXACT CONFLICT EXISTS. The Help overlay pages with Right / l / n, not with\nbrackets, so nothing is shadowed by accident.\n\nTHE ACTUAL QUESTION, WHICH THE TICKET MUST ANSWER RATHER THAN ASSUME: is the\nglobal \"every mode\", or \"every mode whose subject is the grid\"? Falling through\nmeans [ and ] would change the DOCUMENT'S PAGE AXIS while the user is reading the\nHELP OVERLAY -- a real mutation, invisible because the overlay is covering the\ngrid it changed. The same question applies to TileSelect and the three panels,\nthough those are at least about the document.\n\nThree defensible answers:\n a. Global, unshadowed -- paging is always available, and the help overlay\n changing the page underneath is harmless because it is visible on exit.\n b. Global, shadowed to a no-op in Help -- costs a wart, but says plainly that\n an overlay is not a place to mutate the document.\n c. Not global; add it to the specific maps that want it (RecordsNormal already\n inherits Normal, so the real gain is the panels and TileSelect).\n\nPick one, say why in the PR body, and expect the user to have an opinion --\nthis is the kind of thing that is cheap now and annoying to reverse once muscle\nmemory forms.\n\nWHATEVER IS CHOSEN, the browser must follow: improvise-protocol/src/keymap.rs is\nthe shared table both front ends look up, so this is one change, not two -- but\ncheck resolve.rs still composes page-next/page-prev from whatever modes newly\nreach them, and that lockstep still agrees. improvise-p1ym closed the projection\ngap that used to make [ and ] inert in a browser, so they do work there now.","notes":"IMPLEMENTED in PR #123 (merge 7470b61), to the user's ruling of an INTERMEDIATE\nkeymap rather than any of the three options this ticket listed.\n\ndocument_keymap sits between global and Normal / the three panels / TileSelect.\nRecordsNormal reaches it THROUGH Normal. Help, ImportWizard and the text-entry\nmodes take global directly.\n\nTHE ARGUMENT IS NEW RATHER THAN THE GLOBAL'S AGAIN, and it is better than the\none I wrote on this ticket. I framed the question as \"is the document the subject\nright now\", which is a judgement. The agent found a STRUCTURAL line: every key\nthe global binds is a UI action, whereas page-next resolves to SetPageSelection,\nwhich is EffectKind::Model(LayoutGeometry) -- THE FIRST SHARED KEYS THAT MUTATE.\nUnder the help overlay that is an invisible change to the grid it is covering.\nSo the split is not taste; it is \"may a fallthrough key mutate the document\".\n\nThe three asks were met: the name is the guard (document_keymap, not\ncommon/shared, because nothing in the code stops a mode parenting it);\nRecordsNormal inherits ONCE, asserted on CHAIN IDENTITY (exactly four deep)\nrather than behaviour; and the layer gets its OWN Char-only guard, owed for the\nidentical fallthrough reason as the global's.\n\nTHE TEST-SHAPE LESSON: parenting RecordsNormal to the layer directly is a fault\nthe BEHAVIOURAL test does not catch -- it passes, because the key still works.\nOnly records_normal_inherits_the_document_layer_once_through_normal, which\nasserts the chain, sees it. A duplicated inheritance edge is invisible to any\ntest that only presses the key.\n\nprefix_keymaps_are_flat is about the sub-keymaps a PREFIX opens and is unaffected\nby depth. resolve.rs dispatches on command NAME, so the browser followed with no\nsecond table and no resolve.rs change was needed. lockstep.rs unchanged.\nIt did not grow: one binding moved, one layer added.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T06:07:52Z","created_by":"Edward Langley","updated_at":"2026-09-22T06:41:47Z","closed_at":"2026-09-22T06:41:47Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-771e","title":"The browser tile bar says 'Pag': short_label is a TUI column-width device, not a browser one","description":"Spotted by the orchestrator in PR #120's screenshots: the browser tile bar renders\n\"_Measure Col\", \"Product Row\", \"Quarter Pag\".\n\n\"Pag\" is not a CSS clip and not a bug in #120. Axis::short_label() (improvise-core\n/src/view/axis.rs) returns THREE CHARACTERS on purpose -- repo-map gotcha 26 says\nit is \"three columns for the tile bar\", so the TUI's tile bar aligns in a\nfixed-width terminal. Note `None` is \".\" there and is deliberately NOT a\ntruncation.\n\nIn a browser there is no column-alignment constraint, so the abbreviation buys\nnothing and reads to a user as a rendering defect. Axis::plain_label() already\nreturns \"Row\"/\"Col\"/\"Page\"/\"None\".\n\nTHE FIX IS NOT TO CHANGE short_label(). It is shared with the TUI, where the\nthree-column width is load-bearing. Pick the other EXISTING method on the browser\nside. That is not a second derivation -- both are already on Axis and the decision\nstays in the shared layer; only the choice of which vocabulary a front end uses is\nlocal, which is exactly the shape design-principles section 5 blesses for\nAxisColor -\u003e ratatui Color.\n\nCHECK BEFORE CHANGING: tests/grid_frame_agreement.rs asserts the two front ends\ndraw the same grid FRAME. Confirm it does not also pin the tile bar's label\nvocabulary, or this will fail for a reason that is not a bug.","notes":"FIXED in PR #123 (merge 7470b61). The browser tile bar asks plain_label();\nshort_label() now has exactly one caller, src/ui/tile_bar.rs.\n\nTHE FIND THAT MATTERS MORE THAN THE FIX, and it corrects this ticket's own\nguidance. I told the agent to check tests/grid_frame_agreement.rs before\nchanging the label, on the theory that it might pin the tile bar's vocabulary.\nIT DOES NOT -- it does not mention the tile bar at all.\n\nBut harness/tests/drill.spec.js DID: it asserted the tile text exactly, twice,\nwith \"Quarter Pag\" AS THE EXPECTED VALUE, plus \"Quarter ·\" after a cycle. A\nPlaywright spec in a directory this ticket never names, asserting the bug as its\nexpectation. Verified fixed on the merged branch: it now expects \"Quarter Page\".\n\nSO THE RULE IS: A RENDERED STRING IS PINNED WHEREVER ANYTHING READS THE SCREEN.\nThe search is for the STRING, not for the function that produces it -- I pointed\nat the function and named the wrong file. A now-false comment in\ncontract.spec.js naming short_label was fixed in the same PR.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T06:07:11Z","created_by":"Edward Langley","updated_at":"2026-09-22T06:41:46Z","closed_at":"2026-09-22T06:41:46Z","close_reason":"Closed","labels":["browser"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-ycxi","title":"The release preflight's dist plan arm is unchecked by its check_the_checker","description":"Proposed by the improvise-gx5d agent (PR #119) and DECLINED IN THAT PR on stated\ngrounds, which is why it is a bead rather than a fix.\n\nscripts/release-preflight.sh runs `dist plan --tag=\u003ctag\u003e` as a SECOND OPINION after\nits own textual tag-vs-version check, and matches on the refusal text. That arm can\nbe rewritten so it NEVER MATCHES and scripts/release-preflight-test.sh stays green,\nbecause every input the test feeds the script has ALREADY been refused by the\ntextual check -- so control never reaches the dist arm under test.\n\nWHY IT WAS DECLINED RATHER THAN FIXED, and the reasoning is sound: the arm is\nOPTIONAL BY DESIGN. An absent dist must not read as a pass, and a locally-installed\ndist whose version differs from dist-workspace.toml's cargo-dist-version stops\nbefore planning, which the script deliberately tolerates. Pinning the arm in a test\nwould mean installing a dist matching cargo-dist-version -- the exact mismatch the\nscript exists to tolerate.\n\nSO THE VALUE HERE IS NOT COVERAGE, IT IS THAT THE SILENCE IS RECORDED. It is stated\nin context/code/tooling.md so nobody reads the check_the_checker's green as evidence\nabout that arm. This bead exists so the statement does not become the permanent\nanswer by default.\n\nWHAT WOULD ACTUALLY CLOSE IT, if anyone takes it up: feed the dist arm an input the\ntextual check ACCEPTS but dist refuses. That requires a tree whose root version\nmatches the tag while dist still objects -- e.g. a dist-workspace.toml naming a\nmember that does not exist. That is a fixture, not an installed dist, and it may be\ncheap. Worth ten minutes before accepting the decline as permanent.\n\nDO NOT close this by deleting the dist arm. It is the thing that actually refused in\nCI, and running it is worth more than any restatement of its rules; the textual\ncheck is the one that catches the failure the script was written for, but it is not\nthe authority.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T05:40:52Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:40:52Z","dependencies":[{"issue_id":"improvise-ycxi","depends_on_id":"improvise-0s6","type":"parent-child","created_at":"2026-09-25T01:04:34Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -816,7 +853,7 @@ {"_type":"issue","id":"improvise-j31x","title":"Server hosts several documents: a registry above Sessions","description":"DEFERRED by the user (2026-09-19) -- recorded so nothing forecloses it, not to be built now.\n\nimprovise is a multi-document app and the server should be able to host several separate models. The shape:\n server -\u003e many documents\n document -\u003e one App + one Sessions + many ClientSessions\nThat is NOT one App per session (what improvise-q08 ticket text says, written before the session model landed) and NOT one App total.\n\nNOTHING THAT HAS LANDED NEEDS TO CHANGE. Sessions does not own the App -- connect(\u0026mut self, app: \u0026App, ..), apply(\u0026mut self, app: \u0026mut App, ..) and project_all(\u0026mut self, app: \u0026App, ..) all take it as a parameter. So Sessions is already document-agnostic and a Sessions instance is implicitly bound to one document generation sequence, which is right: document_generation lives on the App. Multi-document is a REGISTRY ABOVE Sessions mapping a document id to its (App, Sessions) pair.\n\nWHY IT MATTERS BEYOND TIDINESS: sessions editing independent models are trivially non-conflicting, so ADR 001 concurrency design scopes per document FOR FREE -- the basis version tiers live on the App and each document has its own. A server that shared one App across documents would throw that away.\n\nWHAT TO AVOID IN THE MEANTIME: improvise-q08 must not hard-code a single App into a static or a lazily-initialised singleton. The document should be a PARAMETER even while there is only one, and a connection should be attachable to a document even if the MVP answers that from a CLI argument naming one .improv file.\n\nScope when picked up: the registry, document lifecycle (open/close/last-session-leaves), and how a client names the document it wants. Auth and access control are explicitly NOT in scope here -- file them separately if wanted.","status":"closed","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-19T23:32:31Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:33:15Z","closed_at":"2026-09-20T00:33:15Z","close_reason":"Registry landed in crates/improvise-ws-server/src/registry.rs. 1051 tests pass.\n\nIT CAUGHT A REAL BUG BY VERIFYING THE SEAM INSTEAD OF TRUSTING IT. My brief and the q08 notes both said nothing below the registry needs to change. Nearly true -- but Sessions::next_id is a PER-Sessions counter (confirmed: project.rs holds next_id on Sessions, not globally), so EVERY documents first client is SessionId(0), and transport.rs keyed its outs: HashMap\u003cSessionId, _\u003e on it. A registry that kept that key would have delivered document Bs frames to document As client the moment two documents each had one client.\n\nThe server-wide address is the pair, ClientId { document, session }. Registry::route(document, Vec\u003cAddressed\u003e) is the ONLY place a SessionId acquires a document -- the one it came out of, since that is the only one in scope there -- so cross-document delivery of a projection, a SwitchView broadcast (gotcha 36) or an Applied::Rebased fan-out (gotcha 39) is UNREPRESENTABLE rather than checked. The test names the hazard: an_edit_is_projected_only_to_the_edited_documents_clients asserts on_alpha.session == on_beta.session FIRST.\n\nLIFECYCLE, and the non-obvious half. Open at STARTUP from the files named on the command line -- open-on-request was rejected because deciding which paths a client may open IS access control, which this ticket put out of scope; serving only what was named leaves the question unanswered rather than answered badly. NOT closed when the last session leaves: eviction loses unsaved edits, and worse, document_generation and record_versions start at ZERO in a fresh App (gotcha 40), so a client reconnecting with a STALE basis would have it accepted as current -- a hole in the exact mechanism ADR 001 is building, bought for a memory saving nobody asked for. Registry::is_idle is the hook a future policy asks from; nothing evicts.\n\nA client names its document in the URL PATH (ws://host:port/quarterly), which leaves wire.rs COMPLETELY unchanged so the wasm-client frames keep working; / resolves to the only document when there is exactly one, and with two is ConnectError::Ambiguous rather than a coin toss. A refusal is a websocket CLOSE FRAME carrying the reason -- no ServerMsg::Error variant invented, because that is a connection fact and wire.rs only grows variants the mutations demand.\n\nEvery required claim has a counter-test beside it so it cannot pass vacuously -- e.g. the broadcast-isolation fixture gives BOTH documents a view named Second, so betas silence is the routing and not a name it would have rejected anyway.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-2wi8","title":"ADR-001 e: a forced overwrite is visible to the client it overwrote","description":"ADR 001 layer 1, step e. Depends on step c.\n\nWithout this, force is silent data loss with a confirmation step in front of it. The overwritten client edit was already committed, so this is a NOTIFICATION rather than a conflict -- but it must exist, or \"someone else won\" is indistinguishable from \"my edit never happened\".\n\nDesign note: the notification is about the loser OWN prior edit, so it is per-client and therefore UI -- the model is shared, the UI is not. Do not broadcast it as document state.","acceptance_criteria":"A client whose committed edit was overwritten by a force is TOLD. Without this, force is silent data loss with a confirmation step in front of it — 'someone else won' must be distinguishable from 'my edit never happened'. It is a NOTIFICATION rather than a conflict, because the overwritten edit was already committed. Per-client and therefore UI: do not broadcast it as document state (model-shared-ui-not-shared).","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-19T23:27:34Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:56:22Z","labels":["area:protocol"],"dependencies":[{"issue_id":"improvise-2wi8","depends_on_id":"improvise-3al1","type":"blocks","created_at":"2026-09-19T23:27:47Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-2wi8","depends_on_id":"improvise-5krd","type":"parent-child","created_at":"2026-09-25T01:04:56Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-vb6l","title":"ADR-001 d: when an intent cannot be re-composed, show what changed","description":"ADR 001 layer 1, step d -- the expensive one, and the honest cost of this design. Depends on step c.\n\nConflict class 2 (shape change vs cell edit) has NOTHING COHERENT TO FORCE: the key names a coordinate that no longer exists, and applying anyway writes a CellKey naming a deleted category -- the ghost-coordinate corruption the whole ADR exists to prevent.\n\nSo for class 2 the UI owes the user WHAT CHANGED plus the concrete alternatives -- re-target the edit, or re-create the structure first -- rather than a bare refusal or a force button that cannot be honoured.\n\nFORCE NEVER CREATES STRUCTURE: re-instating a category someone deleted so an edit can land is a different and LARGER intent than the user expressed. It must be an explicit action, never a side effect of forcing a cell edit.\n\nClass 3 (a formula now shadows the value) is forceable WITH A WARNING: the write lands, the display stays derived.\n\nNOTE FOR WHOEVER PICKS THIS UP: the ADR says plainly that if this dialogue is more than we want to build, the fallback is making shape changes rarer under contention -- e.g. a category deletion being explicit about the cells it destroys -- rather than making the conflict cheaper to resolve. That is a product call, not an implementation detail.","acceptance_criteria":"For conflict class 2 the user is shown WHAT CHANGED plus the concrete alternatives — re-target the edit, or re-create the structure first — rather than a bare refusal or a force button that cannot be honoured. FORCE NEVER CREATES STRUCTURE: re-instating a deleted category so an edit can land is a larger intent than the user expressed and must be an explicit action. Class 3 (a formula now shadows the value) is forceable WITH A WARNING. AND THE PRODUCT CALL COMES FIRST: the ADR says plainly that if this dialogue is more than we want to build, the fallback is making shape changes rarer under contention — e.g. a category deletion being explicit about the cells it destroys — rather than making the conflict cheaper to resolve. Answer that before building.","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-19T23:27:33Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:56:22Z","labels":["area:protocol"],"dependencies":[{"issue_id":"improvise-vb6l","depends_on_id":"improvise-3al1","type":"blocks","created_at":"2026-09-19T23:27:46Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-vb6l","depends_on_id":"improvise-5krd","type":"parent-child","created_at":"2026-09-25T01:04:56Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-7b8v","title":"Concurrent edits: advisory reservations (ADR 001, layer 2 — UX only)","description":"Implements layer 2 of context/adr/001-concurrent-edits.md. BLOCKED ON layer 1 by design, not by dependency: correctness must not rest on this layer.\n\nGeneralise the drill staging mechanism so a client publishes the KEYS it has staged, letting other clients grey them out and warn before someone types into a cell that is about to be taken. The VALUES stay private -- the user ruled on that in improvise-tom1: staged edits are uncommitted writes belonging to whoever staged them, which is why gotcha 30 keeps drill_state off ViewState.\n\nTHE DESIGN CONSTRAINT: this layer is ADVISORY ONLY. If a reservation is lost, never arrives, or leaks from a crashed client, the system degrades to \"you find out at commit\" -- never to corruption. That is what makes it safe to be sloppy about timeouts and disconnects, and it is exactly why the basis check must land first and stand alone.\n\nNote the split it implies: the reservation KEY SET is shared (everyone must see it to be warned) while the VALUE is private. That maps onto the existing EffectKind distinction rather than fighting it.\n\nDo not add lease renewal or lock timeouts as correctness machinery. If a reservation outlives its client, the worst outcome is a stale warning, and the basis check still rejects the actual conflict.","acceptance_criteria":"A client publishes the KEYS it has staged; other clients grey them out and warn before typing into a cell about to be taken. THE VALUES STAY PRIVATE — the user ruled on that in improvise-tom1. ADVISORY ONLY: if a reservation is lost, never arrives, or leaks from a crashed client, the system degrades to 'you find out at commit' and NEVER to corruption — which is what makes it safe to be sloppy about timeouts and is why the basis check must land first and stand alone. Do NOT add lease renewal or lock timeouts as correctness machinery.","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-19T23:09:54Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:56:23Z","labels":["area:protocol"],"dependencies":[{"issue_id":"improvise-7b8v","depends_on_id":"improvise-3al1","type":"blocks","created_at":"2026-09-19T23:27:48Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-7b8v","depends_on_id":"improvise-5krd","type":"parent-child","created_at":"2026-09-25T01:04:57Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-7b8v","title":"Concurrent edits: advisory reservations (ADR 001, layer 2 — UX only)","description":"Implements layer 2 of context/adr/001-concurrent-edits.md. BLOCKED ON layer 1 by design, not by dependency: correctness must not rest on this layer.\n\nGeneralise the drill staging mechanism so a client publishes the KEYS it has staged, letting other clients grey them out and warn before someone types into a cell that is about to be taken. The VALUES stay private -- the user ruled on that in improvise-tom1: staged edits are uncommitted writes belonging to whoever staged them, which is why gotcha 30 keeps drill_state off ViewState.\n\nTHE DESIGN CONSTRAINT: this layer is ADVISORY ONLY. If a reservation is lost, never arrives, or leaks from a crashed client, the system degrades to \"you find out at commit\" -- never to corruption. That is what makes it safe to be sloppy about timeouts and disconnects, and it is exactly why the basis check must land first and stand alone.\n\nNote the split it implies: the reservation KEY SET is shared (everyone must see it to be warned) while the VALUE is private. That maps onto the existing EffectKind distinction rather than fighting it.\n\nDo not add lease renewal or lock timeouts as correctness machinery. If a reservation outlives its client, the worst outcome is a stale warning, and the basis check still rejects the actual conflict.","acceptance_criteria":"A client publishes the KEYS it has staged; other clients grey them out and warn before typing into a cell about to be taken. THE VALUES STAY PRIVATE — the user ruled on that in improvise-tom1. ADVISORY ONLY: if a reservation is lost, never arrives, or leaks from a crashed client, the system degrades to 'you find out at commit' and NEVER to corruption — which is what makes it safe to be sloppy about timeouts and is why the basis check must land first and stand alone. Do NOT add lease renewal or lock timeouts as correctness machinery.","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-19T23:09:54Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:56:23Z","labels":["area:protocol"],"dependencies":[{"issue_id":"improvise-7b8v","depends_on_id":"improvise-3al1","type":"blocks","created_at":"2026-09-19T23:27:48Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-7b8v","depends_on_id":"improvise-5krd","type":"parent-child","created_at":"2026-09-25T01:04:57Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"improvise-ry89","title":"cv_regress/cv_classify with k=1 now return plausible numbers instead of NaN","description":"Wave-4 review (2026-09-19), CONFIRMED, LOW. A signal loss, not a wrong answer.\n\nmean_over(total, 0) == 0.0 replaced a 0.0/0.0 NaN, which is right everywhere it is used -- the reviewer looked for a case where an empty set WINS a comparison it should lose and found none (inside fit every candidate scores the same rows; cv_* pool pairs rather than reduce to a per-fold metric).\n\nBut with k=1, folds(n,1) puts every index in the single fold, so the training complement is EMPTY and the model trains on nothing. Pre-change, standardize(\u0026[]) gave mean = NaN and every pooled prediction was NaN -- the degenerate call announced itself. Post-change:\n k=1 pooled[0] = (1.48e-6, 8.0), nan_predictions = 0\nSo a library caller passing k=1 gets 37 near-zero cross-validated predictions from a tree fit on nothing, and anyone computing R2 off them gets a number rather than a NaN.\n\nexamples/fit_csv.rs guards with k \u003e= 2, so no shipped path hits it. Fix by documenting on cv_regress/cv_classify that k\u003c2 is meaningless, or by returning empty for k\u003c2. Note the folds doc argues max(1) is \"the answer the shape gives\" -- true for folds, and no longer true for the cross-validators.\n\nNIT from the same review: standardize_is_bit_for_bit_what_it_was is insensitive -- both perturbations of standardize left it green (one was caught by cv_regress instead). Its four witnesses do not separate accumulation orders on the 37-row fixture. Worth strengthening while here.","notes":"\n--- PARTIALLY ADDRESSED on PR #11 (2026-09-19) ---\nCopilot independently re-found this. The PR agent DOCUMENTED it rather than\nguarding it, and the reasoning is right: returning empty for k \u003c 2 is a\nbehaviour change to the exact functions scoring_known_answers.rs pins\nbit-for-bit, inside a PR whose entire claim is bit-identity. It deserves its\nown commit where it is the headline rather than a rider on a reshape.\n\nSTILL OPEN: decide guard vs document-only. If guarding, the known-answer\nvectors must be re-established afterwards, not assumed -- that is the whole\npoint of them.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T22:18:04Z","created_by":"Edward Langley","updated_at":"2026-09-19T23:40:24Z","closed_at":"2026-09-19T23:40:24Z","close_reason":"GUARDED, not documented. The returned value is pooled predictions from trees that never saw the row; with one fold there are none, so EMPTY IS THE TRUE COUNT, not a sentinel. Documenting would leave a library function whose degenerate answer looks like a models output.\n\nThe asymmetry is resolved by splitting the QUESTION, not by clamping differently: folds keeps its max(1) (a partition into one piece is still a partition) and gains a note that the answer is its alone; a new private cv_folds(n,k,rng) yields no fold below k=2 and is what both cross-validators ask. Declining BEFORE the shuffle costs the caller generator nothing.\n\nTHE TICKET PREMISE WAS FALSE and was checked rather than assumed: it said returning empty for k\u003c2 would change numbers the known-answer vectors pin bit-for-bit. Those vectors only ever call k=4 and k=3, so the guard changes none of them -- all six pass unchanged, not one pinned number edited.\n\nThe insensitive-witness NIT is fixed too: standardize_is_bit_for_bit_what_it_was pinned four numbers and let r.target/std - mean/std (same algebra, different rounding) through AND past both CV vectors; it now pins all 37 normalized targets, which that injection moves 21 of.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-k2i4","title":"The two nullary model commands are unaddressed and act on one client's staged state","description":"Wave-4 review (2026-09-19), CONFIRMED, LOW. Worth a decision and a note rather than an immediate change, since multi-client is not wired.\n\nApplyAndClearDrill and AcceptWizardModel are safe no-ops when nothing is staged (both early-return), so a client cannot corrupt the document by sending them blind. Two residual issues:\n\n(a) SPURIOUS PROJECTIONS. Dirty comes from the EffectKind, not from whether anything happened, so a no-op still dirties everything at that level and reaches every client. Measured: ApplyAndClearDrill dirties grid+category_tree+formulas; AcceptWizardModel dirties those plus view_list+tile_bar, emitting a non-empty projection list for a no-op.\n\n(b) THE STAGED STATE IS GLOBAL AND THE COMMAND IS NOT ADDRESSED. Sessions::apply routes Command::Model with no SessionId, while drill_state and wizard live on the single authoritative App. So client B sending Model(ApplyAndClearDrill) commits client A uncommitted staged edits, and Model(AcceptWizardModel) installs the workbook A half-finished wizard built -- replacing the document under everyone.\n\nThat collides with the user ruling on improvise-tom1: staged drill edits belong to whoever staged them and must not be visible to others. Committing them on someone else behalf is the stronger version of the same violation. Gotcha 30 calls this state session-local residue; the command layer now lets an unaddressed message act on it.\n\nDECIDE: either these commands become addressed (a SessionId, and the effect acts only on that session staged state -- which means the staging map moves off the single App), or they are removed from the wire vocabulary and stay key-path only. The second is cheaper and consistent with tom1.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T22:18:03Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:58:04Z","closed_at":"2026-09-20T00:58:04Z","close_reason":"Stale -- already resolved by improvise-3x3u, verified during improvise-fkfg. 3x3u removed ApplyAndClearDrill and AcceptWizardModel from ModelCommand entirely; only prose mentions remain in reduce.rs, no variants. So the premise of this ticket (two nullary wire variants acting on one client's staged state) no longer holds: those commands have NO WIRE FORM to carry a basis or a SessionId.\n\nConsequences, both checked while landing ADR 001 layer 1: layer 1 does not make this worse, and layer 1 LANDS HONESTLY WITHOUT IT. What survives is the successor 3x3u itself names -- multi-client drill wants the ADDRESSED form of those two commands, which is ADR 001 layer 2 work (improvise-tom1 and the reservations ticket), not this.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-ls3y","title":"[bug] A wire-driven SwitchView does not push the view history; the effect does","description":"Found during improvise-kmgs (2026-09-19), pre-existing, out of that ticket scope.\n\napply_command SwitchView arm calls app.switch_to_view(name) DIRECTLY, while effect::SwitchView first pushes a ViewFrame onto view_back_stack and clears the forward stack. So a switch driven over the wire is NOT undoable by ViewBack, and a key-driven one is -- two spellings of one user action that differ in whether history records it.\n\nSurfaced while building a ViewBack session test: the test had to walk forward through the EFFECT to fill the stack, because the command path never fills it. That asymmetry is the tell.\n\nDecide which is right rather than blindly making them agree: history is arguably UI state (the model is shared, the UI is not), in which case the per-client stack should be fed by whichever door the client used -- and then the command path is the one that is wrong. Note improvise-jytm will make active_view per-client, which touches the same ground.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T22:11:30Z","created_by":"Edward Langley","updated_at":"2026-09-19T22:50:02Z","closed_at":"2026-09-19T22:50:02Z","close_reason":"Found again by Copilot on PR #11 and fixed in ee767f9, merged as eb0c271. apply_command performed Command::SwitchView itself, calling App::switch_to_view directly instead of running effect::SwitchView -- and the EFFECT is the only thing that pushes onto view_back_stack and clears view_forward_stack. It was also the last hand-rolled mutation left in apply_command, which sat badly against the PR own \"effects are the only mutation site\" thesis.\n\nWAVE 4 IS WHAT MADE IT BITE: ModelCommand::ViewBack pops exactly that stack, so a wire client could switch views and then be unable to go back, while the same two keypresses worked. Before ModelCommand there was no wire way to ask, so the missing push was invisible.\n\nDemonstrated first: a_wire_view_switch_records_history_the_way_a_key_switch_does failed with the document still on Second. Nothing else in the suite could see it -- switch_view_moves_the_document_and_the_client_together checks where a switch LANDED, not what it RECORDED.\n\nPart of Copilot suggestion was declined with reasons on the thread: the arm still returns a hand-written Dirty rather than Dirty::of(Model(LayoutGeometry)), because Dirty::of is a per-KIND over-approximation and a view switch is the one member of that kind which leaves the view list alone -- which view is ACTIVE is not in ViewListCache at all. switching_views_projects_the_axis_widgets_only pins that.","dependency_count":0,"dependent_count":0,"comment_count":0}