From cf33a5d78af8fbf5e587a81ba43af5e6116c2512 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Fri, 25 Sep 2026 00:07:05 -0700 Subject: [PATCH 1/2] chore: update issues --- .beads/issues.jsonl | 625 +++++++++++++++++++++++++++----------------- 1 file changed, 378 insertions(+), 247 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 8b1317db..f31ad8de 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,11 +1,22 @@ -{"_type":"issue","id":"improvise-7mro","title":"Browser keys resolved while a scroll is unanswered read the cache through the new offsets: pipelined $ i 7 Enter writes Jan/Revenue where the TUI writes Feb/Revenue","description":"Found while delta-reviewing PR #282 at 6942e53e. This is pre-existing: the reviewer's scratch lockstep gives identical results at gh/main 202e7eac, at #277 aab62a36 and at #282 6942e53e. #282 fixed this exact aliasing in `declined_at` only (improvise-y5eb), with the new `projected_at`, which reads through `AxisFrame::first`.\n\n`resolve::cell_at_cursor` (and `cell_at`/`seed_at`) index `GridCache` with the view's cursor offsets. The client folds its own scroll (SetRowOffset/SetColOffset) at once, but the cache keeps the old window until the host answers. Any key resolved in that gap reads a different cell from the one under the cursor. That includes `edit_or_drill` (`i`), `commit` (its `id`, and `record_at_cursor` for the write's target) and the seed.\n\nWorst case, a silent write to the wrong record. Fixture: `_Measure` rows [Revenue, Profit (explicit item + `Profit = Revenue - Cost`), Cost], Month [Jan, Feb] on columns, every cell stored. Client window (2 rows, 1 col), cursor at (0,0). Keys `$`, `i`, `7`, `Enter` are all folded and sent before any reply is delivered:\n- TUI: Feb/Revenue = 107.\n- Host, fed the client's frames: **Jan/Revenue = 107**, and Feb is untouched. The client read Jan's cell for the seed and for the record the write claims.\n\nOther shapes on the same fixture, with replies held:\n- `G`, `i` from (0,0) in a 2×6 window: the client declines \"Profit is computed by a formula\" on Cost/Jan. The TUI opens.\n- `g g i` from row 2 with row offset 1: the same wrong decline.\n- `G i 7 Enter`: the TUI stores Jan/Cost = 47, and the client writes nothing.\n\nWhen each reply is delivered before the next key, every one of these agrees. So the trigger is pipelining faster than the host answers: a websocket with latency, or a fast typist over a worker.\n\nFix direction: read every cache cell through the frame's own origin (`projected_at`), and treat \"not projected yet\" as unknown rather than as a different cell. The write target especially should never be read through the cursor offsets.\n\nAC: a lockstep test that holds replies across a scroll and then `i`/type/`Enter`, comparing the host's document with the TUI's. It must fail at 6942e53e on the `$` case.\n","status":"in_progress","priority":1,"issue_type":"bug","assignee":"agent-7mro","owner":"el-github@elangley.org","created_at":"2026-09-24T01:58:28Z","created_by":"agent-review-277","updated_at":"2026-09-24T02:05:55Z","started_at":"2026-09-24T02:05:55Z","lease_expires_at":"2026-09-24T02:10:55Z","heartbeat_at":"2026-09-24T02:05:55Z","labels":["adversarial-review","browser","pr-282"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-jq0m","title":"A collapsed group's fold scans every item of its category: grid of collapsed small groups is quadratic, ~25x pre-#239 at 1000 items (yb4b fix regression)","description":"Found in the adversarial review of PR #239 at head f668ddc9 (improvise-yb4b's fix).\n\n`fold_stored` now reads through `DataStore::spanned_values`. That call narrows by `base`, the key with each folded category REMOVED, and then filters each candidate by membership. For a collapsed group, `base` drops the grouped category entirely. So each fold cell scans every record under (other coords), across EVERY item of the category, not just the group's members. The cost per fold cell is O(items in the category), not O(group size). A grid of collapsed small groups is therefore quadratic in the category's size. The old per-member lookup was O(group size) per cell.\n\nMeasured on an Apple M4 Max (16 cores, 64 GB) at load average 50-115, keypress = grid_of(whole grid) + compute_col_widths, min of 10 iterations, 2 alternating rounds. The probe is a dense grid: Day grouped and collapsed on Row, Product on Row, _Measure on Column.\n\n| case | 5932ad74 (pre-#239) | 9948aaf5 | f668ddc9 |\n|---|---|---|---|\n| 364 days in 7-day groups, 20 products, 6 measures | 0.40-0.52 s | 0.92-1.21 s | 1.73-1.75 s |\n| 364 days in pairs, 20 products, 6 measures | 0.54-0.64 s | 0.95-1.09 s | 4.9-6.3 s |\n| 1000 days in pairs, 10 products, 4 measures | 0.44-0.51 s | 0.62-0.65 s | 11.0-15.7 s |\n\nAt 1000 items the fix is ~25x the pre-#239 keypress and ~20x #239's own earlier head. The `grid_projection` bench cannot see this: it has no groups, so its only folds are the column totals, which really do span the whole category.\n\nLikely fix: narrow per span, not per base. For a span whose covered items are few, go through `index[(cat, item)]` for each covered item, intersected with the base. Alternatively, choose between the scan and a member enumeration by comparing the candidate count with the member count. Keep the positions sorted, per gotcha 18. Add a grouped case to benches/grid_projection.rs so the next change is measured against it.\n","status":"in_progress","priority":1,"issue_type":"bug","assignee":"agent-yb4b","owner":"el-github@elangley.org","created_at":"2026-09-24T01:05:19Z","created_by":"agent-review-239b","updated_at":"2026-09-24T01:12:29Z","started_at":"2026-09-24T01:12:29Z","lease_expires_at":"2026-09-24T01:17:29Z","heartbeat_at":"2026-09-24T01:12:29Z","labels":["adversarial-review","pr-239"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-3tt4","title":"Tab at the rightmost column reopens the browser editor on the stale pre-write cache value, so typing overwrites the value just committed (#227 regression)","description":"Found by the adversarial review of PR #227 (improvise-fy9o) at head 8ee5fade. This is a regression introduced by that PR.\n\n`resolve::advanced` seeds the reopened editor from the client's cache (`seed_at`). A comment there says \"The new cell is a different cell, so a stale cache is correct here\". That is false for `Advance::Right` at the rightmost column, which clamps `(col+1).min(col_max)` onto the cell that was just written. The TUI's `EnterEditAtCursor` re-seeds from the model after the write. Before #227 the welded trailing `clear-buffer` blanked both seeds, so the two ends agreed on `\"\"`. #227 removes the weld, so the stale seed becomes visible.\n\nMeasured with the lockstep harness (`precise_host`, cursor (0,1), North/Revenue = 2, window (2,2)):\n- `i`, `9`, `Tab`: the browser buffer is `\"2\"` and the TUI buffer is `\"29\"`. At #205's head 7aff3e77, both are `\"\"`.\n- `i`, `9`, `Tab`, `5`, `Enter`: the browser writes **25**, overwriting the 29 the user just committed. The TUI writes 295. At 7aff3e77, both write 5.\n\nIn a real browser the projection that updates the grid does not repair this. `settle_editor` seeds only an ABSENT buffer, and `opened_editor` stays on the stale `\"2\"`.\n\nThe fix belongs in #227. Where the landed cell is the cursor's own cell, seed with the committed value's edit text, the same text the host's `display_value_of` would read after the write. Or derive the seed so it cannot read the pre-write cache for the written cell. Acceptance: a lockstep test that Tabs at the rightmost column and compares buffers, and the typed-extension variant comparing both ends' cell values. Both fail at 8ee5fade.\n","status":"in_progress","priority":1,"issue_type":"bug","assignee":"agent-ixxx","owner":"el-github@elangley.org","created_at":"2026-09-23T15:24:02Z","created_by":"agent-review-227-230","updated_at":"2026-09-23T15:35:10Z","started_at":"2026-09-23T15:35:10Z","lease_expires_at":"2026-09-23T15:40:10Z","heartbeat_at":"2026-09-23T15:35:10Z","labels":["adversarial-review","pr-227"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-8tk5","title":"ADR 005 clause 3: with no persisted high-water mark, ids are reused after a reload and collide on merge, which defeats the out-of-session identity persistence was for","description":"Found by the adversarial review of PR #210 (ADR 005, head 9d97664c).\n\nClause 3 has the file record no high-water mark: \"after a reload the counters restart at max id seen + 1\". It argues that reuse is harmless because `document_generation` rejects stale commands. That argument covers the wire only. The ADR's own case for persisting ids (\"Alternatives rejected\") is identity *outside* a session: \"a rename visible as a rename in git diff, and a future reference that survives a restart\". Clause 3 defeats both.\n\n- Reuse after reload. Categories A(3) and B(4). Delete B, save, reload, add C. C gets 4, because the max seen is 3. `git diff` from the first commit to the last shows `B(4)` becoming `C(4)`, which reads as a rename, and an external reference to 4 now silently names C. That is the \"silently names a different thing\" failure clause 3 cites from ADR 002 as the thing ids must never do.\n- Merges. Two branches of one `.improv` each add a category, and both get the next integer. The merged file has a duplicate. Clause 5's repair keeps the first and reassigns the second, so one entity's persisted id changes on merge. Nothing in the file breaks, since nothing refers to an id. But the \"stable across a save and a reload\" promise does not extend to copies or branches, and the ADR does not say so.\n- The generation argument is also per-process: `document_generation` starts again at 0 in a new host process.\n\nMissing open question: whether to persist a high-water mark (for example a `next-id:` line, or per kind), and whether ids should be collision-resistant tokens rather than counters. That bears directly on Q1: integer versus word is not only about readability. Q9's argument (\"ids reset on every load gives back most of what persistence was asked for\") is weaker than stated, because under clause 3 ids can already be reused after a load.\n","status":"open","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T06:36:10Z","created_by":"agent-review-210","updated_at":"2026-09-23T06:36:10Z","dependencies":[{"issue_id":"improvise-8tk5","depends_on_id":"improvise-sboz","type":"discovered-from","created_at":"2026-09-23T06:36:10Z","created_by":"agent-review-210","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-80rm","title":"ADR 005 Q3 rests on a false premise: the CSV importer writes 'Name(token)' headers today, and header_decl would silently orphan their data","description":"Found by the adversarial review of PR #210 (ADR 005, head 9d97664c).\n\nADR 005 open question 3 recommends accepting that \"two rare existing header spellings change meaning on load\" and calls `Q3(draft)` \"a spelling nobody is known to use\". That premise is false: improvise's own CSV importer writes that shape today, from ordinary column headers.\n\nReproduced at 4b880846 (gh/main): `improvise import t.csv --no-wizard` on a CSV with headers `Size(cm),Region(code),Amount` saves\n\n ## Category: Region(code)\n ## Category: Size(cm)\n\nbecause `quote_header` has no case for a parenthesised suffix. Under the ADR's `header_decl` these load as category `Region` with id `code`, and `Size` with id `cm`. Every reference keeps the old spelling (clause 5), so `|Region(code)|: row` and every `## Data` key `|Region(code)|=N, ...` name a category the document no longer declares.\n\nLoading the file the way the new grammar would read it (the headers rewritten to `Region`/`Size`, references untouched) through today's `parse_md` succeeds with no error or warning. The axis lines for the old names are dropped, and every cell stays keyed under a category that does not exist, so none of them is drawn. This is not a rename. It silently orphans all the data and view configuration of a CSV-imported document. `Initial View: Q3(draft)` dangles the same way for a view, and falls back to the default without saying so.\n\nWhat the ADR must do: restate Q3's cost as silent data orphaning, and name the importer as a producer of the shape. Also consider an alternative: rewrite references that still spell the pre-split name on load, or warn and refuse. A sigil is not the only alternative.\n","status":"open","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T06:36:08Z","created_by":"agent-review-210","updated_at":"2026-09-23T06:36:08Z","dependencies":[{"issue_id":"improvise-80rm","depends_on_id":"improvise-sboz","type":"discovered-from","created_at":"2026-09-23T06:36:08Z","created_by":"agent-review-210","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-cq1q.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","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-25T04:49:27Z","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.3","type":"blocks","created_at":"2026-09-25T04:50:50Z","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":2,"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","status":"open","priority":1,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T04:49:14Z","created_by":"agent-adr007","updated_at":"2026-09-25T04:49:14Z","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":1,"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":"in_progress","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-25T04:51:42Z","started_at":"2026-09-25T04:51:42Z","lease_expires_at":"2026-09-25T04:56:42Z","heartbeat_at":"2026-09-25T04:51:42Z","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":0,"comment_count":2} +{"_type":"issue","id":"improvise-31oe.1.1","title":"Complete command names on the : command line","description":"First child of the auto-complete epic; the user's top priority. On the ':' command line, completing the command name offers every registered command (names read off the registry's prototypes, gotcha 15 -- no string list), filtered by the typed prefix. Decide and test: the key (Tab, with what on an exact match), single-match insertion, multi-match display (cycle vs list), and that the TUI and the browser use the same engine (improvise-y11g brings the ':' line to the browser). Tests first: every registered command is completable; a prefix narrows; an unknown prefix offers nothing; the candidate set equals the registry's name set (so a new command cannot be missing).","status":"open","priority":1,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-25T03:41:48Z","created_by":"Edward Langley","updated_at":"2026-09-25T03:41:48Z","labels":["command","protocol","ux"],"dependencies":[{"issue_id":"improvise-31oe.1.1","depends_on_id":"improvise-31oe.1","type":"parent-child","created_at":"2026-09-25T03:41:48Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-8jk6.2","title":"Import drops whole records that lack a category value; keep them with the coordinate absent","description":"USER 2026-09-25, verbatim: 'this limit in import should be fixed, it dates to when this wasn't sparse.' Screenshot: 'Skipped 6 of 342 records missing category values: Settlement Date (6)'. build_model_with_warnings (crates/improvise-io/src/import/wizard.rs ~262-284) skips any record with no value for an accepted Category field; improvise-5j9 only made the loss visible. Records are sparse, so such a record should be KEPT with that coordinate absent from its CellKey. Same mechanism as the sparse folder merge (improvise-lpzu), where a whole file lacks a category column -- so it belongs with that work. Decide and test how an absent coordinate behaves downstream: a view with that category on Row/Column (is the record invisible there, shown under a blank/(none) row, or counted only in totals?), on Page (which selection shows it), on None (aggregates include it), records mode (blank cell in that column), .improv round-trip (a Data line simply omits the category), drill, and ADR 007/identity. The display question for Row/Column is the user's to confirm; state the recommendation in the PR.","status":"closed","priority":1,"issue_type":"bug","assignee":"agent-lpzu","owner":"el-github@elangley.org","created_at":"2026-09-25T03:37:31Z","created_by":"Edward Langley","updated_at":"2026-09-25T05:02:21Z","started_at":"2026-09-25T03:37:52Z","closed_at":"2026-09-25T05:02:21Z","close_reason":"Landed in PR #314; reviewed PASS at c43ee724","labels":["import"],"dependencies":[{"issue_id":"improvise-8jk6.2","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T03:37:31Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-felo.1","title":"Raise MAX_CATEGORIES from 12 to 24","description":"USER DECISION 2026-09-25, verbatim: '12 categories is feeling small, we should increase to 24.' Prompted by an Etrade transactions import that hit exactly 12 categories, which left no room for the duplicate-disambiguating category of improvise-cgqt (PR #304). Supersedes context/plan.md:260 ('Do not raise or restructure the MAX_CATEGORIES = 12 limit'). The limit lives in crates/improvise-core/src/model/types.rs and is MIRRORED by a private copy in crates/improvise-io/src/import/wizard.rs — make the wizard read the model's (one home), rather than changing two numbers. Update every doc that states 12 (README, design-principles, context/code/*, plan.md) and every test that fills to the cap by literal count; tests should derive the cap, not spell it. Before changing it, find and state why 12 was chosen (any cost that scales with category count: CellKey size, the secondary index, layout cross products, formula fold) and measure one representative operation at 24 categories.","status":"closed","priority":1,"issue_type":"feature","assignee":"wave-felo1","owner":"el-github@elangley.org","created_at":"2026-09-25T02:06:57Z","created_by":"Edward Langley","updated_at":"2026-09-25T03:10:53Z","started_at":"2026-09-25T02:07:37Z","closed_at":"2026-09-25T03:10:53Z","close_reason":"Landed in PR #307; reviewed PASS at 74e460be","labels":["core","import"],"dependencies":[{"issue_id":"improvise-felo.1","depends_on_id":"improvise-felo","type":"parent-child","created_at":"2026-09-25T02:06:57Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-gtn7.1","title":"TUI horizontal scrolling loses the cursor: grid cursor hides behind the category panel, tile cursor goes off screen","description":"Reported by the user 2026-09-25, possibly a regression. (1) Navigating the data grid, the selected cell can end up BEHIND the category panel -- the viewport's column window is computed as if the grid had the full frame width, not the width left after the side panel. (2) Navigating the tile bar, the tile cursor can move completely off screen -- the tile bar does not scroll to keep its cursor visible. Hypotheses to check, not facts: viewport_effects / compute_col_widths / visible_cols (improvise-command ui/viewport.rs, cmd/navigation.rs) take an extent that ignores panel_open; the tile bar widget in src/ui has no horizontal offset. Bisect for a regression (e.g. the grid-from-cache work improvise-anr9, extent/panel changes) and say which commit if so. Tests first: a unit test over the viewport computation with the category panel open that fails today, and one for the tile cursor at the far right of a bar wider than the screen.","status":"closed","priority":1,"issue_type":"bug","assignee":"agent-gtn7-1","owner":"el-github@elangley.org","created_at":"2026-09-25T01:20:39Z","created_by":"Edward Langley","updated_at":"2026-09-25T01:41:37Z","started_at":"2026-09-25T01:21:15Z","closed_at":"2026-09-25T01:41:37Z","close_reason":"Fixed in PR #303 (merged); reviewed PASS at 241cc5f3. Follow-ups: gtn7.2, j7ij, j4no.","labels":["regression?","tui","ux"],"dependencies":[{"issue_id":"improvise-gtn7.1","depends_on_id":"improvise-gtn7","type":"parent-child","created_at":"2026-09-25T01:20:39Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-pmt0","title":"Epic: Wire vocabulary, projection and multi-client sessions","description":"The shared session machinery in improvise-protocol and improvise-command/ui (reduce.rs, project.rs, host.rs): commands a client cannot yet name on the wire (1ohv, 2mqz, the browser : line y11g), where effects and their wire mirrors disagree, the per-view scoping of the active view and aggregate addressing (azhb, h074, obtu, 8id2, znuk), projection cost and the frame/shape fingerprints behind cursor invalidation (swre, mcq4, b8ng, 9v0j), and protocol-level test gaps (nmm5, 9xsp). Out of scope: the ADR 001 basis and force work (its own epic), browser key resolution and ClientState (improvise-6jk), and security of wire input (the security epic).","status":"open","priority":1,"issue_type":"epic","owner":"el-github@elangley.org","created_at":"2026-09-25T01:04:10Z","created_by":"Edward Langley","updated_at":"2026-09-25T01:04:10Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-cq1q","title":"Epic: Stable identities — implementing ADR 007","description":"Implementing context/adr/007 (stable ids for categories, items, formulas and views, decided in improvise-sboz), and the adversarial-review findings about gaps in the ADR itself. Some of those tickets were filed when the ADR was numbered 005. This covers the .improv id grammar, the persisted high-water mark, re-keying ViewState and projections from names to ids, formula identity, and the wire clauses for ids. Out of scope: ADR 005's exact-decimal arithmetic (the core-model/formula epic), and concurrent-edit semantics that do not depend on ids (the ADR 001 epic).","status":"open","priority":1,"issue_type":"epic","owner":"el-github@elangley.org","created_at":"2026-09-25T01:04:09Z","created_by":"Edward Langley","updated_at":"2026-09-25T01:04:09Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-3uow","title":"pr-reviews-test hard-codes /bin/sleep and /bin/date, absent on the NixOS CI runner: the kb job goes red on it once #296 lands","description":"## Summary\nFound reviewing PR #296 at head 4989bd55, which fixes main's red \"Knowledge base consistency\" job.\n\nCI now runs on `[self-hosted, nixos, microvm]`. On NixOS, `/bin` holds only `sh`, the same premise #287 and #296 rest on. `scripts/pr-reviews-test.sh` hard-codes absolute paths there:\n- **`/bin/sleep \"$BOUND\" \u0026`**, line 17, the watchdog in `bounded_reviews`. Where it does not exist, the background job exits 127 at once, `wait -n` sees the watchdog finish first, and `bounded_reviews` returns 124: \"killed after 20s\".\n- **`exec /bin/date \"$@\"`**, line 221, inside the fake-clock stub. It is reached only for a `date` call other than `+%s`.\n\n## Evidence\n- **main's red run (35961230942, runner `ghr-1`) never reached pr-reviews-test.** The kb job stops at \"The checker runner refuses what it claims to\" (checkers-test 12/2). pr-reviews runs in the next step, \"The remaining checkers…\", so it has **never run on the NixOS runner**.\n- **Simulated on macOS.** I ran a copy of the suite through `checkers.sh` with `/bin/sleep` rewritten to a nonexistent path, standing in for NixOS. Every `--wait` case fails with exit 124, \"killed\". The unmodified suite passes 25/25 on the same machine.\n\nSo once #296 makes checkers-test pass on NixOS, the kb job is expected to go red again, on pr-reviews-test.\n\nNot verified on a real NixOS runner: PR #296's CI was still queued when this was filed.\n\n## Fix\nResolve `sleep` and `date` once, before any case puts a stub on PATH (for example `SLEEP=$(command -v sleep)` at the top), and use those paths. Do not use `/bin/...`. Add the remaining `/bin/\u003ctool\u003e` uses to whatever grep found `/usr/bin:/bin`.","status":"closed","priority":1,"issue_type":"bug","assignee":"agent-main-red","owner":"el-github@elangley.org","created_at":"2026-09-24T06:18:31Z","created_by":"agent-review-tooling-delta","updated_at":"2026-09-24T21:44:57Z","started_at":"2026-09-24T06:23:31Z","closed_at":"2026-09-24T21:44:57Z","close_reason":"Landed in main via #297 (reviewed; merged 2026-09-24).","labels":["adversarial-review","ci","pr-296"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-7mro","title":"Browser keys resolved while a scroll is unanswered read the cache through the new offsets: pipelined $ i 7 Enter writes Jan/Revenue where the TUI writes Feb/Revenue","description":"Found while delta-reviewing PR #282 at 6942e53e. This is pre-existing: the reviewer's scratch lockstep gives identical results at gh/main 202e7eac, at #277 aab62a36 and at #282 6942e53e. #282 fixed this exact aliasing in `declined_at` only (improvise-y5eb), with the new `projected_at`, which reads through `AxisFrame::first`.\n\n`resolve::cell_at_cursor` (and `cell_at`/`seed_at`) index `GridCache` with the view's cursor offsets. The client folds its own scroll (SetRowOffset/SetColOffset) at once, but the cache keeps the old window until the host answers. Any key resolved in that gap reads a different cell from the one under the cursor. That includes `edit_or_drill` (`i`), `commit` (its `id`, and `record_at_cursor` for the write's target) and the seed.\n\nWorst case, a silent write to the wrong record. Fixture: `_Measure` rows [Revenue, Profit (explicit item + `Profit = Revenue - Cost`), Cost], Month [Jan, Feb] on columns, every cell stored. Client window (2 rows, 1 col), cursor at (0,0). Keys `$`, `i`, `7`, `Enter` are all folded and sent before any reply is delivered:\n- TUI: Feb/Revenue = 107.\n- Host, fed the client's frames: **Jan/Revenue = 107**, and Feb is untouched. The client read Jan's cell for the seed and for the record the write claims.\n\nOther shapes on the same fixture, with replies held:\n- `G`, `i` from (0,0) in a 2×6 window: the client declines \"Profit is computed by a formula\" on Cost/Jan. The TUI opens.\n- `g g i` from row 2 with row offset 1: the same wrong decline.\n- `G i 7 Enter`: the TUI stores Jan/Cost = 47, and the client writes nothing.\n\nWhen each reply is delivered before the next key, every one of these agrees. So the trigger is pipelining faster than the host answers: a websocket with latency, or a fast typist over a worker.\n\nFix direction: read every cache cell through the frame's own origin (`projected_at`), and treat \"not projected yet\" as unknown rather than as a different cell. The write target especially should never be read through the cursor offsets.\n\nAC: a lockstep test that holds replies across a scroll and then `i`/type/`Enter`, comparing the host's document with the TUI's. It must fail at 6942e53e on the `$` case.\n","status":"closed","priority":1,"issue_type":"bug","assignee":"agent-7mro","owner":"el-github@elangley.org","created_at":"2026-09-24T01:58:28Z","created_by":"agent-review-277","updated_at":"2026-09-24T21:45:03Z","started_at":"2026-09-24T02:05:55Z","closed_at":"2026-09-24T21:45:03Z","close_reason":"Landed in main via #286/#295 (reviewed; merged 2026-09-24).","labels":["adversarial-review","browser","pr-282"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-jq0m","title":"A collapsed group's fold scans every item of its category: grid of collapsed small groups is quadratic, ~25x pre-#239 at 1000 items (yb4b fix regression)","description":"Found in the adversarial review of PR #239 at head f668ddc9 (improvise-yb4b's fix).\n\n`fold_stored` now reads through `DataStore::spanned_values`. That call narrows by `base`, the key with each folded category REMOVED, and then filters each candidate by membership. For a collapsed group, `base` drops the grouped category entirely. So each fold cell scans every record under (other coords), across EVERY item of the category, not just the group's members. The cost per fold cell is O(items in the category), not O(group size). A grid of collapsed small groups is therefore quadratic in the category's size. The old per-member lookup was O(group size) per cell.\n\nMeasured on an Apple M4 Max (16 cores, 64 GB) at load average 50-115, keypress = grid_of(whole grid) + compute_col_widths, min of 10 iterations, 2 alternating rounds. The probe is a dense grid: Day grouped and collapsed on Row, Product on Row, _Measure on Column.\n\n| case | 5932ad74 (pre-#239) | 9948aaf5 | f668ddc9 |\n|---|---|---|---|\n| 364 days in 7-day groups, 20 products, 6 measures | 0.40-0.52 s | 0.92-1.21 s | 1.73-1.75 s |\n| 364 days in pairs, 20 products, 6 measures | 0.54-0.64 s | 0.95-1.09 s | 4.9-6.3 s |\n| 1000 days in pairs, 10 products, 4 measures | 0.44-0.51 s | 0.62-0.65 s | 11.0-15.7 s |\n\nAt 1000 items the fix is ~25x the pre-#239 keypress and ~20x #239's own earlier head. The `grid_projection` bench cannot see this: it has no groups, so its only folds are the column totals, which really do span the whole category.\n\nLikely fix: narrow per span, not per base. For a span whose covered items are few, go through `index[(cat, item)]` for each covered item, intersected with the base. Alternatively, choose between the scan and a member enumeration by comparing the candidate count with the member count. Keep the positions sorted, per gotcha 18. Add a grouped case to benches/grid_projection.rs so the next change is measured against it.\n","status":"closed","priority":1,"issue_type":"bug","assignee":"agent-yb4b","owner":"el-github@elangley.org","created_at":"2026-09-24T01:05:19Z","created_by":"agent-review-239b","updated_at":"2026-09-24T21:45:07Z","started_at":"2026-09-24T01:12:29Z","closed_at":"2026-09-24T21:45:07Z","close_reason":"Landed in main via #239 (reviewed; merged 2026-09-24).","labels":["adversarial-review","pr-239"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-3tt4","title":"Tab at the rightmost column reopens the browser editor on the stale pre-write cache value, so typing overwrites the value just committed (#227 regression)","description":"Found by the adversarial review of PR #227 (improvise-fy9o) at head 8ee5fade. This is a regression introduced by that PR.\n\n`resolve::advanced` seeds the reopened editor from the client's cache (`seed_at`). A comment there says \"The new cell is a different cell, so a stale cache is correct here\". That is false for `Advance::Right` at the rightmost column, which clamps `(col+1).min(col_max)` onto the cell that was just written. The TUI's `EnterEditAtCursor` re-seeds from the model after the write. Before #227 the welded trailing `clear-buffer` blanked both seeds, so the two ends agreed on `\"\"`. #227 removes the weld, so the stale seed becomes visible.\n\nMeasured with the lockstep harness (`precise_host`, cursor (0,1), North/Revenue = 2, window (2,2)):\n- `i`, `9`, `Tab`: the browser buffer is `\"2\"` and the TUI buffer is `\"29\"`. At #205's head 7aff3e77, both are `\"\"`.\n- `i`, `9`, `Tab`, `5`, `Enter`: the browser writes **25**, overwriting the 29 the user just committed. The TUI writes 295. At 7aff3e77, both write 5.\n\nIn a real browser the projection that updates the grid does not repair this. `settle_editor` seeds only an ABSENT buffer, and `opened_editor` stays on the stale `\"2\"`.\n\nThe fix belongs in #227. Where the landed cell is the cursor's own cell, seed with the committed value's edit text, the same text the host's `display_value_of` would read after the write. Or derive the seed so it cannot read the pre-write cache for the written cell. Acceptance: a lockstep test that Tabs at the rightmost column and compares buffers, and the typed-extension variant comparing both ends' cell values. Both fail at 8ee5fade.\n","status":"closed","priority":1,"issue_type":"bug","assignee":"agent-ixxx","owner":"el-github@elangley.org","created_at":"2026-09-23T15:24:02Z","created_by":"agent-review-227-230","updated_at":"2026-09-24T21:44:54Z","started_at":"2026-09-23T15:35:10Z","closed_at":"2026-09-24T21:44:54Z","close_reason":"Landed in main via #227 (reviewed; merged 2026-09-24).","labels":["adversarial-review","pr-227"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-8tk5","title":"ADR 005 clause 3: with no persisted high-water mark, ids are reused after a reload and collide on merge, which defeats the out-of-session identity persistence was for","description":"Found by the adversarial review of PR #210 (ADR 005, head 9d97664c).\n\nClause 3 has the file record no high-water mark: \"after a reload the counters restart at max id seen + 1\". It argues that reuse is harmless because `document_generation` rejects stale commands. That argument covers the wire only. The ADR's own case for persisting ids (\"Alternatives rejected\") is identity *outside* a session: \"a rename visible as a rename in git diff, and a future reference that survives a restart\". Clause 3 defeats both.\n\n- Reuse after reload. Categories A(3) and B(4). Delete B, save, reload, add C. C gets 4, because the max seen is 3. `git diff` from the first commit to the last shows `B(4)` becoming `C(4)`, which reads as a rename, and an external reference to 4 now silently names C. That is the \"silently names a different thing\" failure clause 3 cites from ADR 002 as the thing ids must never do.\n- Merges. Two branches of one `.improv` each add a category, and both get the next integer. The merged file has a duplicate. Clause 5's repair keeps the first and reassigns the second, so one entity's persisted id changes on merge. Nothing in the file breaks, since nothing refers to an id. But the \"stable across a save and a reload\" promise does not extend to copies or branches, and the ADR does not say so.\n- The generation argument is also per-process: `document_generation` starts again at 0 in a new host process.\n\nMissing open question: whether to persist a high-water mark (for example a `next-id:` line, or per kind), and whether ids should be collision-resistant tokens rather than counters. That bears directly on Q1: integer versus word is not only about readability. Q9's argument (\"ids reset on every load gives back most of what persistence was asked for\") is weaker than stated, because under clause 3 ids can already be reused after a load.\n","status":"open","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T06:36:10Z","created_by":"agent-review-210","updated_at":"2026-09-23T06:36:10Z","dependencies":[{"issue_id":"improvise-8tk5","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T01:04:51Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-8tk5","depends_on_id":"improvise-sboz","type":"discovered-from","created_at":"2026-09-23T06:36:10Z","created_by":"agent-review-210","metadata":"{}"}],"comments":[{"id":"a2adbdee-78b2-5ec5-8705-95239e34e42b","issue_id":"improvise-8tk5","author":"agent-adr007","text":"agent-adr007 planning (2026-09-24): superseded by the accepted ADR. Revision 8 uses word ids from slugify, and answer 13 (Q10(b), 'Yes, b') accepts reuse across a reload with the counter not persisted. A merge that produces a duplicate id is refused at load (answers 11 and 29). Recommend closing as answered. improvise-cq1q.1 adds one sentence on merges.","created_at":"2026-09-25T04:51:17Z"},{"id":"5aa4ce76-6329-5fe2-a0c1-44adc4fec5ea","issue_id":"improvise-8tk5","author":"Edward Langley","text":"Decisions answered by ADR 007 answer 13; the merge consequence (two branches adding different things under one slug produce a file whose load is refused) is not yet stated -- improvise-cq1q.1 adds it. Close when cq1q.1 merges.","created_at":"2026-09-25T06:35:50Z"}],"dependency_count":0,"dependent_count":0,"comment_count":2} +{"_type":"issue","id":"improvise-80rm","title":"ADR 005 Q3 rests on a false premise: the CSV importer writes 'Name(token)' headers today, and header_decl would silently orphan their data","description":"Found by the adversarial review of PR #210 (ADR 005, head 9d97664c).\n\nADR 005 open question 3 recommends accepting that \"two rare existing header spellings change meaning on load\" and calls `Q3(draft)` \"a spelling nobody is known to use\". That premise is false: improvise's own CSV importer writes that shape today, from ordinary column headers.\n\nReproduced at 4b880846 (gh/main): `improvise import t.csv --no-wizard` on a CSV with headers `Size(cm),Region(code),Amount` saves\n\n ## Category: Region(code)\n ## Category: Size(cm)\n\nbecause `quote_header` has no case for a parenthesised suffix. Under the ADR's `header_decl` these load as category `Region` with id `code`, and `Size` with id `cm`. Every reference keeps the old spelling (clause 5), so `|Region(code)|: row` and every `## Data` key `|Region(code)|=N, ...` name a category the document no longer declares.\n\nLoading the file the way the new grammar would read it (the headers rewritten to `Region`/`Size`, references untouched) through today's `parse_md` succeeds with no error or warning. The axis lines for the old names are dropped, and every cell stays keyed under a category that does not exist, so none of them is drawn. This is not a rename. It silently orphans all the data and view configuration of a CSV-imported document. `Initial View: Q3(draft)` dangles the same way for a view, and falls back to the default without saying so.\n\nWhat the ADR must do: restate Q3's cost as silent data orphaning, and name the importer as a producer of the shape. Also consider an alternative: rewrite references that still spell the pre-split name on load, or warn and refuse. A sigil is not the only alternative.\n","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T06:36:08Z","created_by":"agent-review-210","updated_at":"2026-09-25T06:35:49Z","closed_at":"2026-09-25T06:35:49Z","close_reason":"Answered by accepted ADR 007 (revision 8); verified by the PR #318 review (issuecomment-5827855684)","dependencies":[{"issue_id":"improvise-80rm","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T01:04:50Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-80rm","depends_on_id":"improvise-sboz","type":"discovered-from","created_at":"2026-09-23T06:36:08Z","created_by":"agent-review-210","metadata":"{}"}],"comments":[{"id":"01a9b2f8-83c4-5f7b-9842-7db54390f4fc","issue_id":"improvise-80rm","author":"agent-adr007","text":"agent-adr007 planning: answered by answer 15 and clause 9. An old-version file keeps whole-line headers, and the writer pipe-quotes a display that would split. Recommend closing. The regression test lives in improvise-cq1q.5 and improvise-cq1q.10.","created_at":"2026-09-25T04:51:19Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} {"_type":"issue","id":"improvise-o1y0","title":"A collapsed group's formula re-evaluates every shared reference: exponential in the formula DAG, 3x-300x on the held-key path","description":"Found by the adversarial review of PR #203 at head 2a4f35d7 (improvise-eccl/f2oo).\n\nA collapsed group's formula cell is now evaluated with `Reach::Fold`. The chain is `eval_formula_folded` -\u003e `eval_expr` -\u003e `Expr::Ref` -\u003e `evaluate_fold` -\u003e `eval_formula_folded` again, and nothing memoises it. Before the PR, the fold summed its members' `evaluate_aggregated`, and those hit `formula_cache`. Now every reference to another formula target is re-evaluated in full at every use, and every leaf is a `fold_stored` scan over each covered member's records. The cost is therefore exponential in how often the formula DAG reuses a node. A diamond like `Margin = Profit / Revenue` + `Profit = Revenue - Cost` already pays for it. `grid_of` re-evaluates the whole grid every frame (gotcha 44), so this lands on the held-key path.\n\nMeasured on an Apple M4 Max (Mac16,6), `--release`. The document has 18,000 cells: Month 12 (four quarters) x Region 25 x Product 20 x 3 measures. Product and Month are on Row, `_Measure` on Column, Region on None. Each figure is `GridLayout::new` plus an evaluation of every cell, best of 5. The formulas are `Profit`, `Margin`, `F0 = Revenue + Cost`, and a chain `Fi = F(i-1) + F(i-1)`.\n\n| case | gh/main b4f58e9c | PR 2a4f35d7 |\n|---|---|---|\n| expanded, no chain | 30.1 ms | 30.9 ms |\n| four quarters collapsed, no chain | 31.2 ms | 99.6 ms (3.2x) |\n| collapsed, chain 3 | 30.8 ms | 366 ms (12x) |\n| collapsed, chain 8 | 33.1 ms | 9.83 s (300x) |\n\n`Aggregation::Exact` (nothing on None) was already exponential on main through `evaluate_depth`. A chain of 12 costs 2^13 nodes, 2.1 ms for one cell. That is cheap there, because each leaf is an O(1) `data.get`. The PR routes the common OverHidden + fold case through the same uncached recursion, and makes every leaf a scan.\n\nFix direction: memoise per top-level fold evaluation, with a map keyed by the resolved `CellKey`, owned by the `evaluate_covering` call and threaded through `Reach::Fold`. Circularity detection must survive it, so an in-progress marker should yield \"circular\". `r_circular` in the review's scratch tests is the guard. Evidence to demand: a known-answer test plus a node-count witness, or an injected counter showing linear visits on a diamond chain. Wall-clock time alone is not evidence.\n","status":"closed","priority":1,"issue_type":"bug","assignee":"agent-collapse-fu","owner":"el-github@elangley.org","created_at":"2026-09-23T06:06:19Z","created_by":"agent-review-203","updated_at":"2026-09-23T15:28:30Z","started_at":"2026-09-23T06:13:13Z","closed_at":"2026-09-23T15:28:30Z","close_reason":"Fixed in PR #203, merged after adversarial re-review at b3379d1; the later changes were evidence-only merges and a docs-only fix to §14. Follow-ups: improvise-h6z1, improvise-d7aj (#232), improvise-wm20.","labels":["adversarial-review pr-203"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-au6c","title":"PR #188's head fails CI: web/app.js now calls client.edit_target, which the wasm-surface ratchet does not list","description":"Found re-reviewing PR #188 at head a5fbd84f. web/tests/bundle/wasm_surface.test.js 'the page still reaches the client through a handful of calls' pins the exact set of client methods web/app.js calls. #188 adds client.edit_target(row, col) and does not add 'edit_target' to that list. The test fails locally (node-test.sh 'web/tests/bundle/*.test.js': actual has 'edit_target', expected does not). It also fails in CI, run 35822048537, job 'Browser harness (OPFS contract + shell)', step 'The page's calls match the bundle it calls', headSha a5fbd84f. This is why that check is red on the PR. The fix is one line in the ratchet list, with a comment naming improvise-2wu3. The arity test in the same file passes: edit_target is declared with 2 args. The miss happened because the local gate list runs 'web/tests/*.test.js' and not 'web/tests/bundle/*.test.js', and the bundle suite needs a built web/pkg.","status":"closed","priority":1,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T05:42:12Z","created_by":"agent-review-188b","updated_at":"2026-09-23T06:35:13Z","started_at":"2026-09-23T05:50:59Z","closed_at":"2026-09-23T06:35:13Z","close_reason":"Fixed in PR #188, merged as de70373 after adversarial re-review at a5fbd84. au6c and 4img were the review-requested fixes; later merges touched evidence only. Follow-up: improvise-vndi.","labels":["adversarial-review","ci","pr-188","web"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-fy9o","title":"Enter twice clears the cell the first Enter advanced onto: the welded clear-buffer blanks the reopened editor","description":"Found on improvise-ixxx. TextEntry::commit welds clear-buffer after every commit key, so after CommitAndAdvance (TUI) or resolve::commit's advance (browser) re-seeds the next cell's editor, the weld empties that buffer. The reopened editor opens BLANK while its captured OpenedEditor seed is the landed cell's text. Pressing Enter again with nothing typed commits an empty buffer, which is ClearCell. Measured on fix/ixxx-untouched-commit: 2x2 pivot, i on (West,Cost), Enter, Enter, and (North,Revenue)=2.0 is gone (get_cell None). Unchanged by ixxx (untouched() only ever suppresses writes). Same harm class as umi4/ixxx: a keystroke that looks like it changes nothing deletes data. Decision needed: is the blank re-entry intended typewriter bulk entry (then the seed captured should be empty so an untouched Enter writes nothing), or should the weld not clear the re-seeded buffer? context/test-evidence/clients.md enter_commits_the_cell_advances_and_re_enters_editing calls the trailing clear odd-looking and mirrors it on purpose.","notes":"Orchestrator raised this to P1. Pressing Enter, Enter with nothing typed deletes a stored value, and that is an ordinary keystroke sequence. It predates #205. Needs a ruling: should the editor that reopens after an advance show the next cell's value (re-seeded), or open blank on purpose? If it should open blank on purpose, then an empty commit of an editor whose seed is non-empty must not mean ClearCell.\nUSER RULING 2026-09-23: the editor that reopens after an advance shows the next cell's current value (re-seeded), not blank. With improvise-ixxx (#205), an unchanged Enter then writes nothing. Implement after #205 merges.","status":"in_progress","priority":1,"issue_type":"bug","assignee":"agent-ixxx","owner":"el-github@elangley.org","created_at":"2026-09-23T05:38:58Z","created_by":"agent-ixxx","updated_at":"2026-09-23T14:46:21Z","started_at":"2026-09-23T14:46:21Z","lease_expires_at":"2026-09-23T14:51:21Z","heartbeat_at":"2026-09-23T14:46:21Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-fy9o","title":"Enter twice clears the cell the first Enter advanced onto: the welded clear-buffer blanks the reopened editor","description":"Found on improvise-ixxx. TextEntry::commit welds clear-buffer after every commit key, so after CommitAndAdvance (TUI) or resolve::commit's advance (browser) re-seeds the next cell's editor, the weld empties that buffer. The reopened editor opens BLANK while its captured OpenedEditor seed is the landed cell's text. Pressing Enter again with nothing typed commits an empty buffer, which is ClearCell. Measured on fix/ixxx-untouched-commit: 2x2 pivot, i on (West,Cost), Enter, Enter, and (North,Revenue)=2.0 is gone (get_cell None). Unchanged by ixxx (untouched() only ever suppresses writes). Same harm class as umi4/ixxx: a keystroke that looks like it changes nothing deletes data. Decision needed: is the blank re-entry intended typewriter bulk entry (then the seed captured should be empty so an untouched Enter writes nothing), or should the weld not clear the re-seeded buffer? context/test-evidence/clients.md enter_commits_the_cell_advances_and_re_enters_editing calls the trailing clear odd-looking and mirrors it on purpose.","notes":"Orchestrator raised this to P1. Pressing Enter, Enter with nothing typed deletes a stored value, and that is an ordinary keystroke sequence. It predates #205. Needs a ruling: should the editor that reopens after an advance show the next cell's value (re-seeded), or open blank on purpose? If it should open blank on purpose, then an empty commit of an editor whose seed is non-empty must not mean ClearCell.\nUSER RULING 2026-09-23: the editor that reopens after an advance shows the next cell's current value (re-seeded), not blank. With improvise-ixxx (#205), an unchanged Enter then writes nothing. Implement after #205 merges.","status":"closed","priority":1,"issue_type":"bug","assignee":"agent-ixxx","owner":"el-github@elangley.org","created_at":"2026-09-23T05:38:58Z","created_by":"agent-ixxx","updated_at":"2026-09-24T21:44:54Z","started_at":"2026-09-23T14:46:21Z","closed_at":"2026-09-24T21:44:54Z","close_reason":"Landed in main via #227 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-uo2p","title":"ApplyAndClearDrill applies staged edits in hash order: a measure edit plus a category rename on one row writes a ghost record","description":"Found in adversarial review of PR #187 (improvise-hzjo), reproduced at gh/main da2e5ab merged with fc0b13c.\n\n`ApplyAndClearDrill::apply` (crates/improvise-command/src/ui/effect.rs) walks `drill.pending_edits`, a `HashMap\u003c(usize, RecordCol), String\u003e`, in hash order. The edits are not commutative when one row has both a measure edit and a category edit:\n\n- measure first, then rename: the new value is written at the old key, and the rename moves it. Correct.\n- rename first, then measure: the rename moves the row's cells to the new coordinates, then the measure edit writes `row.cell_key(measure)`, the OLD key. That re-creates a ghost record at the old coordinates carrying the new value, and the moved record keeps the stale value.\n\nTwo category edits on one row have the same shape: the second rename finds nothing at the old keys, so one rename is silently dropped, and which one depends on hash order.\n\nRepro: demo.improv, drill `Product/Latte Quarter/Q2` (1 record), stage `Measure(Cost)=999` and `Category(Quarter)=Q9`, then ApplyAndClearDrill. Over 40 fresh Apps: 24 gave `Q2 Cost=None, Q9 Cost=999` (correct), and 16 gave `Q2 Cost=999, Q9 Cost=8800` (a ghost row plus a lost edit). This is reachable from the TUI: edit a measure and then the category column in the same drill row, and leave the drill.\n\nThis is gotcha 18's class (HashSet/HashMap iterated for order), in an effect's `apply` rather than a command. So the determinism law of improvise-hzjo cannot see it: `ApplyAndClearDrill` is a unit struct, and its `Debug` is equal in every App.\n\nFix direction: apply the edits in a defined order that is correct. Measures before category renames within a row, and renames composed rather than each reading the snapshot's old keys. Alternatively, stage by record into an ordered map. The test must use several fresh Apps, since one HashMap iterates the same way twice.\n","status":"closed","priority":1,"issue_type":"bug","assignee":"agent-uo2p","owner":"el-github@elangley.org","created_at":"2026-09-23T05:03:32Z","created_by":"agent-review-182","updated_at":"2026-09-23T06:03:08Z","started_at":"2026-09-23T05:16:52Z","closed_at":"2026-09-23T06:03:08Z","close_reason":"Fixed in PR #204 (RecordRewrite), merged after adversarial review at 92eefee; the only later change was an evidence-only merge of gh/main. Follow-ups: improvise-phg1, improvise-qz9p, improvise-kndq.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-ixxx","title":"An untouched edit of a text cell that reads as a number (Text 007) commits Number(7): typed() cannot know the cell was text","description":"Found fixing improvise-umi4. After umi4 the edit seed is CellValue::edit_text, which round-trips every Number bit-for-bit, but a Text cell whose text parses as f64 (\"007\", \"1e3\", \"inf\", \"2024\") still does not survive i then Enter with nothing typed: the seed is \"007\" and typed() (improvise-protocol resolve.rs, the one classifier, j7v6) reads it as Number(7.0). Confirmed in both TUI pivot and records mode by adding Text(\"007\") to values_display_rounds in command/cmd/commit.rs tests: stored Some(Number(7.0)). Same shape in the browser (same typed()). Not a seed bug: the seed is exactly the text; the classifier has no way to know the cell was text. Options, each a design decision: (a) a commit whose buffer equals the seed writes nothing (no-op commit is a no-op; also fixes it for every value), (b) seed text cells with an explicit text marker the classifier honours, (c) accept it. (a) looks smallest but must be decided for both front ends at once, since the browser composes SetCell through the same typed().","notes":"Raised to P1 by the orchestrator: it's the same harm as improvise-umi4, a keystroke that should change nothing silently changes data. i then Enter on the text '007' stores Number(7). The umi4 fix made the SEED right ('007'), so this is now purely the commit's typed() reclassifying. The finder's candidate fix: a commit whose buffer equals the seed writes nothing, which is cheap, total, and doesn't change how genuinely typed input is classified. That is a design choice for both front ends (they share typed()), so the #194 reviewer is asked to weigh it.","status":"closed","priority":1,"issue_type":"bug","assignee":"agent-ixxx","owner":"el-github@elangley.org","created_at":"2026-09-23T04:47:57Z","created_by":"agent-umi4","updated_at":"2026-09-23T15:24:59Z","started_at":"2026-09-23T05:07:44Z","closed_at":"2026-09-23T15:24:59Z","close_reason":"Fixed in PR #205, merged after adversarial re-review at 7aff3e7; the later changes were an evidence-only gh/main merge plus the two 465t tests the reviewer requested. Follow-ups: improvise-jibp, improvise-ugj4.","comments":[{"id":"573383d7-6c70-505f-913a-8fab7066a98d","issue_id":"improvise-ixxx","author":"agent-review-194","text":"PR #194 reviewer (agent-review-194) view on option (a). Right, and a follow-up, not #194. After umi4 the seed comes from the STORED raw value, never from the formula result or the display format, so seed != stored happens only when typed(edit_text(v)) != v. That is number-shaped Text (007, 1e3, inf, nan, Infinity, 2024) and a raw CellValue::Error, whose edit_text is 'ERR:msg' and which an untouched commit turns into Text('ERR:msg'). In every such case skipping keeps v, which is what an untouched commit means. The only write it refuses is 'convert this text cell to a number by pressing i Enter', which is not a real intent: type 7. Four design constraints: (1) compare against the seed CAPTURED AT OPEN, not a seed recomputed at commit. The TUI's ctx.display_value and the browser's projected edit_text both move if another client writes the cell mid-edit. Comparing to the current seed makes an untouched editor write its stale text over the other client's value, and the browser's basis claim, taken at compose time, will not reject it. Capturing at open fixes that lost update too. (2) Skip only the WRITE. CommitAndAdvance's advance and re-seed, the mode change, and the records-mode transient-row cleanup (Tab contract) must still run. (3) The browser mouse editor (web/app.js edit/close) calls client.typed_cell unconditionally on commit, so the skip must reach typed_cell_frame too. Put it in one shared protocol function beside typed(), not in JS. (4) It does NOT fix genuinely typed '007' becoming Number(7). That is classification, and it stays out of scope.","created_at":"2026-09-23T05:04:39Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} {"_type":"issue","id":"improvise-zlj2","title":"pr-watch judges a healthy PR BLOCKED in the needs: gap before the required browser check registers","description":"Found in adversarial review of PR #186 (head 128a2a0).\n\n`scripts/pr-watch` decides \"checks finished\" as `total \u003e 0 \u0026\u0026 running == 0`. The required check `Browser harness (OPFS contract + shell)` is the `browser` job, which has `needs: build`, and GitHub creates its check run only when `build` completes. On PR #186's own head the check-run IDs show it: the six CI jobs are 107047718800..107047719088 (created 04:41:45), and the harness is 107048169932, created about 04:43:53, after `Build all bundles` completed at 04:43:52. `Test and lint (host)` had already completed at 04:43:45.\n\nSo for a window of about 1-3 s per push, every registered check is COMPLETED and SUCCESS, the required harness check does not exist yet, and GitHub reports MERGEABLE/BLOCKED. A poll that lands in that window (roughly window/interval, so about 5-15% at the default 20 s) gets:\n- on main (before #186): exit 0 on a PR whose required browser check has not run. A false green.\n- with #186: exit 1 on a healthy PR. BLOCKED is refused rather than waited on, so the watch cries wolf.\n\nReproduced with a fixture sequence taken from the real rollup: build IN_PROGRESS/BLOCKED, then build COMPLETED with no harness/BLOCKED, then harness IN_PROGRESS, then CLEAN. The PR-186 script exits 1 after 2 polls.\n\ncontext/code/tooling.md's claim that \"with zero required approvals nothing else produces [BLOCKED] here\" misses this. A required check that is not registered yet is the ordinary case, not a rare one.\n\nSuggested fix: treat MERGEABLE/BLOCKED with no failed check as a wait, the same way UNKNOWN is treated. A required check that never runs then reaches the timeout and exits 3. That is still not 0, and \"not answered yet\" is the accurate report for it. The `table: MERGEABLE/BLOCKED` expectation changes from 1 to 3, and a gap-sequence case is added. The alternative is to read the required contexts from the ruleset and require each one to be present in the rollup.\n","status":"closed","priority":1,"issue_type":"bug","assignee":"agent-tooling3","owner":"el-github@elangley.org","created_at":"2026-09-23T04:47:52Z","created_by":"agent-review-186","updated_at":"2026-09-23T05:05:45Z","started_at":"2026-09-23T04:51:56Z","closed_at":"2026-09-23T05:05:45Z","close_reason":"Fixed in PR #186, merged as 5bcc3ca after adversarial re-review at 04230a7. Follow-ups: improvise-9pfs, improvise-1qaa, improvise-qvpu.","labels":["adversarial-review-pr-186"],"comments":[{"id":"217a26c0-7261-542e-98f7-490035b9a045","issue_id":"improvise-zlj2","author":"agent-tooling3","text":"Fixed on PR #186 at 04230a7","created_at":"2026-09-23T04:59:22Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} @@ -19,13 +30,14 @@ {"_type":"issue","id":"improvise-6uva","title":"improvise script cannot move the cursor: every Move resolves through the pure-parse arm","description":"`improvise script` and `improvise cmd` resolve every command through CmdRegistry::parse -- the PURE arm, which builds CursorState::default() (row 0, col 0, row_count 0, col_count 0). The interactive arm, which builds CursorState::from_ctx(ctx), is never reached from a script. See command/cmd/core.rs:188 for the two-arm register.\n\nConsequence: every Move command (move-selection, scroll-rows, page-scroll, all four jump-*) and enter-advance snapshots a ZEROED cursor at PARSE time, clamps to (0,0), and emits SetSelected(0,0). It is worse than a no-op -- it RESETS the cursor. There is no way to position the cursor from a script, so every cursor-reading command is permanently pinned to the origin: clear-cell, yank, paste, cycle-axis-at-cursor, hide-selected-row-item, edit-or-drill, drill-into-cell, toggle-group-under-cursor, delete-*-at-cursor, commit-and-advance.\n\nDEMONSTRATED AT HEAD (a9cad15), web/demo.improv, Product on row:\n script A: \"move-selection 2 0\" then \"hide-selected-row-item\"\n script B: \"hide-selected-row-item\"\nBoth documents came out IDENTICAL, both carrying `hidden: Product/Espresso` -- row 0. diff reports IDENTICAL.\n\nBOTH DIRECTIONS DEMONSTRATED. Built the parent of the breaking commit (649d80c) in a worktree and probed:\n \"move-selection 2 0 . clear-selected-cell\" -\u003e cleared Product=Cold (row 2)\n \"clear-selected-cell\" -\u003e cleared Product=Espresso (row 0)\nThe cursor moved there. At HEAD it does not. So neither result is vacuous.\n\nBROKEN BY 35946af (2026-04-04), \"refactor(command): pre-resolve cell key and grid dimensions in CmdContext\", which introduced CursorState, InteractiveFn and the register/register_pure/register_nullary split. Verified ancestry: 35946af is an ancestor of HEAD and of 30c4bb6 (2026-09-19, the first wasm-client commit), so THE REGRESSION PREDATES THE BROWSER WORK BY FIVE MONTHS. The commit message says nothing about scripts.\n\nTHE SHAPE TO REMEMBER: a registry with two construction arms silently demotes every caller of the weaker arm, and nothing type-checks which arm a caller deserves.\n\nREPAIR (not written): run_headless_commands already builds a CmdContext for execute -- it just does not use one for CONSTRUCTION. There is no context-aware script parser today (parse_line_with also calls registry.parse), so closing this needs a parse_line_interactive(registry, line, ctx) calling registry.interactive, plus a PER-COMMAND re-parse inside the loop, since command n must see command n-1's cursor.\n\nA regression test must assert the cursor MOVED, not that the script ran: three identical replays of a fixture whose cursor moves are silent no-ops prove nothing, which is how this survived.\n\nFound by the improvise-ilvl replay audit.","status":"closed","priority":1,"issue_type":"bug","assignee":"agent-cmd","owner":"el-github@elangley.org","created_at":"2026-09-23T01:24:42Z","created_by":"Edward Langley","updated_at":"2026-09-23T02:17:29Z","started_at":"2026-09-23T01:45:53Z","closed_at":"2026-09-23T02:17:29Z","close_reason":"PR #151. improvise cmd/script now build each command PER SEGMENT against a live CmdContext (parse::segments + parse::parse_segment_interactive); construction cannot be hoisted, because command n must see n-1's cursor. Line atomicity preserved by keeping the whole-line parse_line as a syntax gate, so the only behavioural change is which cursor the commands see. VERIFIED BY THE ORCHESTRATOR with a real binary on main: 'move-selection 2 0 . hide-selected-row-item' now writes hidden: Region/East while a bare hide writes Region/North. THREE INSTANCES OF THE TICKET'S OWN LESSON, all found during the fix, none predicted: (1) the TUI's ':' line had the IDENTICAL defect -- ExecuteCommand also used parse_line_with, so :move-selection 2 0 reset the cursor; (2) register_pure's two arms DISAGREED, its interactive arm refusing empty args while its pure arm accepted them, so 'w' parsed one way and refused the other; (3) found by ChatGPT Codex -- clear-cell, yank, paste and drill-into-cell had interactive arms taking |_args, ctx| that DISCARDED explicit coordinates, harmless only while scripts reached the pure arm, so after the fix 'clear-cell Region/East' cleared the origin. Here the WEAKER arm was the one that HAD the context. Resolved by key_from_args_or_cursor: explicit coordinates win, cursor is the fallback.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-xgra","title":"pr-reviews is blind to inline review comments","description":"scripts/pr-reviews printed review BODIES only. On PR #122 the user reviewed with the body 'Generally good, added comments inline.' and five substantive rulings as inline comments; the script printed the one-line body and nothing else, and an agent reported the PR as awaiting review. Extend it to all three surfaces (reviews, pulls/{n}/comments, issues/{n}/comments) interleaved in time order, and give it a fixture-driven check_the_checker.","notes":"FIXED in PR #133 (merge 22ab58f). pr-reviews now prints all three surfaces --\nreview bodies, INLINE comments, issue comments -- interleaved in time order, with\na fixture-driven check_the_checker (22 cases) in CI's kb job.\n\nTHE DAMAGE IT REPAIRS, for the record: on PR #122 the user reviewed with the body\n\"Generally good, added comments inline.\" and five substantive rulings underneath.\npr-reviews printed the one-liner, and the orchestrator reported the PR as awaiting\nreview while the answers had been sitting there. The user had to say so.\n\nTHE FINDING THAT IS SHARPER THAN THE BUG -- VERIFIED BY THE ORCHESTRATOR AGAINST\nTHE LIVE API: commit_id is PER-COMMENT, not per-review, and they differ INSIDE ONE\nSUBMISSION. On #122, one review submitted 19:48-19:53 carries five inline\ncomments: three naming 246e8f4a and two naming 6a4c7dcd, while its body names\n246e8f4a. GitHub pins each comment to the file version it was written against, so\na comment on an outdated diff position keeps the older commit while its siblings\nmove on.\n\nSo the old head check was answering about the WRONG OBJECT, not merely reading too\nfew of them. A head comparison against the review BODY is not even a proxy for its\nown comments.\n\n--wait: AN INLINE COMMENT AT THE HEAD SATISFIES IT, argued rather than assumed --\na pulls/{n}/comments entry carries a commit_id, so it is a claim about which tree\nwas read. The clincher is that GitHub's \"Add single comment\" CREATES A REVIEW\nWHOSE BODY IS EMPTY: under the old rule that reviewer's exit code was right while\ntheir words were invisible, so the surfaces and the wait had to move together or\nthe tool would be honest and useless. AN ISSUE-LEVEL COMMENT DOES NOT satisfy it\n-- no commit, so no claim about a tree; \"I am rebasing shortly\" satisfying a wait\nfor a review is the widening that looks like the fix, and there is a fixture and\nan injected fault for exactly it.\n\nSIX FAULTS INJECTED, each caught by the case that names it. The two found by Codex\non the PR are the interesting ones and are bash-5-only:\n - `local n=\"$1\" name=\"$2\" f=\"...$n...\"` is NOT left-to-right on bash 5: every\n name is created before the assignments run, so $n is unbound under set -u.\n macOS ships bash 3.2, where it works. THE SUITE PASSED 19/19 LOCALLY AND 2/19\n IN CI ON THE SAME COMMIT -- which is the argument for wiring a shell checker\n into CI in the SAME change that adds it.\n - gh api --paginate emits one array PER PAGE, so a long thread is several\n concatenated documents.\n\nAND THE DIAGNOSTIC WORTH CARRYING: both failed as EXIT 2 WITH NO OUTPUT AT ALL,\nbecause a dead command substitution hands jq --argjson an empty string and jq\nanswers an invalid --argjson with a USAGE error, which is 2. A shell fault at the\ntop of a script arrives wearing jq's exit code at the bottom.\n\nNOT VERIFIED: the LIVE --paginate path. No PR in this repo has enough inline\ncomments to reach a second page (#122 has five), so pagination is exercised by\nfixture only -- and the fixture runs through the SAME pages_to_array, so it can\nhold the concatenated shape the live fetch actually produces.\n\nTHE AGENT ALSO WALKED INTO THIS PR'S OWN TRAP WHILE BUILDING IT: it ran\n`./scripts/pr-watch 133 \u003e f 2\u003e\u00261; echo \"EXIT=$?\"` and its tooling reported exit 0\nover a FAILING kb job, because the echo took the status. pr-watch had correctly\nexited 1. That is the strongest argument for the widened header wording being in\nthe file rather than in anyone's memory.","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T20:44:08Z","created_by":"Edward Langley","updated_at":"2026-09-22T21:04:54Z","closed_at":"2026-09-22T21:04:54Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-e0ex","title":"A ratio renders as an integer: number_format is per view, which is too coarse for one grid","description":"USER 2026-09-22: \"Right now, a Formula like `Margin = Profit / Revenue` displays\nuseless numbers. Probably, in most cases 2 decimal places displayed is sufficient\"\n\nTHE IMMEDIATE CAUSE IS THE FORMAT, NOT THE ARITHMETIC, and that is worth stating\nplainly because the user's own diagnosis pointed at decimal math (filed separately\nas the numeric-tower ticket, which is legitimate on its own merits but is NOT what\nproduces this symptom).\n\nView::default() sets number_format to \",.0\" (improvise-core/src/view/types.rs:56),\nand web/demo.improv sets \",.0\" explicitly on both views. `.0` is ZERO DECIMAL\nPLACES. So Margin = Profit / Revenue, a ratio around 0.66, is rounded\nhalf-away-from-zero (gotcha 8) and drawn as \"1\". Exact decimal arithmetic would\nnot change that by one character: 0.66 formatted with \",.0\" is still \"1\".\n\nTHE REAL DEFECT IS THAT number_format IS PER VIEW, WHICH IS TOO COARSE. One\nformat cannot serve Revenue (18,400 -- wants \",.0\") and Margin (0.66 -- wants\n\".2\" or a percent) in the same grid. Blanket-changing the default to 2 decimals\njust moves the damage: every currency measure becomes \"18,400.00\". So the fix is\nNOT a new default; it is a format that can vary per measure.\n\nWHAT TO DECIDE, in the PR body, rather than assume:\n - PER WHAT? Per _Measure item is the obvious unit and matches how a user\n thinks (\"Margin is a percentage\"). Per formula is another candidate and is\n narrower -- note a formula TARGET is a _Measure item (gotcha 7), so the two\n are not independent and picking both would be two answers to one question.\n - WHERE DOES IT LIVE? A per-measure format is document data, so it persists --\n which means the .improv grammar gains something and persistence/improv.pest\n is the single source of truth (design-principles section 3). Grammar first,\n then the tree-walker.\n - WHAT IS THE FALLBACK? The view's number_format must remain the answer for a\n measure with no format of its own, or every existing document changes\n appearance on load.\n - IS A SENSIBLE DEFAULT INFERRABLE? A formula whose expression is a division\n is almost always a ratio. Inferring is tempting and is the kind of rule that\n is wrong 10% of the time and unexplainable when it is -- if you propose it,\n propose it as a DEFAULT THE USER CAN OVERRIDE and say how they override it.\n\nROUND-TRIP IS THE TEST THAT MATTERS: persistence/roundtrip_tests.rs and the\ngrammar-walking generator. A new format field that does not survive\nformat_md -\u003e parse_md is a silent data loss, and the generator is what will find\nthe spellings you did not think of.\n\nNOTE FOR SCOPE: this is display only. format_f64 is called in rendering and\nnowhere else; formula eval uses full f64 and must keep doing so (gotcha 8 -- never\nfeed a rounded value back into eval). Nothing here touches the evaluator.","notes":"USER RULING 2026-09-22, which NARROWS this ticket from what it was filed as:\n\"We need to default to showing decimals: the expectation is that 2/3 shows\nsomething like .66... rather than 1 for this sort of application, unless the\nformat is explicit in the formula\"\n\nTWO CLAUSES, and the second changes the design:\n\n1. THE DEFAULT SHOWS DECIMALS. View::default()'s \",.0\" becomes decimal-showing.\n Combined with the user's earlier \"in most cases 2 decimal places displayed is\n sufficient\", that reads as \",.2\": Revenue draws 18,400.00 and Margin draws\n 0.66. Accounting applications do exactly this, so the noise on whole-number\n measures is the expected cost rather than a regression.\n\n2. THE OVERRIDE IS SPELLED IN THE FORMULA. This SUPERSEDES the \"per-_Measure\n item\" framing this ticket was filed with. Per-formula is narrower and is what\n the user asked for -- and the two nearly coincide, because a formula TARGET is\n a _Measure item (gotcha 7). So: a formula may carry its own format, which wins\n for its target; a RAW measure keeps the view's format. Clean split, and the\n grammar change lands in the `## Formulas` line rather than in a new section.\n\nTHE MIGRATION IS CLEANER THAN EXPECTED -- verified, not assumed. format_md writes\n`format: \u003cfmt\u003e` whenever number_format is non-empty (persistence/format.rs:121),\nand View::default() is non-empty, so EVERY DOCUMENT IMPROVISE HAS EVER WRITTEN\ncarries an explicit format line. format_line is optional in the grammar\n(improv.pest:118/126), so changing the default affects only (a) newly created\nviews and (b) hand-written documents that omit the line. No migration is needed\nfor round-tripped files.\n\nBUT web/demo.improv SETS \",.0\" EXPLICITLY ON BOTH VIEWS (lines 11 and 19), so the\ndefault change alone leaves the front page showing the bug the user reported.\nChange the demo in the same commit, and note tests/web_demo_model.rs asserts\nproperties of that document.\n\nGRAMMAR NOTES for the formula-side format:\n - The `## Formulas` line already carries a bracket suffix for the target\n category: `- Tax = Revenue * 0.1 [CustomCat]`. A format needs a spelling that\n cannot be confused with a category name. Decide it in improv.pest FIRST --\n the grammar is the single source of truth and the tree-walker follows\n (design-principles section 3).\n - The grammar-walking generator reads improv.pest via pest_meta and will\n exercise whatever you write, including spellings you did not intend. Note it\n SKIPS PREDICATES (gotcha 34), so a rule guarded only by a lookahead is a rule\n the generator can violate -- use a real token, as WHERE's `sp+` does.\n - Formula::raw is what the writer emits, not the reconstructed text, so a\n format that round-trips through `raw` may appear to work while being dropped\n from the parsed structure. gotcha 34 records exactly that failure for the\n formula TARGET: the round-trip tests never saw it because raw was unaffected.\n Assert the PARSED format, not the re-emitted line.\n\nSTILL DISPLAY ONLY. format_f64 is called in rendering and nowhere else; eval uses\nfull f64 and must keep doing so (gotcha 8). ADR 005 (improvise-l2b8) is the\nseparate question of what the arithmetic itself is, and nothing here waits on it.\nUSER CONSTRAINT 2026-09-22: \"improvise-e0ex should not affect persistence at all,\nexcept to store format metadata.\"\n\nALREADY TRUE BY CONSTRUCTION -- verified before recording, so the job is to NOT\nBREAK IT rather than to build it:\n\n - DISPLAY formatting is format_f64 in improvise-core/src/format.rs. Its only\n callers are rendering paths.\n - PERSISTENCE formatting is a DIFFERENT function in a DIFFERENT crate:\n persistence/format.rs:38 `fn format_number(n: f64)`. It writes `{n}` --\n Rust's shortest round-trip representation -- for non-integers, `{n as i64}`\n for integers below 1e15, and named forms for inf/-inf/nan.\n - `grep format_f64` over crates/improvise-io, crates/improvise-core/src/*.rs\n and src/*.rs returns ONLY format.rs's own definition and its tests.\n PERSISTENCE NEVER CALLS THE DISPLAY FORMATTER.\n\nSo stored values keep full round-trip precision regardless of what any view's\nnumber_format says, and a ,.2 default cannot round anything on disk.\n\nTHE REGRESSION TO WATCH FOR, stated because it is the one way this constraint\ngets violated by someone trying to be helpful: routing a save through the display\nformatter \"so the file matches what the user sees\". That would make the on-disk\nvalue depend on a VIEW's setting -- and a model has many views (gotcha 29), so\nthere is no single right answer and the last save would win. gotcha 8 already\nforbids feeding a rounded value back into eval; this is the persistence twin of\nit and is now written down.\n\nWHAT IS IN SCOPE ON THE PERSISTENCE SIDE, and it is only metadata:\n 1. The per-formula format spelling in the `## Formulas` line -- grammar first\n in improv.pest, then the tree-walker.\n 2. The `format:` line's VALUE for newly created views changes with the default\n (\",.0\" -\u003e the decimal default). That is metadata, not data, and existing\n documents are unaffected because format_md has always written the line\n explicitly (format.rs:121) whenever number_format is non-empty.\n\nNOTHING ELSE IN .improv CHANGES. No data line is rewritten, no number is\nre-rendered, no migration is needed.\nFIXED in PR #125 (merge 5fdce21). 1654 tests, all gates green.\n\nMEASURED, not asserted -- web/demo.improv through project::grid_of, the path both\nfront ends use:\n Revenue Cost Profit Margin\n before 18,400 6,200 12,200 1\n after 18,400.00 6,200.00 12,200.00 0.66\n with FORMAT .4 on a ,.0 view 0.6630\n\nONE CORRECTION TO THIS TICKET AND TO THE BRIEF I WROTE FROM IT: the demo has NO\nMargin formula -- only `Profit = Revenue - Cost`. Verified on main. So the front\npage was never literally showing the reported symptom; it was showing ,.0 on\nevery measure. My brief asserted the demo would \"still show the bug\" after a\ndefault change, which was wrong because I assumed a ratio that is not there.\nThe agent declined to ADD a Margin formula: that is demo content nobody asked for\nand it moves tests/web_demo_model.rs's asserted column list plus harness\nassertions. It is a two-line change if the user wants the ratio on the front page.\n\nTHE GRAMMAR DESIGN IS BETTER THAN THE BRIEF ASKED FOR. improv.pest is UNCHANGED\n-- verified. formula_raw already takes the `## Formulas` line verbatim up to the\noptional [Category] suffix, so the format rides in the FORMULA TEXT:\n format_clause = ${ ^\"FORMAT\" ~ sp+ ~ format_spec }\nin formula.pest, after the optional WHERE, with sp+ rather than a lookahead for\nWHERE's reason (the grammar-walking generator skips predicates). Consequences:\nthe generator has no new spelling to exercise, and the text a user types into the\nTUI minibuffer is the text the file carries -- one namespace, one spelling rule.\nRejected: a `{.2}` line suffix, which puts the format somewhere a user cannot\ntype it.\n\nTHE raw-VS-PARSED TRAP WAS ANSWERED WITH A MEASUREMENT. Injecting a walker that\nDISCARDS the format clause fails exactly ONE test and passes 242 others in\nimprovise-io, including the whole format_md/parse_md characterization suite and\nthe grammar-walking properties. No text-comparing test can see the field go\nmissing -- which is the gotcha-34 shape this ticket warned about, now quantified.\n\nBOTH CODEX FINDINGS WERE REAL, and both are the kind that only review catches:\n 1. FormatPlan used a HashMap keyed by target. A target category need not be\n _Measure, so a key can be claimed twice and HASH ORDER DECIDED THE SPELLING\n while model order decided the value -- gotcha 18's shape in a new place.\n Now: every formula's (cat, target, Option\u003cformat\u003e) in MODEL order, FIRST\n match wins, and a match naming no format falls through to the view.\n 2. A column total took its format from ROW 0. The agent's own context/ prose\n had argued this was safe \"for the case it cannot arise in\" -- and it arises,\n because a formula's target category is any category and can own a ROW-AXIS\n item. New GridLayout::column_key(col) gives the coordinates surviving the\n row aggregation.\n\nTWO FIXTURES PINNED TO ,.0 RATHER THAN RE-BASELINED, and the first is the\nimportant one: improvise_gp::dataset::a_cell_reaches_the_table_unrounded asserted\nits fixture's format WAS ,.0 as a precondition, then checked a stored 5.25\nreaches a fit unrounded. UNDER A ,.2 DEFAULT THAT TEST WOULD HAVE GONE VACUOUS\nWHILE PASSING. The fixture now sets ,.0 explicitly.\n\nNO .improv DATA LINE CHANGED. The user's constraint held.","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T06:11:33Z","created_by":"Edward Langley","updated_at":"2026-09-22T07:16:26Z","closed_at":"2026-09-22T07:16:26Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-y11g","title":"The : command line must be present in the browser (core feature): a request frame carrying the line","description":"USER RULING 2026-09-22: \"The : command line needs to be present in the browser,\nit's another core feature.\"\n\nThis REVERSES the decline. resolve.rs::stranding() currently refuses\n`enter-mode command-mode` (with SavePrompt and ExportPrompt), and the\nimprovise-h3y7 audit classified it \"host-only unless the ruling is otherwise\n-- shipping it to a browser means shipping the registry\". The ruling is\notherwise, AND the audit's framing of the cost was wrong.\n\nTHE COST IS NOT WHAT IT LOOKED LIKE. The registry does not have to reach the\nclient, because IN BOTH DEPLOYMENTS THE HOST ALREADY HAS ONE:\n\n - socket (?ws=): the host is native improvise-ws-server. Obviously has it.\n - worker (no ?ws=): web/build.sh builds TWO wasm bundles -- web/pkg from\n improvise-wasm-client (thin) and web/worker-pkg from improvise-worker-server,\n which depends on improvise-command and therefore ALREADY CONTAINS THE FULL\n CmdRegistry, in the browser, today.\n\nSo a `:` line is a REQUEST FRAME CARRYING A STRING, executed by the host against\nits own App and CmdContext. Zero new bundle weight on the main thread, and no\nsecond implementation of the command language.\n\nTHIS MATTERS BECAUSE THE ALTERNATIVE IS EXPLICITLY FORBIDDEN. improvise-wasm-client's\nCargo.toml carries a written argument for why improvise-command is deliberately NOT\na dependency: a binding names an intent resolved against a CmdContext -- a Model, a\nGridLayout and an App the client does not have -- so depending on it would drag the\nwhole host into the bundle to reach a lookup that could not be used. Do not do that.\n\nSHAPE, and note it is the SAME shape as the export/download ticket (improvise-2mqz),\nwhich is a reason to land them together or at least to design them once:\n ClientFrame -\u003e the line, as a string\n ServerFrame -\u003e Rejected on a parse or execution failure, else the ordinary\n projection stream carries whatever the command changed\n\nWHY A FRAME AND NOT A Command, for improvise-368h's reason: ExecuteCommand runs an\narbitrary script line and is runtime-fallible, so it must not sit inside a\nClientFrame::Commands batch that promises all-or-nothing.\n\nBLOCKED ON improvise-jz6w: a resolver cannot emit a frame today.\n\nWHAT MUST LAND WITH IT, not after:\n - the minibuffer must actually RENDER. The user reported 2026-09-21 that the\n minibuffer for typing a category name \"doesn't actually show up anywhere so\n you're typing blind\". A `:` line you cannot see is not the feature.\n - remove the CommandMode arm from stranding() and say so in the named list.\n - help page 5 already advertises the whole `:` surface to browser users\n (improvise-uln5). Shipping this closes the advertised-but-unreachable gap for\n `:` specifically; check what uln5 has left afterwards rather than closing it.\n\nSECURITY NOTE, because `:` is a wider door than any other frame: a line can reach\n`:w`, `:e` and the import wizard, i.e. the host's Storage. In the worker deployment\nthat is the user's own OPFS and is fine. In the socket deployment it is SOMEONE\nELSE'S FILESYSTEM, and improvise-kc7n's Origin check answers what page is driving a\nclient, never WHO is connecting -- improvise-kpnr (authentication) is still open.\nDecide explicitly whether the socket deployment accepts `:` lines at all, or whether\nthe frame is refused there until kpnr lands. Do not let this arrive by accident.","status":"open","priority":1,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T05:13:57Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:13:57Z","labels":["browser","protocol"],"dependencies":[{"issue_id":"improvise-y11g","depends_on_id":"improvise-jz6w","type":"blocks","created_at":"2026-09-22T05:14:02Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-y11g","depends_on_id":"improvise-owep","type":"blocks","created_at":"2026-09-22T05:24:34Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-fdk1","title":"A records coordinate rename has no wire form, and the surrogate is why","description":"Found by the improvise-h3y7 completeness audit, WHICH CORRECTED THE TICKET'S OWN\nDESCRIPTION OF THIS GAP -- and the correction changes the fix.\n\nimprovise-h3y7 said \"grepping reduce.rs for Rename returns zero\". True, and\nMISLEADING. Two paths do this:\n\n - effect::apply_coordinate_rename (ui/effect.rs:655), inside ApplyAndClearDrill,\n which is deliberately off the wire (gotcha 38).\n - commit.rs::rename_record_coordinate (cmd/commit.rs:366), the plain records path,\n which ALREADY decomposes into [AddItem, ClearCell(old), SetCell(new)] -- three\n commands that all have ModelCommand forms.\n\nSo it reads as though the plain path were already wire-expressible. IT IS NOT, for a\nreason the ticket does not state: ModelCommand::SetCell takes a RecordCell, whose\nRecordHash is an OPAQUE SURROGATE the server resolves against cells that ALREADY\nEXIST (App::resolve_record, ui/app.rs:516). The renamed record does not exist yet, so\na client cannot name it.\n\nTwo things that look like they solve this and do not. `will_be_created_by_the_frame`\n(ui/reduce.rs:284) exempts such a record from the BASIS CHECK -- it says nothing about\nhow to NAME one, and the two are easy to confuse. And RecordHash::of being pub is not\na licence: a client computing surrogates is precisely the invention ADR 002 exists to\nprevent.\n\nWHAT TO ADD: one position-free variant naming the existing record by surrogate and the\nnew coordinate by value --\n\n ModelCommand::RenameRecordCoordinate { record: RecordHash, category: String, item: String }\n\n-- mapped to a SINGLE effect factored out of the two copies above. Position-free, so\nimprovise-hkbc's rule still holds. Add it to records_named's match: it names one\nrecord, and creating the ITEM is internal to the effect, as both existing copies\nalready do.\n\nCONSOLIDATING THE TWO COPIES IS WORTH DOING REGARDLESS. They differ today only in how\nthey report the empty-value refusal, and gotcha 19 already warns that the synthetic-\nrecords branch and the plain branch drift.\n\nREMEMBER gotcha 1: renaming a category coordinate in wide records mode moves EVERY\nmeasure cell in the row, not just the one under the cursor. That is also why the basis\nunit is the record (gotcha 40) -- a cell-level unit is violated by this one legal edit.\n\nRELATED: improvise-o9uq is about the same code. Read it in full and say whether this\nsubsumes it before starting.","notes":"agent-wire2 2026-09-22, NOT BUILT, and here is the argument. There is no producer for the variant. A browser cannot enter records editing: edit_or_drill declines when state.grid.records is set (improvise-bd9o), toggle-records-mode and drill-into-cell are both in declined(), and a records coordinate cell projects id: None (improvise-exdg), so a client has no surrogate for it to echo. RenameRecordCoordinate would be a ModelCommand that nothing composes, which is the promise-not-kept shape vocabulary_is_composed.rs refuses for Command. Composing it needs the browser half of commit_plain_records_edit, and that needs RecordCol, which is session-local by gotcha 1. bd9o records that as a design question. So this ticket is BLOCKED on that design, not on vocabulary. What stands regardless: consolidating effect::apply_coordinate_rename with commit.rs::rename_record_coordinate (gotcha 19 drift). That work is in the command layer, outside the wire lane.","status":"in_progress","priority":1,"issue_type":"task","assignee":"agent-hzjo2","owner":"el-github@elangley.org","created_at":"2026-09-22T04:47:16Z","created_by":"Edward Langley","updated_at":"2026-09-23T06:06:11Z","started_at":"2026-09-23T06:06:11Z","lease_expires_at":"2026-09-23T06:11:11Z","heartbeat_at":"2026-09-23T06:06:11Z","labels":["protocol"],"comments":[{"id":"16e0ef09-54e4-5ca9-86d6-4dddfa573ef0","issue_id":"improvise-fdk1","author":"Edward Langley","text":"agent-review-193-196, reviewing PR #196 (6b3dd8c): #196 fits this ticket and does not foreclose it. It names the coordinate cell CellId::Coordinate but gives it no operand. This ticket's NOTES list 'a records coordinate cell projects id: None, so a client has no surrogate for it to echo' as a blocker. That blocker still stands after #196: a unit variant gives a client nothing to echo. When a producer exists, RenameRecordCoordinate needs the row's RecordHash from the PROJECTION, as Coordinate { record: RecordHash }. A client could also borrow it from a sibling Record cell in the same row, but that would be a second derivation of which cells share a record, which ADR 002 forbids. Growing the variant then is an additive wire change. protocol.md §3's line 'the operand belongs with the command that consumes it' should not be read as saying the command's operand can come from anywhere but the cell.","created_at":"2026-09-23T05:20:39Z"}],"dependency_count":0,"dependent_count":1,"comment_count":1} -{"_type":"issue","id":"improvise-2mqz","title":"SaveAs and ExportCsv have no wire form, which is why two prompts are stranded","description":"Found by the improvise-h3y7 completeness audit (direction A: effects -\u003e vocabulary).\nOf 32 ViewOnly effects, these are the only two that write storage and have NO wire\nform of any kind -- exactly the hole `Save` was in before improvise-368h.\n\neffect::SaveAs(path) and effect::ExportCsv(path) are unreachable from any client.\nresolve.rs::stranding() refuses SavePrompt and ExportPrompt for precisely this\nreason, so THE DECLINE IS A SYMPTOM: closing it means closing this, not editing the\ndecline.\n\nSHAPE: request FRAMES, not Commands -- improvise-368h's stated reason applies\nunchanged. A runtime-fallible write inside a ClientFrame::Commands batch breaks the\nall-or-nothing promise; [Save, SetCell] reported a failed write and then applied the\nedit. And neither mutates shared state or any client's slice, so reduce_view has\nnothing to fold and Checked has no basis to answer for.\n\n ClientFrame::SaveAs(PathBuf) / ClientFrame::ExportCsv(PathBuf)\n\nanswered ServerFrame::Rejected(..) on failure, to the asker alone, and SILENTLY on\nsuccess -- nothing moved, so no widget is dirty.\n\nBLOCKED ON the KeyOutcome shape gap (a resolver cannot emit a frame today). Land that\nfirst.\n\nTHE OPEN QUESTION THIS RAISES AND DOES NOT ANSWER -- flag it in the PR and get a\nruling before shipping: a browser client naming a path in the HOST's Storage is a\ndifferent act from a TUI user doing it (gotcha 2 -- the host picks the backend). The\nalternative is that a browser gets a download and never names a host path. Say which\nin context/code/protocol.md; different protocols follow from each.","notes":"=== USER RULING 2026-09-22 -- THE SHAPE ABOVE IS SUPERSEDED. READ THIS FIRST. ===\n\n'Save can save to OPFS, but exports should be downloads. There should also be a\nway to download the .improv file.'\n\nSo the description's proposed ClientFrame::SaveAs(PathBuf) / ExportCsv(PathBuf) is\nWRONG and must not be built. A browser client never names a path in the host's\nStorage. That also ANSWERS the open question the description flagged, in the safe\ndirection -- do not re-open it.\n\nTHE CORRECT SHAPE: a request frame that asks the host for BYTES, and a page that\nturns bytes into a download. Uniform across both deployments, names no path, and\ncarries no filesystem semantics across the wire at all.\n\n ClientFrame -\u003e a request for the rendered document or export\n ServerFrame -\u003e the bytes (or Rejected)\n web/app.js -\u003e Blob + an \u003ca download\u003e, which is the ONLY new browser-side code\n\nTWO ARTEFACTS ARE WANTED, not one: the CSV export, and THE .improv FILE ITSELF.\nThe second is new scope the original ticket did not have and is arguably the more\nimportant of the two -- it is how a browser user gets their document out of OPFS\nand into a file they own. persistence::format_md already renders it; nothing new\nis needed server-side beyond the frame.\n\nWHY BYTES AND NOT CLIENT-SIDE RENDERING, which is the tempting shortcut:\nimprovise-io IS in the browser bundle, so the worker deployment could render CSV\nlocally. The socket deployment could not -- a thin client holds no Model. Rendering\nlocally where possible and asking otherwise would be TWO derivations of one answer,\nwhich is the disease design-principles §5 names and improvise-edp is the worked\nexample of. Ask the host in both.\n\nNOTE render_csv must build its layout with with_frozen_records, NOT GridLayout::new\n(gotcha 1, improvise-iot6) -- export.rs already does; do not regress it.\n\nSAVE ITSELF NEEDS NOTHING NEW: the worker already persists to OPFS on the dirty\nflag (gotcha 45), and ClientFrame::Save exists for the socket deployment. What is\nmissing there is only the KEY that reaches it -- improvise-jz6w.\n\nSTILL BLOCKED ON improvise-jz6w for the same reason as before: a resolver cannot\nemit a frame today.\nagent-wire2 2026-09-22: NOT TAKEN. It is not small, and by its own text it is a design question. (1) It is blocked on the KeyOutcome shape gap: a resolver cannot emit a frame today. (2) It needs a ruling first: should a browser name a path in the HOST's Storage (gotcha 2), or get a download and never name one? The PR #172 forward guard records SaveAs/ExportCsv as Verdict::Gap(improvise-2mqz), so closing this must also delete those two NOT_ON_THE_WIRE entries, or the_verdict_list_names_nothing_that_is_on_the_wire fails. That is only true if the closure adds Command variants of those names; ClientFrame variants would change the verdict to Frame(..).","status":"open","priority":1,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T04:47:04Z","created_by":"Edward Langley","updated_at":"2026-09-23T04:24:12Z","labels":["protocol"],"dependencies":[{"issue_id":"improvise-2mqz","depends_on_id":"improvise-jz6w","type":"blocks","created_at":"2026-09-22T04:48:02Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-2mqz","depends_on_id":"improvise-owep","type":"blocks","created_at":"2026-09-22T05:24:35Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-y11g","title":"The : command line must be present in the browser (core feature): a request frame carrying the line","description":"USER RULING 2026-09-22: \"The : command line needs to be present in the browser,\nit's another core feature.\"\n\nThis REVERSES the decline. resolve.rs::stranding() currently refuses\n`enter-mode command-mode` (with SavePrompt and ExportPrompt), and the\nimprovise-h3y7 audit classified it \"host-only unless the ruling is otherwise\n-- shipping it to a browser means shipping the registry\". The ruling is\notherwise, AND the audit's framing of the cost was wrong.\n\nTHE COST IS NOT WHAT IT LOOKED LIKE. The registry does not have to reach the\nclient, because IN BOTH DEPLOYMENTS THE HOST ALREADY HAS ONE:\n\n - socket (?ws=): the host is native improvise-ws-server. Obviously has it.\n - worker (no ?ws=): web/build.sh builds TWO wasm bundles -- web/pkg from\n improvise-wasm-client (thin) and web/worker-pkg from improvise-worker-server,\n which depends on improvise-command and therefore ALREADY CONTAINS THE FULL\n CmdRegistry, in the browser, today.\n\nSo a `:` line is a REQUEST FRAME CARRYING A STRING, executed by the host against\nits own App and CmdContext. Zero new bundle weight on the main thread, and no\nsecond implementation of the command language.\n\nTHIS MATTERS BECAUSE THE ALTERNATIVE IS EXPLICITLY FORBIDDEN. improvise-wasm-client's\nCargo.toml carries a written argument for why improvise-command is deliberately NOT\na dependency: a binding names an intent resolved against a CmdContext -- a Model, a\nGridLayout and an App the client does not have -- so depending on it would drag the\nwhole host into the bundle to reach a lookup that could not be used. Do not do that.\n\nSHAPE, and note it is the SAME shape as the export/download ticket (improvise-2mqz),\nwhich is a reason to land them together or at least to design them once:\n ClientFrame -\u003e the line, as a string\n ServerFrame -\u003e Rejected on a parse or execution failure, else the ordinary\n projection stream carries whatever the command changed\n\nWHY A FRAME AND NOT A Command, for improvise-368h's reason: ExecuteCommand runs an\narbitrary script line and is runtime-fallible, so it must not sit inside a\nClientFrame::Commands batch that promises all-or-nothing.\n\nBLOCKED ON improvise-jz6w: a resolver cannot emit a frame today.\n\nWHAT MUST LAND WITH IT, not after:\n - the minibuffer must actually RENDER. The user reported 2026-09-21 that the\n minibuffer for typing a category name \"doesn't actually show up anywhere so\n you're typing blind\". A `:` line you cannot see is not the feature.\n - remove the CommandMode arm from stranding() and say so in the named list.\n - help page 5 already advertises the whole `:` surface to browser users\n (improvise-uln5). Shipping this closes the advertised-but-unreachable gap for\n `:` specifically; check what uln5 has left afterwards rather than closing it.\n\nSECURITY NOTE, because `:` is a wider door than any other frame: a line can reach\n`:w`, `:e` and the import wizard, i.e. the host's Storage. In the worker deployment\nthat is the user's own OPFS and is fine. In the socket deployment it is SOMEONE\nELSE'S FILESYSTEM, and improvise-kc7n's Origin check answers what page is driving a\nclient, never WHO is connecting -- improvise-kpnr (authentication) is still open.\nDecide explicitly whether the socket deployment accepts `:` lines at all, or whether\nthe frame is refused there until kpnr lands. Do not let this arrive by accident.","status":"open","priority":1,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T05:13:57Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:13:57Z","labels":["browser","protocol"],"dependencies":[{"issue_id":"improvise-y11g","depends_on_id":"improvise-jz6w","type":"blocks","created_at":"2026-09-22T05:14:02Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-y11g","depends_on_id":"improvise-owep","type":"blocks","created_at":"2026-09-22T05:24:34Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-y11g","depends_on_id":"improvise-pmt0","type":"parent-child","created_at":"2026-09-25T01:05:00Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-fdk1","title":"A records coordinate rename has no wire form, and the surrogate is why","description":"Found by the improvise-h3y7 completeness audit, WHICH CORRECTED THE TICKET'S OWN\nDESCRIPTION OF THIS GAP -- and the correction changes the fix.\n\nimprovise-h3y7 said \"grepping reduce.rs for Rename returns zero\". True, and\nMISLEADING. Two paths do this:\n\n - effect::apply_coordinate_rename (ui/effect.rs:655), inside ApplyAndClearDrill,\n which is deliberately off the wire (gotcha 38).\n - commit.rs::rename_record_coordinate (cmd/commit.rs:366), the plain records path,\n which ALREADY decomposes into [AddItem, ClearCell(old), SetCell(new)] -- three\n commands that all have ModelCommand forms.\n\nSo it reads as though the plain path were already wire-expressible. IT IS NOT, for a\nreason the ticket does not state: ModelCommand::SetCell takes a RecordCell, whose\nRecordHash is an OPAQUE SURROGATE the server resolves against cells that ALREADY\nEXIST (App::resolve_record, ui/app.rs:516). The renamed record does not exist yet, so\na client cannot name it.\n\nTwo things that look like they solve this and do not. `will_be_created_by_the_frame`\n(ui/reduce.rs:284) exempts such a record from the BASIS CHECK -- it says nothing about\nhow to NAME one, and the two are easy to confuse. And RecordHash::of being pub is not\na licence: a client computing surrogates is precisely the invention ADR 002 exists to\nprevent.\n\nWHAT TO ADD: one position-free variant naming the existing record by surrogate and the\nnew coordinate by value --\n\n ModelCommand::RenameRecordCoordinate { record: RecordHash, category: String, item: String }\n\n-- mapped to a SINGLE effect factored out of the two copies above. Position-free, so\nimprovise-hkbc's rule still holds. Add it to records_named's match: it names one\nrecord, and creating the ITEM is internal to the effect, as both existing copies\nalready do.\n\nCONSOLIDATING THE TWO COPIES IS WORTH DOING REGARDLESS. They differ today only in how\nthey report the empty-value refusal, and gotcha 19 already warns that the synthetic-\nrecords branch and the plain branch drift.\n\nREMEMBER gotcha 1: renaming a category coordinate in wide records mode moves EVERY\nmeasure cell in the row, not just the one under the cursor. That is also why the basis\nunit is the record (gotcha 40) -- a cell-level unit is violated by this one legal edit.\n\nRELATED: improvise-o9uq is about the same code. Read it in full and say whether this\nsubsumes it before starting.","notes":"agent-wire2 2026-09-22, NOT BUILT, and here is the argument. There is no producer for the variant. A browser cannot enter records editing: edit_or_drill declines when state.grid.records is set (improvise-bd9o), toggle-records-mode and drill-into-cell are both in declined(), and a records coordinate cell projects id: None (improvise-exdg), so a client has no surrogate for it to echo. RenameRecordCoordinate would be a ModelCommand that nothing composes, which is the promise-not-kept shape vocabulary_is_composed.rs refuses for Command. Composing it needs the browser half of commit_plain_records_edit, and that needs RecordCol, which is session-local by gotcha 1. bd9o records that as a design question. So this ticket is BLOCKED on that design, not on vocabulary. What stands regardless: consolidating effect::apply_coordinate_rename with commit.rs::rename_record_coordinate (gotcha 19 drift). That work is in the command layer, outside the wire lane.","status":"closed","priority":1,"issue_type":"task","assignee":"agent-hzjo2","owner":"el-github@elangley.org","created_at":"2026-09-22T04:47:16Z","created_by":"Edward Langley","updated_at":"2026-09-24T21:45:10Z","started_at":"2026-09-23T06:06:11Z","closed_at":"2026-09-24T21:45:10Z","close_reason":"Landed in main via #221 (reviewed; merged 2026-09-24).","labels":["protocol"],"comments":[{"id":"16e0ef09-54e4-5ca9-86d6-4dddfa573ef0","issue_id":"improvise-fdk1","author":"Edward Langley","text":"agent-review-193-196, reviewing PR #196 (6b3dd8c): #196 fits this ticket and does not foreclose it. It names the coordinate cell CellId::Coordinate but gives it no operand. This ticket's NOTES list 'a records coordinate cell projects id: None, so a client has no surrogate for it to echo' as a blocker. That blocker still stands after #196: a unit variant gives a client nothing to echo. When a producer exists, RenameRecordCoordinate needs the row's RecordHash from the PROJECTION, as Coordinate { record: RecordHash }. A client could also borrow it from a sibling Record cell in the same row, but that would be a second derivation of which cells share a record, which ADR 002 forbids. Growing the variant then is an additive wire change. protocol.md §3's line 'the operand belongs with the command that consumes it' should not be read as saying the command's operand can come from anywhere but the cell.","created_at":"2026-09-23T05:20:39Z"}],"dependency_count":0,"dependent_count":1,"comment_count":1} +{"_type":"issue","id":"improvise-2mqz","title":"SaveAs and ExportCsv have no wire form, which is why two prompts are stranded","description":"Found by the improvise-h3y7 completeness audit (direction A: effects -\u003e vocabulary).\nOf 32 ViewOnly effects, these are the only two that write storage and have NO wire\nform of any kind -- exactly the hole `Save` was in before improvise-368h.\n\neffect::SaveAs(path) and effect::ExportCsv(path) are unreachable from any client.\nresolve.rs::stranding() refuses SavePrompt and ExportPrompt for precisely this\nreason, so THE DECLINE IS A SYMPTOM: closing it means closing this, not editing the\ndecline.\n\nSHAPE: request FRAMES, not Commands -- improvise-368h's stated reason applies\nunchanged. A runtime-fallible write inside a ClientFrame::Commands batch breaks the\nall-or-nothing promise; [Save, SetCell] reported a failed write and then applied the\nedit. And neither mutates shared state or any client's slice, so reduce_view has\nnothing to fold and Checked has no basis to answer for.\n\n ClientFrame::SaveAs(PathBuf) / ClientFrame::ExportCsv(PathBuf)\n\nanswered ServerFrame::Rejected(..) on failure, to the asker alone, and SILENTLY on\nsuccess -- nothing moved, so no widget is dirty.\n\nBLOCKED ON the KeyOutcome shape gap (a resolver cannot emit a frame today). Land that\nfirst.\n\nTHE OPEN QUESTION THIS RAISES AND DOES NOT ANSWER -- flag it in the PR and get a\nruling before shipping: a browser client naming a path in the HOST's Storage is a\ndifferent act from a TUI user doing it (gotcha 2 -- the host picks the backend). The\nalternative is that a browser gets a download and never names a host path. Say which\nin context/code/protocol.md; different protocols follow from each.","notes":"=== USER RULING 2026-09-22 -- THE SHAPE ABOVE IS SUPERSEDED. READ THIS FIRST. ===\n\n'Save can save to OPFS, but exports should be downloads. There should also be a\nway to download the .improv file.'\n\nSo the description's proposed ClientFrame::SaveAs(PathBuf) / ExportCsv(PathBuf) is\nWRONG and must not be built. A browser client never names a path in the host's\nStorage. That also ANSWERS the open question the description flagged, in the safe\ndirection -- do not re-open it.\n\nTHE CORRECT SHAPE: a request frame that asks the host for BYTES, and a page that\nturns bytes into a download. Uniform across both deployments, names no path, and\ncarries no filesystem semantics across the wire at all.\n\n ClientFrame -\u003e a request for the rendered document or export\n ServerFrame -\u003e the bytes (or Rejected)\n web/app.js -\u003e Blob + an \u003ca download\u003e, which is the ONLY new browser-side code\n\nTWO ARTEFACTS ARE WANTED, not one: the CSV export, and THE .improv FILE ITSELF.\nThe second is new scope the original ticket did not have and is arguably the more\nimportant of the two -- it is how a browser user gets their document out of OPFS\nand into a file they own. persistence::format_md already renders it; nothing new\nis needed server-side beyond the frame.\n\nWHY BYTES AND NOT CLIENT-SIDE RENDERING, which is the tempting shortcut:\nimprovise-io IS in the browser bundle, so the worker deployment could render CSV\nlocally. The socket deployment could not -- a thin client holds no Model. Rendering\nlocally where possible and asking otherwise would be TWO derivations of one answer,\nwhich is the disease design-principles §5 names and improvise-edp is the worked\nexample of. Ask the host in both.\n\nNOTE render_csv must build its layout with with_frozen_records, NOT GridLayout::new\n(gotcha 1, improvise-iot6) -- export.rs already does; do not regress it.\n\nSAVE ITSELF NEEDS NOTHING NEW: the worker already persists to OPFS on the dirty\nflag (gotcha 45), and ClientFrame::Save exists for the socket deployment. What is\nmissing there is only the KEY that reaches it -- improvise-jz6w.\n\nSTILL BLOCKED ON improvise-jz6w for the same reason as before: a resolver cannot\nemit a frame today.\nagent-wire2 2026-09-22: NOT TAKEN. It is not small, and by its own text it is a design question. (1) It is blocked on the KeyOutcome shape gap: a resolver cannot emit a frame today. (2) It needs a ruling first: should a browser name a path in the HOST's Storage (gotcha 2), or get a download and never name one? The PR #172 forward guard records SaveAs/ExportCsv as Verdict::Gap(improvise-2mqz), so closing this must also delete those two NOT_ON_THE_WIRE entries, or the_verdict_list_names_nothing_that_is_on_the_wire fails. That is only true if the closure adds Command variants of those names; ClientFrame variants would change the verdict to Frame(..).","status":"open","priority":1,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T04:47:04Z","created_by":"Edward Langley","updated_at":"2026-09-23T04:24:12Z","labels":["protocol"],"dependencies":[{"issue_id":"improvise-2mqz","depends_on_id":"improvise-jz6w","type":"blocks","created_at":"2026-09-22T04:48:02Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-2mqz","depends_on_id":"improvise-owep","type":"blocks","created_at":"2026-09-22T05:24:35Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-2mqz","depends_on_id":"improvise-pmt0","type":"parent-child","created_at":"2026-09-25T01:04:59Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-jz6w","title":"KeyOutcome cannot express 'send a frame', so Ctrl-S cannot reach the ClientFrame::Save that already exists","description":"Found by the improvise-h3y7 completeness audit. It is a SHAPE gap, not a vocabulary\ngap, which is why an audit that enumerates Command/ModelCommand against effects finds\nnothing here.\n\nClientFrame::Save landed with improvise-368h and Host::handle answers it (ui/host.rs:83).\nBut KeyOutcome is `Local(Vec\u003cCommand\u003e) | Upstream(KeyEvent) | Ignored`\n(improvise-protocol/src/resolve.rs:20), so a resolver has NO WAY to say \"send this\nframe\". Consequently `save` is still on the_command_names_that_still_need_the_host_are_these,\nresolves to Upstream, and Host::handle answers Upstream with an empty vec (gotcha 47:\nan upstream key is a BLACK HOLE, not a slow path).\n\nSo: an implemented server feature that no key can reach.\n\nWHAT TO ADD: a fourth KeyOutcome variant carrying a ClientFrame -- or a narrower\n`Request` enum, if a resolver handing out arbitrary frames is too wide. ARGUE THE\nCHOICE in the PR rather than picking silently; the narrow form is the one that keeps\na resolver from composing a Viewport or a Commands batch by accident.\n\nThen bind Ctrl-S through it and REMOVE `save` from the host list in the same commit --\ngotcha 47: closing a gap has to come to that list and say so.\n\nTHE GUARD: end to end, in the style of tests/browser_round_trip.rs -- Ctrl-S on a\nClientState yields the save request, and Host::handle on that request writes the\ndocument through a real App. Not two half-tests that never meet.\n\nTHIS UNBLOCKS the SaveAs/ExportCsv frames (filed separately), which need the same\nmechanism. Do this one first; it is small on its own.\n\nNOTE for whoever reads KeyOutcome afterwards: context/code/clients.md should say that\nKeyOutcome's variants are part of the protocol surface and belong in any completeness\nenumeration. That omission is why this gap was invisible to the audit's first pass.","notes":"FIXED in PR #134 (merge a799e24). Reproduced at HEAD first: the new end-to-end\ntest failed with left: Key { code: Char('s'), modifiers: CONTROL } right: Save --\nCtrl-S went Upstream -\u003e ClientFrame::Key -\u003e Host::handle's empty vec.\n\nTHE WIDTH WAS THE DECISION, and it is argued rather than defaulted:\nKeyOutcome::Request(Request) with a NARROW enum (Request::Save, plus\nRequest::frame as the one mapping to an envelope), NOT a ClientFrame. Two reasons:\n 1. An arbitrary frame lets a resolver compose a Viewport, whose extent is the\n PAGE's fact and reaches no derivation from a keymap.\n 2. The serious one: it lets a resolver compose a Commands batch, which is\n INDISTINGUISHABLE ON THE WIRE FROM A Local while skipping the local fold\n Local's contract requires. That desync has nothing to observe it.\n\nTwo consequences guarded rather than asserted in prose: `requested` is checked\nFIRST in step, so no name sits in two lists; and a request cannot travel inside a\nBinding::Sequence (a sequence is one frame), with\nno_binding_puts_a_request_inside_a_sequence keeping that refusal arm unreachable\nrather than merely untested. NOT routed through Commands -- gotcha 45's reason\nholds. `save` left the host-needed list in the same commit.\n\nTHE LOAD-BEARING RIVAL: Request::Save =\u003e ClientFrame::Commands(vec![]). The\nSTORAGE assertion alone cannot separate \"reached the host\" from \"reached the host\nas the wrong kind of thing\", because an empty batch writes nothing either way.\nAsserting the FRAME is the discriminator.\n\nTWO FINDINGS WORTH MORE THAN THE FIX:\n\n1. THE AUDIT THAT FOUND THIS TICKET COULD NOT HAVE FOUND IT. improvise-h3y7\n enumerated Command/ModelCommand against the effects and nothing was missing\n from either -- the gap was in KEYOUTCOME'S SHAPE. KeyOutcome's variants are\n protocol surface too, and an enumeration that stops at the frames and the\n commands will miss the next one the same way.\n\n2. THE HOST SIDE WAS ALREADY TESTED, by a_client_can_ask_its_host_to_save, WHICH\n CONSTRUCTS THE FRAME ITSELF. A passing test over a feature nothing can reach is\n the \"green suite is not evidence\" shape exactly -- improvise-368h shipped that\n way. Worth checking whether other Host::handle arms have only\n self-constructed-frame coverage.\n\nNOTE ON repo-map: the agent measured 1686 at d672985 over 38 binaries, where\nrepo-map still says 1464/35 -- stale by 222. The File Inventory's own preamble\nsays those are a dated snapshot, so this is the preamble being right rather than\na defect.","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T04:47:03Z","created_by":"Edward Langley","updated_at":"2026-09-22T20:59:52Z","closed_at":"2026-09-22T20:59:52Z","close_reason":"Closed","labels":["browser","protocol"],"dependency_count":0,"dependent_count":2,"comment_count":0} {"_type":"issue","id":"improvise-m0sx","title":"Command::ClearBuffer is invented, and disagrees with the only implementation of its own name","description":"Found by the improvise-h3y7 completeness audit (direction B: vocabulary -\u003e effects),\nand VERIFIED INDEPENDENTLY by the orchestrator before filing.\n\nTHE DIVERGENCE. reduce_view's arm (improvise-protocol/src/reduce.rs:269) does\n`view.buffers.remove(name)` -- it REMOVES the key. The host's `clear-buffer`\ncommand (command/cmd/effect_cmds.rs:226) composes `core::write_buffer(name, String::new())`,\nwhich is `effect::SetBuffer` -- and SetBuffer INSERTS, leaving `buffers[name] == \"\"`.\nresolve.rs::write_buffer composes the same SetBuffer. So the one behaviour both\nends actually implement leaves an empty string, and the wire variant says otherwise.\n\nThere is NO `ClearBuffer` effect anywhere in the workspace. The variant is therefore\nnot derived from the effect set, violating decision(improvise-cqi,\ncommand_set_derived_not_invented). It is the exact INVERSE of the ViewBack defect\nthat improvise-h3y7 was opened over: ViewBack was an effect with no Command; this is\na Command with no effect.\n\nREACHABILITY. `grep -rn \"Command::ClearBuffer\" crates/ src/ web/` returns exactly\nthree hits, ALL inside reduce.rs: the definition at :39 and two of reduce.rs's own\ntests at :405 and :465. No production code composes it. (Note `ClearBufferCmd` in\neffect_cmds.rs and registry.rs is the command-layer NAME, not the wire variant --\nthey are different things that read the same, which is part of why this survived.)\n\nImpact if it were ever sent: a Snapshot seeded from a ViewState where the host left\n`buffers[\"edit\"] == \"\"` against an incrementally-folded client with no key -- two\nclients whose ViewStates are PartialEq-unequal for the \"same\" state.\n\nDECIDE ONE, AND SAY WHICH IN context/code/protocol.md:\n(a) delete the variant and its two tests -- free today, since nothing composes it; or\n(b) keep it as the derived form and change `clear-buffer`'s effect to remove the key,\n which makes ViewState equality agree between a folded client and a snapshot-seeded one.\n\nTHEN ADD THE GUARD that makes this whole class visible: an exhaustive test over\n`variants::declared::\u003cCommand\u003e()` asserting every variant is composed by some\nproduction resolver or relay. The machinery is already in\ncrates/improvise-protocol/src/variants.rs. Prove it discriminating by deleting one\nresolver arm and watching it fail -- a variant nothing composes is either dead or a\npromise, and both need naming.\n\nNOTE the two existing tests currently PIN the divergent behaviour (they assert\n`!view.buffers.contains_key(\"edit\")`), so whichever answer is taken, they change.","status":"closed","priority":1,"issue_type":"bug","assignee":"agent-wire","owner":"el-github@elangley.org","created_at":"2026-09-22T04:46:49Z","created_by":"Edward Langley","updated_at":"2026-09-23T03:29:39Z","started_at":"2026-09-23T02:48:53Z","closed_at":"2026-09-23T03:29:39Z","close_reason":"Merged in PR #160. Closed by the orchestrator after the session handover; the implementing agent's claim was abandoned when its process exited. Details of the fix, the rival injected and what the ticket did not predict are on the PR thread.","labels":["protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-h3y7","title":"The wire vocabulary must be complete against what needs the server, not merely correct","description":"USER RULING 2026-09-21: \"the wire protocol needs to cover the full range of commands that need the server side.\"\n\nThis is a COMPLETENESS requirement on the vocabulary, and it lands on a\nweakness the wave just demonstrated rather than on a suspicion.\n\nTHE PRECEDENT THAT MAKES IT URGENT. PR #115 found that gotcha 38's account of\nwhy ViewBack/ViewForward were off the wire was FALSE: it said they were\nsession-local residue like ApplyAndClearDrill, but view_back_stack is in\nViewState -- the portable slice -- so by the view half's own rule\n(decision(improvise-cqi): the ViewOnly effects targeting the portable slice)\nthey were always Commands and were simply MISSING. The derivation had been\nchecked for CORRECTNESS -- every listed variant belongs -- and never for\nCOMPLETENESS. The cost was a live divergence nobody could see: reduce_view's\nSwitchView arm set active_view and nothing else while effect::SwitchView pushes\nthe back stack, so NO CLIENT EVER HAD A BACK HISTORY.\n\nSo this bead is: enumerate the vocabulary AGAINST the rule, in both directions,\nand close what the enumeration finds.\n\nTHE KNOWN GAPS AT 523d571, to be treated as a starting list rather than the\nanswer:\n\n1. NO ModelCommand FOR A COORDINATE RENAME. ApplyAndClearDrill's\n RecordCol::Category(_) arm is apply_coordinate_rename -- add an item, then\n move EVERY measure cell in the row (gotcha 1) -- and grepping reduce.rs for\n Rename returns zero. A drill's category-column commit therefore has no wire\n form. This is a missing ModelCommand, NOT a missing addressed form, and is\n much smaller than it was assumed to be.\n\n2. SIX NAMES IN declined() (resolve.rs:232) that a browser cannot ask for at all:\n drill-into-cell, toggle-prune-empty, toggle-records-mode,\n add-record-row, delete-formula-at-cursor, view-back\n plus enter-mode via stranding (SavePrompt, ExportPrompt, CommandMode).\n view-back becomes removable when PR #115 merges -- the comment blaming the\n drill commit is wrong as of that PR. The others each need their own answer:\n some want a ModelCommand, some want a projection, and some may be genuinely\n host-only. SAY WHICH, PER NAME.\n\n3. EIGHT NAMES in the_command_names_that_still_need_the_host_are_these:\n hide-selected-row-item, toggle-group-under-cursor, search,\n search-navigate, search-or-category-add, enter-advance, save, force-quit\n (search-or-category-add leaves with improvise-koh6/PR #114.) Note gotcha 47:\n an UPSTREAM key is a black hole, not a slow path -- Host::handle answers\n ClientFrame::Key with an explicit empty vec -- so every name on this list\n does NOTHING in a browser rather than being merely slower.\n\n4. ClientFrame::Key ITSELF. It exists, is deployment-independent by gotcha 42's\n argument, and is answered with an empty vec. Either it should carry\n something or it should not exist; today it is a promise the protocol does\n not keep.\n\nWHAT THIS BEAD IS NOT: a licence to put everything on the wire. gotcha 38\nrecords two effects deliberately EXCLUDED -- ApplyAndClearDrill and\nAcceptWizardModel -- because their operands are session-local residue addressed\nto precisely one session, and an unaddressed form lets client B commit client\nA's typing (demonstrated against Sessions::apply before removal). The rule is\n\"everything that NEEDS the server\", and residue does not. The test for each\ncandidate is the one gotcha 38 already states: does the argument for excluding\nit name the thing it differs in?\n\nACCEPTANCE: a per-name verdict for every entry in (2) and (3) -- on the wire,\ndeliberately excluded with the argument, or blocked on a named prerequisite --\nplus the enumeration in (1) closed or filed. A list of what is missing is worth\nmore than a partial fix, and doctrine(a_stale_list_of_what_is_missing_is_worse_than_no_list)\nmeans the verdict list has to be maintained where it can be checked, not left\nin this bead.\n","notes":"AUDIT COMPLETE 2026-09-22 against 44b8819. Both derivations run.\n\nMECHANISM: brace-matched impl Effect blocks in ui/effect.rs =\u003e 26 EffectKind::Model(_),\nverified against gotcha 38's stated 26/24. NOTE THE MECHANISM UNDER-REPORTS BY 9: it\nmisses the 8 simple_effect! macro expansions and the one impl Effect in\ncommand/keymap.rs (SetTransientPrefix). Total effects = 58: 26 Model(_), 32 ViewOnly.\nAll 9 missed are ViewOnly, which is exactly the half the ViewBack bug lived in.\nGotcha 38 warns the naive awk OVER-reports by picking up HelpPageSet; the brace-matcher\nit recommends instead UNDER-reports, and nothing in context/ said so.\n\nDIRECTION A (effects -\u003e vocabulary): no gap in the model half -- all 26 accounted for.\nIn the view half, 21 of 32 have a Command; 9 of the remaining 11 are soundly excluded\nand TWO ARE A GAP: SaveAs and ExportCsv write storage and have no wire form at all\n(=\u003e improvise-2mqz).\n\nDIRECTION B (vocabulary -\u003e effects), NEVER RUN BEFORE, three finds:\n - Command::ClearBuffer is INVENTED and contradicts the only implementation of its\n name (=\u003e improvise-m0sx, P1, orchestrator-verified independently)\n - effect::HelpPageSet unclamped where its mirror clamps (=\u003e improvise-4n4i)\n - effect::SwitchView pushes the back stack before a guarded switch (=\u003e improvise-swzk)\n\nSTRUCTURAL PROPERTY FOUND AND NOT PREVIOUSLY WRITTEN DOWN: apply_command handles only\nSwitchView and Model; every other Command falls to _ =\u003e Dirty::NOTHING and is folded\ninto the asking session's mirror by reduce_view -- THE SAME FUNCTION both ends run. So\nmirror-vs-client drift is structurally impossible, and the whole direction-B risk\ncollapses to ~21 pairs of 'does reduce_view's arm do what its effect does'. That is an\nafternoon, and re-running it after any wave touching reduce_view or effect.rs is the\nonly thing standing between this repo and another ViewBack.\n\nTHE HEADLINE, which corrects this ticket's own framing: THE VOCABULARY IS FAR MORE\nCOMPLETE THAN ASSUMED. Of 13 gaps examined, EIGHT are closed in the vocabulary and open\nonly in the RESOLVER. Taken literally, this ticket would have produced new variants for\nthings already on the wire. Only three things need new wire surface: SaveAs/ExportCsv\n(frames), the coordinate rename (a ModelCommand), add-record-row (a new naming mode).\n\nGENERALISABLE FINDING: declined() is NOT evidence of a vocabulary gap and is NOT\nmaintained against the vocabulary. It is a sentence written when a name was hard, and\nnothing re-checks it when the thing that made it hard lands. view-back is the proven\ninstance (improvise-cfly is cleaning up after PR #115); toggle-prune-empty and\ntoggle-records-mode are two more sitting in that state now. The structural fix is that\nthe decline list and the host list must be DERIVED FROM or CHECKED AGAINST the\nvocabulary, not hand-kept beside it.\n\nALSO: a SHAPE gap can hide behind a vocabulary audit. 'save' looks like a missing\nCommand and is not -- ClientFrame::Save exists and is answered; the only missing thing\nis a KeyOutcome variant that can express sending a frame (=\u003e improvise-jz6w).\nKeyOutcome's variants are part of the protocol surface and belong in any completeness\nenumeration; context/code/clients.md should say so.\n\nAND: the surrogate is the binding constraint on the whole CREATION class. RecordHash is\nresolved server-side against cells that exist, so a client cannot name a record it is\nabout to create. Invisible from the ModelCommand list. will_be_created_by_the_frame\nexempts such a record from the BASIS CHECK and says nothing about NAMING one -- the two\nare easy to confuse. Deserves a line in ADR 002.\n\nCHILDREN FILED: improvise-m0sx (P1), improvise-jz6w (P1), improvise-2mqz (P1, blocked\nby jz6w), improvise-fdk1 (P1), improvise-fo0w (P2), improvise-1ohv (P2, blocked by\nfdk1), improvise-4n4i (P3), improvise-swzk (P3).\nALREADY FILED, not duplicated: improvise-7xyx (raised P3-\u003eP2: it is a black-hole key,\nnot just a dead field), improvise-hs0u (prefixes), improvise-gd28 (drill),\nimprovise-bd9o (records mode), improvise-uln5 (advertised-vs-resolvable).\nFOUR PRODUCT RULINGS OWED -- see the orchestrator's hand-off.","status":"closed","priority":1,"issue_type":"task","assignee":"agent-wire2","owner":"el-github@elangley.org","created_at":"2026-09-22T02:51:02Z","created_by":"Edward Langley","updated_at":"2026-09-23T04:28:18Z","started_at":"2026-09-23T03:33:55Z","closed_at":"2026-09-23T04:28:18Z","close_reason":"PR #172 MERGED (confirmed at 119f84f), adversarially reviewed then re-reviewed after fixes (merge). h3y7: the forward vocabulary guard (ui/reduce/vocabulary_is_complete.rs) requires every Effect to be a same-named Command/ModelCommand variant or carry a NOT_ON_THE_WIRE verdict (Command/Frame/Off/Gap), checked in both directions. hkbc: position_free destructures every ModelCommand with no '..'. c7mu: one shared scanner, improvise_protocol::source_scan::production_code, blanks comments and literals and drops test code for BOTH vocabulary guards. 94x2: name_after skips attributes, so an attributed simple_effect! is still seen; re-review injected three attribute spellings and all were caught. Residual: improvise-59q8 (P3, the scanner's latent line-scan holes).","labels":["architecture","protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-koh6","title":"[bug] Following the help overlay's N to add a category overwrites a data cell","description":"DEMONSTRATED end to end under Chromium at 523d571, on web/demo.improv, with\nthree controls (C, P, T) in the same loop that DID move -- otherwise a blind\nprobe and a black hole look identical.\n\n cell (0,0) before: \"18,400\"\n keys: N R e g i o n Enter\n cell (0,0) after: \"18400on\" \u003c- Espresso/Revenue, overwritten with text\n categories: UNCHANGED -- no category was created\n status: \"...records mode is not available in the browser yet\"\n\nTHE SEQUENCE, measured one key at a time:\n\n - `N` resolves Upstream. Host::handle answers a Key frame with an empty vec, so\n the FULL DOM snapshot -- mode, hint, status, grid, tiles, minibuffer -- is\n BYTE-IDENTICAL before and after. Completely silent.\n - The user then types the category name into NORMAL, where every character is a\n live Normal binding:\n R -\u003e status \"records mode is not available in the browser yet\"\n e, g -\u003e nothing\n i -\u003e INSERT on the grid cell under the cursor, PRE-FILLED with its value\n o, n -\u003e appended to that edit buffer\n - Enter commits.\n\n`N` is advertised by the browser's OWN help overlay, page 4:\n\"N -- Quick-add a new category (from anywhere)\".\n\nWHY THIS IS NOT THE MINIBUFFER BUG IT WAS REPORTED AS: the minibuffer works\nperfectly. Measured: C then n gives mode NEW CATEGORY, minibuffer \"new category:\"\nin yellow, 1280x28.6 at y=691, and each typed character appears live; Enter\ngives 'Added category \"Reg\"'. Verified in two more modes (F,n -\u003e \"formula:\" cyan;\nC,a -\u003e \"add item to Product:\" green). The user's \"typing blind\" was LITERAL -- a\ndead key followed by characters landing as commands.\n\nTHE CATEGORY THIS ADDS TO THE DOCTRINE: clients.md enumerates black holes as\nkeys that do nothing. A black hole that leaves the keyboard in a MUTATING mode\nis strictly worse and is not currently distinguished. The cost here is a\ncorrupted value, not a confused user.\n\nSECOND, COMPOUNDING PROBLEM: the decline messages actively mislead. Pressing N\nthen R answers \"records mode is not available in the browser yet\" -- confident,\ntrue, and about a different subject entirely, arriving adjacent in time to an\nunanswered gesture. clients.md already names the failure (\"a reader who is told\nsomething plausible stops looking\"); this is a new route to it.\n\nIS THERE A ONE-LINE FIX? NO, and the investigating agent resisted calling it one.\nSearchOrCategoryAdd::execute (command/cmd/search.rs:181) is:\n\n if !ctx.search_query.is_empty() { SearchNavigate(false).execute(ctx) }\n else { vec![SetPanelOpen{Category,true}, change_mode(AppMode::category_add())] }\n\nThe else-branch is exactly two commands the browser already composes elsewhere,\nand search_query is on ViewState, so the client HAS everything it needs. But a\nconditional arm makes N's outcome STATE-DEPENDENT, and\nenter_in_an_empty_view_panel_resolves_locally_rather_than_upstream\n(resolve.rs:1364) exists precisely to forbid making the host-needed list\nstate-dependent. So it is a decision, not a line.\n\nCHEAP INTERIM that stops the data loss without deciding that: one arm in\ndeclined() giving search-or-category-add its own true subject. That ends the\nsilence but NOT the sequel, because the user's next keystrokes still land in\nNORMAL where `i` opens an editor.\n","notes":"ALREADY FIXED by 7a44616, an ancestor of 7470b61. Stale-open -- the fifth ticket\nclosed by that single commit.\n\nresolve.rs:379 answers search-or-category-add in composed() in BOTH arms: the\nempty-query arm builds SetPanelOpen{Category,true} + ChangeMode(category_add),\nand the other declines out loud. So N is no longer Upstream, and therefore no\nlonger a black hole that left the keyboard in Normal where i opens an editor over\na data cell -- which was this ticket's whole mechanism.\n\nNOTE A MISREADING WORTH RECORDING: an earlier agent reported that\nsearch-or-category-add \"left the host-needed list WITH improvise-koh6\", which I\nread as a side effect of that PR. It was the whole close. A ticket named in\nanother ticket's departure note is easy to read as context rather than as\nresolution.\n\nNOT VERIFIED END TO END: the browser half was confirmed by reading the resolver\narm and its ancestry, not by driving Chromium -- harness/ was outside the\nverifying agent's file scope, and this ticket's original evidence was gathered\nthere.","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T00:22:11Z","created_by":"Edward Langley","updated_at":"2026-09-22T07:40:28Z","closed_at":"2026-09-22T07:40:28Z","close_reason":"Closed","labels":["browser","data-loss","ux"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-cgqt","title":"Import collision policy should be a wizard step, not a fixed default","description":"improvise-2cw9 decided that two source rows landing on one coordinate tuple are\nSUMMED and reported. That decision is recorded on the bead and is right as a\ndefault -- it is what a pivot table does and what measure_agg defaults to.\n\nIt is WRONG for a measure the user later sets to Avg: the value is already\npre-summed, so the average is taken over the wrong denominator. The warning is\nwhat currently keeps that from being invisible.\n\nThe user is the only one who knows which they want. The shape is a wizard step\nthat asks, on the field where the collision was detected -- not a change to the\ndefault, and not a silent guess from the field's inferred kind.\n\nSearch `duplicate`, `collision`, `aggregate` before filing anything adjacent.\n","status":"closed","priority":1,"issue_type":"feature","assignee":"agent-cgqt","owner":"el-github@elangley.org","created_at":"2026-09-21T21:59:43Z","created_by":"Edward Langley","updated_at":"2026-09-25T02:22:13Z","started_at":"2026-09-25T01:10:23Z","closed_at":"2026-09-25T02:22:13Z","close_reason":"Landed in PR #304 (merged); reviewed PASS at 22cbfa67","labels":["import","ux"],"dependencies":[{"issue_id":"improvise-cgqt","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T01:05:28Z","created_by":"Edward Langley","metadata":"{}"}],"comments":[{"id":"0a4ce58b-d381-5b92-bc64-ba1e32c2f5f4","issue_id":"improvise-cgqt","author":"Edward Langley","text":"USER DECISION 2026-09-25 (answered in orchestrator session, after an Etrade transactions import summed 31 colliding cells; user called it critical): when import rows collide on every coordinate, the wizard ASKS, offering (a) sum them (today's default, 2cw9), (b) keep them separate by adding a row-number category, (c) cancel/go back. Chosen over 'always keep every row via a hidden virtual row category' and over 'refuse the import'. Raised P3 -\u003e P1. Also in scope: the Confirm step truncates the collision warning to one line, so the user cannot see which rows collided -- the step must show them. Open detail the implementer must surface, not guess: option (b) adds a REGULAR category, and the reported import already had 12 (MAX_CATEGORIES), so (b) cannot be offered at the cap as described.","created_at":"2026-09-25T01:10:07Z"},{"id":"5c2662ac-64d5-58fa-8a75-4200c9c180a7","issue_id":"improvise-cgqt","author":"Edward Langley","text":"USER REFINEMENT 2026-09-25, verbatim: 'ask during import and offer to generate a disambiguating category.' Option (b) is therefore a generated category whose job is only to tell colliding rows apart (preferred: occurrence index within each collision group, items 1..n in source order), with a user-editable suggested name.","created_at":"2026-09-25T01:10:17Z"},{"id":"98b45303-397e-559a-a284-cb51ff3ff6b3","issue_id":"improvise-cgqt","author":"Edward Langley","text":"USER, 2026-09-25, verbatim: 'a message like this should wrap not truncate so I can read the whole thing.' In scope here for every import-wizard warning; other surfaces to be filed separately.","created_at":"2026-09-25T01:10:48Z"}],"dependency_count":0,"dependent_count":0,"comment_count":3} {"_type":"issue","id":"improvise-1wft","title":"Browser boot failures have no diagnosis channel, so five different causes all read as silence","description":"Five open beads, filed independently by three different reviewers, are one\nfailure mode: the browser's boot can fail for several reasons and the page\neither says nothing specific or nothing at all.\n\n improvise-i0v9 OPFS handle held by another context (or a prior session)\n improvise-86nw start() rejects for a non-OPFS reason; onFrame then drops\n every frame while `server` is null\n improvise-9cma invalid ?doc= -- generic watchdog text only\n improvise-iobc bad or missing ?seed= -- silent empty document, CORS/404\n visible only in the console\n improvise-vep8 the watchdog arms AFTER `await transport.ready`, so a\n connection hang never arms it at all\n\nTHE SHARED ROOT: there is no channel carrying a boot failure's REASON from the\nworker to the page. gotcha 46 already records the mechanism -- a worker that\nrejects in start() fires NO `error` on the main thread, because worker.js\ncatches its own -- so the only survivor is a generic first-frame watchdog, and\nimprovise-vep8 shows that watchdog does not even arm in one case.\n\nThe result is the worst shape a failure can take, measured on i0v9: `#status`\nreads \"in-page worker - \u003cdoc\u003e\" at 13ms, which looks like SUCCESS, and 8 seconds\nlater is replaced by \"this browser may not allow it\" -- blaming the browser for\na condition the code knows precisely, since it is holding the exact\nNoModificationAllowedError naming the exact document.\n\nWHAT THIS TICKET IS: the diagnosis channel, not the five triggers. The worker\nmust be able to report a structured boot failure across postMessage, and the\npage must render it instead of a timeout. Each of the five then becomes a\nmatter of producing the right reason rather than of plumbing.\n\nTwo things to decide while building it, both raised by the triggers above:\n - a boot failure is a STATE, not a rejected promise (i0v9 needs the page to\n open and paint read-only, which means boot must succeed without a handle)\n - the watchdog stays, but as a LAST resort that is armed before anything can\n hang (vep8), and is disarmed rather than pre-empted by a precise failure\n (gotcha 48 already states that rule and PR #21 implemented half of it)\n\nNot a new investigation: every one of the five is already demonstrated. This\nexists so the five are not fixed five different ways.\n","status":"closed","priority":1,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-21T21:26:34Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:58:54Z","closed_at":"2026-09-21T21:58:54Z","close_reason":"Fixed in the 2026-09-21 wave, integrated into main at 1574 passed / 0 failed / 7 ignored. Playwright 42 passed / 0 failed / 4 skipped (WebKit, no OPFS) -- up from 37, so the count MOVED. All gates green.\n\nTHE CHANNEL: a protocol frame crosses the port as a STRING, a shell notice as an OBJECT. Total discriminator, zero protocol change, since ServerFrame JSON is a string by construction of on_message.\n\nA boot failure is now a STATE, not a rejected promise: a document another context holds the handle to opens READ-ONLY over vfs::ReadOnly rather than dying. Only BootFailed is fatal. The watchdog is armed BEFORE transport.ready and every notice routes through shell.fail, so gotcha 48's disarm half holds.\n\nMoving the watchdog forced its message to change, and that is load-bearing: armed before ready, it can no longer claim 'accepted the connection but sent no document' -- the page does not know that yet. The existing spec asserted the OLD wording and failed, which is the right way round and is how it was found.","labels":["architecture","browser","ux"],"dependency_count":0,"dependent_count":6,"comment_count":0} {"_type":"issue","id":"improvise-55l5","title":"dispatch swallows interactive() errors silently","description":"## Summary\n\n`dispatch` uses `.ok()?` on `interactive()`, swallowing `Err` (argument parse failures, mode mismatch for `commit-buffer item`, etc.). The user presses a key and nothing happens — no status message. Violates `doctrine(an_upstream_key_is_a_black_hole)` for failures that *are* resolvable as user-visible errors.\n\n## Expected / Actual\n\n- **Expected:** Construction/resolve errors surface as status (or equivalent loud feedback).\n- **Actual:** `.ok()?` turns Err into silent no-op.\n\n## Evidence / verify\n\nBind a key to `commit-buffer item` in Normal mode (not ItemAdd). Press the key — command fails to construct with no status message.\n\n## Doctrine\n\n`doctrine(an_upstream_key_is_a_black_hole)` — silent no-ops that look like black holes when the failure is knowable.\n\n## Location\n\n`crates/improvise-command/src/command/keymap.rs` ~L37\n\n## PR / Attribution\n\nPR #100 F05. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","notes":"FIXED in PR #126 (merge 4625593).\n\nTHE CAUSE: dispatch spelled construction as\nregistry.interactive(name, args, ctx).ok()?, so Option::None carried TWO FACTS --\n\"no binding matched\" and \"a binding matched but would not build\" -- and\nApp::dispatch_key answers None with Dirty::NOTHING and no status. A bound key was\na black hole.\n\nTHE ERROR CLASSES, separated, which is the part worth keeping:\n\n CLASS A -- reaches a user, and NOTHING STATIC CAN SEE IT. \"no cell at cursor\",\n from six registrations that read ctx.cell_key(): clear-cell, yank-cell,\n paste-cell, drill-into-cell, commit-cell-edit, commit-and-advance-right. It is\n STATE-dependent, so no registration-time check applies. The two commit entries\n are the worst case: the user is in Editing, has typed a value, presses Enter,\n and the batch is discarded with the minibuffer still up -- where\n draw_bottom_bar paints INSTEAD of the status line (gotcha 21). This is the\n whole ticket.\n\n CLASS B -- already guarded and unreachable from a shipped keymap. \"Unknown\n command\", require_args, \"Unknown mode\", and \"commit-buffer item requires\n item-add mode\". every_bound_command_name_is_registered refuses all of these at\n test time, checking BOTH halves of the old expression precisely because\n improvise-s13 made a typo'd mode name compile into a dead key (gotcha 15).\n\n SO THE REPRO THIS TICKET ITSELF PROPOSED IS THE ONE CASE THAT CANNOT HAPPEN.\n\nTHE FIX: None means \"no binding matched\" and nothing else; a construction failure\nbecomes Some(vec![set_status(\"\u003cname\u003e: \u003cerr\u003e\")]). Class B surfaces too, and that is\ndeliberate -- a list of blessed errors would be a second description of which\nfailures matter, free to disagree with the first.\n\nTHE TEST PAIR IS THE INTERESTING PART, because one test alone cannot separate the\ntwo facts the Option used to carry:\n - rival \"return Some, swallow the error\": fails\n a_bound_key_whose_command_cannot_be_built_says_so; the control passes.\n - rival \"report a failed LOOKUP as a status too\": fails\n an_unbound_key_stays_silent; the other passes. Without that control, the fix\n would write a status for every unmapped character in every mode.\n\nNOT VERIFIED: whether x/y/p on an empty grid is reachable in a SHIPPED document.\nThe fixture constructs the state (an item-less category on Axis::Row) rather than\nreaching it by keystrokes. Real for a user mid-add-cat; not driven.\n\nFOLLOW-UP NOT FILED: a \"no cell at cursor\" refusal from commit-cell-edit still\nleaves the user in Editing with the minibuffer covering the status line. The\nmessage is now written; whether it is DRAWN in that mode is gotcha 21's rendering\ntrap and a separate question.","status":"closed","priority":1,"issue_type":"bug","owner":"box@local","created_at":"2026-09-21T20:48:19Z","created_by":"external-grok-bot","updated_at":"2026-09-22T07:40:42Z","closed_at":"2026-09-22T07:40:42Z","close_reason":"Closed","external_ref":"gh-pr-100-F05","labels":["adversarial-review","pr-100"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-86nw","title":"Worker startup failure leaves page in silent dead state","description":"## Summary\n\nIf worker `started` rejects (e.g. WASM `init()`/`start()` throws for a non-OPFS reason), `.catch()` only logs. The `onFrame` listener stays installed and calls `handle()` while `server` is null. Frames accumulate in the error log with no UI indication beyond the eventual watchdog; if the watchdog never arms (worker never posts), the page sits dead.\n\n## Expected / Actual\n\n- **Expected:** Startup failure surfaces a loud, specific status (and stops accepting frames / arms watchdog).\n- **Actual:** Console-only catch; silent dead UI until generic watchdog (if any).\n\n## Distinct from improvise-16af\n\nimprovise-16af tracks intermittent blank grid from OPFS `SyncAccessHandle` / `NotAllowedError` conflicts. This bead is the general **startup-without-OPFS-conflict** path: WASM init/start rejection leaves `server === null` with only a logged catch. Related symptom, different root.\n\n## Location\n\n`web/worker.js` ~L43-60\n\n## PR / Attribution\n\nPR #100 F03. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","status":"closed","priority":1,"issue_type":"bug","owner":"box@local","created_at":"2026-09-21T20:48:18Z","created_by":"external-grok-bot","updated_at":"2026-09-21T21:58:55Z","closed_at":"2026-09-21T21:58:55Z","close_reason":"Fixed in the 2026-09-21 wave, integrated into main at 1574 passed / 0 failed / 7 ignored. Playwright 42 passed / 0 failed / 4 skipped (WebKit, no OPFS) -- up from 37, so the count MOVED. All gates green.\n\nstart() rejecting for any reason posts BootFailed; the page renders it and disarms the watchdog.","external_ref":"gh-pr-100-F03","labels":["adversarial-review","pr-100"],"dependencies":[{"issue_id":"improvise-86nw","depends_on_id":"improvise-16af","type":"relates-to","created_at":"2026-09-21T20:48:19Z","created_by":"external-grok-bot","metadata":"{}"},{"issue_id":"improvise-86nw","depends_on_id":"improvise-1wft","type":"blocks","created_at":"2026-09-21T21:31:50Z","created_by":"Edward Langley","metadata":"{}"}],"comments":[{"id":"91dcae3c-8f35-56e5-b794-18191771873a","issue_id":"improvise-86nw","author":"external-grok-bot","text":"Related to improvise-16af (OPFS handle conflict blank grid). This bead covers non-OPFS WASM startup rejection only. — PR #100","created_at":"2026-09-21T20:48:19Z"}],"dependency_count":1,"dependent_count":0,"comment_count":1} @@ -74,10 +86,10 @@ {"_type":"issue","id":"improvise-6kj","title":"Pest parse errors lack file:line context for users","description":"persistence/mod.rs:294 wraps pest errors as 'Parse error: {e}' which shows Pair offsets like 'Pair(6..12), expected \"=\", found \",\"' instead of a line preview. Users debugging a malformed .improv file have no way to find the offending line. Fix: extract (line, col) from pest::error::Error via line_col(), include the line contents and surrounding context in the wrapped error.","acceptance_criteria":"A syntactically invalid .improv file produces an error containing the source line number (and file path when loading from disk); pest spans already carry this. Test asserts the error message contains the expected line:col.","status":"closed","priority":1,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:13Z","created_by":"Ed L","updated_at":"2026-06-10T04:45:53Z","closed_at":"2026-06-10T04:45:53Z","close_reason":"Fixed in 3418162/47ad9e6 (test-first): export_csv via csv::Writer honoring number format; malformed numbers -\u003e CellValue::Error; formula [Category] suffix moved into pest grammar; parse errors report line/col + offending line. 155 io tests green.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-hmu","title":"TAB/Enter on bottom-right of records view should insert record below (TAB→first cell, Enter→same column); o inserts below cursor","description":"Current records view data entry is awkward. On last row + rightmost cell: TAB should add record below + go to first cell of new row + edit. Enter should add below + stay in same column + edit. 'o' should insert below cursor like Vim (not always at end). Red-green-refactor: add failing test first, then minimal implementation in layout.rs + related command/effect code, then refactor duplication. Builds on improvise-rbv and improvise-c5v.","status":"closed","priority":1,"issue_type":"feature","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-15T10:37:52Z","created_by":"Edward Langley","updated_at":"2026-04-16T05:08:00Z","closed_at":"2026-04-16T05:08:00Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-9x6","title":"Worker-hosted server bundle (wasm, MessageChannel transport)","description":"New wasm bundle that hosts the full App + command pipeline + projection layer + VFS persistence inside a browser worker. Communicates with the main-thread side via a MessageChannel carrying Command messages in the same shape as the websocket protocol. The worker-server is the 'server' half of the standalone deployment — structurally it mirrors improvise-ws-server but swaps tokio + tungstenite + std::fs for wasm + postMessage + OPFS. Both ws-server and worker-server reuse the same projection layer (improvise-cqq); only the transport and persistence backend differ.","design":"crates/improvise-worker-server/. Depends on improvise-core, improvise-formula, improvise-command, improvise-io (persistence half), improvise-protocol, and the OPFS Storage implementation from improvise-i34. wasm-bindgen entry points: init(opts) initializes App and VFS-backed Storage, loads an initial file from OPFS if present; on_message(serialized_command) deserializes the Command, feeds it through the App + projection layer, posts any outbound projection commands back via self.postMessage(). OPFS is async; handle by returning JsPromise from on_message or by queuing outbound messages and letting the event loop deliver them. Projection layer (improvise-cqq) is reused wholesale — transport-agnostic, already tracks per-session viewport state. The only new code specific to this issue is (a) wasm-bindgen bootstrap, (b) MessageChannel binding via self.onmessage / self.postMessage, (c) wiring VFS/OPFS storage into the persistence layer. Worker choice: Dedicated Worker first. Service Worker later if PWA install is desired.","acceptance_criteria":"(1) Crate compiles to wasm32-unknown-unknown. (2) Worker bundle runs the full App end-to-end: loads file from OPFS, processes commands, persists changes, emits projections. (3) Size budget: under 3 MB compressed. (4) Driven by a test harness that simulates main-thread postMessage traffic.","notes":"Companion to improvise-djm — two halves of the standalone deployment. Depends on improvise-cqq (projection layer), improvise-cqi (protocol), improvise-ywd (wasm compat), improvise-6mq (VFS abstraction), improvise-i34 (OPFS impl). Effectively a wasm-flavored analogue of improvise-q08 (ws-server); both wrap the same projection layer with a different transport + persistence.","status":"closed","priority":1,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:39:29Z","created_by":"spot","updated_at":"2026-09-20T02:10:39Z","closed_at":"2026-09-20T02:10:39Z","close_reason":"Done. The server as a wasm blob in a Web Worker: same App, same Sessions, same Host, behind self.onmessage/self.postMessage with OPFS underneath. Closed with --force because the graph still lists improvise-xf1 (browser verification of OpfsFs), which is DEFERRED by user decision -- that block is waived, not satisfied.\n\nAC 1 met (wasm32 clean, builds through wasm-pack, added to the CI wasm job). AC 3 met with room: 1,334,892 bytes raw / 496,965 GZIPPED plus 6,700 of JS glue -- about 492 KB against a 3 MB budget; with the client's unchanged 122,599 the pair is ~600 KB. AC 4 met: tests/post_message.rs drives a real Worker with nothing but the JSON a MessagePort carries.\n\nAC 2 IS MET HOST-SIDE, NOT BROWSER-SIDE, and the line is exact. Tested: worker.rs (16t), vfs.rs, post_message.rs (6t) -- handshake, Addressed filter, rejection path, persist trigger, seeding and list are all on this side. Untested: bindings.rs (cfg wasm32) and web/worker.js -- compile-checked, node --check-ed, all three calls confirmed in the generated .d.ts with matching arity, nothing more. createSyncAccessHandle exists in no harness, so OPFS ITSELF REMAINS NEVER RUN: if persistence misbehaves in a browser it is the first place to look.\n\nHOST WAS MOVED, NOT MIRRORED -- the one non-additive call, sanity-checked at merge. Host is App + Sessions + handle, contains no socket, and lived in a tokio crate the worker cannot depend on. It now lives in improvise_command::ui::host beside Sessions; ws-server re-exports so every path resolves; registry stayed behind because a worker hosts ONE document. Re-implementing the loop would have been the mirror improvise-xks6 had just deleted for the frames, and a drifting Applied/Broadcast match is invisible until a client sees it. Verified at merge: host.rs imports only crate::ui::{app,project} and improvise_protocol, and improvise-command's dependency list is unchanged.\n\nTHE DOCUMENT IS NAMED OUT OF BAND, in the worker's URL -- the analogue of the socket naming it in the ws:// path. NO FOURTH ClientFrame. Per the user's ruling mid-flight, loading is THE VFS, not a bespoke entry point: a seed is a Storage::write read back like any other document, so a fetched demo, an upload and a saved file share one parse path and a seed never clobbers an edit.\n\nClientFrame::Key IS LEFT ANSWERING NOTHING, and the brief's premise that a worker could resolve it was WRONG for a reason worth keeping. A worker CAN call reduce_key; the blocker is downstream and identical on both transports -- NOTHING IN THE PROJECTION STREAM CARRIES THE HOST'S OWN ViewState. A client's slice moves only by folding Commands, and project emits document projections, a grid diff and a basis, none of which is a mode or cursor change. So resolving a key host-side does not under-deliver, it DIVERGES: the host believes it is in Editing while the client draws Normal, and viewport() reads the mirror's cursor so the grid would not even scroll. That is improvise-bpq2, now a DEMONSTRATED property rather than prose -- a_key_resolved_on_the_host_moves_state_no_client_can_hear fires reduce_key against a real App and asserts the mirror did not follow.\n\nFOUR REAL OBSTACLES, AND NONE WAS OPFS: Host in the wrong crate; Save unreachable from the wire; dirty never set by a wire edit; the async seam unreachable through a Box. THREE OF THE FOUR ARE GAPS IN THE SHARED LAYER THAT THE SOCKET SERVER HAS TOO -- it never noticed, because its documents are opened from a CLI and saved by a human pressing a key in a TUI that is not there. Filed separately. Being the second real consumer of an abstraction is what audits it.\n\n1155 passing (+22), clippy/fmt clean, wasm32 clean on all SEVEN bundle crates, actionlint clean. Delivery stayed cheap: two lines in build.sh, one cp -rf plus one cp in the assemble step.","dependencies":[{"issue_id":"improvise-9x6","depends_on_id":"improvise-6mq","type":"blocks","created_at":"2026-04-14T00:40:12Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-9x6","depends_on_id":"improvise-cqi","type":"blocks","created_at":"2026-04-14T00:40:11Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-9x6","depends_on_id":"improvise-cqq","type":"blocks","created_at":"2026-04-14T00:40:10Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-9x6","depends_on_id":"improvise-i34","type":"blocks","created_at":"2026-04-14T00:40:13Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-9x6","depends_on_id":"improvise-xf1","type":"blocks","created_at":"2026-08-31T23:11:01Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-9x6","depends_on_id":"improvise-ywd","type":"blocks","created_at":"2026-04-14T00:40:12Z","created_by":"spot","metadata":"{}"}],"dependency_count":6,"dependent_count":3,"comment_count":0} -{"_type":"issue","id":"improvise-bck","title":"Standalone web MVP: end-to-end offline demo","description":"Final milestone of the standalone epic. Open the deployed static site (or run it locally from file://), load a bundled demo .improv file, edit cells, add a formula, save to OPFS, reload the page, verify persistence. No server process running anywhere. This is the architectural payoff — improvise running entirely in the browser.","design":"Architecture under test: main-thread wasm-client (thin client), dedicated worker hosting worker-server bundle, MessageChannel transport between them, OPFS persistence via the VFS abstraction. Test plan: (1) Visit the deployed GH Pages URL or run the shell locally. (2) Bundled demo file loads automatically via the worker on init. (3) Navigate with arrow keys — view effects dispatched locally in the thin-client wasm, no postMessage round-trip. (4) Enter edit mode, type a number, commit — command flows to worker via MessageChannel, worker updates model + persists to OPFS, projection command flows back. (5) Add a formula — same path, plus formula recompute in worker. (6) Trigger save (explicit save to user-picked path via File System Access API, or implicit autosave to OPFS). (7) Close the tab, reopen the URL, verify edits persisted. (8) Upload a different .improv file via file picker, verify it opens. Performance: cursor moves under 16ms (local, no worker hop); edit commits under 100ms (worker hop + OPFS write).","acceptance_criteria":"(1) All test plan steps pass in Chrome and Firefox. (2) Page load to interactive under 3 seconds on a typical connection. (3) Wasm bundle under 3 MB compressed. (4) Screencast or screenshot series documenting the demo attached to the issue. (5) README updated with link to live demo.","notes":"INHERITS improvise-d31's AC 4 -- a bundled demo model -- which could not be satisfied there. TWO DISTINCT SUB-GAPS, both real:\n\n(a) NOTHING IN THE BROWSER LOADS A .improv AT ALL. The ws-server takes the file on its CLI; the worker needs an 'init from these bytes / this URL' entry before a bundled file has any consumer. So this is genuinely after improvise-9x6, not merely sequenced after it.\n\n(b) examples/demo.improv IS UNUSABLE AS A DEMO and the fix is not an axis flip. Customer and Date sit on the None axis, so every cell is an aggregate and nothing is editable -- correct behaviour, useless demo. Moving Date to page does NOT obviously fix it: Product x Region by Date_Month x Measure with Customer and Date fixed can still aggregate. This needs a model BUILT AND CHECKED against the 'is every visible cell a Record' question, not an axis changed and hoped for. The check is cheap now that ADR 002 exists -- a cell is settable iff its CellId is Record -- so write the assertion first and make a model that satisfies it.\nGROOMED 2026-09-20 after wave 1. Most of this milestone is now DEMONSTRABLE BY TEST rather than by hand, which is a better outcome than a manual sign-off, and the remainder is narrowed.\n\nDEMONSTRATED by harness/ (improvise-vvl3), running in CI and able to fail the build:\n * the seeded demo document drawing in the STANDALONE WORKER deployment -- no server process anywhere;\n * click-to-edit committing through the host and the host's formatted value coming back;\n * PERSISTENCE ACROSS A RELOAD at the storage layer, including that a committed delete stays deleted -- which was the one acceptance criterion no test in this repo could structurally reach;\n * every #status message the shell must write.\n\nNOT DEMONSTRATED, and one of them is a real gap in the criteria rather than in the harness:\n * 'ADD A FORMULA' -- the browser has no formula entry at all. That is the honest remaining item and it is a FEATURE, not a verification gap. It wants its own bead before this milestone can be called met as written.\n * Safari (Playwright's WebKit has no OPFS -- improvise-xf1);\n * two tabs;\n * bindings.rs and web/worker.js as CODE PATHS -- the harness drives OpfsFs directly and never instantiates the shim, so bindings.rs is now the dark corner OpfsFs used to be.\n\nAlso note improvise-plcx: web/demo.improv has no groups and one category per axis, so the harness never exercises the multi-level and group paths PR #23 added.\nVERIFICATION PASS, wave 2 WU-P, 2026-09-20 at 52507a6. Each acceptance\ncriterion walked against the DEPLOYED page, the harness and the code. Two are\nmet with evidence, one is met and was previously only asserted, and two are\nnot met — the ticket stays OPEN and the reason is (1), not paperwork.\n\n(1) ALL TEST PLAN STEPS PASS IN CHROME AND FIREFOX — NOT MET, two reasons.\n First, test-plan step 5, \"add a formula\", has no implementation: a browser\n can ENTER FormulaEdit and cannot commit it (improvise-fdt6, which this\n ticket already depends on). Second, \"and Firefox\" is not true even for the\n steps that do work. harness/playwright.config.js runs shell.spec.js on\n CHROMIUM ONLY; Firefox and WebKit are restricted to opfs.spec.js by\n `testMatch`, and WebKit skips that on a runtime OPFS probe. So the whole\n shell — the page drawing the demo out of the worker, click-to-edit\n committing — is verified on exactly one engine. That second half is NOT\n recorded anywhere in this ticket's earlier annotation, which said \"Chrome\n and Firefox\" was covered by the harness.\n\n(2) PAGE LOAD TO INTERACTIVE UNDER 3 SECONDS — MET, measured rather than\n argued. Three cold loads of https://fiddlerwoaroof.github.io/improvise/\n driven with Playwright Chromium, a freshly launched browser per run (so\n empty HTTP cache and empty OPFS), timing `goto` until `#grid table` AND the\n first `#grid td.editable` are both visible: 1172 ms, 278 ms, 408 ms, zero\n page errors, `#status` reading \"in-page worker · demo.improv\" and 12 cells\n drawn. The honest caveat: that is a fast connection with no throttling, so\n it bounds instantiation and handshake, not \"a typical connection\". The\n transfer it has to make is small enough that this is unlikely to be the\n binding constraint — see (3).\n\n(3) WASM BUNDLE UNDER 3 MB COMPRESSED — MET, by an order of magnitude, and\n measured OFF THE DEPLOYED SITE rather than off a local build. Requested\n with `Accept-Encoding: gzip` from GitHub Pages:\n improvise_wasm_client_bg.wasm 151,522 B gzipped\n improvise_worker_server_bg.wasm 547,952 B gzipped\n both .js glue files 10,019 B\n app.js + worker.js + style.css 7,741 B\n index.html + demo.improv 2,161 B\n Two wasm binaries total 699 KB gzipped; the whole site is about 719 KB.\n\n(4) SCREENCAST OR SCREENSHOT SERIES ATTACHED TO THE ISSUE — NOT MET, and not\n meetable as written: bd has no attachments. A full-page screenshot of the\n live site was captured during this pass and the numbers in (2) came off the\n same run. The earlier annotation's argument stands and is the right one —\n harness/tests/shell.spec.js proves the path in CI where a screenshot proves\n a moment — so the useful outcome is to REWRITE this criterion, not to\n satisfy it.\n\n(5) README UPDATED WITH LINK TO LIVE DEMO — MET. README's \"In the browser\"\n section carries https://fiddlerwoaroof.github.io/improvise/ and describes\n the no-server deployment. Wave 2 also corrected the same section's claim\n that a deploy waits on \"cargo test and the wasm32 check\": it waits on the\n browser harness too (`needs: [test, wasm, browser, pages-build]`).\n\nREMAINING CONTENT OF THIS TICKET: improvise-fdt6, plus deciding whether \"and\nFirefox\" in (1) is a requirement or a date. If it is a requirement it is a\n`testMatch` change in harness/playwright.config.js and the reason it is\nChromium-only today (routeWebSocket being slower and flakier across three\nengines) applies only to the FOUR ?ws= specs, not to the two standalone ones.\nCORRECTION TO THE NOT-DEMONSTRATED LIST, from adversarial review of PR #47.\n\nThat list says \"bindings.rs and web/worker.js as CODE PATHS\" are not\ndemonstrated, \"the harness drives OpfsFs directly and never instantiates the\nshim\". That is no longer true, and has not been since improvise-vvl3.\n\n`harness/tests/shell.spec.js`'s standalone tests load /app/index.html with no\n?ws=, which spawns the real worker: app.js -\u003e worker.js -\u003e the worker bundle's\n`start()` in bindings.rs -\u003e `OpfsFs::open().await`. Both `bindings.rs` AND\n`web/worker.js` are therefore executed under Chromium on every CI run, and the\nspec's own comment says so. Verified by running it at 2ccd21f and watching\n/app/worker.js and /app/worker-pkg/improvise_worker_server_bg.wasm fetched and\nthe grid drawn from OPFS.\n\nSo one item on that list is met rather than unmet. What genuinely remains\nunmet: Safari (improvise-xf1), two tabs, and \"add a formula\" (improvise-fdt6).\nFirefox is improvise-zkkw — a second ENGINE for the shim, which is a real gap\nand a different claim from \"never run at all\".\n\nA criterion recorded unmet when it is met is the same defect as one recorded met\nwhen it is not: both are a claim nobody rechecked.\nURL CORRECTION to the verification annotation above.\n\nThat annotation cites `https://fiddlerwoaroof.github.io/improvise/` as what was\nmeasured for criteria (2) page-load-to-interactive, (3) bundle size and (5) the\nREADME link. Those measurements were taken against the browser APP, and were\ncorrect when taken — but this same PR then moved the app, on a user ruling\n(improvise-kh8): the site root is now the LANDING PAGE and the app is at\n`https://fiddlerwoaroof.github.io/improvise/app/`.\n\nThe measurements stand — 1172/278/408 ms to an editable grid, 699 KB gzipped for\nthe two wasm binaries — and every URL that produced them now names the wrong\npage. Read every bare `…github.io/improvise/` in that annotation as\n`…github.io/improvise/app/`. Anyone re-running them against the root will be\ntiming four asciinema players instead, and would get a number that looks\nplausible and means nothing.\nGROOM 2026-09-21: this bead read READY -- every dependency closed -- while its own criterion (1) was failing. Two blockers added, both found today and neither existing when the tree was last true:\n\n - improvise-1wft: the browser's boot fails silently for five distinct causes. One of them (improvise-i0v9) is the SHIPPED DEMO showing a blank grid with a reassuring status line for 8 seconds and then blaming the browser. A demo that does that has not met 'all test plan steps pass'.\n - improvise-p7lh: the shipped demo has a page axis and the browser draws no page bar, and no column totals.\n\nCriterion (1) also says Chrome AND FIREFOX. harness/tests/shell.spec.js is CHROMIUM ONLY by deliberate configuration (engine-independent logic; routeWebSocket is slower and flakier across three), so nothing currently runs the shell on Firefox. improvise-zkkw is closed and recorded that gap; the criterion still names Firefox. Decide whether to run the shell spec on Firefox or to amend the criterion -- do not close this bead by quietly reading 'Chrome' alone.\n\nCriteria (2) load under 3s, (3) wasm under 3MB compressed, (4) screencast and (5) README link are unverified here and are cheap to measure. They are the actual remaining work on this bead.","status":"open","priority":1,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:34:38Z","created_by":"spot","updated_at":"2026-09-21T21:27:14Z","labels":["area:epic"],"dependencies":[{"issue_id":"improvise-bck","depends_on_id":"improvise-1wft","type":"blocks","created_at":"2026-09-21T21:31:53Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-bck","depends_on_id":"improvise-9x6","type":"blocks","created_at":"2026-04-14T00:40:17Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-bck","depends_on_id":"improvise-d31","type":"blocks","created_at":"2026-04-14T00:40:16Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-bck","depends_on_id":"improvise-djm","type":"blocks","created_at":"2026-04-14T00:40:16Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-bck","depends_on_id":"improvise-fdt6","type":"blocks","created_at":"2026-09-20T16:21:00Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-bck","depends_on_id":"improvise-p7lh","type":"blocks","created_at":"2026-09-21T21:31:54Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-bck","depends_on_id":"improvise-zkkw","type":"blocks","created_at":"2026-09-20T18:02:34Z","created_by":"wave2-p-milestones","metadata":"{}"}],"dependency_count":7,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"improvise-bck","title":"Standalone web MVP: end-to-end offline demo","description":"Final milestone of the standalone epic. Open the deployed static site (or run it locally from file://), load a bundled demo .improv file, edit cells, add a formula, save to OPFS, reload the page, verify persistence. No server process running anywhere. This is the architectural payoff — improvise running entirely in the browser.","design":"Architecture under test: main-thread wasm-client (thin client), dedicated worker hosting worker-server bundle, MessageChannel transport between them, OPFS persistence via the VFS abstraction. Test plan: (1) Visit the deployed GH Pages URL or run the shell locally. (2) Bundled demo file loads automatically via the worker on init. (3) Navigate with arrow keys — view effects dispatched locally in the thin-client wasm, no postMessage round-trip. (4) Enter edit mode, type a number, commit — command flows to worker via MessageChannel, worker updates model + persists to OPFS, projection command flows back. (5) Add a formula — same path, plus formula recompute in worker. (6) Trigger save (explicit save to user-picked path via File System Access API, or implicit autosave to OPFS). (7) Close the tab, reopen the URL, verify edits persisted. (8) Upload a different .improv file via file picker, verify it opens. Performance: cursor moves under 16ms (local, no worker hop); edit commits under 100ms (worker hop + OPFS write).","acceptance_criteria":"(1) All test plan steps pass in Chrome and Firefox. (2) Page load to interactive under 3 seconds on a typical connection. (3) Wasm bundle under 3 MB compressed. (4) Screencast or screenshot series documenting the demo attached to the issue. (5) README updated with link to live demo.","notes":"INHERITS improvise-d31's AC 4 -- a bundled demo model -- which could not be satisfied there. TWO DISTINCT SUB-GAPS, both real:\n\n(a) NOTHING IN THE BROWSER LOADS A .improv AT ALL. The ws-server takes the file on its CLI; the worker needs an 'init from these bytes / this URL' entry before a bundled file has any consumer. So this is genuinely after improvise-9x6, not merely sequenced after it.\n\n(b) examples/demo.improv IS UNUSABLE AS A DEMO and the fix is not an axis flip. Customer and Date sit on the None axis, so every cell is an aggregate and nothing is editable -- correct behaviour, useless demo. Moving Date to page does NOT obviously fix it: Product x Region by Date_Month x Measure with Customer and Date fixed can still aggregate. This needs a model BUILT AND CHECKED against the 'is every visible cell a Record' question, not an axis changed and hoped for. The check is cheap now that ADR 002 exists -- a cell is settable iff its CellId is Record -- so write the assertion first and make a model that satisfies it.\nGROOMED 2026-09-20 after wave 1. Most of this milestone is now DEMONSTRABLE BY TEST rather than by hand, which is a better outcome than a manual sign-off, and the remainder is narrowed.\n\nDEMONSTRATED by harness/ (improvise-vvl3), running in CI and able to fail the build:\n * the seeded demo document drawing in the STANDALONE WORKER deployment -- no server process anywhere;\n * click-to-edit committing through the host and the host's formatted value coming back;\n * PERSISTENCE ACROSS A RELOAD at the storage layer, including that a committed delete stays deleted -- which was the one acceptance criterion no test in this repo could structurally reach;\n * every #status message the shell must write.\n\nNOT DEMONSTRATED, and one of them is a real gap in the criteria rather than in the harness:\n * 'ADD A FORMULA' -- the browser has no formula entry at all. That is the honest remaining item and it is a FEATURE, not a verification gap. It wants its own bead before this milestone can be called met as written.\n * Safari (Playwright's WebKit has no OPFS -- improvise-xf1);\n * two tabs;\n * bindings.rs and web/worker.js as CODE PATHS -- the harness drives OpfsFs directly and never instantiates the shim, so bindings.rs is now the dark corner OpfsFs used to be.\n\nAlso note improvise-plcx: web/demo.improv has no groups and one category per axis, so the harness never exercises the multi-level and group paths PR #23 added.\nVERIFICATION PASS, wave 2 WU-P, 2026-09-20 at 52507a6. Each acceptance\ncriterion walked against the DEPLOYED page, the harness and the code. Two are\nmet with evidence, one is met and was previously only asserted, and two are\nnot met — the ticket stays OPEN and the reason is (1), not paperwork.\n\n(1) ALL TEST PLAN STEPS PASS IN CHROME AND FIREFOX — NOT MET, two reasons.\n First, test-plan step 5, \"add a formula\", has no implementation: a browser\n can ENTER FormulaEdit and cannot commit it (improvise-fdt6, which this\n ticket already depends on). Second, \"and Firefox\" is not true even for the\n steps that do work. harness/playwright.config.js runs shell.spec.js on\n CHROMIUM ONLY; Firefox and WebKit are restricted to opfs.spec.js by\n `testMatch`, and WebKit skips that on a runtime OPFS probe. So the whole\n shell — the page drawing the demo out of the worker, click-to-edit\n committing — is verified on exactly one engine. That second half is NOT\n recorded anywhere in this ticket's earlier annotation, which said \"Chrome\n and Firefox\" was covered by the harness.\n\n(2) PAGE LOAD TO INTERACTIVE UNDER 3 SECONDS — MET, measured rather than\n argued. Three cold loads of https://fiddlerwoaroof.github.io/improvise/\n driven with Playwright Chromium, a freshly launched browser per run (so\n empty HTTP cache and empty OPFS), timing `goto` until `#grid table` AND the\n first `#grid td.editable` are both visible: 1172 ms, 278 ms, 408 ms, zero\n page errors, `#status` reading \"in-page worker · demo.improv\" and 12 cells\n drawn. The honest caveat: that is a fast connection with no throttling, so\n it bounds instantiation and handshake, not \"a typical connection\". The\n transfer it has to make is small enough that this is unlikely to be the\n binding constraint — see (3).\n\n(3) WASM BUNDLE UNDER 3 MB COMPRESSED — MET, by an order of magnitude, and\n measured OFF THE DEPLOYED SITE rather than off a local build. Requested\n with `Accept-Encoding: gzip` from GitHub Pages:\n improvise_wasm_client_bg.wasm 151,522 B gzipped\n improvise_worker_server_bg.wasm 547,952 B gzipped\n both .js glue files 10,019 B\n app.js + worker.js + style.css 7,741 B\n index.html + demo.improv 2,161 B\n Two wasm binaries total 699 KB gzipped; the whole site is about 719 KB.\n\n(4) SCREENCAST OR SCREENSHOT SERIES ATTACHED TO THE ISSUE — NOT MET, and not\n meetable as written: bd has no attachments. A full-page screenshot of the\n live site was captured during this pass and the numbers in (2) came off the\n same run. The earlier annotation's argument stands and is the right one —\n harness/tests/shell.spec.js proves the path in CI where a screenshot proves\n a moment — so the useful outcome is to REWRITE this criterion, not to\n satisfy it.\n\n(5) README UPDATED WITH LINK TO LIVE DEMO — MET. README's \"In the browser\"\n section carries https://fiddlerwoaroof.github.io/improvise/ and describes\n the no-server deployment. Wave 2 also corrected the same section's claim\n that a deploy waits on \"cargo test and the wasm32 check\": it waits on the\n browser harness too (`needs: [test, wasm, browser, pages-build]`).\n\nREMAINING CONTENT OF THIS TICKET: improvise-fdt6, plus deciding whether \"and\nFirefox\" in (1) is a requirement or a date. If it is a requirement it is a\n`testMatch` change in harness/playwright.config.js and the reason it is\nChromium-only today (routeWebSocket being slower and flakier across three\nengines) applies only to the FOUR ?ws= specs, not to the two standalone ones.\nCORRECTION TO THE NOT-DEMONSTRATED LIST, from adversarial review of PR #47.\n\nThat list says \"bindings.rs and web/worker.js as CODE PATHS\" are not\ndemonstrated, \"the harness drives OpfsFs directly and never instantiates the\nshim\". That is no longer true, and has not been since improvise-vvl3.\n\n`harness/tests/shell.spec.js`'s standalone tests load /app/index.html with no\n?ws=, which spawns the real worker: app.js -\u003e worker.js -\u003e the worker bundle's\n`start()` in bindings.rs -\u003e `OpfsFs::open().await`. Both `bindings.rs` AND\n`web/worker.js` are therefore executed under Chromium on every CI run, and the\nspec's own comment says so. Verified by running it at 2ccd21f and watching\n/app/worker.js and /app/worker-pkg/improvise_worker_server_bg.wasm fetched and\nthe grid drawn from OPFS.\n\nSo one item on that list is met rather than unmet. What genuinely remains\nunmet: Safari (improvise-xf1), two tabs, and \"add a formula\" (improvise-fdt6).\nFirefox is improvise-zkkw — a second ENGINE for the shim, which is a real gap\nand a different claim from \"never run at all\".\n\nA criterion recorded unmet when it is met is the same defect as one recorded met\nwhen it is not: both are a claim nobody rechecked.\nURL CORRECTION to the verification annotation above.\n\nThat annotation cites `https://fiddlerwoaroof.github.io/improvise/` as what was\nmeasured for criteria (2) page-load-to-interactive, (3) bundle size and (5) the\nREADME link. Those measurements were taken against the browser APP, and were\ncorrect when taken — but this same PR then moved the app, on a user ruling\n(improvise-kh8): the site root is now the LANDING PAGE and the app is at\n`https://fiddlerwoaroof.github.io/improvise/app/`.\n\nThe measurements stand — 1172/278/408 ms to an editable grid, 699 KB gzipped for\nthe two wasm binaries — and every URL that produced them now names the wrong\npage. Read every bare `…github.io/improvise/` in that annotation as\n`…github.io/improvise/app/`. Anyone re-running them against the root will be\ntiming four asciinema players instead, and would get a number that looks\nplausible and means nothing.\nGROOM 2026-09-21: this bead read READY -- every dependency closed -- while its own criterion (1) was failing. Two blockers added, both found today and neither existing when the tree was last true:\n\n - improvise-1wft: the browser's boot fails silently for five distinct causes. One of them (improvise-i0v9) is the SHIPPED DEMO showing a blank grid with a reassuring status line for 8 seconds and then blaming the browser. A demo that does that has not met 'all test plan steps pass'.\n - improvise-p7lh: the shipped demo has a page axis and the browser draws no page bar, and no column totals.\n\nCriterion (1) also says Chrome AND FIREFOX. harness/tests/shell.spec.js is CHROMIUM ONLY by deliberate configuration (engine-independent logic; routeWebSocket is slower and flakier across three), so nothing currently runs the shell on Firefox. improvise-zkkw is closed and recorded that gap; the criterion still names Firefox. Decide whether to run the shell spec on Firefox or to amend the criterion -- do not close this bead by quietly reading 'Chrome' alone.\n\nCriteria (2) load under 3s, (3) wasm under 3MB compressed, (4) screencast and (5) README link are unverified here and are cheap to measure. They are the actual remaining work on this bead.","status":"open","priority":1,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:34:38Z","created_by":"spot","updated_at":"2026-09-21T21:27:14Z","labels":["area:epic"],"dependencies":[{"issue_id":"improvise-bck","depends_on_id":"improvise-1wft","type":"blocks","created_at":"2026-09-21T21:31:53Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-bck","depends_on_id":"improvise-9x6","type":"blocks","created_at":"2026-04-14T00:40:17Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-bck","depends_on_id":"improvise-d31","type":"blocks","created_at":"2026-04-14T00:40:16Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-bck","depends_on_id":"improvise-djm","type":"blocks","created_at":"2026-04-14T00:40:16Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-bck","depends_on_id":"improvise-fdt6","type":"blocks","created_at":"2026-09-20T16:21:00Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-bck","depends_on_id":"improvise-p7lh","type":"blocks","created_at":"2026-09-21T21:31:54Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-bck","depends_on_id":"improvise-tm6","type":"parent-child","created_at":"2026-09-25T01:07:37Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-bck","depends_on_id":"improvise-zkkw","type":"blocks","created_at":"2026-09-20T18:02:34Z","created_by":"wave2-p-milestones","metadata":"{}"}],"dependency_count":7,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-d31","title":"Static HTML shell + GitHub Pages deploy workflow","description":"HTML/JS shell that hosts the standalone wasm deployment. Registers the worker-server (dedicated worker for MVP, service worker as a later PWA enhancement), instantiates the MessageChannel transport, loads the main-thread wasm-client, wires them together, and presents the DOM renderer. Also publishes the whole bundle to GitHub Pages via a build + deploy workflow so anyone can open improvise in a browser with no install.","design":"docs/ or web/ directory with index.html (#app div), main.js (main-thread wasm loader + worker registration + MessageChannel setup + keyboard event wiring), worker.js (loads the worker-server wasm bundle and handles postMessage), style.css. No websocket code — transport is local MessageChannel only. GitHub Actions: on push to main, build both wasm bundles (main-thread and worker) with cargo + wasm-bindgen, run wasm-opt for size, assemble into a static site, push to gh-pages branch. Path-filtered to skip if neither bundle changed. Worker registration: start with Dedicated Worker (simpler lifecycle, same-tab scope) until PWA install requirements justify Service Worker complexity.","acceptance_criteria":"(1) Static site loads improvise in a modern browser with no backend. (2) GitHub Actions workflow builds and deploys on every main push. (3) Deployed URL is listed in the README. (4) Demo .improv file is bundled so first-time visitors see something, not an empty grid.","notes":"PROGRESS 2026-09-20 -- the DELIVERY half is done and landed; the ticket stays OPEN because the half it is named for (a static shell that works) genuinely does not, and bd is right to refuse the close while improvise-djm blocks it.\n\nDONE: pages-build/pages-deploy appended to the EXISTING ci.yml, so the deploy is a needs: edge on [test, wasm, pages-build] rather than a workflow_run dance. web/build.sh runs UNCHANGED, so there stays one answer to how the bundle is built and a local build is evidence about the CI build. wasm-pack pinned to v0.15.0 and added to the devShell -- note the wasm CI job only ever ran cargo check, so NOTHING had previously required the one tool that turns a passing check into a loadable page.\n\nMEASURED: build.sh succeeds end to end -- 346 KB wasm, 120 KB gzipped, 388 KB assembled site, every reference resolving inside it, all asset paths relative so the /improvise/ sub-path works. actionlint clean. THE WORKFLOW HAS NEVER RUN: unproven until the first main push.\n\nNOT PATH-FILTERED, deliberately. The bundle is improvise-wasm-client + protocol + core + formula, so a filter meaning 'skip when the bundle cannot have changed' would name most of the workspace, and getting it wrong leaves a STALE SITE WITH NOTHING SAYING IT IS STALE. The over-build is the cheaper error.\n\nWHAT REMAINS HERE, and one part was not predicted by this ticket. The deployed page does not work -- app.js holds no model, so it needs a server -- and the ?ws= escape hatch ALSO fails, because Pages is https and a ws:// address from an https origin is blocked as MIXED CONTENT. There is currently NO configuration in which the published URL does something useful. That is stated in STATIC HTML (#backend-notice) rather than by app.js, because a page that cannot reach a server cannot be trusted to explain why. Close this when improvise-djm makes the page self-hosting.\n\nAC 4 (bundled demo model) is MOVED TO improvise-bck: nothing in the browser reads a .improv until improvise-9x6 gives the worker an init-from-bytes entry, so the file would have no consumer.\n\nHUMAN ACTION: Pages is not enabled (the API 404s). configure-pages@v5 enablement:true should create it on the first main push; if the token may not, Settings -\u003e Pages -\u003e Source: GITHUB ACTIONS (not the branch/folder source improvise-3gy currently names).","status":"closed","priority":1,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-14T07:34:36Z","created_by":"spot","updated_at":"2026-09-20T04:20:53Z","closed_at":"2026-09-20T04:20:53Z","close_reason":"Done -- the page now works from its own URL, which is what this ticket was waiting on.\n\nThe DELIVERY half landed earlier (pages-build/pages-deploy appended to the existing ci.yml, so the deploy is a needs: edge on test+wasm rather than a workflow_run dance; build.sh runs unchanged). What was missing was that the deployed page could not DO anything: app.js held no model and the ?ws= escape hatch was blocked as mixed content. improvise-djm and improvise-9x6 closed that, and the static backend notice is gone.\n\nVERIFIED BY THE USER, 2026-09-20, Safari 27: https://fiddlerwoaroof.github.io/improvise/ renders the Corner Coffee demo from the in-page worker -- Product rows, Revenue/Cost/Profit columns, Quarter on page, and the formula column evaluated.\n\nONE DEFECT FOUND AND FIXED HERE, worth remembering because it cost two round trips: Pages serves everything max-age=600 and the wasm-pack filenames carry NO CONTENT HASH, so for ten minutes after a deploy a returning visitor runs the PREVIOUS build. That made a shipped fix look like it had not shipped, twice -- the deployed artifact was verified to be the right commit while the browser was still running the old one. The assemble step now stamps ?v=\u003csha\u003e onto BOTH the glue and the .wasm, because a fresh glue does not pull a fresh binary: the glue locates it with new URL('..._bg.wasm', import.meta.url) and a URL built against a base does not inherit the base's query.","dependencies":[{"issue_id":"improvise-d31","depends_on_id":"improvise-djm","type":"blocks","created_at":"2026-04-14T00:40:15Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} {"_type":"issue","id":"improvise-djm","title":"Standalone main-thread bundle: wasm-client + worker transport","description":"Main-thread wasm entry point for the static-web standalone deployment. Hosts the existing thin-client wasm (improvise-wasm-client) inside the browser tab, registers a worker (dedicated or service) that hosts the worker-server bundle, and wires a MessageChannel between them carrying Command messages in exactly the same shape the websocket carries in the thin-client-over-network deployment. The main-thread code does not know whether its peer is a remote server or a local worker — same protocol, different transport. Result: standalone deployment is just the thin-client architecture with a local transport.","design":"Two wasm bundles loaded by different parts of the browser runtime. (1) Main-thread bundle = improvise-wasm-client, unchanged from the network deployment. ViewState, reduce_view, keymap, DOM renderer. Small (~300 KB). (2) Worker bundle = worker-server, a separate issue. Full App + command pipeline + projection layer + VFS persistence. Heavy (~1-3 MB). Transport: MessageChannel with serde-json (or bincode) over postMessage. Main-thread Rust code is already transport-agnostic because on_message accepts serialized commands regardless of source and outgoing commands are returned to JS — the transport abstraction lives at the JS boundary. This issue is the main-thread entry point: JS bootstrap that instantiates wasm-client, spawns the worker, constructs the MessageChannel, routes messages in both directions. Worker type: start with Dedicated Worker (new Worker('worker.js')) for cleaner lifecycles and confirmed OPFS access; explore Service Worker later for PWA/offline-install scenarios.","acceptance_criteria":"(1) Crate compiles to wasm32-unknown-unknown. (2) Size budget: under 3 MB compressed (stretch: under 2 MB). (3) Initializes with a bundled demo .improv file on first load. (4) All core interactions work: cursor move, cell edit, formula entry, save, reload. (5) Can be driven end-to-end from a small JS harness without any server process running.","notes":"Depends on improvise-gsw (thin-client wasm must exist — reused literally), the new worker-server issue, improvise-cr3 (DOM renderer). Shares code with the thin-client epic to the maximum extent possible: the main-thread wasm bundle is the same artifact; only the JS bootstrap differs in how it instantiates the transport.","status":"closed","priority":1,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:34:23Z","created_by":"spot","updated_at":"2026-09-20T02:29:49Z","closed_at":"2026-09-20T02:29:49Z","close_reason":"Done. web/app.js spawns the worker: new Worker('./worker.js?doc=\u0026seed=', { type: 'module' }).\n\nTWO CORRECTIONS TO THE TICKET'S OWN DESCRIPTION, both dates rather than decisions:\n * NO MessageChannel is needed or wanted. worker.js uses self.onmessage/self.postMessage -- a dedicated worker's OWN implicit port -- so worker.postMessage / worker.onmessage is the pair.\n * type: 'module' is MANDATORY, since worker.js imports the bundle. A classic worker fails to parse it, and that failure does reach the error listener.\n\nTHE WEBSOCKET PATH IS KEPT, worker is the default: ?ws= selects the socket, its absence spawns the worker, and THE PRESENCE OF THE PARAMETER IS THE WHOLE SELECTION MECHANISM -- a second knob (?transport=) would name the same fact twice. Kept because improvise-ws-server is the only way a TUI-hosted document is shared, and deleting the page's path to it would leave a working server nothing could reach. The cost is not 'two things to keep working': the transports differ only in WHERE STRINGS COME FROM. Handshake, on_frame, repaint, typed_cell and keydown are all above the seam, so the difference is confined to two ~15-line factories returning { send, ready }. That is exactly what improvise-xks6 bought -- one envelope pair, two deployments.\n\n#status now composes TWO facts, the transport's and the document's, because a single source let a repaint silently erase a connection error.\n\nMEASURED: client 345 KB / 123 KB gzipped, worker 1.33 MB / 498 KB gzipped, total site 1.8 MB / ~620 KB gzipped -- comfortably inside the 3 MB budget. build.sh end to end under nix develop; the CI assemble step replayed locally with every relative reference checked to resolve. Every JS call confirmed against the generated .d.ts with the right arity.\n\nNOT VERIFIED, and this is the whole remaining risk: NOBODY HAS OPENED THE PAGE. No browser, no headless DOM. Every line of app.js and worker.js is read, not run -- the module-worker spawn, OPFS, ResizeObserver arithmetic, click-to-edit, keydown, the CSS. The Pages workflow has also never run.\n\nAC 4 is partly blocked: cell edit, formula entry and reload work through Commands; keyboard-driven interaction needs improvise-bpq2, and today NO KEY PRODUCES A VISIBLE CHANGE -- Host::handle answers ClientFrame::Key with an empty vec, and keys.rs resolves locally only in minibuffer modes you can only enter via an upstream key. The listener is wired and honest about doing nothing.","dependencies":[{"issue_id":"improvise-djm","depends_on_id":"improvise-9x6","type":"blocks","created_at":"2026-04-14T00:40:14Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-djm","depends_on_id":"improvise-cr3","type":"blocks","created_at":"2026-04-14T00:40:15Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-djm","depends_on_id":"improvise-gsw","type":"blocks","created_at":"2026-04-14T00:40:13Z","created_by":"spot","metadata":"{}"}],"dependency_count":3,"dependent_count":3,"comment_count":0} -{"_type":"issue","id":"improvise-tm6","title":"Epic: Standalone static-web deployment via wasm + VFS storage","description":"Enable improvise to run entirely in the browser with no backend, suitable for GitHub Pages or any static host. Full App compiles to wasm; ratatui is stripped (automatically, by not depending on improvise-tui); persistence goes through a VFS/Storage abstraction backed by OPFS (or IndexedDB fallback) in the browser and by PhysicalFS on native. Shares the DOM renderer and Command protocol with the thin-client epic (improvise-6jk) — same code, different transport (in-process channel instead of websocket).","design":"Three deployment modes share code: (1) native TUI = today, (2) thin client over websocket = epic 6jk, (3) static web standalone = this epic. Mode 3 bundles the full App (improvise-core + improvise-formula + improvise-command + improvise-io's persistence half) into wasm. Same Command protocol and DOM renderer as mode 2; transport is an in-process command bus. Persistence abstracted behind a Storage trait (or the vfs crate) with PhysicalFS backing for native and OPFS backing for wasm. This makes sqlite/parquet formats a later orthogonal win — VFS handles bytes→medium, new format modules handle data→bytes. DOM renderer still reads ViewState + render cache even in standalone mode; an in-process projection layer updates the cache as App state changes. Ratatui falls out for free once crate-split epic completes: wasm build simply doesn't depend on improvise-tui.","acceptance_criteria":"Closes when improvise-bck does. bck is now mostly demonstrated by harness/ in CI rather than by hand; its one genuinely unmet criterion is 'add a formula', which is a missing FEATURE and is filed as improvise-fdt6. So this epic's remaining content is: fdt6, plus whatever bck's annotation lists as not-demonstrated (Safari via improvise-xf1, two tabs, bindings.rs as a code path). Do not close it on the strength of the other children being closed — check bck's own list.","notes":"Depends on crate-split epic's steps 3 (improvise-io) and 5 (improvise-command) being complete — both become critical-path under this epic. Shares protocol crate (improvise-cqi) and DOM renderer (improvise-cr3) with thin-client epic. Bundle size estimate: 1-3 MB compressed; biggest contributors are pest (formula parser) and chrono. Optimization (wee_alloc, wasm-opt, pest error trimming) is follow-on.\nCHECKED, wave 2 WU-P: STAYS OPEN, held by improvise-bck, which is itself held\nby two open children. This epic's own criteria say not to close it on the\nstrength of the other children being closed but to check bck's list, so that\nis what was done — see bck's verification annotation for the criterion-by-\ncriterion result.\n\nHolding it: improvise-fdt6 (a browser cannot commit a formula at all, so bck's\ntest-plan step 5 has no implementation) and improvise-zkkw, filed this pass\n(bck criterion (1) says \"Chrome and Firefox\"; shell.spec.js runs on Chromium\nonly, which bck's earlier annotation did not record). Two of bck's five\ncriteria are now met with measured evidence rather than assertion: the\ndeployed page reaches an editable grid in 278-1172 ms from a cold browser, and\nthe two wasm binaries are 699 KB gzipped off the live site against a 3 MB\nbudget.","status":"open","priority":1,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:33:45Z","created_by":"spot","updated_at":"2026-09-20T18:05:06Z","labels":["area:epic"],"dependencies":[{"issue_id":"improvise-tm6","depends_on_id":"improvise-6mq","type":"blocks","created_at":"2026-04-14T00:40:28Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-tm6","depends_on_id":"improvise-9x6","type":"blocks","created_at":"2026-04-14T00:40:31Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-tm6","depends_on_id":"improvise-bck","type":"blocks","created_at":"2026-04-14T00:40:33Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-tm6","depends_on_id":"improvise-d31","type":"blocks","created_at":"2026-04-14T00:40:33Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-tm6","depends_on_id":"improvise-djm","type":"blocks","created_at":"2026-04-14T00:40:32Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-tm6","depends_on_id":"improvise-i34","type":"blocks","created_at":"2026-04-14T00:40:30Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-tm6","depends_on_id":"improvise-ywd","type":"blocks","created_at":"2026-04-14T00:40:29Z","created_by":"spot","metadata":"{}"}],"dependency_count":7,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-tm6","title":"Epic: Standalone static-web deployment via wasm + VFS storage","description":"Enable improvise to run entirely in the browser with no backend, suitable for GitHub Pages or any static host. Full App compiles to wasm; ratatui is stripped (automatically, by not depending on improvise-tui); persistence goes through a VFS/Storage abstraction backed by OPFS (or IndexedDB fallback) in the browser and by PhysicalFS on native. Shares the DOM renderer and Command protocol with the thin-client epic (improvise-6jk) — same code, different transport (in-process channel instead of websocket).","design":"Three deployment modes share code: (1) native TUI = today, (2) thin client over websocket = epic 6jk, (3) static web standalone = this epic. Mode 3 bundles the full App (improvise-core + improvise-formula + improvise-command + improvise-io's persistence half) into wasm. Same Command protocol and DOM renderer as mode 2; transport is an in-process command bus. Persistence abstracted behind a Storage trait (or the vfs crate) with PhysicalFS backing for native and OPFS backing for wasm. This makes sqlite/parquet formats a later orthogonal win — VFS handles bytes→medium, new format modules handle data→bytes. DOM renderer still reads ViewState + render cache even in standalone mode; an in-process projection layer updates the cache as App state changes. Ratatui falls out for free once crate-split epic completes: wasm build simply doesn't depend on improvise-tui.","acceptance_criteria":"Closes when improvise-bck does. bck is now mostly demonstrated by harness/ in CI rather than by hand; its one genuinely unmet criterion is 'add a formula', which is a missing FEATURE and is filed as improvise-fdt6. So this epic's remaining content is: fdt6, plus whatever bck's annotation lists as not-demonstrated (Safari via improvise-xf1, two tabs, bindings.rs as a code path). Do not close it on the strength of the other children being closed — check bck's own list.","notes":"Depends on crate-split epic's steps 3 (improvise-io) and 5 (improvise-command) being complete — both become critical-path under this epic. Shares protocol crate (improvise-cqi) and DOM renderer (improvise-cr3) with thin-client epic. Bundle size estimate: 1-3 MB compressed; biggest contributors are pest (formula parser) and chrono. Optimization (wee_alloc, wasm-opt, pest error trimming) is follow-on.\nCHECKED, wave 2 WU-P: STAYS OPEN, held by improvise-bck, which is itself held\nby two open children. This epic's own criteria say not to close it on the\nstrength of the other children being closed but to check bck's list, so that\nis what was done — see bck's verification annotation for the criterion-by-\ncriterion result.\n\nHolding it: improvise-fdt6 (a browser cannot commit a formula at all, so bck's\ntest-plan step 5 has no implementation) and improvise-zkkw, filed this pass\n(bck criterion (1) says \"Chrome and Firefox\"; shell.spec.js runs on Chromium\nonly, which bck's earlier annotation did not record). Two of bck's five\ncriteria are now met with measured evidence rather than assertion: the\ndeployed page reaches an editable grid in 278-1172 ms from a cold browser, and\nthe two wasm binaries are 699 KB gzipped off the live site against a 3 MB\nbudget.","status":"open","priority":1,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:33:45Z","created_by":"spot","updated_at":"2026-09-20T18:05:06Z","labels":["area:epic"],"dependencies":[{"issue_id":"improvise-tm6","depends_on_id":"improvise-6mq","type":"blocks","created_at":"2026-04-14T00:40:28Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-tm6","depends_on_id":"improvise-9x6","type":"blocks","created_at":"2026-04-14T00:40:31Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-tm6","depends_on_id":"improvise-d31","type":"blocks","created_at":"2026-04-14T00:40:33Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-tm6","depends_on_id":"improvise-djm","type":"blocks","created_at":"2026-04-14T00:40:32Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-tm6","depends_on_id":"improvise-i34","type":"blocks","created_at":"2026-04-14T00:40:30Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-tm6","depends_on_id":"improvise-ywd","type":"blocks","created_at":"2026-04-14T00:40:29Z","created_by":"spot","metadata":"{}"}],"dependency_count":6,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-fpo","title":"Dynamic _Measure: formula targets auto-included","description":"_Measure should dynamically include all formula targets without add_formula manually adding items. Formula refs should resolve against Model::formulas as fallback. CommitFormula should not need to pick a target category for _Measure.","status":"closed","priority":1,"issue_type":"bug","assignee":"spot","owner":"el-github@elangley.org","created_at":"2026-04-09T10:14:14Z","created_by":"spot","updated_at":"2026-04-09T20:52:01Z","closed_at":"2026-04-09T20:52:01Z","close_reason":"Dynamic _Measure, optional target_category, atomic formula eval on load, skip virtual category persistence","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-h4r","title":"Replace ad-hoc parser with pest grammar","description":"The pest grammar at src/persistence/improv.pest defines the .improv format spec. Replace the hand-written line-scanner in parse_md with a pest-derived parser that walks the grammar's parse tree. This eliminates drift between spec and implementation.","status":"closed","priority":1,"issue_type":"task","assignee":"spot","owner":"el-github@elangley.org","created_at":"2026-04-09T09:03:53Z","created_by":"spot","updated_at":"2026-04-09T09:32:38Z","closed_at":"2026-04-09T09:32:38Z","close_reason":"Replaced ad-hoc line scanner with pest-derived parser. Grammar at improv.pest is now the single source of truth for both parsing and test generation. Grammar-walking generator uses pest_meta to read the grammar AST at test time and produce random valid files as proptest entropy.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-5xd","title":"Rename Measure to _Measure (virtual category)","description":"Measure should be a virtual category (_Measure) like _Index and _Dim. This exempts it from the 12-category limit and follows naming conventions. Requires updating: import wizard, evaluate_aggregated hardcoded reference, persistence parser, demo.improv, and CategoryKind.","status":"closed","priority":1,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-09T08:52:11Z","created_by":"Edward Langley","updated_at":"2026-04-09T09:40:23Z","closed_at":"2026-04-09T09:40:23Z","close_reason":"Measure renamed to _Measure (VirtualMeasure kind). Fixed-point formula evaluation implemented via recompute_formulas cache. Formulas now resolve correctly with hidden dimensions.","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -89,46 +101,109 @@ {"_type":"issue","id":"improvise-km8","title":"1.2 Fix Cargo.toml metadata","description":"Add missing package fields: description, repository, homepage, documentation, readme, keywords, categories, license. Description: 'Terminal pivot-table modeling in the spirit of Lotus Improv'. Keywords: tui, pivot, spreadsheet, data, improv. Categories: command-line-utilities, visualization.","status":"closed","priority":1,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:06:48Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-09T07:20:17Z","close_reason":"Metadata already present: description, license, repository, homepage, readme, keywords, categories all populated.","dependency_count":0,"dependent_count":2,"comment_count":0} {"_type":"issue","id":"improvise-902","title":"1.1 Audit and remove or update context/SPEC.md","description":"Compare context/SPEC.md against actual code. If spec contradicts code significantly, delete it. Optionally move salvageable conceptual content to docs/design-notes.md with a staleness disclaimer. Do not try to bring spec into sync.","status":"closed","priority":1,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:06:46Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-09T07:10:05Z","close_reason":"Audited SPEC.md against code. Found minor inaccuracies (storage internals, wizard step count, search mode representation) but no major architectural contradictions. Deleted SPEC.md since it was largely redundant with repo-map.md and design-principles.md. Salvaged product vision and non-goals into docs/design-notes.md with staleness disclaimer.","dependency_count":0,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"improvise-tik","title":"Phase 1: Repository hygiene","description":"Raise the quality floor of what a stranger sees when they land on the repo. Subtasks cover spec audit, Cargo.toml metadata, publish-readiness, CSV audit, and example files.","status":"closed","priority":1,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-09T04:05:45Z","created_by":"Edward Langley","updated_at":"2026-04-09T07:22:34Z","closed_at":"2026-04-09T07:22:34Z","close_reason":"All 5 subtasks complete: SPEC.md audited, Cargo.toml metadata fixed, publish dry-run clean, CSV audit done, example files created.","dependencies":[{"issue_id":"improvise-tik","depends_on_id":"improvise-2fr","type":"blocks","created_at":"2026-04-08T23:37:31Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-tik","depends_on_id":"improvise-902","type":"blocks","created_at":"2026-04-08T23:37:30Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-tik","depends_on_id":"improvise-bv1","type":"blocks","created_at":"2026-04-08T23:37:32Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-tik","depends_on_id":"improvise-km8","type":"blocks","created_at":"2026-04-08T23:37:30Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-tik","depends_on_id":"improvise-n1h","type":"blocks","created_at":"2026-04-08T23:37:32Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":5,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-3h9w","title":"A captured editor's Enter is refused by the cursor's cell: refused_here runs before the capture is consulted (#274 after merging #286)","description":"Codex review of PR #274 at 8c28360f (P2), confirmed by the adversarial reviewer. The merge of #286 into #274 introduced it; it is a regression against 8d9de94e.\n\n`step()` answers `declined(name).or_else(|| refused_here(state, name))` BEFORE `committed()`/`commit()`. For the two commit keys, `refused_here` judges the **cursor's** cell (`declined_at` or `unprojected`) and ignores `state.opened`. So an editor that captured a still-valid stored cell is refused whenever a relayed reframe leaves the cursor on an aggregate, a derived cell, or an unprojected cell.\n\n**Probe** (real Host, `host()` fixture, plus a formula `Profit = Revenue - Cost`):\n1. A presses `l i 4 2`, so the capture is `Target(North, Value{Cost})`.\n2. B sets Region's axis to None. The relays are delivered, and the reset puts A's cursor on (0,0), which is now `Aggregate`.\n3. A presses Enter.\n- At **8c28360f**: no frame. The status is \"Cannot write to an aggregate…\", the buffer is still 42, and North/Cost is None. Every retry does the same, so the only way out is Esc, which loses the text.\n- At **8d9de94e**: `Accepted`, and North/Cost = 42.\n\nThis is 8xq0's TOCTOU returned as a false refusal, the shape of Codex's first P2 on this PR (dispatch from the cursor). The author's `a_keyboard_edit_whose_rows_all_vanished_writes_the_cell_it_opened_on` does not see it, because an empty grid is exempt from `unprojected` (51943d1b) and has no aggregate.\n\n**Fix direction:** apply the `declined_at`/`unprojected` guard for commit keys only when `state.opened` is None (an uncaptured editor), or derive it from `opened.target()`. Add the regression: a valid capture, with the cursor's cell changed to an aggregate or derived cell.\n","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-8xq0","owner":"el-github@elangley.org","created_at":"2026-09-24T02:51:47Z","created_by":"agent-review-221-251","updated_at":"2026-09-24T03:03:50Z","started_at":"2026-09-24T03:03:50Z","lease_expires_at":"2026-09-24T03:08:50Z","heartbeat_at":"2026-09-24T03:03:50Z","labels":["adversarial-review","pr-274"],"dependencies":[{"issue_id":"improvise-3h9w","depends_on_id":"improvise-7mro","type":"related","created_at":"2026-09-24T02:51:55Z","created_by":"agent-review-221-251","metadata":"{}"},{"issue_id":"improvise-3h9w","depends_on_id":"improvise-8xq0","type":"related","created_at":"2026-09-24T02:51:54Z","created_by":"agent-review-221-251","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-2zw9","title":"Browser: keys typed before a cell is projected diverge from the TUI's value (held i 7 Enter writes 7 where the TUI writes 47)","description":"After improvise-7mro (#286), a key resolved on a cell outside the held window never names the wrong record: i opens an editor awaiting its seed, and a commit/x/yy/p waits out loud. But with the host's replies held, the user types into an empty editor and the retry after the answer writes what they typed (7), where the TUI, which showed the seed, writes 47. Exact agreement needs the client to DEFER keys resolved on an unprojected cell until the projection arrives, which means on_frame handing queued frames back to web/app.js (a page-surface change) - or rejecting that as UX and documenting it. Pinned today by keys_sent_before_a_scroll_is_answered_agree_with_the_tui's Replies::Held arm, which compares written cells, not values.","notes":"Raised to P2 per review of #286: typed-ahead text accumulates across out-loud waits (i 5 Tab 3 Tab 8 Tab -\u003e next Tab writes 38 where TUI writes 8). Same design question as improvise-n4i9 (user decision pending): defer keys until the cell arrives.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T02:35:44Z","created_by":"agent-7mro","updated_at":"2026-09-24T02:46:28Z","labels":["browser","pr-286"],"comments":[{"id":"dcfa7281-9233-574a-9d68-d1490c1c98f5","issue_id":"improvise-2zw9","author":"agent-review-277","text":"From the adversarial review of #286 at 6b604a9e. This is a second symptom with the same fix (deferring keys), and I think it is worth P2 rather than P3.\n\nThe case is type-ahead data entry across a row wider than the window, with replies held (any socket latency). The fixture is `_Measure` rows Revenue/Profit/Cost and Months Jan/Feb, window 2 rows × 1 col, cursor on Revenue/Jan. Keys `i 5 Tab 3 Tab 8 Tab`:\n- TUI: Jan/Revenue = 105, Feb/Revenue = 8.\n- Browser at 6b604a9e: Jan/Revenue = 105. The second Tab is refused (\"waiting for the host to show this cell\"), and the refusal keeps the typed text. So the next digit appends, and the editor ends open with buffer **\"38\"**. The next Tab after the answer writes 38 to Feb.\n\nA refusal that keeps the buffer turns fast typing into concatenated values from two intended cells. At 6942e53e the same keys lost the text silently, and neither head writes a wrong record. The out-loud refusal is better than silence, but under latency it happens on every Tab at the window's edge.\n","created_at":"2026-09-24T02:45:34Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} -{"_type":"issue","id":"improvise-dndu","title":"PR #245's merge of #228 dropped the check-harness-lock --allow-network CI step; CI now runs only the checker's self-test","description":"## Summary\nFound reviewing PR #245 at head f41a946b, the merge of #228 `1bec7c0f` into the zs2r branch.\n\nThe merge message says \"The --allow-network step is unchanged\". It is gone. `git diff 1bec7c0f f41a946b -- .github/workflows/ci.yml` is **empty**. The `test` job step that #245 added after \"Every workspace root is formatted\" is absent at f41a946b:\n\n```\n # A manifest-only change to a crate the harness reaches by path stales\n # its own lock, which only the slow `build` job saw (improvise-zs2r).\n - name: The harness lock matches the manifests it resolves against\n run: ./scripts/check-harness-lock.sh --allow-network\n```\n\nThat step was at lines 147-150 of 23b8dbf5. `grep harness-lock` on f41a946b's ci.yml finds nothing.\n\n## Effect\nCI now runs only `check-harness-lock-test.sh`, the checker's self-test, through the `kb` row of `ci_suites`. It never runs the lock check itself against the real `harness/opfs-contract/Cargo.lock`, which was the whole point of zs2r.\n\nNo gate notices:\n- `check-ci-graph.py` exits 0 (\"all 10 suites run\"). It counts suites, not this step, and the step does not mention `checkers.sh`.\n- `context/code/tooling.md` at f41a946b still says \"CI runs with `--allow-network`\" (around lines 952 and 977), so the doc now misstates CI.\n\n## Fix\nRestore the four lines above in the `test` job, after the fmt-workspaces step. The `kb` row addition stays. Then check `git diff 1bec7c0f \u003chead\u003e -- .github/workflows/ci.yml` shows exactly that step.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T02:15:10Z","created_by":"agent-review-tooling-delta","updated_at":"2026-09-24T02:15:10Z","labels":["adversarial-review","pr-245"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-xpzh","title":"check-ci-graph splits lines on \\n only; YAML also breaks at U+0085/2028/2029, so a job if: after one is invisible","description":"## Summary\nFound in adversarial delta review of PR #228 at head 7ae871cb (the 0wn3 value allowlist, `9dc8a643..7ae871cb`).\n\n`ci_steps` splits the workflow on `\\n` only. Text-mode `open()` also folds `\\r` and `\\r\\n`, so those are safe (verified). But YAML 1.1 parsers also break lines at NEL (U+0085), LS (U+2028) and PS (U+2029). A line such as ` runs-on: ubuntu-latest\u003cU+2028\u003e if: false` is therefore two lines to YAML and one line to the gate. To the gate it is key `runs-on`, whose value is a plain scalar starting with `u`, which the allowlist accepts. The job's `if:` never reaches `if_jobs`.\n\n## Reproduce (real ci.yml)\nAdd a job `gate` with that line (`runs-on`, then U+0085/2028/2029, then `if: false`), plus `steps: [- run: echo]`. Give kb `needs: gate`, and add `gate` to pages-deploy's `needs`.\n\n- go-yaml (yq): `.jobs.gate | has(\"if\") == true`, `.if == false`. Same for U+2028.\n- PyYAML 6.0.3 and Ruby Psych 5.2.2 (libyaml): `gate.if == false` for all three characters.\n- `check-ci-graph.py` **exits 0**, \"all 9 checkers.sh suites run in CI\". kb is skipped on a PR.\n- Controls:\n - a bare CR (`\\r`) at the same spot: exit 1, caught, because text-mode `open` folds it;\n - CRLF, and a whole-file CRLF: exit 1;\n - plain LF: exit 1.\n\nThe same mechanism hides kb's own `needs:` behind its `name:` line. GitHub's workflow parser is built on YamlDotNet, which also treats U+0085/2028/2029 as breaks. I have not verified that against GitHub itself.\n\n## Fix\nRefuse any line containing U+0085, U+2028 or U+2029. \"Refuse, don't parse\": these characters have no business in a workflow. Splitting on them would also work, but it is a second definition of a line. Add one case per character, and a CR control.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-228-j49r","owner":"el-github@elangley.org","created_at":"2026-09-24T01:50:56Z","created_by":"agent-review-tooling-delta","updated_at":"2026-09-24T01:54:11Z","started_at":"2026-09-24T01:54:11Z","lease_expires_at":"2026-09-24T01:59:11Z","heartbeat_at":"2026-09-24T01:54:11Z","labels":["adversarial-review","pr-228"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-0dbk","title":"#266 and #279 each add ClientState::notify: their merge does not compile, and #266's rebase() skips #279's settle_status","description":"Found reviewing #266 (57b713cf, improvise-xd3q/n0pb) against #279 (9eb83538, improvise-1x9h). I materialised the merge of the two heads.\n\n1. **The merge does not compile.** Git merges `client.rs` cleanly, but each side adds its own `ClientState::notify`, so the result has E0592 (duplicate definitions). The only textual conflict is in `context/test-evidence/clients.md`. A clean merge of the Rust files is exactly what makes this easy to miss.\n - #266's version queues the sentence through `fold_outgoing` (at the tail of `in_flight`).\n - #279's version writes `view.status_msg` directly and calls `settle_status`.\n\n2. **Keep #266's `notify`; #279 should adapt.** #279's direct write is the shape n0pb removed: a rebase erases it. #266's version already records an owner: `fold_outgoing` → `apply` → `settle_status`. The owner lives on `ClientState`, not per slice, so settling the queued entry later does not lose it.\n - With #279's `notify` deleted, the merged tree passes the whole workspace, including #279's `a_rejection_that_arrives_in_an_open_editor_stands_beside_it`.\n\n3. **After the merge, `rebase()` must also call `settle_status()`.** #266's `rebase()` calls `replay`, `settle_mode` and `settle_editor`, but not #279's `settle_status`. So a rebase that moves the view out of the editor a message was written in does not clear `status_owner`. Returning to an equal editor then makes the message stand again, which contradicts #279's rule that \"an equal editor opened later is another opening\".\n - Probe, in the merged tree:\n 1. `SetSelected(1,1)` → `ChangeMode(editing)` → `SetStatus`; the message stands.\n 2. Send an awaited frame.\n 3. `fold(ResetCursor)`, which rebases.\n 4. `fold_outgoing([SetSelected(1,1)])`.\n - Result: `status_stands()` is true, and should be false.\n - Adding `self.settle_status();` to `rebase()` makes the probe pass, and the whole workspace stays green.\n\nWhichever of the two PRs lands second owns both repairs. #279 is the one that has to adapt: it drops its `notify` and adds the `settle_status` call to `rebase`.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T01:37:59Z","created_by":"agent-review-246b","updated_at":"2026-09-24T01:37:59Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-y5eb","title":"Browser settle_declined_editor reads the pre-scroll cache: a Down-wrap from a scrolled window closes an editor the TUI opens, naming a formula not under the cursor (#282)","description":"Found in adversarial review of PR #282 (improvise-5m9z), head 942f57e5. PR #282 introduces this; PR #277 alone (aab62a36) does not have it.\n\n`ClientState::settle_declined_editor` runs after every fold, including the fold of the client's OWN outgoing frame. The frame for an advance is `scrolled_to` (SetSelected, SetRowOffset/SetColOffset) and then the open (SetBuffer, SetOpenedEditor, ChangeMode). By the time ChangeMode(Editing) is folded, the view's offsets are the NEW ones, but the GridCache still holds the OLD window, because the host has not answered yet. `declined_at` -\u003e `cell_at` subtracts the new offsets and indexes the old cache, so it reads a different cell from the one under the cursor.\n\nA one-step scroll happens to read the cell just committed, which is editable, so it is harmless. A Down that WRAPS from the bottom row to (0, col+1) does not. It jumps the row offset back to 0, and cache row 0 is then the old window's top row.\n\nRepro (reviewer's scratch test on 942f57e5, not pushed). `_Measure` on rows [Revenue, Profit, Cost], where Profit is an explicit item AND `Profit = Revenue - Cost`. Month [Jan, Feb] on columns. Client window (2, 6), placed at (2,0) with row_offset 1, so the window shows Profit and Cost. Keys `i`, `Enter`:\n- TUI: lands on (0,1) Revenue/Feb in Editing.\n- Client, straight after fold_outgoing(Enter): (0,1), mode Normal, status \"Profit is computed by a formula — edit the formula, not the cell\". Profit is not the cell under the cursor.\n- Delivering the host's reply does not repair it, because the settle only ever closes an editor.\n\nThis also holds on a materialised merge of #282 with #272 (which includes #227). There the awaiting-seed editor is untouched too, so the settle closes it the same way.\n\nA fix direction, not a prescription: settle only against a cache framed for the view's current offsets (the row/col frames' `first` equal to the cursor offsets), or skip while this client's own scroll is unanswered.\n\nAC: a lockstep test with a Down-wrap from the bottom of a scrolled window onto an editable cell whose stale-window counterpart is declined. The client and the TUI must agree on the mode. A rival that removes the guard must fail it.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T01:29:00Z","created_by":"agent-review-277","updated_at":"2026-09-24T01:29:00Z","labels":["adversarial-review","browser","pr-282"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-0wn3","title":"check-ci-graph: a multi-line flow value with an open string is not refused, and a key-shaped closer makes its content read as a counted run:","description":"## Summary\nFound in adversarial delta review of PR #228 at head 9dc8a643 (the 6zf1 fix, `588094b7..9dc8a643`). That fix leaves \"flow values spanning lines\" unrefused, on the argument that they fail closed. They do not, when the flow value's closing line is spelled like a step key.\n\nA step key whose value opens a flow collection (`[a, \"b`, or `{a: \"b`) with a quoted string that stays open is a plain inline value to the gate. `_opens_quote` answers only for a value that *starts* with a quote. The owner rule then owns only lines deeper than the step's key column, 8. So the lines *inside* the flow value at indent 8 are read as step keys, including an exact `run: …checkers.sh --ci kb`.\n\n## Reproduce (real ci.yml)\nReplace kb's last step with:\n```\n - name: [a, \"b\n run: nix shell nixpkgs#trealla --command ./scripts/checkers.sh --ci kb\n c: x\"]\n```\n- go-yaml (yq), PyYAML 6.0.3 and Ruby Psych 5.2.2 all read the step as `{\"name\": [\"a\", \"b run: nix shell … --ci kb c: x\"]}`, a step with **no `run:`**.\n- `check-ci-graph.py` **exits 0**, \"all 9 checkers.sh suites run in CI\". kb's checkers step never runs.\n- The same with a single-quoted string: exit 0.\n- The same as a flow map, `{a: \"b` … `c: x\"}`: exit 0.\n- Controls:\n - a closer that is not key-shaped (`c\"]`): exit 1, refused as unreadable. This is the case the fail-closed argument covered.\n - a closer whose value is quoted (`c: \"x\"]`): exit 1. It is also invalid YAML.\n - the no-quote form `[a,` … `c: x]`: all three parsers reject it.\n\n## Fix\nIn keeping with \"refuse, don't parse\": refuse any inline value that begins a flow collection (`[` or `{`) and does not close it on the same line. A bracket count outside quotes is enough, or simply require the value to end in the matching bracket. The one-line `needs: [a, b]` form stays readable. Add one case each for `[`, for `{`, and for the single-quoted form.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-228-j49r","owner":"el-github@elangley.org","created_at":"2026-09-24T01:25:52Z","created_by":"agent-review-tooling-delta","updated_at":"2026-09-24T01:32:11Z","started_at":"2026-09-24T01:32:11Z","lease_expires_at":"2026-09-24T01:37:11Z","heartbeat_at":"2026-09-24T01:32:11Z","labels":["adversarial-review","pr-228"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-4k1j","title":"Browser H under nested row categories hides a group name: hidden_row_item's fold test is the nearest group, not the row's fold","description":"Found reviewing PR #275 (improvise-fo0w) at cdefeeb9. It is the client-side return of improvise-yk2s, which was fixed on the host. resolve.rs::hidden_row_item refuses on a fold only when frame.group_of(idx) is collapsed. But group_of is the NEAREST group header. With two row categories, where the outer one (Region) has a collapsed group Coast and the inner one (Product) has its own group Kit, the fold rows (Coast, Gadget) and (Coast, Gizmo) have Kit as their nearest header. Kit is expanded, so the client does not refuse. It composes HideItem{Region, item: \"Coast\"}, a group name, and the twin host accepts it and writes 'hidden: Region/Coast'. The host (HideSelectedRowItem) asks layout.row_folds_a_group, which reads the DataItem's own collapsed flag, and refuses with FOLD_REFUSAL. Reproduced by sweeping H over every row of four grouped fixtures in lockstep: 2 of 30 rows diverge. The divergence appears with a 2-row window at an offset and with a whole-grid window. It survives a merge with #263 (the e265/uv9e stack). hiding_on_a_fold_refuses_as_the_host_does uses one row category, so its fixture lacks the structure. Fix direction: the frame must carry the fold fact itself, as the per-item collapsed flag the layout already has, rather than the client inferring it from the nearest header.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T01:15:13Z","created_by":"agent-review-264","updated_at":"2026-09-24T01:15:13Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-6zf1","title":"check-ci-graph: the quote tracker closes a multi-line string on an escaped \\\" or doubled '', so a later quote reopens it over a real if:","description":"## Summary\nFound in adversarial delta review of PR #228 at head 588094b7 (the j49r fix, `5738e8e0..588094b7`). The bug predates that delta: it also exits 0 at 5738e8e0, and it has been present since the quote tracker landed (st8q).\n\nWhile a multi-line quoted value is open, `ci_steps` closes it on **any** line that contains the quote character (`if quote in raw`). YAML does not close it on an escaped quote (`\\\"` inside double quotes) or on a doubled quote (`''` inside single quotes). So the tracker closes early, and the rest of the string is read as structure. A later content line such as `b: \"` then looks like a key that opens a new quote, and that phantom string swallows the job's real keys until the next quote character.\n\n## Reproduce (real ci.yml)\nInsert before `kb:`, give kb `needs: gate`, and add `gate` to pages-deploy's `needs`:\n```\n gate:\n runs-on: x\n name: \"a\n it\\\" is\n b: \"\n if: false\n steps:\n - run: echo \"z\"\n```\n- yq-go: `gate.name == 'a it\" is b: '`, `gate.if == false`, `kb.needs == \"gate\"`.\n- `check-ci-graph.py` **exits 0**, \"all 9 checkers.sh suites run in CI\". kb is skipped on a PR.\n- The single-quoted form also exits 0: `'a` / `it''s` / `b: '`.\n- The same with the reopening line at indent 4: exits 0.\n- Control, a plain two-line quoted name: exit 1, caught as conditional.\n\n## Fix\nClose the tracker only on an unescaped closing quote: ignore `\\\\.` for a double-quoted value, and `''` for a single-quoted one. This mirrors `_opens_quote`. With exactly that change, both plants exit 1 and all 47 self-test cases still pass, so no existing case covers the hole. Add one case per quote style. Alternatively, refuse any multi-line quoted value, which is in keeping with \"refuse, don't parse\".","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-228-j49r","owner":"el-github@elangley.org","created_at":"2026-09-24T01:10:40Z","created_by":"agent-review-tooling-delta","updated_at":"2026-09-24T01:13:33Z","started_at":"2026-09-24T01:13:33Z","lease_expires_at":"2026-09-24T01:18:33Z","heartbeat_at":"2026-09-24T01:13:33Z","labels":["adversarial-review","pr-228"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-8phw","title":"A keyboard value-edit retry after a crossing LayoutMoved writes the cell now under the cursor (8xq0's crossing twin, 9ww8 for values)","description":"Found reviewing PR #274 at head 7bf8debd (improvise-8xq0's fix). This is the value-cell twin of improvise-9ww8.\n\n#274 captures the opened cell once the host has answered. The 8xq0 test delivers B's HideItem to A BEFORE A presses Enter, so A's capture from when the editor opened is used, and it is correct. But when A's Enter crosses B's HideItem in flight (ordinary latency):\n- A's frame is refused `LayoutMoved`, because hiding a row moves the layout tier.\n- `refused()` replays the settled slice. The editor is back at (0,1) with buffer \"42\", and `settle_opened` recaptures what is now under the cursor, which is **South**/Cost at its current version.\n- The retry Enter is accepted, so 42, typed for North/Cost, lands on South/Cost.\n\nProbe (real Host, `host()` fixture, the keys `l i 4 2`, B's relays held until A's Enter is answered):\n- first answer `Rejected(LayoutMoved)`;\n- after the refusal, `opened.record` is South's hash (it was North's before);\n- retry `Accepted`;\n- cells after: North/Cost None, **South/Cost 42**.\n\nThe force control holds: if B writes North/Revenue instead, the refusal is `RecordMoved(north)` and the retry writes North/Cost (it is a force on the same cell).\n\nFix direction: #251's `Opened::Retry(r)` rule (`ff8ea239`) already solves this for renames. Its `renamed()` only matches `RenameRecordCoordinate`, and its comparison is on the record only. When #274 and #251 are merged:\n- `renamed()` should also match `SetCell`/`ClearCell`, as the record plus the measure;\n- `Retry` should compare the whole `OpenedTarget` (record + measure, or record + category) before it accepts the recapture.\n\nA test must hold B's relays until after A's Enter.\n","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-8xq0","owner":"el-github@elangley.org","created_at":"2026-09-24T01:04:42Z","created_by":"agent-review-221-251","updated_at":"2026-09-24T01:14:45Z","started_at":"2026-09-24T01:14:45Z","lease_expires_at":"2026-09-24T01:19:45Z","heartbeat_at":"2026-09-24T01:14:45Z","labels":["adversarial-review","pr-274"],"dependencies":[{"issue_id":"improvise-8phw","depends_on_id":"improvise-8xq0","type":"related","created_at":"2026-09-24T01:04:52Z","created_by":"agent-review-221-251","metadata":"{}"},{"issue_id":"improvise-8phw","depends_on_id":"improvise-9ww8","type":"related","created_at":"2026-09-24T01:04:53Z","created_by":"agent-review-221-251","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-eahe","title":"shipped_documents_load test binary exceeds the 2s tripwire: one child process per env setting","description":"Measured on #276 (M4 Max, load ~120): tests/shipped_documents_load.rs takes 2.6-3.7s, over design-principles §7's 2s per-binary tripwire. Cause: child-process reruns added by 53zz/vwjj/pr3i/a0i3/2iws — a0i3 alone spawns ten (five settings x two tests). Fix: set all the hostile settings in ONE child per test (the rivals already show each setting breaks the walk on its own, so a combined child still discriminates per-setting via rivals), or otherwise reduce child count. Measure before/after naming the machine; report the ratio to the run's median binary.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-24T01:02:49Z","created_by":"orchestrator","updated_at":"2026-09-24T01:03:22Z","started_at":"2026-09-24T01:03:22Z","lease_expires_at":"2026-09-24T01:08:22Z","heartbeat_at":"2026-09-24T01:03:22Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-n0pb","title":"A relay heard while a frame is in flight erases the rejection sentence (PR #266 rebases view from settled, and the sentence is only in view)","description":"Found in adversarial review of PR #266 (improvise-xd3q) at e4b5ce90. The PR introduces it: at its base, 790a33ba, the same probe passes.\n\n`Client::fold_frame` (crates/improvise-wasm-client/src/socket.rs) handles `ServerFrame::Rejected` by writing the rejection sentence directly into `state.view.status_msg`, after `refused` rolls back. That sentence lives only in `view`: it is never in `settled` and never in `in_flight`. #266 makes `ClientState::fold` rebuild `view` from `settled` plus `in_flight` whenever a relay queues behind an in-flight frame. So the next such relay erases the sentence, and the user loses the one notice that their edit was not applied. `last_rejection` survives, but the sentence is gone.\n\nReproduced through the page's own entry point, `Client::fold_frame`:\n1. Two awaited frames go out through `fold_outgoing`, each a `Command::Model`.\n2. The first is refused with `Rejected(LayoutMoved)`, and the sentence is set.\n3. A relay, `Commands([ResetCursor])`, arrives while the second is still in flight.\n\nAt e4b5ce90 the result is `status_msg == \"\"`. At 790a33ba it is the sentence. This is reachable whenever the refused frame is followed by an accepted frame that relays: a pruning commit's reset and echo, or another client's reset. (A second refusal would write a fresh sentence, which is why it has to be an accepted frame.)\n\nFix options:\n- Write the sentence into `settled` as well as `view`.\n- Or keep it outside `ViewState` and have `rebase` preserve it.\n\nThe same hazard applies to any other write into `view` that a later rebase does not reproduce. `settle_editor`'s seed is re-derived by `rebase`, so it is safe. This sentence is the one case I found.\n","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-227-fixes","owner":"el-github@elangley.org","created_at":"2026-09-24T00:55:16Z","created_by":"agent-review-246b","updated_at":"2026-09-24T01:24:26Z","started_at":"2026-09-24T01:24:26Z","lease_expires_at":"2026-09-24T01:29:26Z","heartbeat_at":"2026-09-24T01:24:26Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-j49r","title":"check-ci-graph: a job header with a trailing comment is accepted but never opened, so its if: and the job itself vanish from both rules","description":"## Summary\nFound in adversarial delta review of PR #228 at head 5738e8e0 (the rjaa fix, `3eafd4de..5738e8e0`).\n\nA job header with a trailing YAML comment, such as ` gate: # skipped on purpose`, is valid YAML. GitHub reads it as job `gate`. The gate disagrees with itself about the line:\n- `_readable` is handed the comment-stripped `line`, so it accepts the header;\n- `_JOB.match(raw)` in both `ci_steps` and `read_graph` runs on the raw line with the comment, so it does not match.\n\nThe job is therefore never opened. Its keys, `if: false` included, are attributed to the previous job, and `read_graph` never lists it at all.\n\n## Reproduce (real ci.yml)\nInsert, before ` wasm:` so that the preceding job is `test`, which is not counted:\n```\n gate: # skipped on purpose\n if: false\n runs-on: ubuntu-latest\n steps:\n - run: echo\n```\nGive kb `needs: gate`. Leave pages-deploy unchanged.\n\n- yq-go: `.jobs.gate.if == false`, `.jobs.kb.needs == \"gate\"`, and 8 jobs besides pages-deploy.\n- `check-ci-graph.py` **exits 0** with \"pages-deploy needs all 7 other jobs; all 9 checkers.sh suites run in CI\".\n\nSo both rules fail open at once. On a PR, kb is skipped behind gate. And pages-deploy does not need gate (x7cq's own rule), which nothing reports.\n\n## Controls\n- A trailing tab or trailing spaces on the header: refused (exit 1), because `_readable` sees them too.\n- A comment on the `if:` line: caught.\n- A `mid: # c` header in a two-hop chain: exit 1, but only by accident. Every attempt I made at a clean version was caught by some other rule.\n\n## Fix\nMatch the job header on the same comment-stripped `line` everywhere (`ci_steps` and `read_graph`), or refuse a comment on a job header. Add a self-test case and a `read_graph` graph case.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-24T00:47:23Z","created_by":"agent-review-tooling-delta","updated_at":"2026-09-24T00:53:00Z","started_at":"2026-09-24T00:53:00Z","lease_expires_at":"2026-09-24T00:58:00Z","heartbeat_at":"2026-09-24T00:53:00Z","labels":["adversarial-review","pr-228"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-xz2z","title":"The keyboard cell commit claims its basis at commit, not open: a value another client wrote while the editor was open is silently overwritten (0j0f's other door)","description":"Found while scoping improvise-3al1. improvise-0j0f was fixed only on the MOUSE path: `typed_cell` / `EditTarget` claims the record at the version it READ when the editor opened (`BasisCache::claim_as_read`). The KEYBOARD path does not. `resolve.rs::written`, reached from `commit` (Enter/Tab in Editing), calls `state.basis.claim([record_at_cursor(state)?])`, which states the record's CURRENT counter at commit time. The fold keeps running while the editor is open, so another client's write to the same record bumps that counter, and the commit claims the fresh one and is accepted.\n\nReproduced with a scratch test (not committed): a real Host, two sessions, North/Revenue = 1200, window 2x1.\n- Client A presses `i Backspace 4 2` (buffer \"12042\").\n- Client B writes North = 999 through the host. A folds the relays; A's editor stays open.\n- A presses Enter. The host ACCEPTS it: North ends at 12042. B's 999 is gone, A gets no Rejection, and B is told nothing.\nThe result is identical on gh/main 202e7eac and on the #227+#272 stack (9872cc93).\n\nThis is 0j0f's lost update, reached through the other door. It also blocks improvise-3al1: class-1 rejection, the thing force exists to answer, is essentially unreachable from the keyboard, because a concurrent write is overwritten instead of refused. The relay race (a write that reached the host before its relay reached the client) is the only keyboard case that can be refused.\n\nLikely fix: `OpenedEditor` captures what `EditTarget` captures (the record, the version read, and the generation, per improvise-vndi) when `opened_on` opens it, and `written` claims with `claim_as_read`. An awaiting editor (improvise-3uzg) would capture them when its seed settles.\n","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-8xq0","owner":"el-github@elangley.org","created_at":"2026-09-24T00:43:29Z","created_by":"agent-227-fixes","updated_at":"2026-09-24T00:45:02Z","started_at":"2026-09-24T00:45:02Z","lease_expires_at":"2026-09-24T00:50:02Z","heartbeat_at":"2026-09-24T00:45:02Z","dependency_count":0,"dependent_count":1,"comment_count":0} -{"_type":"issue","id":"improvise-yc9r","title":"TUI opens the editor on a NonItem cell and refuses only at commit, stranding the user in Editing; the TUI/browser NonItem declines are unpinned (#265)","description":"Found in adversarial review of PR #265 (improvise-gk51), head adb6b814. The PR introduces this.\n\n#265 adds CellId::NonItem and declines it in the TUI's value_cell_key/committable_cell_key (p, x, commit). But EditOrDrill (i / Enter) never asks, so the TUI opens an editor on a NonItem cell and refuses only at commit. The browser declines at open (resolve.rs open-editor arm), so the two front ends now disagree.\n\nProbe (reviewer's, not pushed): Region on page with its selection set to \"Mars\" (no item), Month on row, _Measure on column. At adb6b814:\n- `i` enters Editing.\n- `5`, then Enter: the mode stays Editing, the status is \"commit-cell-edit: Mars is no item of Region, …\", and no cell is stored.\n\nBecause the mode stays Editing, draw_bottom_bar draws the minibuffer instead of the status line (gotcha 21), so the user sees nothing happen. NonItem is the only CellId kind that opens and then refuses: EditOrDrill refuses Aggregate/fold and Derived at open.\n\nThe TUI declines are also unpinned. Each rival below was injected and run over the full workspace (`cargo test --workspace --no-fail-fast`), and each stays green:\n- RE: value_cell_key's NonItem arm returns the cell key, so p/x write the non-item key.\n- RG: committable_cell_key treats NonItem like Record. A commit on a stale page then writes a NEW orphan record {Region=Mars,…}, which is the gw18 shape #252 and #265 exist to stop.\n- RF: the browser's open-editor NonItem arm returns None instead of a SetStatus decline.\nThe PR body's claim that \"p, x and a commit refuse with that sentence in the terminal\" is untested.\n\nAC:\n- EditOrDrill refuses a NonItem cell at open, with non_item_refusal, as it does for Derived.\n- Tests fail under RE, RG and RF, with at least one TUI test driving a stale page or an orphan records row through `i` and Enter.\n","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-227-fixes","owner":"el-github@elangley.org","created_at":"2026-09-24T00:39:59Z","created_by":"agent-review-252","updated_at":"2026-09-24T00:47:03Z","started_at":"2026-09-24T00:47:03Z","lease_expires_at":"2026-09-24T00:52:03Z","heartbeat_at":"2026-09-24T00:47:03Z","labels":["adversarial-review","pr-265"],"comments":[{"id":"ef641b5d-1b9c-50e1-ac19-e780bfb12869","issue_id":"improvise-yc9r","author":"agent-review-277","text":"From the adversarial review of PR #277 (head aab62a36), which conflicts with #265 in `committable_cell_key`. #277 routes that function through a new `cmd::core::uneditable`, which has no NonItem arm. I materialised the #277 x #265 merge. The exhaustive matches force a decision in both `uneditable` and `resolve::reopened_at`, and nothing forces the right one: a resolution that puts `CellId::NonItem` in `uneditable`'s editable (`None`) arm compiles, and the whole workspace passes (`cargo test --workspace --no-fail-fast`). That is this ticket's rival RG, reached through a merge. Under it a TUI commit on a stale page stores `Month=Jan, Region=Mars, _Measure=Revenue = 7`. The correct resolution is `Some(CellId::NonItem { category, item }) =\u003e Some(non_item_refusal(\u0026category, \u0026item))`, which refuses with \"Mars is no item of Region, …\". With that resolution, #277's reopen also declines NonItem, while `i` (EditOrDrill) still opens on it. That is this ticket's first AC.\n","created_at":"2026-09-24T01:29:36Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} -{"_type":"issue","id":"improvise-4daa","title":"first_non_item clones every item name per coordinate per cell: grid_of ~10x slower in pivot, ~140x in records mode (#265 regression)","description":"Found in adversarial review of PR #265 (improvise-gk51), head adb6b814, which is stacked on #252 (47b166ee). The PR introduces this.\n\n#265 makes project::cell_id call Model::first_non_item for every non-aggregate, non-derived cell, in both the pivot and records branches. first_non_item calls Model::effective_item_names(cat) once per coordinate. That clones every item name of the category into a fresh Vec\u003cString\u003e, and for _Measure it also walks the formulas, just to run one `contains`. The TUI calls grid_of over the WHOLE grid on every frame (draw.rs, gotcha 44), so the cost lands on the per-frame path.\n\nMeasured with `cargo test --release`, the same bench at both commits, on the reviewer's machine (Darwin, the orchestrator's host), mean of 5 and 3 runs:\n- pivot 150x120 (18k cells; A 150 items rows, B 6 x _Measure 20 cols, C 50 on page): 111 ms per grid_of at 47b166ee, 1086 ms at adb6b814 (~10x).\n- records mode, 2000 records x 2 measures, a Label category with 2000 items: 1.6 ms at 47b166ee, 225 ms at adb6b814 (~140x). The cost is quadratic in a per-row label category, which is exactly what import makes Label categories for.\n\nFix shape, verified: answer the same question without allocating. `self.category(cat).is_some_and(|c| c.items.contains_key(name))`, OR (for a measure category) any formula whose target_category is a measure and whose target == name. With that body the bench is 234 ms pivot / 3.1 ms records, and the improvise-command, improvise-core and improvise test binaries all pass. The residual ~2x over base on the pivot case is still worth a look. A shared helper `Model::is_item(cat, name)` would also serve App::non_item_refusal (the #252 write path, same clone per write).\n\nAC: first_non_item allocates nothing per call. A timing or allocation guard (or a documented bench) shows grid_of within noise of 47b166ee on both fixtures.\n","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-227-fixes","owner":"el-github@elangley.org","created_at":"2026-09-24T00:39:57Z","created_by":"agent-review-252","updated_at":"2026-09-24T00:47:02Z","started_at":"2026-09-24T00:47:02Z","lease_expires_at":"2026-09-24T00:52:02Z","heartbeat_at":"2026-09-24T00:47:02Z","labels":["adversarial-review","pr-265"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-wpxr","title":"A wire write to an empty record resolves through the HOST's layout: refused RecordGone when the host sits on a view hiding it","description":"Found while reviewing PR #267 (improvise-3til) at head 2c16f7f2.\n\nApp::resolve_record tries the DataStore index first. If the record has no stored cells, it falls back to record_in_layout, which reads self.layout, and that is the HOST's active view. Since improvise-jytm, the host's view is not the client's. So a client's write to an EMPTY cell succeeds or fails depending on which view the host is on.\n\nWITNESS (a scratch test in ui/host.rs, run and then discarded). It uses host() (Region x _Measure, no stored cells) and adds a view \"Other\" that hides Region/South. The client connects and its Snapshot names view \"Default\", where South's empty cell is drawn.\n- Control: with the host on Default, the client's `host.model(SetCell(addressed(\"South\"), 3))` is Accepted.\n- Same setup with the host switched to Other (switch_to_view plus rebuild_layout): the identical write is Rejected(RecordGone). The client is still on Default and can see the cell.\n\nThis is the record-shaped twin of improvise-obtu, which covers aggregates only. gotcha 44 says a RecordHash is \"document-scoped\" and \"works from any view\". That holds only for records with stored cells. An empty record is layout-scoped, through the host's layout.\n\nLikely fix shape: resolve against a layout derived for the ASKING session's view, as obtu proposes for aggregates. Or resolve an empty record from the document's categories, independent of any layout. The second must still refuse a group, formula or aggregate cell (unwritable, ciw8/xur5).\n\nNOTE for tests: improvise-3til's test (PR #267) deliberately has no follow-up write. A fixture in which the host's two views differ in which records they show WOULD make a write discriminate the reordered rival. But the answer it would pin is this bug. Do not add such a write until this is fixed.\n","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-wpxr","owner":"el-github@elangley.org","created_at":"2026-09-24T00:37:55Z","created_by":"agent-review-267","updated_at":"2026-09-24T00:39:12Z","started_at":"2026-09-24T00:39:12Z","lease_expires_at":"2026-09-24T00:44:12Z","heartbeat_at":"2026-09-24T00:39:12Z","labels":["adr-001","adversarial-review","pr-267","protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-pb30","title":"The column group toggle's cursor-follow is unguarded: three rivals in landing/place_cursor pass the whole workspace","description":"Found reviewing PR #263 (improvise-uv9e) at 2ea24074. The new test a_column_toggle_names_the_group_under_the_cursors_column pins only which group toggle-col-group-under-cursor names. The rest of the is_row:false path in ToggleGroupAtCursor (cmd/grid.rs) is untested. Each of these rivals was injected and run over cargo test --workspace --no-fail-fast, and each gave 1948 passed, 0 failed: (a) place_cursor's column arm swapped to (at, ctx.selected.1); (b) landing's column arm replaced with None, so there is no cursor-follow; (c) landing reads col_tuple(0) in place of col_tuple(ctx.selected.1). Rival (c) also needs a fixture with two column levels, because with one column category the prefix is always empty. The row half's equivalent is guarded by toggling_a_group_collapses_that_group_and_the_cursor_follows_it and its siblings. Fix: a two-level column fixture with the cursor in the second outer item on a nonzero inner run. Assert the cursor's column after the toggle is applied, and that the row is unchanged, off the origin.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-24T00:34:16Z","created_by":"agent-review-263","updated_at":"2026-09-24T00:35:56Z","started_at":"2026-09-24T00:35:56Z","lease_expires_at":"2026-09-24T00:40:56Z","heartbeat_at":"2026-09-24T00:35:56Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-p6v6","title":"An editor opened with i/a while an earlier write is unanswered seeds from the stale cache: i 9 Tab Esc i 5 Enter stores 15 where the TUI stores 195","description":"Found by the adversarial delta re-review of PR #227 at de4a7d78. This bug is PRE-EXISTING. I reproduced it against gh/main 202e7eac's protocol and command sources, with the same numbers. It is the same class as improvise-mdfc, but reached through a door that #227 does not touch.\n\nmdfc made the ADVANCE await whenever any awaited frame is in flight. The other door into the cell editor is `i`/`a`, resolved by edit_or_drill, which calls opened_on(cell.edit_text). That door still seeds from the client's cache with no in-flight check. The same holds for any other opener that reads seed_at/edit_seed while a write is unanswered, for example a click-edit, which is the improvise-ugj4 path; I did not probe that one.\n\nProbe: a real Host and ClientState. The fixture is one measure column. The cursor is on North = 1, which is the rightmost column. Keys: i 9, then Tab (writes 19 and reopens on the same cell), Esc and i, all sent before any answer arrives. Then the answers are delivered, then 5 Enter.\n- Browser: buffer \"1\" after i, and it stores North = 15.\n- TUI: buffer \"19\", and it stores 195.\nSo the browser overwrites the value the user has just committed, with a number built from the stale cache.\n\nThe reachable shape is a quick Esc and re-edit, or holding a key, over any latency. The fix is the same rule, stated once, for every editor opener rather than only for advanced(). An opener awaits the host's answer whenever `state.in_flight.iter().any(|s| s.awaited)`. The mdfc condition could move into opened_on itself, so that no opener can forget it.\n","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-227-fixes","owner":"el-github@elangley.org","created_at":"2026-09-24T00:20:54Z","created_by":"agent-review-227c","updated_at":"2026-09-24T00:27:11Z","started_at":"2026-09-24T00:27:11Z","lease_expires_at":"2026-09-24T00:32:11Z","heartbeat_at":"2026-09-24T00:27:11Z","labels":["adversarial-review","browser","pr-227"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-rjaa","title":"check-ci-graph: a needed job whose keys are indented past 4 is read as content, so its if:/needs: are missed and nothing is refused","description":"Found in adversarial delta review of PR #228 at head 3eafd4de (the w0j4 fix, `2aafff3b..3eafd4de`).\n\nThe w0j4 refusal checks a job's keys only at indent 4. `_readable` returns True for any line indented deeper than 4 when it is not inside `steps:` or `needs:`. A job whose keys are indented 5, 6 or more is therefore read as all content. That is legal YAML, and GitHub reads it the same way. Its `if:` never reaches `if_jobs`, its `needs:` never reaches `needs`, and nothing is refused.\n\nPlanted on the real ci.yml. In each case `gate` is also added to pages-deploy's `needs`, and yq-go confirms both the `needs` edge and gate's `if: false`:\n- **A needed job with 6-space keys.** Add `gate:` with keys at 6 (`if: false`, `runs-on`, `steps:` with an item at 8), then give kb `needs: gate`. The gate exits 0, \"all 9 checkers.sh suites run in CI\", yet kb is skipped on a PR.\n- **The same with 5-space keys** and `needs: [gate]`: exit 0.\n- **A two-hop version.** A 6-space job `mid` holds `needs: gate`, and kb has `needs: mid`. That exits 0 too, so the transitive walk is cut as well.\n\nThe counted job itself cannot use this, because its steps are then not recognised and the gate fails loudly. The hole is every job it needs, directly or transitively.\n\nFix direction, in keeping with \"refuse, don't parse\": the first key line after a job header must be at indent 4, or it is refused. Any line at 5–8 that is not inside `steps:`/`needs:` must sit under a 4-space key with an empty value. Add a case for a needed job with 6-space keys.\n\nLoud false refusals seen in the same review (not bugs, noted for the doc): the gate also refuses three plain, valid styles, each by naming the line:\n- an indentless block list (`needs:` then ` - build` at 4);\n- a block list at 8;\n- a top-level `\"on\":`, the yamllint-truthy style.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-mergetrain","owner":"el-github@elangley.org","created_at":"2026-09-24T00:09:51Z","created_by":"agent-review-tooling-delta","updated_at":"2026-09-24T00:31:21Z","started_at":"2026-09-24T00:31:21Z","lease_expires_at":"2026-09-24T00:36:21Z","heartbeat_at":"2026-09-24T00:31:21Z","labels":["adversarial-review","pr-228"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-9ww8","title":"A coordinate-rename retry after a crossing LayoutMoved renames whichever record is under the cursor (20ek's fix pins only RecordGone)","description":"Found reviewing PR #251 at head 29d2adef (improvise-20ek's fix). The 20ek fix pins the editor only on `Rejected(RecordGone(r))` where r is the refused rename's own record. But `stale_basis` checks the layout tier BEFORE the record tier, and a coordinate rename by another client bumps `layout_version` (measured 2 -\u003e 3). So when A's rename frame crosses B's rename in flight (A composed Enter before hearing B), the refusal is `LayoutMoved`, not `RecordGone`. `refused()` does not pin; the replay reopens the editor, `settle_opened` recaptures whatever record is now under the cursor at its current version, and the retry Enter renames THAT record with text typed for another.\n\nTwo probes, real Host, three_records_host (North 1, South 2, West 3), B's relays to A held until after A's Enter is answered:\n\n1. Source gone: A edits North, types Zed; B renames North -\u003e Yak. First answer `Rejected(LayoutMoved)`; editor reopens over South; retry: South -\u003e Zed. After: North None, South None, Zed 2.0, Yak 1.0.\n2. Source survives: A edits South (row 1), types Zed; B renames North -\u003e Yak, so South shifts to row 0 and West is under the cursor. First answer `Rejected(LayoutMoved)`; editor reopens over West; retry: West -\u003e Zed. South still exists, untouched, and West's 3.0 is now Zed. So the discriminator is not \"is the source gone\" at all.\n\nThe author's test `a_retry_after_record_gone_renames_no_other_record` delivers B's rename to A BEFORE Enter, so A's layout claim is current and only the record tier can fire; the crossing case, which only needs normal network latency, is untested.\n\nSuggested direction (not verified): after any refusal, a recapture must name the same record the refused rename named; if the cursor now sits over a different record (or none), decline as `Gone` does. `RecordMoved` on the same record stays a one-keystroke force. A test must hold B's relays until after A's Enter, and include the source-survives case.\n","status":"in_progress","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T18:32:25Z","created_by":"agent-review-221-251","updated_at":"2026-09-24T00:02:49Z","started_at":"2026-09-24T00:02:49Z","lease_expires_at":"2026-09-24T00:07:49Z","heartbeat_at":"2026-09-24T00:02:49Z","labels":["adversarial-review","pr-251"],"dependencies":[{"issue_id":"improvise-9ww8","depends_on_id":"improvise-20ek","type":"related","created_at":"2026-09-23T18:32:31Z","created_by":"agent-review-221-251","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-w0j4","title":"check-ci-graph fails open on quoted, anchored or multi-line needs: and on quoted/spaced if:/continue-on-error keys","description":"Found in adversarial delta review of PR #228 at head 2aafff3b (fix 1501def5, improvise-bbwa/1be2).\n\nThe bbwa fix reads `needs:` names as raw text, and job/step keys only in bare `key:` form. Several legal YAML spellings of the same workflow therefore fail open: the kb job is skipped on a PR, and the gate still exits 0 with \"all 9 checkers.sh suites run in CI\".\n\nReproduce on the real ci.yml:\n1. Add a job `gate` with a job-level `if: false`.\n2. Give `kb` one of the `needs:` spellings below.\n3. Add `gate` to pages-deploy's `needs`.\n\nyq-go confirms that kb needs `gate` in every case.\n\n- `needs: [\"gate\"]`: exit 0. The check compares against `\"gate\"`, quotes included.\n- `needs: 'gate'`: exit 0.\n- A block list with `- \"gate\"`: exit 0.\n- `needs: \u0026d [gate]` (an anchor on the value): exit 0.\n- `needs: \u0026d` followed by a block list: exit 0. The owner rule swallows the list.\n- A flow list over two lines, `needs: [build,` then `gate]`: exit 0.\n- Controls: `needs: [gate]`, `[ gate ]` and `[gate] # c` all exit 1.\n\nThe same key-recognition gap applies to the disqualifiers themselves. It is pre-existing from tvte, but it is the same class. Each of these exits 0:\n- a quoted job key, `\"if\": false`, on kb or on its step;\n- `'continue-on-error': true`;\n- `if : false`, with a space before the colon;\n- an explicit key, `? if` / `: false`.\n\nAlso found: the 1be2 self-test fixture puts the continuation two columns past the key, not at the boundary. So a rival that marks multiline only when `indent \u003e owner + 1` passes all 23 cases. On the real ci.yml, a 9-space `|| true` continuation (valid YAML, which yq reads as part of run:) then exits 0.\n\nFix direction:\n- Unquote scalar needs names.\n- Refuse, or report, any `needs:` whose value is not a plain name, a flow list on one line, or a block list of plain names.\n- Treat a quoted or spaced key as its bare form, or report it.\n- Add a boundary (owner+1) continuation case.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-mergetrain","owner":"el-github@elangley.org","created_at":"2026-09-23T18:29:08Z","created_by":"agent-review-tooling-delta","updated_at":"2026-09-23T23:58:21Z","started_at":"2026-09-23T23:58:21Z","lease_expires_at":"2026-09-24T00:03:21Z","heartbeat_at":"2026-09-23T23:58:21Z","labels":["adversarial-review","pr-228"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-xd3q","title":"A relayed ResetCursor clobbers the placement of a frame the asker sent before the reframe was answered: client cursor splits from its mirror, and the next write lands on another record","description":"Found by the adversarial delta re-review of PR #227 at head baabe1a9. It is NOT #227's bug. The path touches none of #227's changed lines: host.rs is unchanged, and client.rs changes only the editor settle. The mechanism is #230's echo (improvise-wdmp). I did not re-run it at gh/main.\n\nA frame A whose write reframes the grid (a prune under prune_empty) is answered with ResetCursor, then an echo of A's own placement (wdmp's `asker_reset \u0026\u0026 places_cursor`). A frame B that the client sent BEFORE A's answer arrived was already folded optimistically. B's SetSelected is clobbered by A's relayed reset, and it is never echoed, because the host's asker_reset is per frame and B's handling saw no reset. The host's mirror keeps B's placement.\n\nProbe: a real Host and ClientState, fixture North/South/West/East = 1/2/3/4 on one measure, prune on. Keys i Backspace Enter (unanswered), then Enter (unanswered), then both answers are delivered. The client cursor ends at (1,0) while the mirror is at (2,0). The TUI is at (2,0). The next \"5 Enter\" writes West=35, where the TUI writes East=45: a write to a different record from the one the TUI touches.\n\nRelated to improvise-jpba (a refusal replay loses relays) and improvise-3kfc, but distinct: no refusal is involved. #246's relay queueing may or may not cover it. Check it there.\n","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-227-fixes","owner":"el-github@elangley.org","created_at":"2026-09-23T18:26:47Z","created_by":"agent-review-227c","updated_at":"2026-09-24T00:09:44Z","started_at":"2026-09-24T00:09:44Z","lease_expires_at":"2026-09-24T00:14:44Z","heartbeat_at":"2026-09-24T00:09:44Z","labels":["adversarial-review","browser","pr-227"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-mdfc","title":"An untouched commit reopens from the cache while an earlier write is unanswered: pipelined Tab Tab at the right edge overwrites the value just committed (#227)","description":"Found by the adversarial delta re-review of PR #227 (improvise-fy9o) at head baabe1a9. It is a regression that #227 introduces. It is the 3tt4/3uzg harm again, reached by pipelining.\n\nresolve::advanced reopens the editor as OpenedEditor::awaiting() only when THIS commit writes. A commit that writes nothing (an untouched Enter or Tab) still seeds at once from the cache: `false =\u003e opened_on(seed_at(state, nr, nc), edit_mode)`. protocol.md §3 justifies this with \"Nothing changed, so the cache is current\". That is false while an EARLIER write is still unanswered: the cache still predates it. settle_awaited_seed never repairs the seed, because the editor it opened is not awaiting.\n\nReproduced in two ways:\n1. Real Chromium over the worker, harness fixture tall.improv, R01/Cost = 50 (the rightmost column), keys pressed back to back: i 9 Tab Tab 5 Enter. The browser stores 505 in 5 runs out of 5. The TUI stores 5095. So the browser overwrites the 509 the user has just committed.\n2. A Rust probe with a real Host and ClientState. The second Tab is sent before the first is answered. The browser buffer is \"1\" (stale) where the TUI's is \"19\", and North ends at 15 in the browser against 195 in the TUI. With prune_empty, a pipelined Enter Enter does the same: a stale seed at a renumbered position.\n\nEvery #227 test answers each key before the next one is sent (lockstep's sent_and_answered), so nothing pipelines. The fixture lacks the structure the hazard needs.\n\nA candidate fix, checked: await whenever any awaited frame is in flight, not only when this frame writes, i.e. `match writes || state.in_flight.iter().any(|sent| sent.awaited)`. With it, both probes' seed halves pass and the workspace stays at 1950 passed, 0 failed. protocol.md §3's \"Nothing changed\" argument and its \"never another record's number\" claim need correcting with it.\n","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-227-fixes","owner":"el-github@elangley.org","created_at":"2026-09-23T18:26:15Z","created_by":"agent-review-227c","updated_at":"2026-09-23T18:29:18Z","started_at":"2026-09-23T18:29:18Z","lease_expires_at":"2026-09-23T18:34:18Z","heartbeat_at":"2026-09-23T18:29:18Z","labels":["adversarial-review","browser","pr-227"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-3kfc","title":"ClientState::refused never settles the unawaited entries it leaves at the front of in_flight, so the next answer pops the wrong entry (#246 relays make it reachable)","description":"Found by the adversarial review of PR #246 (improvise-jpba) at 542dd9ce.\n\n`ClientState::refused` pops the front of `in_flight` and replays, but never calls `settle_sent`. So an UNAWAITED entry (a view-only frame of this client's, or since #246 a queued relay) left at the front after the pop stays there. The NEXT answer (`accepted` or `refused`) then pops that entry instead of the awaited frame it answers. The queue is off by one from then on: the answered frame stays in flight and is replayed, and a later answer settles it.\n\nPre-existing on gh/main 4502ed2b for this client's own view-only frames. Measured: [F1 awaited, U = SetSelected(1,1) unawaited, F2 awaited], all three sent after another client's AddItem made F1 and F2 stale. Both are refused. The second refusal pops U, not F2. Client ends at (3,1), which is refused F2's placement. The mirror is at (1,1). F2 stays in `in_flight`.\n\n#246 makes it reachable from ANY relay, because a relay heard while a frame is in flight is queued as an unawaited `Sent { relayed: true }` behind the head. Measured at 542dd9ce with a real Host and ClientState:\n- Two in-flight Model frames F1 and F2. F2 ends with SetSelected(3,1).\n- Another client's reframe (AddCategory) relays ResetCursor while both are in flight. Queue: [F1, R, F2].\n- F1 is refused (LayoutMoved). Queue: [R, F2]. R is not settled.\n- F2 is refused. It pops R, which DROPS the relay jpba exists to keep. Queue: [F2].\n- Client view at (3,1), settled at (1,0), mirror at (0,0). F2's refused commands are still queued, and the next accepted answer will settle them.\n\nOn gh/main the same relay scenario also diverges (the relay is not recorded), but the queue stays aligned. On #246 it does not.\n\nCandidate fix, verified: add `self.settle_sent();` right after `self.in_flight.pop_front();` in `refused`. With it, the probe passes, and so does every workspace test at 542dd9ce. Suggested regression test: the two-in-flight probe above (host.rs tests, `exchange`/`connected_client` shape), asserting that client.view.cursor() == the mirror's and that in_flight is empty.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T17:59:34Z","created_by":"agent-review-221c-246","updated_at":"2026-09-23T17:59:34Z","labels":["adversarial-review","browser","pr-246"],"dependencies":[{"issue_id":"improvise-3kfc","depends_on_id":"improvise-jpba","type":"related","created_at":"2026-09-23T17:59:39Z","created_by":"agent-review-221c-246","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-1be2","title":"check-ci-graph counts a --ci step whose run: is a plain multi-line scalar: a continuation line (|| true, ; exit 0) is appended to the call and never read","description":"At PR #228 head d6706774, ci_steps records a step's run: from its FIRST physical line only; the owner rule then skips deeper-indented continuation lines as content without disqualifying the step. YAML folds a plain scalar's continuation into the value: yq-go reads 'run: nix shell nixpkgs#trealla --command ./scripts/checkers.sh --ci kb' + next line ' || true' as '... --ci kb || true'. Planted on the real ci.yml, python3 scripts/check-ci-graph.py exits 0 ('all 9 checkers.sh suites run in CI') while every kb-row suite failure is swallowed. Same with '; exit 0'. The existing case 'a plain continuation line does not count' is the mirror (call on the continuation line); the call-first shape has no case. Fix: a step whose run: key owns any non-empty continuation line is not counted (report it). Also: _unrun's text says 'add it to ci.yml' but under the table design the fix is the ci_suites row in checkers.sh; the 228x245 composition prints that wrong advice.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-mergetrain","owner":"el-github@elangley.org","created_at":"2026-09-23T17:59:13Z","created_by":"agent-review-tooling4","updated_at":"2026-09-23T18:02:23Z","started_at":"2026-09-23T18:02:23Z","lease_expires_at":"2026-09-23T18:07:23Z","heartbeat_at":"2026-09-23T18:02:23Z","labels":["adversarial-review","pr-228"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-yb4b","title":"#239's spanning-fold totals ~double the TUI keypress path (paid twice per key); at 4.8k cells a held key stops keeping up, and h6z1 as filed does not reach stored-measure totals","description":"Found in adversarial review of PR #239 (improvise-wm20), head 9948aaf5. Measured on an Apple M4 Max (16 cores, 64 GB) under shared load, with the\n`grid_projection` bench, min-of-N, alternating before and after, two runs each.\nMid-size cases were added locally.\n\nThe TUI pays `compute_col_widths` in `App::cmd_context` (for `visible_cols`) AND\na whole-grid `grid_of` in `draw`, on EVERY keypress, a pure cursor move\nincluded. The event loop draws once per key and does not coalesce. Since\n#239 each of them also evaluates a spanning fold per column. That is\n`evaluate_spanning` over every drawn row, which re-derives every member key and\nre-reads the store. So the per-keypress cost roughly doubles:\n\n| cells | per keypress before (gh/main 5932ad74) | after (9948aaf5) |\n|---|---|---|\n| 400 | ~1.3 ms | ~2.1 ms |\n| 1.2k | ~5.0 ms | ~8.2 ms |\n| 2.4k | ~14.6 ms | ~25.1 ms |\n| 4.8k | ~31.4 ms | ~52.4 ms |\n| 18k | ~200 ms | ~330 ms |\n| 90k | ~2.7 s | ~4.7 s |\n\n(col_widths + whole-grid, for example 18k: 78-91 → 135-157 ms, plus\n118-122 → 185-207 ms.) The user-visible line is key repeat, about 30 Hz, or\n33 ms. At 4.8k cells the TUI kept up with a held arrow key before (31 ms) and\nfalls behind after (52 ms): the cursor keeps moving after the key is released.\nThe threshold moves from about 5k cells to about 3k on the fastest hardware\navailable here. The browser pays the same `grid_in` on every scroll step and\nevery model change.\n\n**improvise-h6z1 as filed does not reach this.** Its memo is keyed by (fold\nkey, formula), and the bench's columns are stored measures under Sum, with no\nformulas. The added cost is `fold_stored`/`evaluate_unfolded` per member, which\nno formula memo touches. Levers that do reach it:\n\n- a per-grid-build value memo keyed by cell key and shared between the walk and\n the totals, since the spanning fold's members ARE the walk's cell keys in a\n dense grid;\n- computing the total once per frame rather than twice. `cmd_context` and\n `draw` each run it;\n- (argued against by design-principles §5, \"a derivation done twice\") combining\n the walk's row values for decomposable aggregators (Sum, Count, Min, Max),\n with the spanning fold reserved for Avg, formulas and text. If this is\n chosen it needs a property test against `evaluate_spanning`.\n\nFor comparison, the largest previously accepted keypress cost is gotcha 24:\n+2.65 ms at 18k (3.04 → 5.69 ms), with 56 ms at 90k called \"3.4 frames\".\nThis one is +20 ms at 4.8k and +130 ms at 18k.\n","notes":"USER RULING 2026-09-23: fix the speed first. #239 is held until the totals stop roughly doubling keypress cost.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-yb4b","owner":"el-github@elangley.org","created_at":"2026-09-23T16:17:05Z","created_by":"agent-review-235-239","updated_at":"2026-09-23T23:57:48Z","started_at":"2026-09-23T23:57:48Z","lease_expires_at":"2026-09-24T00:02:48Z","heartbeat_at":"2026-09-23T23:57:48Z","labels":["adversarial-review","performance","pr-239"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-b5dp","title":"wm20 rulings (1) and (2) disagree for a formula column: a row's div/0 is drawn in the column and the total under it is a clean pooled number","description":"Found in adversarial review of PR #239 (improvise-wm20), head 9948aaf5. A ruling conflict that needs the user, not a code bug.\n\nThe wm20 notes record ruling (2) as: \"An error in any row propagates, so the\ntotal is an error.\" PR #239 implements it only for STORED values. For a formula\ncolumn it deliberately does not, and says so in `context/code/command.md`: \"a\nrow's div/0 is not an input and does not propagate. Only the fold's own error\ndoes.\"\n\nMeasured at 9948aaf5. Region rows North, South and East, with Product on None\nand `Profit = Revenue - Cost`, `Margin = Profit / Revenue`. East has Revenue 0.\nThe rows draw `0.83`, `0.90` and `ERR:div/0`. The total draws **`0.80`**, not an\nerror. The same happens for `Share = Revenue / SUM(Revenue)`.\n\nThis is what ruling (1) implies. \"The column's cell as if every drawn row were\none fold\" is 48/60 = 0.80: collapsing every row into one group reads 0.80 too. So\nrulings (1) and (2) disagree for formula columns, and the PR chose (1) without\nthe user deciding. A user sees an error in the column and a clean total under\nit.\n\nAsk the user which one wins for a formula column. If (2) wins, the total must be\nthe error whenever any drawn row's formula errors, which is an extra per-row\nevaluation that the pooled fold does not do.\n","notes":"USER RULING 2026-09-23: pooled ratio. A formula total is evaluated over the pooled inputs, so a per-row formula error doesn't poison it; stored-value errors still propagate.","status":"in_progress","priority":2,"issue_type":"task","assignee":"agent-yb4b","owner":"el-github@elangley.org","created_at":"2026-09-23T16:17:04Z","created_by":"agent-review-235-239","updated_at":"2026-09-23T23:57:49Z","started_at":"2026-09-23T23:57:49Z","lease_expires_at":"2026-09-24T00:02:49Z","heartbeat_at":"2026-09-23T23:57:49Z","labels":["adversarial-review","needs-user-ruling","pr-239"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-53zz","title":"The shipped-documents scratch-repo tests inherit the caller's repository env and global config, so under a worktree hook they stage files into the real index","description":"Found reviewing PR #243 (improvise-c0aj) at head 228f130e.\n\ntests/shipped_documents_load.rs shells out to git with the caller's environment and global config. Two consequences, both measured:\n\n1. **An inherited GIT_DIR writes into another repository's index.** Git exports GIT_DIR and an absolute GIT_INDEX_FILE to hooks when run from a linked worktree (measured: a pre-commit hook in a `git worktree add` checkout sees GIT_DIR=.../.git/worktrees/wt and GIT_INDEX_FILE=.../worktrees/wt/index; a normal checkout exports only a relative GIT_INDEX_FILE, pre-push exports neither, rebase -x exports neither). Every wave agent works in a linked worktree. With GIT_DIR set, `git -C \u003cscratch\u003e init` re-initialises the outer repo and `git -C \u003cscratch\u003e add .gitignore web/demo.improv` stages the scratch files into the OUTER index, because GIT_DIR wins over -C discovery and the worktree defaults to cwd. Measured: `GIT_DIR=\u003cthrowaway\u003e/.git cargo test --test shipped_documents_load` left the throwaway index holding `.gitignore`, `docs/model.improv.gz`, `secret/forced.improv`, `web/demo.improv`, and three of the four shape tests still reported ok. A pre-commit hook that runs cargo test from a worktree would therefore replace the commit's root .gitignore with `*.improv\\n!web/demo.improv\\n` and add three junk files. Latent: no hook in this repo runs cargo test today.\n\n2. **A global core.excludesFile fails a shape test falsely.** With GIT_CONFIG_GLOBAL pointing at a config whose excludesFile contains `*.gz` (a common global ignore), `a_gzipped_document_is_walked` fails at `git add` (\"paths are ignored by one of your .gitignore files\"). commit.gpgsign and core.hooksPath do not matter: no test commits, and add fires no hooks.\n\nFix: in run_git and shipped_documents, remove the repository-local env vars (`git rev-parse --local-env-vars`: GIT_DIR, GIT_WORK_TREE, GIT_INDEX_FILE, GIT_OBJECT_DIRECTORY, GIT_COMMON_DIR, ...). For the scratch repos also set GIT_CONFIG_GLOBAL=/dev/null and GIT_CONFIG_NOSYSTEM=1. Rival: re-run with GIT_DIR pointing at a throwaway repo and assert its index stays empty.\n","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-refuse-fu","owner":"el-github@elangley.org","created_at":"2026-09-23T16:03:57Z","created_by":"agent-review-243","updated_at":"2026-09-23T18:02:09Z","started_at":"2026-09-23T18:02:09Z","lease_expires_at":"2026-09-23T18:07:09Z","heartbeat_at":"2026-09-23T18:02:09Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-3uzg","title":"Under prune_empty, a commit that prunes its own row reopens the browser editor on the renumbered neighbour's stale cache value (#227 regression)","description":"#227 (improvise-fy9o, head a093d126) regression. Found by the adversarial review of the 3tt4 fix.\n\n`resolve::advanced` seeds the reopened editor from the client's pre-write cache (`seed_at`) everywhere except the cursor's own cell. `landed_seed`'s doc says: \"The cache is stale only where the frame just wrote, which is the cursor's own cell\". That is false under `prune_empty`. A commit that empties the last value in a row (or a column) prunes that row. The grid renumbers, so the cell the advance lands on is a different cell from the one the cache holds at that position. The TUI's `CommitAndAdvance` puts `RebuildLayout` in front of `EnterEditAtCursor` (gotcha 23), so the TUI seeds from the renumbered grid.\n\nRepro (lockstep harness): three regions North/South/West = 1/2/3, one measure, `prune_empty = true`, cursor (0,0), window (3,3).\n- `i Backspace Enter`: the browser buffer is \"2\" (South, the stale cache at row 1). The host buffer is \"3\" (West, which row 1 now is). Both cursors are at (1,0).\n- Then a projection arrives: a fresh cache, with the client's ViewState kept. `5 Enter` then leaves the browser document at West=25 and the host at West=35. The browser writes a value built from another record's number.\n- At gh/main 5932ad74 both ends agree (buffer \"\" on both, West=5 on both). This PR introduces the divergence. Before fy9o, the welded clear-buffer blanked both seeds.\n\nThe same mechanism applies to a column pruned by Tab. That case was not run.\n\nSame harm shape as improvise-3tt4: \"the reopened editor shows the next cell's current value\" fails wherever the write reshapes the grid. A client cannot tell from its cache that a write will prune. So the fix probably cannot be a smarter `seed_at`. Options: defer the seed to the projection (`settle_editor` already seeds an absent buffer), or seed blank whenever the frame writes.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T16:01:45Z","created_by":"agent-review-227b-240","updated_at":"2026-09-23T16:01:45Z","labels":["adversarial-review","browser","pr-227"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-st8q","title":"check-ci-graph fails open on a bare checkers.sh line inside a heredoc, multi-line string, or folded/plain-multiline run: scalar","description":"Found by adversarial re-review of PR #228 at head c1417359.\n\ncheck-ci-graph.py reads a run: value one physical line at a time. It does not track a multi-line quoted string or a heredoc body, and it does not apply YAML's folded (\u003e) or plain-multiline scalar rules. So a bare `./scripts/checkers.sh` on a line of its own reads as a bare invocation, which checkers.sh --would-run answers as EVERY suite. A suite that no step runs then passes, silently. This is the same fail-open class as improvise-maax item 1 (a bare mention reads as every suite runs), reached through a different mechanism.\n\nDemonstrated against the real ci.yml, with a planted suite zz-new (scripts/zz-new plus zz-new-test.sh) that no step runs. The gate exits 1 on the plant alone. It exits 0 after either of these edits:\n1. A step-summary heredoc (a realistic step):\n run: |\n cat \u003e\u003e \"$GITHUB_STEP_SUMMARY\" \u003c\u003c'EOF'\n To reproduce locally, run\n ./scripts/checkers.sh\n from the repository root.\n EOF\n2. Folding the kb job's last step as `run: \u003e` with `./scripts/checkers.sh` on one line and `pr-watch release-preflight pr-reviews fmt-workspaces` on the next. yq confirms that the YAML value is `./scripts/checkers.sh pr-watch release-preflight pr-reviews fmt-workspaces`, so only four suites run.\n\nThe same result comes from a multi-line double-quoted string that contains the bare line, and from a plain multi-line scalar (`run: ./scripts/checkers.sh` followed by an indented `kb`). A third mechanism gives the same result: `\"--command\" in words[k:]` matches any command, so `echo use --command ./scripts/checkers.sh` also reads as every suite.\n\nThe claim in context/code/tooling.md, \"A line shlex cannot parse contributes nothing, which fails closed\", is true of that one line only. The following lines are then misread. The same paragraph also says \"a CI step that spells it anywhere is a usage error rather than a green step that ran nothing\". That is false for the first position: `checkers.sh --would-run kb` in a step exits 0 and runs nothing. The gate still catches it, because asking `--would-run --would-run kb` is exit 4, so nothing is counted. But the step itself is green.\n\nFix direction (fail closed): tokenise the whole script rather than each line, so quotes span lines. Contribute nothing from a heredoc body, or refuse a script that contains `\u003c\u003c`. Fold `\u003e` and plain-multiline values the way YAML does. Honour `--command` only when the command word is `nix`. Add each shape as a SUITE_SELF_TEST case that requires a suite run only through the shape.\n","status":"in_progress","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T15:41:47Z","created_by":"agent-review-228b","updated_at":"2026-09-23T15:48:08Z","started_at":"2026-09-23T15:48:08Z","lease_expires_at":"2026-09-23T15:53:08Z","heartbeat_at":"2026-09-23T15:48:08Z","labels":["adversarial-review","pr-228"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-jpba","title":"A client refusal replays from a settled slice that never folded the host's relays: after a reframe, the next refused frame splits the client cursor from its mirror","description":"Found while adversarially reviewing PR #230 (improvise-wdmp) at head 93c455db. It is pre-existing: reproduced at gh/main 194d8f52. It is not caused by #230.\n\n`ClientState::refused` rolls back by `replay()`: `view = settled.clone()`, then re-folding every in-flight frame. But `settled` only ever absorbs the client's OWN frames, in `accepted()`. A command the host RELAYS to the asker (`ResetCursor` from `adopt_cursor_epoch`, the per-client `SwitchView` from `adopt_deleted_views`, and now #230's echoed placements) is folded into `view` only. The next refusal therefore rewinds the client past every relay it has heard, while the host's mirror keeps them.\n\nReproduction, as a host test in ui/host.rs with a real `Host` and a real `ClientState` (it folds every frame addressed to the asker):\n1. The asker sends `[SetSelected(1,0)]`. It is accepted, and client, settled and mirror are all at (1,0).\n2. The asker sends `[ChangeMode(Normal), AddCategory(\"Product\")]`. The mirror is reset to (0,0) and `ResetCursor` is relayed, so the client view is (0,0). `settled` stays at (1,0).\n3. The asker sends an `AddCategory` whose basis was taken before step 2. It is refused (stale layout), and `refused()` calls `replay()`.\nResult: the client is at (1,0) and its mirror at (0,0). This is the client/mirror cursor split improvise-wdmp closes, reopened by any later refusal. With #230, the placed case also leaves `settled`'s row/col offsets un-reset: `[SetRowOffset(1), AddCategory, SetSelected]` gives settled row_offset 1 and mirror 0.\n\nThe likely fix: relayed commands must reach `settled` too. For example, `fold` for a server-originated command also reduces into `settled`, or replay composes relays in arrival order. Acceptance is the test above, together with a rival that restores the current `fold`.\n","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-viewhist","owner":"el-github@elangley.org","created_at":"2026-09-23T15:29:18Z","created_by":"agent-review-227-230","updated_at":"2026-09-23T15:34:15Z","started_at":"2026-09-23T15:34:15Z","lease_expires_at":"2026-09-23T15:39:15Z","heartbeat_at":"2026-09-23T15:34:15Z","labels":["adversarial-review","browser","pr-230"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-maax","title":"check-ci-graph suites_run fails open: non-command mentions, --list, and --self-test's discarded names read as suites run","description":"Found in adversarial review of PR #228 (head cee61b94). `scripts/check-ci-graph.py::suites_run` fails OPEN on four shapes, each planted and run against `suite_verdict([\"kb\",\"zz-new\",\"checkers\"], suites_run(text))` with `zz-new` run by no step:\n\n1. A bare `scripts/checkers.sh` in text that is not a command reads as \"every suite runs\" (verdict []): an inline comment `run: echo skipped # ./scripts/checkers.sh` (ChatGPT Codex's P1 on the PR), a step name `- name: Runs scripts/checkers.sh`, and `run: echo scripts/checkers.sh`. Only a line whose FIRST non-space char is `#` is skipped.\n2. `checkers.sh --list` alone returns ALL, but `--list` prints and exits without running anything (checkers.sh main: `--list) suites; return 0`).\n3. `checkers.sh --self-test zz-new kb` is counted as running checkers, zz-new and kb (verdict []), but checkers.sh sets `names=(checkers)` when --self-test is given and DISCARDS the names. Consolidating the CI steps into one `--self-test kb pr-watch ...` line would silently stop running every named suite with the gate green.\n4. `checkers.sh --list kb zz-new` counts kb and zz-new as run; nothing runs.\n\nAlso (fails closed, loud but wrong): only the first invocation on a line counts (`./scripts/checkers.sh kb \u0026\u0026 ./scripts/checkers.sh zz-new` reports zz-new missing), and quoted names (`'zz-new'`) are read with their quotes.\n\nFix direction: model checkers.sh's own argv semantics (--list =\u003e runs nothing, --self-test =\u003e {checkers} only whatever else is named, -h/unknown flag =\u003e runs nothing) instead of \"no positional names =\u003e ALL\"; strip a shell comment before matching; only look inside `run:` values. Each shape above should become a SUITE_SELF_TEST case.\n","status":"in_progress","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T15:24:35Z","created_by":"agent-review-228-229","updated_at":"2026-09-23T15:27:36Z","started_at":"2026-09-23T15:27:36Z","lease_expires_at":"2026-09-23T15:32:36Z","heartbeat_at":"2026-09-23T15:27:36Z","labels":["adversarial-review","pr-228"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-znuk","title":"A pivot client's first write into an empty cell is RecordGone when the host sits on a records view","description":"Found by agent-collapse-fu while scoping improvise-8id2. Creating an EMPTY cell resolves its record only through the host's layout (record_in_layout). So when the host is on a records view and a pivot client writes into an empty pivot cell, the record doesn't resolve and the write is refused RecordGone. Two clients on different views of one document is supported (gotcha 29), so this is a real multi-client failure. Resolve an empty-cell write against the view the CLIENT was shown (the view the command names), not the host's. Pin it with a Host-level test with the host on records and the client on pivot.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T15:17:41Z","created_by":"orchestrator","updated_at":"2026-09-23T15:17:41Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-milr","title":"RenameRecordCoordinate overwrites a destination record records_named does not name: a concurrent write to it is lost with no Rejection","description":"Found by the adversarial review of PR #221 (improvise-fdk1) at ee5cba88.\n\nModelCommand::RenameRecordCoordinate WRITES the destination record (when the new coordinate lands on an existing record, per improvise-kndq's overwrite rule) but records_named names only the source record. So the basis check never asks about the destination, and a concurrent write to it is lost silently.\n\nRepro (three_records_host: North=1, South=2, West=3, records mode, a real Host):\n1. A opens the coordinate editor on North and types \"South\".\n2. B writes South/Revenue = 999 (accepted). A folds the relay.\n3. A presses Enter: ACCEPTED. South/Revenue is now 1 (North's value). B's 999, which A was never shown when it opened the editor and never claimed a version of, is gone. No Rejection, no status.\n\nThis is the lost update ADR 001's record-level basis exists to prevent, and it is new with the wire form: before #221 the overwrite was reachable only from one TUI user's own keys, where there is no concurrent writer. improvise-kndq decides the POLICY for a collision (refuse, merge or overwrite); this ticket is narrower and stands whatever kndq decides unless kndq picks \"refuse\": a command that writes a record must name it in records_named, or the check is a force by omission (gotcha 43). The obstacle is that a client may not be able to claim the destination (it may be off-screen, unpublished, or not exist), so naming it probably means \"refuse if the destination exists and the frame's basis does not state it\", which is RecordUnstated on the destination.\n","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-hzjo2","owner":"el-github@elangley.org","created_at":"2026-09-23T15:06:51Z","created_by":"agent-review-221","updated_at":"2026-09-23T15:13:31Z","started_at":"2026-09-23T15:13:31Z","lease_expires_at":"2026-09-23T15:18:31Z","heartbeat_at":"2026-09-23T15:13:31Z","labels":["adversarial-review","pr-221"],"dependencies":[{"issue_id":"improvise-milr","depends_on_id":"improvise-kndq","type":"related","created_at":"2026-09-23T15:06:55Z","created_by":"agent-review-221","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-20ek","title":"A coordinate-rename retry after RecordGone renames whichever record moved under the cursor (browser, fdk1)","description":"Found by the adversarial review of PR #221 (improvise-fdk1) at ee5cba88.\n\nA browser client's coordinate-rename retry after Rejected(RecordGone) renames a DIFFERENT record: the one that has since moved under the cursor.\n\nRepro (probe in crates/improvise-wasm-client/tests/browser_round_trip.rs shape, three_records_host: North=1, South=2, West=3, records mode):\n1. A opens the editor on North's Region coordinate (row 0) and types \"Zed\".\n2. B renames North -\u003e Yak (RenameRecordCoordinate, accepted). A folds the relay: North's row is gone, so row 0 is now South.\n3. A presses Enter: correctly Rejected(RecordGone(North)).\n4. The refusal replays the settled slice, so A is editing again at (0, coordinate) with buffer \"Zed\". settle_opened sees (editing, opened=None, nothing awaited) and captures the record NOW under the cursor, South, at its current version.\n5. A presses Enter again: accepted. South is renamed to Zed. South is gone.\n\nMeasured: after the refusal, opened = South's RecordHash at version 0, cell text under the cursor \"South\", buffer \"Zed\". After the second Enter, Region=South/Revenue is None and Region=Zed/Revenue = 2.\n\nWhy it matters: context/code/clients.md (\"A retry after a refusal is a force\") argues the retry is the one-keystroke force RecordMoved promises (gotcha 43). That argument holds only when the record is still under the cursor. For RecordGone, gotcha 43 says there is nothing to force (\"force never creates structure\"), and the retry here is not a force of the refused edit at all: it applies text typed for North to South. The user did see an editor over South's row, but its buffer is the text they typed for North, so one Enter rewrites a record they never opened.\n\nCandidate fixes (not decided): keep `opened` across a refusal that named that record (so the retry is RecordGone again and the user must Esc and reopen); or clear the buffer/leave editing on RecordGone; or capture only when the cursor's record equals the refused one. Any fix must keep a_second_rename_after_a_reorder_renames_the_row_the_host_shows green, which pins the opposite capture after an ACCEPTED rename (an injected \"never clear opened\" rival makes this probe pass and that test fail, so the two pull in different directions).\n","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-hzjo2","owner":"el-github@elangley.org","created_at":"2026-09-23T15:06:49Z","created_by":"agent-review-221","updated_at":"2026-09-23T15:13:29Z","started_at":"2026-09-23T15:13:29Z","lease_expires_at":"2026-09-23T15:18:29Z","heartbeat_at":"2026-09-23T15:13:29Z","labels":["adversarial-review","pr-221"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-cq1q.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":"open","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-25T06:19:38Z","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":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-8jk6.16","title":"A --measure value that does not read as a number is dropped silently and the import exits 0","description":"Found in the adversarial review of PR #321 at efaa5050. A value in a column the user forced to Measure that does not read as a number is dropped with no warning, and the import exits 0. This is the same \"saved less than was asked\" family that context/code/cli.md section 13 closes for --category, --formula and kept-nothing, but it is not covered there.\n\nRepro, identical on main at c91cc055 and on the PR head:\n\n printf 'k,v\\na,abc\\nb,7\\nc,\"1,000\"\\n' \u003e nn.csv\n improvise import nn.csv --no-wizard --measure v -o nn.improv; echo \"exit=$?\"\n\nWhat happens: only `_Measure=v, k=b = 7` is written, \"Saved to\" is printed, and the exit status is 0. The `abc` and `1,000` values are gone with no report. Row `a` also loses its whole record, which is the improvise-8jk6.14 loss, but here the value was present and could not be read. That is a different reason.\n\nSince 8jk6.4, `csv_parser::number_in` is the one gate that decides whether a measure's CSV text is a number (via `Scalars::number` in `Plan::place`). So there is one place to count what it refused. Decide: warn with a count and the column (the 5j9 and 8jk6.6 partial-skip shape), or refuse headless. A thousands separator such as \"1,000\" is worth deciding about explicitly, since `number_in` does not read it.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T06:14:58Z","created_by":"Edward Langley","updated_at":"2026-09-25T06:14:58Z","labels":["headless","import"],"dependencies":[{"issue_id":"improvise-8jk6.16","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T06:14:58Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-10o9","title":"Formula WHERE/IS naming a GROUP matches none of its members, so a date hierarchy cannot be crosstabbed by formula","description":"The crosstab half of improvise-8jk6.3 does not exist yet. The user: \"I think dates should usually be one category and we can use formulas to crosstab\". But a formula `WHERE` or `IS` that names a GROUP matches none of that group's members. Both compare the record's coordinate to the name exactly, and no record carries the group's name.\n\nMeasured by the adversarial review of PR #322 at 6b910889. The file was a headless import of a two-year CSV with `--extract Date:Year --extract Date:Quarter --extract Date:Month`, so `Date` has items `2024-01`… in groups `2024-Q1`… under years `2024`/`2025`:\n\n- `Y24 = SUM(Revenue WHERE Date = |2024|)` gives -0.00 on every row. The expected value is 1111.\n- `IsY = (Date IS |2024|) * Revenue` gives 0 on every row, including the 2024 months.\n- Naming an item works: `SUM(Revenue WHERE Date = |2024-01|)` = 11, and `(Date IS |2024-01|) * Revenue` = 11 on the 2024-01 row.\n\nThe PR author measured the same and reported it for filing (PR #322 body, and `context/code/formula-and-io.md` \"Crosstabs by formula are not available yet\").\n\nShape: resolve a name that is a group of the filtered category to its covered items, as the fold already does via `Category::items_in_group` (felo.6). Decide the item-vs-group name ambiguity first: improvise-vwc7 says an item may share a name with a group of its category. Tests first: a 3-level Year\u003eQuarter\u003eMonth fixture, with a WHERE and an IS on each level.\n","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-25T06:09:18Z","created_by":"Edward Langley","updated_at":"2026-09-25T06:09:18Z","labels":["core","formula"],"dependencies":[{"issue_id":"improvise-10o9","depends_on_id":"improvise-8jk6.3","type":"discovered-from","created_at":"2026-09-25T06:09:18Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-1xfb.9","title":"checkers.sh's env allowlist drops the Nix linker variables, so no suite can link Rust on macOS","description":"Found in the PR #313 review (issuecomment-5827613256): inside the .#ci shell on macOS, 'env -i HOME PATH TMPDIR cargo build --bin improvise' fails with 'ld: library not found for -liconv' (a trivial crate still links). scripts/checkers.sh scrubs the environment down to HOME/PATH/TMPDIR plus three NIX_* variables and drops the ones the Nix cc/linker wrapper needs (NIX_LDFLAGS, NIX_CFLAGS_COMPILE and friends). So any suite that builds Rust fails on macOS under checkers.sh; repo-stats-test.sh worked around it by requiring a prebuilt improvise. context/code/tooling.md (~line 911) overstates the allowlist. Fix: keep the wrapper's variables (or re-enter the dev shell inside the scrub); test by building a crate with a system-library dependency under checkers.sh's env on macOS. CI (Linux) is unaffected as far as known.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T06:00:52Z","created_by":"Edward Langley","updated_at":"2026-09-25T06:00:52Z","labels":["ci","tooling"],"dependencies":[{"issue_id":"improvise-1xfb.9","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T06:00:52Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-31oe.12","title":"apply_effects reports only a batch-wide refusal: record, per effect, whether it refused AND which of its parts landed (prerequisite of ADR 006's recorder)","description":"App::batch_refused (crates/improvise-command/src/ui/app.rs:454) is one flag per apply_effects batch, set by App::refuse and reset at batch start. ADR 006 (PR #315, clause 2) commits a recorded line when its effect CHANGED something replay compares, narrowed to the parts that landed, and never commits a line for an effect that changed nothing. So the recorder needs a per-effect outcome that is NOT a single refused/landed bit: an effect can refuse and still write. ApplyAndClearDrill refuses an empty category edit (RecordRewrite::of, crates/improvise-command/src/ui/effect.rs:810-812) yet keeps that row's other writes (compose :873-880, effects() :931-945); only a deleted category drops the whole commit (merged :901-913). Reading the batch flag after each effect also cannot tell a later effect's outcome once an earlier one refused (the shape improvise-bj61 reports). Wanted: a per-effect outcome observable in the apply_effects loop that says (a) whether the effect refused, (b) whether it changed anything replay compares: the loop's content/config fingerprints (app.rs:1037-1052, compared only after CellData/LayoutGeometry effects), a move of dirty, a file written (Save/SaveAs/ExportCsv), OR a move of document_generation, which is the only signal a WholeWorkbook effect (LoadModel, ImportJsonHeadless, AcceptWizardModel, all via install_workbook, app.rs:594-604) gives when dirty is already raised, and (c) which of its parts landed. Tests: a MIXED drill commit (one empty category edit plus a value edit in the same row) reports refused AND written, with only the value edit as landed; a batch [refusing effect that writes nothing, landing effect] reports each correctly, and in the reverse order too; a LoadModel applied while dirty is already true reports changed.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T05:42:52Z","created_by":"agent-adr006","updated_at":"2026-09-25T06:39:58Z","dependencies":[{"issue_id":"improvise-31oe.12","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T05:42:52Z","created_by":"agent-adr006","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-3o2c","title":"view_shape's fold hash is unguarded below a hierarchy's root","description":"Found reviewing PR #317 (improvise-felo.6). App's view_shape (improvise-command/src/ui/app.rs) hashes the collapsed groups of every drawn item's chain, at every depth; observe_reshape diffs it to bump layout_version, the basis tier of ADR 001. Injected fault: restrict the walk to the root, (0..chain.len().min(1)). The whole workspace suite stayed green (2169 tests), so toggling a quarter under an open year could stop bumping layout_version with nothing failing. A client whose frame was composed before the fold would then pass the layout-tier check. Want: a test that folds a depth-1 group and asserts layout_version moved, and fails under that fault. Related: improvise-9v0j.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T05:31:46Z","created_by":"Edward Langley","updated_at":"2026-09-25T05:31:46Z","labels":["core","tests"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-31oe.10","title":"Eight view-scoped commands (set-axis, set-page, toggle-group, hide-item, show-item, set-format, transpose, toggle-prune-empty) take no view operand: the command-language half of improvise-lltt","description":"Found reviewing PR #315 (ADR 006, head 50234b49). Citations are at gh/main b639266b.\n\nEight view-scoped commands in the command language build `ViewTarget::Active`, and take no view operand:\n\n- `set-axis`, `set-page`, `toggle-group`, `hide-item`, `show-item`, `set-format` (`crates/improvise-command/src/command/cmd/effect_cmds.rs:338`, `:351`, `:364`, `:377`, `:390`, `:412`)\n- `transpose` (`command/cmd/cell.rs:711`)\n- `toggle-prune-empty` (`command/cmd/grid.rs:882`)\n\nWhat each one changes therefore depends on which view the running session is on. The wire closed the same gap with improvise-lltt, where every view-scoped `ModelCommand` carries `view: String` (gotcha 29), but the command language, which is the first-class product surface for `improvise cmd`/`script`, did not. So a script cannot say \"set the axis in view B\" without first `switch-view B`, which is session UI state. Nor can any record of a view-configuration change be replayed without replaying the UI. There is also no explicit-operand command at all for `CycleAxis` (only `cycle-axis-at-cursor`/`cycle-axis-for-tile`).\n\nNot covered by improvise-31oe.2 (unknown names are silent no-ops) or improvise-31oe.3 (pipe quoting, extra arguments). Neither mentions a view operand. improvise-lltt is the wire half and is closed.\n\nFix direction: an optional view operand on each command, defaulting to the active view so interactive use is unchanged. It resolves strictly: a view the document lacks is refused, not a fallback, as wpxr ruled for `SetCell`/`ClearCell`. The spelling (a trailing argument, or a flag) has to agree with improvise-31oe.3's argument-count rules, so coordinate with it. Demonstrate first: a script that creates two views and runs `set-axis` naming the non-active one must change that view and leave the active one alone.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T05:21:41Z","created_by":"Edward Langley","updated_at":"2026-09-25T05:21:41Z","dependencies":[{"issue_id":"improvise-31oe.10","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T05:21:41Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-31oe.9","title":"set-cell cannot write text that reads as a number: \"100\" and |100| both miss, and inf/nan become Numbers","description":"Found reviewing PR #315 (ADR 006, head 50234b49), and confirmed by running `improvise cmd` at b639266b.\n\n`set-cell` decides a value's type by trying `f64` first (`crates/improvise-command/src/command/cmd/effect_cmds.rs:224-228`): `args[0].parse::\u003cf64\u003e()` becomes `CellValue::Number`, and anything else becomes `Text`. There is no spelling that forces text, so:\n\n- `set-cell 100 Region/East _Measure/Code` stores Number(100).\n- `set-cell \"100\" …` stores Number(100). The parser strips the double quotes before the command sees them.\n- `set-cell |100| …` stores Text(\"|100|\"): the command language does not read pipe quoting (improvise-31oe.3), so the pipes are kept literally, and the file writes `|\\|100\\||`.\n- `set-cell inf …` stores a Number (`f64::from_str` accepts `inf`, `infinity` and `nan`, case-insensitively), where a user writing the word almost certainly meant text.\n\nSo a text cell holding `100`, which `.improv` writes pipe-quoted precisely so it reads back as text (gotcha 34, `pipe_quote`), cannot be written from the command language at all. That makes it a clause-1 gap for ADR 006, and it blocks any canonical-line recording of a `SetCell` with such a value. The browser had the mirror-image bug on its edit path (improvise-ixxx, closed).\n\nReproduction:\n improvise cmd 'add-category Region' 'add-item Region East' 'add-item _Measure Code' 'set-cell \"100\" Region/East _Measure/Code' -f t.improv\n # Data: Region=East, _Measure=Code = 100 (a Number)\n\nFix direction: give the value a spelling that cannot read as a number. For example, a pipe-quoted value is always text, once improvise-31oe.3 lands, which matches `.improv`'s own rule. Decide `inf`/`nan` at the same time. Demonstrate with a failing unit test on `SetCellCmd` first.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T05:21:39Z","created_by":"Edward Langley","updated_at":"2026-09-25T05:21:39Z","dependencies":[{"issue_id":"improvise-31oe.9","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T05:21:39Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-8jk6.15","title":"Folder import: the merged Default (initial) view pages a category only some files have, so it opens empty","description":"Found in the delta review of PR #314 at c43ee724. It was already true at 4e68372c, and the round-1 review missed it. The merged Default view seats every category by the single-file rule, so a category only one file has can land on Page. Sparse records never match a page selection they lack (PR #314 Q1, pinned by a_record_with_no_coordinate_is_drawn_only_where_that_category_is_off_the_grid). Reproduced: improvise import \u003cdir\u003e --no-wizard with age.csv (entity,age-months; 2 rows) and coupling.csv (entity,coupled,degree; 1 row). Default then has \"_Measure: page\" (defaulting to age-months) and \"coupled: page\" (defaulting to src/b.rs). The age records lack coupled and coupling has no age-months, so the document opens on an empty grid. Related to improvise-gtn7.10 (records the view cannot place), but narrower: an import should not choose an initial view that hides most of its own records. One option: in a multi-source merge, Default puts a category not every source has on None rather than on Page/Row/Column.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T04:58:28Z","created_by":"Edward Langley","updated_at":"2026-09-25T04:58:28Z","labels":["import"],"dependencies":[{"issue_id":"improvise-8jk6.15","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T04:58:28Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-8jk6.14","title":"Import silently stores nothing for a record with no measure value; a measureless CSV in a folder vanishes","description":"Found in the delta review of PR #314 at c43ee724. A record whose accepted measures are all absent writes no cell, and no warning says so. The categories and items it named are created, but the record is gone. The same thing held for a single-file import before #314, but folder import makes it easy to hit. Reproduced: improvise import \u003cdir\u003e --no-wizard, where dir holds age.csv (entity,age-months / src/a.rs,2) and tags.csv (entity,tag / e1,red). The output has items e1 and red and a records view for tags, but ## Data holds only the age line, and the tags view draws zero rows. The CLI prints only \"Saved to\". The test a_file_with_no_measure_gets_a_records_view_not_another_files_measure asserts row_count 0 (\"a record with no measure has no cell\"), which pins the loss rather than the intent. Decide: warn and count (as 5j9 did for skips), or store the record, e.g. as a Text cell or a presence marker. Records are sparse, and the user wants sparse records kept.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T04:58:03Z","created_by":"Edward Langley","updated_at":"2026-09-25T04:58:03Z","labels":["import"],"dependencies":[{"issue_id":"improvise-8jk6.14","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T04:58:03Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-n28d.1","title":"Drill shows only informative columns: drop categories constant across the drilled records","description":"USER 2026-09-25: 'I think 1+2 make sense, reseting is fine'. When drilling a cell, the _Drill view's records columns include the categories the drill fixed (they are on Axis::Filter at one item, gotcha 27) -- constant across every drilled record, so they carry no information. Default drill columns: categories that vary among the drilled records, plus the measures the drilled cell shows (not every measure in the model). Drill columns RESET on each drill (the user: 'reseting is fine'); _Drill stays virtual and unpersisted. Builds on improvise-31oe.5 (records columns follow the view). Tests first: a drill fixes Region and Month, so neither is a column; a category that varies stays; a hidden measure is absent; re-drilling resets. Mind pending-edit addressing by (row, RecordCol) (gotcha 1): column indices change with the column set.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-25T04:57:25Z","created_by":"Edward Langley","updated_at":"2026-09-25T04:57:25Z","labels":["drill","records","ux"],"dependencies":[{"issue_id":"improvise-n28d.1","depends_on_id":"improvise-31oe.5","type":"blocks","created_at":"2026-09-25T04:57:32Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-n28d.1","depends_on_id":"improvise-n28d","type":"parent-child","created_at":"2026-09-25T04:57:25Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-cq1q.10","title":"ADR 007 step 9: import generates ids by slugify and never parses them from headers","description":"ADR 007 answer 8 and clause 5, \"Import\". CSV and JSON dimension names are displays and are never parsed for ids. Every id comes from the workbook's IdAllocator, so two headers that slug alike get a gensym suffix. The saved file is the new version. Most of this falls out of step 3, when creation sites allocate ids. This bead is the import-specific evidence, plus whatever the wizard's preview or naming steps show.\n\nImport is other agents' ground (improvise-8jk6.3/.4/.5/.6 and improvise-lpzu), so coordinate and merge.\n\n## Acceptance Criteria\n- Tests first, through `improvise import --no-wizard`:\n - headers `Region(code)`, `Size(cm)` and `Time Period`/`Time_Period` import as displays with ids `regioncode`, `sizecm`, `time_period` and `time_period-\u003cn\u003e`;\n - the saved file pipe-quotes `|Region(code)|(regioncode)`;\n - the saved file reloads with every cell still drawn (improvise-80rm).\n- Rival: parse a trailing `(token)` as an id, and the first test fails.\n","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T04:50:38Z","created_by":"agent-adr007","updated_at":"2026-09-25T04:50:38Z","dependencies":[{"issue_id":"improvise-cq1q.10","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T04:50:38Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.10","depends_on_id":"improvise-cq1q.5","type":"blocks","created_at":"2026-09-25T04:51:03Z","created_by":"agent-adr007","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-cq1q.9","title":"ADR 007 step 8: clause 12 — a formula edit captures its display-to-id mapping when the editor opens","description":"ADR 007 clause 12: a formula edit maps its display names to ids when the editor opens, following the OpenedCell pattern. It is blocked by the user question in improvise-hexb item 2 and improvise-m56i item 6: which side holds the mapping, what ReplaceFormula.raw carries on the wire, and how an ambiguous typed name resolves. The TUI and the browser must reach the same answer.\n\n## Acceptance Criteria\n- The required regression test, as corrected by improvise-hexb item 1:\n 1. Open the editor on `Profit = Revenue - Cost`.\n 2. From another client, rename `Revenue` to `Sales`, and rename a different item to `Revenue`.\n 3. Commit `Revenue - Cost * 1`.\n 4. The stored formula references the original item's id, and the commit is not refused.\n- Two rivals, each seen to fail: resolving at commit time (binds to the new Revenue), and refusing on rename.\n- A newly typed name that does not resolve is refused, and the refusal names it.\n- A captured id that is gone at commit gets Rejection::Gone.\n","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-25T04:50:26Z","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.7","type":"blocks","created_at":"2026-09-25T04:50:59Z","created_by":"agent-adr007","metadata":"{}"}],"dependency_count":1,"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","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-25T04:50:03Z","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.4","type":"blocks","created_at":"2026-09-25T04:50:57Z","created_by":"agent-adr007","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"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","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-25T04:49:52Z","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":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T04:48:44Z","created_by":"agent-adr007","updated_at":"2026-09-25T04:48:44Z","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":"in_progress","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-25T04:48:48Z","started_at":"2026-09-25T04:48:48Z","lease_expires_at":"2026-09-25T04:53:48Z","heartbeat_at":"2026-09-25T04:48:48Z","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":"in_progress","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-25T04:48:47Z","started_at":"2026-09-25T04:48:47Z","lease_expires_at":"2026-09-25T04:53:47Z","heartbeat_at":"2026-09-25T04:48:47Z","dependencies":[{"issue_id":"improvise-nn4s.1","depends_on_id":"improvise-nn4s","type":"parent-child","created_at":"2026-09-25T04:48:21Z","created_by":"agent-faces","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-31oe.8","title":"create-view silently replaces an existing view of the same name and exits 0","description":"Found by the PR #313 review (issuecomment-5826870397): Workbook::create_view is an IndexMap::insert, so 'create-view X' when a view X exists overwrites it -- its axes, page selections, hidden items and collapse state -- with no message, and headless 'improvise cmd/script' exits 0. The repo-stats pipeline's interim create-view replaced a per-file view that folder import (#314) had built. Refuse (naming the view) unless an explicit replace is asked for; tests first in both the TUI path and headless. Related: improvise-31oe.2/31oe.3 (refusals for unknown names, in progress).","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T04:43:03Z","created_by":"Edward Langley","updated_at":"2026-09-25T04:43:03Z","labels":["headless","view"],"dependencies":[{"issue_id":"improvise-31oe.8","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T04:43:03Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-felo.8","title":"Semi-additive measures: a different summary per category","description":"USER 2026-09-25, 'yes, file these'. One summary per measure cannot express a semi-additive measure: holdings value is SUMMED across Account but takes LAST across Date; a stock price is AVG or LAST across Date but meaningless summed across Ticker. Add per-category overrides to a measure's aggregation, e.g. in '## Aggregation': '- Value: sum; Date: last' (grammar first). Decide the fold semantics when several categories are folded at once (apply per-category functions in a defined order -- e.g. the overriding categories innermost -- since sum-of-last and last-of-sum differ), how a collapsed group of the overriding category uses it, and totals. Depends on the ordered last/first ticket for the canonical examples. Interacts with ADR 009's incremental aggregates.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-25T04:30:51Z","created_by":"Edward Langley","updated_at":"2026-09-25T04:30:51Z","labels":["core","formula"],"dependencies":[{"issue_id":"improvise-felo.8","depends_on_id":"improvise-felo","type":"parent-child","created_at":"2026-09-25T04:30:51Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-felo.7","title":"Ordered summary functions: last and first (closing/opening value)","description":"USER 2026-09-25: 'we probably should also be able to specify a summary function. e.g. stock price doesn't make sense to add' -- and on the proposal: 'yes, file these'. Today a measure's summary is one of sum/avg/min/max/count (AggFunc, improvise-formula ast.rs; per measure in Model::measure_agg, '## Aggregation' section '- Price: avg'), applied to every fold incl. collapsed groups. A stock price wants LAST (closing) or FIRST (opening) over a period. Those need an ORDER over the folded values; the natural one is the item order of the category being folded (e.g. months within a year of a date hierarchy, improvise-8jk6.3/felo.6). Decide: which category orders the fold when several are folded at once (the time-like one? an explicit per-measure 'along' category?), ties, and what last/first mean when the ordering category is on an axis rather than folded. Grammar first (agg_kind in improv.pest). Interacts with ADR 009 (PR #309): its integrated per-group state is an unordered multiset -- last/first need ordered state; raise it there. Test with a stock-price fixture.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-25T04:30:50Z","created_by":"Edward Langley","updated_at":"2026-09-25T04:30:50Z","labels":["core","formula"],"dependencies":[{"issue_id":"improvise-felo.7","depends_on_id":"improvise-felo","type":"parent-child","created_at":"2026-09-25T04:30:50Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-31oe.3","title":"The command language does not read |pipe quoting|: create-view |By area| makes a view named |By, and extra arguments are silently ignored","description":"The `:` command language, used by `improvise cmd` and `improvise script`, does not read `|pipe quoting|`, though `.improv` files and formulas both use it for names (improvise_formula::quote). It also silently ignores extra arguments. Found by the repo-stats pipeline, PR #313 (improvise-1xfb.5.1).\n\nRepro:\n\n improvise cmd 'create-view |By area|' -f m.improv\n grep '## View' m.improv\n\nWhat happens: a view named `|By` is created, and `area|` is dropped without a word.\n\nWhat should happen:\n- A pipe-quoted argument is one argument with the pipes removed and the escapes applied. The rules are the ones `.improv` uses, and the code should come from the one shared home rather than a new copy.\n- A command given more arguments than it takes refuses with an argument error, and the headless run exits non-zero.\n","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-cmdlang","owner":"el-github@elangley.org","created_at":"2026-09-25T04:29:56Z","created_by":"agent-gapfile","updated_at":"2026-09-25T04:33:11Z","started_at":"2026-09-25T04:33:11Z","lease_expires_at":"2026-09-25T04:38:11Z","heartbeat_at":"2026-09-25T04:33:11Z","labels":["area:cli","headless"],"dependencies":[{"issue_id":"improvise-31oe.3","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T04:29:56Z","created_by":"agent-gapfile","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-31oe.2","title":"set-axis and set-page with an unknown category, axis or item are silent no-ops that exit 0 headless","description":"In `improvise cmd` and `improvise script`, `set-axis` and `set-page` with an unknown category, axis or item do nothing, print nothing and exit 0. A typo in a script therefore goes unnoticed. Found by the repo-stats pipeline, PR #313 (improvise-1xfb.5.1).\n\nRepro, on any model without a category called `Regoin`:\n\n improvise cmd 'set-axis Regoin row' -f m.improv; echo \"exit=$?\"\n improvise cmd 'set-page Region Nowhere' -f m.improv; echo \"exit=$?\"\n improvise cmd 'set-axis Region sideways' -f m.improv; echo \"exit=$?\"\n\nWhat happens: all three exit 0 with no message, and nothing changes.\n\nWhat should happen: each one refuses with a message that names what it could not find, and the headless run exits non-zero. This is the same path other refusals take since improvise-udt2 and improvise-j6x8. An unparseable axis name is an argument error. An unknown category or item is a refusal.\n","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-cmdlang","owner":"el-github@elangley.org","created_at":"2026-09-25T04:29:55Z","created_by":"agent-gapfile","updated_at":"2026-09-25T04:33:10Z","started_at":"2026-09-25T04:33:10Z","lease_expires_at":"2026-09-25T04:38:10Z","heartbeat_at":"2026-09-25T04:33:10Z","labels":["area:cli","headless"],"dependencies":[{"issue_id":"improvise-31oe.2","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T04:29:55Z","created_by":"agent-gapfile","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-8jk6.6","title":"An import that skipped records, even all of them, exits 0 and saves an empty model","description":"An import that skipped records exits 0 and saves the model, even when it skipped every record and the model is empty. Found by the repo-stats pipeline, PR #313 (improvise-1xfb.5.1).\n\nPR #314 (improvise-8jk6.2) removes one cause, the skip on a missing category value. Other skips remain. For example, a JSON array element that is not an object is skipped.\n\nRepro:\n\n echo '[1, 2, \"three\"]' \u003e r.json\n improvise import r.json --no-wizard -o r.improv; echo \"exit=$?\"\n\nWhat happens: the file is written with no data, and the exit status is 0.\n\nWhat should happen: the user must hear about skipped records. At minimum, print a count on stderr. An import that kept no records at all should exit non-zero rather than save an empty model as a success. Whether a partial skip is a warning or an error, and whether a flag makes it strict, is a decision for the fix.\n","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-headless-import","owner":"el-github@elangley.org","created_at":"2026-09-25T04:29:53Z","created_by":"agent-gapfile","updated_at":"2026-09-25T04:33:06Z","started_at":"2026-09-25T04:33:06Z","lease_expires_at":"2026-09-25T04:38:06Z","heartbeat_at":"2026-09-25T04:33:06Z","labels":["headless","import"],"dependencies":[{"issue_id":"improvise-8jk6.6","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T04:29:53Z","created_by":"agent-gapfile","metadata":"{}"}],"comments":[{"id":"4ba3a96b-9421-522a-b648-0299ee89fbba","issue_id":"improvise-8jk6.6","author":"Edward Langley","text":"USER DECISIONS 2026-09-25 (PR #321): (1) a folder of only header-only CSVs: 'Refuse (as #321 does)' -- an import that keeps no record is refused, folder or not; (2) partial skip: 'Add a --strict flag' -- default stays exit 0 with a counted warning, --strict makes any skip non-zero; (3) JSON 1e3 forced to Category: '1000.0' (keep serde_json's rendering, as #321 does).","created_at":"2026-09-25T06:20:01Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} +{"_type":"issue","id":"improvise-8jk6.5","title":"import --formula silently drops a formula it cannot parse and exits 0","description":"`improvise import --formula` silently drops a formula it cannot parse. The import still saves and exits 0, so a script cannot tell that the formula is missing. Found by the repo-stats pipeline, PR #313 (improvise-1xfb.5.1).\n\nRepro:\n\n printf 'a,x\\nk,1\\n' \u003e f.csv\n improvise import f.csv --no-wizard --measure x --formula 'Y = x +' -o f.improv; echo \"exit=$?\"\n grep -A3 'Formulas' f.improv\n\nWhat happens: no formula is written, nothing is reported, and the exit status is 0.\n\nWhat should happen: a formula that does not parse is an error the user asked for by name. Report it with the parse error on stderr and exit non-zero, the same way a headless refusal exits non-zero since improvise-udt2 and improvise-j6x8. Whether to also refuse to write the output file is a decision for the fix. Refusing is the conservative default.\n","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-headless-import","owner":"el-github@elangley.org","created_at":"2026-09-25T04:29:52Z","created_by":"agent-gapfile","updated_at":"2026-09-25T04:33:05Z","started_at":"2026-09-25T04:33:05Z","lease_expires_at":"2026-09-25T04:38:05Z","heartbeat_at":"2026-09-25T04:33:05Z","labels":["headless","import"],"dependencies":[{"issue_id":"improvise-8jk6.5","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T04:29:52Z","created_by":"agent-gapfile","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-8jk6.4","title":"import --category still parses a forced category as a number: 00565796 becomes 565796, 220944e2 becomes 22094400.0","description":"A column the user forced to Category with `import --category` is still parsed as a number, so its text is lost. Found by the repo-stats pipeline, PR #313 (improvise-1xfb.5.1): commit `00565796` became item `565796`, and `220944e2` was read as scientific notation and became `22094400.0`.\n\nRepro:\n\n printf 'commit,lines\\n00565796,3\\n220944e2,5\\n' \u003e c.csv\n improvise import c.csv --no-wizard --category commit --measure lines -o c.improv\n grep -A2 'Category: commit' c.improv\n\nWhat happens: the items are `565796` and `22094400.0`.\n\nWhat should happen: forcing a field to Category means its values are item names, and item names are text. The items must be `00565796` and `220944e2`, verbatim. Numeric parsing is for measures, and should not run on a forced category.\n","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-headless-import","owner":"el-github@elangley.org","created_at":"2026-09-25T04:29:50Z","created_by":"agent-gapfile","updated_at":"2026-09-25T04:33:00Z","started_at":"2026-09-25T04:33:00Z","lease_expires_at":"2026-09-25T04:38:00Z","heartbeat_at":"2026-09-25T04:33:00Z","labels":["headless","import"],"dependencies":[{"issue_id":"improvise-8jk6.4","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T04:29:50Z","created_by":"agent-gapfile","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-8jk6.3","title":"Import a date column as ONE hierarchical category (Year \u003e Quarter \u003e Month), crosstab via formulas","description":"USER 2026-09-25, verbatim: 'I think dates should usually be one category and we can use formulas to crosstab'. Today a date column becomes up to three FLAT categories (Date_Year '2024', Date_Month '2024-01', Date_Quarter '2024-Q1'; analyzer.rs:81-93), never hierarchical -- groups predate date extraction (eae00522 vs a73fe160) but the import has never created a group. Change the default: one category named after the column, whose items are the finest chosen component (e.g. months) grouped into quarters grouped into years ('\u003e Q1-2024[2024]'), so it costs one of the 24 category slots instead of three and collapses naturally. Crosstabs (year x month) are done with formulas, not by splitting the category -- verify the formula language can do that today (a WHERE/IS naming a GROUP rather than an item: does it cover the group's members?) and file the gap if not. Keep the flat split available as a wizard choice. Blocked by the group-parent ticket. Decide item naming (a month item must stay unique across years: '2024-01', not 'Jan'), and headless flag parity (--time / --extract).","status":"in_progress","priority":2,"issue_type":"feature","assignee":"agent-hier","owner":"el-github@elangley.org","created_at":"2026-09-25T04:03:02Z","created_by":"Edward Langley","updated_at":"2026-09-25T05:01:29Z","started_at":"2026-09-25T05:01:29Z","lease_expires_at":"2026-09-25T05:06:29Z","heartbeat_at":"2026-09-25T05:01:29Z","labels":["import"],"dependencies":[{"issue_id":"improvise-8jk6.3","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T04:03:02Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-8jk6.3","depends_on_id":"improvise-felo.6","type":"blocks","created_at":"2026-09-25T04:03:03Z","created_by":"Edward Langley","metadata":"{}"}],"comments":[{"id":"5aff5014-0515-5078-8dc1-b5ef15cceeb3","issue_id":"improvise-8jk6.3","author":"Edward Langley","text":"Per the user (2026-09-25, on improvise-felo.6): hierarchies start closed unless a view opts in to a depth -- so an imported date category shows years folded by default.","created_at":"2026-09-25T04:04:41Z"}],"dependency_count":1,"dependent_count":0,"comment_count":1} +{"_type":"issue","id":"improvise-felo.6","title":"Honour group parents: nested groups within one category","description":"Group::parent (model/category.rs:34) is parsed ('\u003e Q1[2025]', improv.pest group_hierarchy), written (format.rs:210), round-tripped and hashed (app.rs:195), but NOTHING in layout, the grid frame, collapse or aggregation reads it -- so Q1 is never drawn inside 2025. Make the within-category hierarchy real: expand_category emits nested group runs (reusing the GroupHeader{level}/GroupEnd{level} machinery that already nests runs across categories, core.md 'Group runs nest'); AxisFrame/FrameGroup carry the depth so the TUI and the browser draw the same headers (design-principles section 5); collapsing a group at any level folds everything under it into one row whose value is the fold over all covered leaf items (evaluate_covering must expand a group to its leaves through child groups); z toggles the innermost group at the cursor; hidden items and prune_empty behave at every level. Decide: cycles and dangling parents in a file (refuse on load vs ignore), and whether an item may sit directly in a parent group alongside child groups. Tests first, including a 3-level Year\u003eQuarter\u003eMonth fixture. Prerequisite for importing dates as one hierarchical category. Interacts with ADR 008 (operator shape includes group membership).","status":"closed","priority":2,"issue_type":"feature","assignee":"agent-hier","owner":"el-github@elangley.org","created_at":"2026-09-25T04:03:00Z","created_by":"Edward Langley","updated_at":"2026-09-25T06:39:32Z","started_at":"2026-09-25T04:05:54Z","closed_at":"2026-09-25T06:39:32Z","close_reason":"Landed in PR #317; reviewed PASS at 44718e8a","labels":["browser","core","tui","view"],"dependencies":[{"issue_id":"improvise-felo.6","depends_on_id":"improvise-felo","type":"parent-child","created_at":"2026-09-25T04:03:00Z","created_by":"Edward Langley","metadata":"{}"}],"comments":[{"id":"ff4b12b2-f571-55e3-aa28-96e355d35faf","issue_id":"improvise-felo.6","author":"Edward Langley","text":"USER 2026-09-25, verbatim: 'I think hierarchies should start closed unless we somehow opt into a specific one opened to a certain depth (view config maybe?)'. So: groups start COLLAPSED by default; a view opts in to opening a category's hierarchy to a given depth (e.g. a per-category depth in View, persisted as a new view line such as 'expand: Date 2'), with per-group toggles (z) on top. Today View::collapsed_groups (view/types.rs:40) records exceptions to an EXPANDED default and .improv writes 'collapsed:' lines -- this inverts the default, so decide: (a) state = depth + a set of per-group overrides in both directions (opened-beyond-depth, closed-within-depth); (b) migration of existing files, whose 'collapsed:' lines assume groups default open -- a file with groups and no 'collapsed:' line would suddenly render folded; options: keep old files' meaning by version (the file-version literal selects the regime, as ADR 007 does), or accept the change. The migration choice is the user's; ask on the PR. Also affects the import default (improvise-8jk6.3: an imported date hierarchy opens closed).","created_at":"2026-09-25T04:04:39Z"},{"id":"72c7ea45-210e-576a-b25a-448b7a41b26b","issue_id":"improvise-felo.6","author":"Edward Langley","text":"USER 2026-09-25, verbatim: 'I think, in most cases, the closed by default start is preferable because it makes the grid more dense.' (The reason for the closed default.) Bears on the migration question: it argues for old files also opening closed rather than version-gating the old open default -- pending the user's explicit answer.","created_at":"2026-09-25T04:06:05Z"},{"id":"29921739-1f03-5c2d-b0f7-9636a94fd0bf","issue_id":"improvise-felo.6","author":"Edward Langley","text":"USER DECISION 2026-09-25, verbatim: 'Everything opens closed, including old files.' No version gating: old files open closed too; their 'collapsed:' lines agree with the default.","created_at":"2026-09-25T04:06:39Z"},{"id":"73d76560-7ebf-52f8-bb02-ab7a856c2041","issue_id":"improvise-felo.6","author":"Edward Langley","text":"USER 2026-09-25, verbatim: 'Yes, and we can add an expanded: line if it's not in the grammar. Since it's an additive change (and there's no real users), I'm not too concerned about strict compatibility so no version bump needed.' So: per-group opens persist as an 'expanded:' view line (mirroring 'collapsed:'), added to improv.pest; no file-version bump.","created_at":"2026-09-25T04:17:52Z"},{"id":"7a483598-1078-5d71-ba40-93444af3b91b","issue_id":"improvise-felo.6","author":"Edward Langley","text":"USER 2026-09-25, verbatim: 'I think we'll need a depth for expanded, something like expanded(2): ...'. So the expanded line carries an optional depth: 'expanded(N): \u003ctarget\u003e'. Proposed semantics for the implementer to confirm in the PR: target 'Cat' opens the category's top N levels; target 'Cat/Group' opens that group and N-1 levels beneath it; bare 'expanded:' means N=1; collapsed: and z still override individual groups.","created_at":"2026-09-25T04:20:23Z"},{"id":"93ce9859-0231-52b7-a781-86560834865e","issue_id":"improvise-felo.6","author":"Edward Langley","text":"USER 2026-09-25, verbatim: 'expanded+collapsed in the same group is an error'. A view naming the same Cat/Group in both an expanded(...) line and a collapsed line is refused on load (naming it). Only the SAME group is an error; a collapsed group inside a region opened by an ancestor's or the category's expanded(N) is a legitimate override.","created_at":"2026-09-25T04:22:51Z"}],"dependency_count":0,"dependent_count":1,"comment_count":6} +{"_type":"issue","id":"improvise-31oe.1","title":"Auto-complete system (commands first)","description":"USER 2026-09-25, verbatim: 'We need a bead for an auto-complete system: commands are highest priority'. One completion engine in the shared layer (design-principles section 5: a front end displays state, it does not reimplement behaviour), used by the TUI and the browser alike: given a prompt kind and the text so far, return candidates. Sources, in priority order: (1) command names on the ':' command line -- from the registry, whose names come off prototypes (gotcha 15), never a hand-kept list; (2) command arguments -- mode names (parse_mode_name), panel names, axis keywords, category/item/view names from the model, file paths via Storage::list (not std::fs; the browser host must reuse it); (3) name completion in the text-entry minibuffers (category-add, item-add, formula edit refs with pipe quoting). Open design questions for the children: where the engine lives (improvise-command vs improvise-protocol, given the browser needs it and improvise-y11g puts the ':' line in the browser); key binding (Tab conflicts?); cycling vs a candidate list (a which-key-style popup exists in src/ui/which_key.rs); how candidates reach a thin client (projection vs client-side from a shared table).","status":"open","priority":2,"issue_type":"epic","owner":"el-github@elangley.org","created_at":"2026-09-25T03:41:47Z","created_by":"Edward Langley","updated_at":"2026-09-25T03:41:47Z","labels":["command","protocol","ux"],"dependencies":[{"issue_id":"improvise-31oe.1","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T03:41:47Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-1xfb.5.1","title":"scripts/repo-stats: monitor churn by area, prose-to-code ratio, doc growth and change coupling","description":"USER 2026-09-25: 'add some tooling to monitor these stats' -- the stats the user ran by hand with code-maat on 2026-09-25 (coupling, age, abs-churn) plus the orchestrator's area split (since 09-19 on gh/main: Rust src +57k/-24k, context/test-evidence +25k/-1.4k, context/code +18k/-1.5k, site +5.6k/-0). Build a repo-owned script (no java/code-maat dependency; python or the repo's existing script language, run under nix develop) that reports over a window (default: last 7 days, on a named ref, default gh/main, no-merges): (1) added/deleted lines by area (rust src, rust tests, context/code, context/test-evidence, other context, repo-map, kb.pl, agent docs/skills, scripts/CI, web+harness, generated); (2) the prose-to-code ratio and each area's deletion ratio; (3) current size of the watched docs (lines of each context/*.md, repo-map gotcha count and longest gotcha, test-evidence entry count); (4) top-N change-coupled file pairs (co-change degree and revisions, code-maat's definition, min-revs threshold), flagging pairs that cross crate boundaries and pairs among the agent-rule docs (a rule stated in several places). Output: human-readable text plus --json. Add a CI step that runs it on pushes to main and writes the report to GITHUB_STEP_SUMMARY -- report only, never fails the build (budgets are a separate decision under the compaction epic). Give it a test in the repo's scripts/*-test.sh style against a fixture repo, wired into checkers.sh. Document it in context/code/tooling.md.","status":"in_progress","priority":2,"issue_type":"task","assignee":"agent-stats","owner":"el-github@elangley.org","created_at":"2026-09-25T03:21:11Z","created_by":"Edward Langley","updated_at":"2026-09-25T03:22:03Z","started_at":"2026-09-25T03:22:03Z","lease_expires_at":"2026-09-25T03:27:03Z","heartbeat_at":"2026-09-25T03:22:03Z","labels":["docs","tooling"],"dependencies":[{"issue_id":"improvise-1xfb.5.1","depends_on_id":"improvise-1xfb.5","type":"parent-child","created_at":"2026-09-25T03:21:11Z","created_by":"Edward Langley","metadata":"{}"}],"comments":[{"id":"84080845-4c53-5f4a-8ecf-2c5c468f5815","issue_id":"improvise-1xfb.5.1","author":"Edward Langley","text":"USER 2026-09-25 pointed at https://github.com/adamtornhill/code-maat/releases/download/v1.0.4/code-maat-1.0.4-standalone.jar -- so USE code-maat for the analyses it provides (coupling, age, abs-churn, soc, etc.) rather than reimplementing them. Pin the jar in nix (fetchurl with its hash) and add a JRE to the dev and ci shells; the script prepares the git log in code-maat's git2 format itself (scoped to a ref, --no-merges, a window) and adds only what code-maat does not do: the by-area split, prose-to-code and deletion ratios, doc sizes, and flags on the coupling rows (crosses a crate boundary; agent-rule docs).","created_at":"2026-09-25T03:21:25Z"},{"id":"11ba9c08-a926-5fec-8589-3c873ba4a4b9","issue_id":"improvise-1xfb.5.1","author":"Edward Langley","text":"USER 2026-09-25, verbatim: 'The stat tooling could use improvise as its scripting engine.' So: churn/ratio analysis becomes an .improv model built headlessly (improvise import --no-wizard + cmd/script for formulas and views), with the .improv file as the artifact; code-maat stays for coupling/age/soc. Gaps in improvise's headless surface found along the way are findings to file, not silent workarounds.","created_at":"2026-09-25T03:28:35Z"},{"id":"41996941-7d1c-5bb6-a00d-4412929a1734","issue_id":"improvise-1xfb.5.1","author":"Edward Langley","text":"USER 2026-09-25, verbatim: 'code-maat generates CSV files in a directory that we process with improvise and use this to notice features and regressions at the command/model layer (which is its own first-class product, separate from the UIs)'. Depends on folder import (improvise-lpzu).","created_at":"2026-09-25T03:30:03Z"}],"dependency_count":0,"dependent_count":0,"comment_count":3} +{"_type":"issue","id":"improvise-1xfb.5","title":"Doc compaction: context/ states current rules, history lives in git","description":"USER 2026-09-25: 'doc compaction is a good idea'. Since 2026-09-19 context/code + context/test-evidence grew +42k/-2.8k lines (about 0.7 lines of prose per line of Rust source added) and almost never shrink; the repo-map gotchas run to pages and many narrate their own corrections ('this paragraph said X until...'). Every change pays to read and patch them, and today's reviews found stale copies each time a rule was stated in several places (code-maat: agent-rules/AGENTS.md/wave SKILL/code-review SKILL change together 39-48%). Goal: each doc states the CURRENT rule and its reason once; superseded narration goes (git and the ticket keep it); a rule stated in several files gets one home and pointers. Keep test-evidence entries keyed by test name (check-comment-runs --check-evidence) but compact them to what broke, the injected fault, and why the test can fail. Consider a size budget enforced by a checker so growth does not return. Sequence per area to avoid conflicting with in-flight PRs; repo-map.md is orchestrator-only.","status":"open","priority":2,"issue_type":"epic","owner":"el-github@elangley.org","created_at":"2026-09-25T03:20:21Z","created_by":"Edward Langley","updated_at":"2026-09-25T03:20:21Z","labels":["docs","tooling"],"dependencies":[{"issue_id":"improvise-1xfb.5","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T03:20:21Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-1xfb.4","title":"Stop tracking site/: it is web/assemble.sh output","description":"USER 2026-09-25: 'remove site/' (and 'keep TAGS for now'). site/ is the directory web/assemble.sh assembles (landing page + scripts/build-doc-site.mjs output); 27 generated files are tracked on gh/main, grew +5.6k/-9 lines since 09-19, and drift stale (the category-cap review found site/docs/** still saying 12). Remove site/ from the index (git rm -r --cached), add it to .gitignore, and confirm CI's Pages job builds it from source rather than reading the tracked copy; check nothing (harness specs, pages.spec.js, landing.spec.js, doc-site tests, release scripts) reads the committed site/. Do not touch TAGS.","status":"closed","priority":2,"issue_type":"task","assignee":"agent-1xfb4","owner":"el-github@elangley.org","created_at":"2026-09-25T03:20:19Z","created_by":"Edward Langley","updated_at":"2026-09-25T03:52:30Z","started_at":"2026-09-25T03:20:45Z","closed_at":"2026-09-25T03:52:30Z","close_reason":"Landed in PR #312; reviewed PASS at 67634767","labels":["tooling"],"dependencies":[{"issue_id":"improvise-1xfb.4","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T03:20:19Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-felo.5","title":"An acyclic formula chain deeper than MAX_EVAL_DEPTH evaluates to Error(\"circular\")","description":"Found by the ADR 009 agent (PR #309, question 4), 2026-09-25: recompute_formulas iterates the fixed point a bounded number of passes (MAX_EVAL_DEPTH), and a chain of 16 formulas each depending on the previous one -- no cycle at all -- has not stabilized when the bound is hit, so it reads Error(\"circular\"). Gotcha 9 and core.md section 3 describe the bound as the circular-reference detector; that conflates 'deep' with 'cyclic'. A topological evaluator (ADR 009) would give the right answer and disagree with eager, so the differential test would fail on correct code. Tests first: a 17-link acyclic chain evaluates to its value; a 2-cycle still gives Error(\"circular\"). Fix shape to decide: detect cycles in the formula dependency graph and bound only those, or evaluate acyclic formulas in dependency order.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T03:19:21Z","created_by":"Edward Langley","updated_at":"2026-09-25T03:19:21Z","labels":["core","formula"],"dependencies":[{"issue_id":"improvise-felo.5","depends_on_id":"improvise-felo","type":"parent-child","created_at":"2026-09-25T03:19:21Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-1xfb.3","title":"Agents set the git author name to include their BEADS_ACTOR","description":"USER, 2026-09-25, verbatim: 'agents should override git author name, I think to include an agent identifier (matching BEADS_ACTOR)'. Today every agent commit is authored as the repository owner, so history cannot say which agent wrote what. Proposed default (confirm on the PR): author name 'Claude (\u003cBEADS_ACTOR\u003e)', author email unchanged (the owner's configured email, so GitHub still links it), committer untouched; the Co-Authored-By trailer stays. Mechanism must survive a shell that does not persist env between commands and must NOT change shared git config: worktrees share .git/config, so 'git config user.name' in one worktree renames every agent. Candidates: 'git -c user.name=... commit' per commit, GIT_AUTHOR_NAME per command, or a tiny scripts/agent-commit wrapper reading BEADS_ACTOR (refusing when unset). Put the rule in .agents/skills/wave/agent-rules.md next to 'Claim first: export BEADS_ACTOR', and in the brief checklist in wave/SKILL.md. Related: improvise-xfsb (agents posting to GitHub under their own identity, deferred).","status":"closed","priority":2,"issue_type":"task","assignee":"agent-1xfb3","owner":"el-github@elangley.org","created_at":"2026-09-25T03:15:45Z","created_by":"Edward Langley","updated_at":"2026-09-25T03:54:49Z","started_at":"2026-09-25T03:16:08Z","closed_at":"2026-09-25T03:54:49Z","close_reason":"Landed in PR #310; reviewed PASS at 0539fb37","labels":["agents","tooling"],"dependencies":[{"issue_id":"improvise-1xfb.3","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T03:15:45Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-felo.4","title":"ADR 009: incremental evaluation — an in-house DBSP-core crate, pluggable","description":"USER DECISION 2026-09-25, verbatim: 'I think doing our own implementation of the core of dbsp as a crate we can plugin is probably dieal [sic] here. Write the ADR and Update ADR 008 to assume the decisions in that one'. Prompted by the user pointing at https://github.com/brurucy/dbsp-from-scratch (a teaching implementation of the DBSP paper: Z-sets, streams, delay/differentiate/integrate, lifted/incremental join, distinct, recursion by nested streams) as a way to evaluate formulas so data can stream. Scope: an ADR (next free number, 009) specifying a workspace crate implementing the DBSP core (not depending on Feldera's dbsp), and how improvise plugs it in: cell writes as Z-set deltas; incremental group-by aggregates (SUM/COUNT linear, AVG derived, MIN/MAX and the text-unanimity rule via integrated per-group multisets); per-row occupancy counts for prune_empty; formula dependencies updated in topological order with the circular check kept (DBSP's least-fixed-point recursion is not our circular-error semantics); f64 non-associativity vs ADR 005 exact decimals (improvise-l2b8); ADR 003 live-backed sources as delta streams; wasm32 cleanliness. Then ADR 008 (PR #308) is revised to assume ADR 009's decisions (notably its question 6, totals).","status":"in_progress","priority":2,"issue_type":"decision","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-25T03:03:27Z","created_by":"Edward Langley","updated_at":"2026-09-25T03:04:04Z","started_at":"2026-09-25T03:04:04Z","lease_expires_at":"2026-09-25T03:09:04Z","heartbeat_at":"2026-09-25T03:04:04Z","labels":["adr","core","formula"],"dependencies":[{"issue_id":"improvise-felo.4","depends_on_id":"improvise-felo","type":"parent-child","created_at":"2026-09-25T03:03:27Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-felo.3","title":"Virtualize the grid: no limit on grid size, realize only what is visible","description":"layout.rs:935 cross_product and layout.rs:690 prune_empty matrix. The category cap never bounded either; a user who puts many categories on Row or Column builds an arbitrarily large grid. Decide whether a guard (refuse, warn, or lazy layout) is wanted. Found by the improvise-felo.1 coupling inventory (MAX_CATEGORIES 12 -\u003e 24, PR #307; context/code/core.md section 9). Reachable at 12 too; more likely at 24.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"felo3-adr","owner":"el-github@elangley.org","created_at":"2026-09-25T02:30:30Z","created_by":"Edward Langley","updated_at":"2026-09-25T02:33:29Z","started_at":"2026-09-25T02:33:29Z","lease_expires_at":"2026-09-25T02:38:29Z","heartbeat_at":"2026-09-25T02:33:29Z","labels":["core","perf"],"dependencies":[{"issue_id":"improvise-felo.3","depends_on_id":"improvise-felo","type":"parent-child","created_at":"2026-09-25T02:30:30Z","created_by":"Edward Langley","metadata":"{}"}],"comments":[{"id":"2ba6da4a-78fa-593e-84f9-6372c7f1afe8","issue_id":"improvise-felo.3","author":"Edward Langley","text":"USER DIRECTION 2026-09-25, verbatim: 'Ideally, there should be no limit on the size of the grid, we should just virtualize it so we only have to realize what's visible.' So the answer to 'refuse, warn, or lazy layout' is: lazy -- no guard, no size limit. Today GridLayout eagerly builds cross_product per axis (layout.rs:935) and prune_empty builds a rows x cols matrix (layout.rs:690). Known hard parts to design, not assume away: (1) prune_empty needs to know whether a row/column is empty across the WHOLE grid, not just the window; (2) column totals are sums over every row, including ones never scrolled to (gotcha 44 -- must stay projected, not client-derived); (3) row/column COUNT and index-\u003etuple mapping without materializing (mixed-radix indexing over item lists works when nothing is pruned or collapsed; groups, collapse and hidden items break pure arithmetic); (4) records mode rows (see improvise-sk32); (5) GridCache is already viewport-indexed for the browser, so the protocol side may need little change. Likely warrants an ADR.","created_at":"2026-09-25T02:31:59Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} +{"_type":"issue","id":"improvise-8jk6.1","title":"Import wizard field list does not scroll, so the over-cap case cannot show every field it asks the user to fix","description":"import_wizard_ui.rs:136 stops at the popup bottom (popup at most 30 rows, line 39), unlike the steps that use the scrolling list PR #304 added. About 22 fields fit; a CSV with more columns hides fields, including the ones the over-cap precondition tells the user to Skip. Use the same scrolling/Panel machinery. Found by the improvise-felo.1 coupling inventory (MAX_CATEGORIES 12 -\u003e 24, PR #307; context/code/core.md section 9). Reachable at 12 too; more likely at 24.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T02:30:28Z","created_by":"Edward Langley","updated_at":"2026-09-25T02:30:28Z","labels":["import","tui","ux"],"dependencies":[{"issue_id":"improvise-8jk6.1","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T02:30:28Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-gtn7.8","title":"TUI side panels do not scroll: items past the panel height are undrawn and the cursor can move onto them","description":"src/ui/panel.rs:77-81: the category/formula/view panels draw from the top and stop at the panel height; the cursor keeps moving into undrawn rows. The category panel lists every category plus expanded items, so 24 categories overflow a normal terminal. Scroll to keep the cursor visible, with more-above/below markers; test at a small height. Related: improvise-n8vh (long names overflow the border), improvise-j7ij. Found by the improvise-felo.1 coupling inventory (MAX_CATEGORIES 12 -\u003e 24, PR #307; context/code/core.md section 9). Reachable at 12 too; more likely at 24.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T02:30:27Z","created_by":"Edward Langley","updated_at":"2026-09-25T02:30:27Z","labels":["tui","ux"],"dependencies":[{"issue_id":"improvise-gtn7.8","depends_on_id":"improvise-gtn7","type":"parent-child","created_at":"2026-09-25T02:30:27Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-gtn7.7","title":"TUI page bar is one unclipped line: with many Page categories most selectors are cut off with no marker","description":"grid.rs:452-465 draws the page bar as a single line; default seating (view/types.rs:127) puts every category after the second on Page, so a 24-category import shows 22 page selectors on one line, clips most silently, and opens on one slice. Wrap or scroll it (user rule messages-wrap-not-truncate applies to anything the user must read), with a visible marker. Found by the improvise-felo.1 coupling inventory (MAX_CATEGORIES 12 -\u003e 24, PR #307; context/code/core.md section 9). Reachable at 12 too; more likely at 24.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T02:30:26Z","created_by":"Edward Langley","updated_at":"2026-09-25T02:30:26Z","labels":["tui","ux"],"dependencies":[{"issue_id":"improvise-gtn7.7","depends_on_id":"improvise-gtn7","type":"parent-child","created_at":"2026-09-25T02:30:26Z","created_by":"Edward Langley","metadata":"{}"}],"comments":[{"id":"aa6e2320-d872-5de9-b1fa-97727d3c7e0b","issue_id":"improvise-gtn7.7","author":"Edward Langley","text":"Also (PR #307 review): the page bar's set_string is not clipped to inner.width, so it can overwrite the right border too.","created_at":"2026-09-25T02:40:25Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} +{"_type":"issue","id":"improvise-gtn7.6","title":"TUI visible_rows ignores column header levels, the group row and the page bar, so the cursor scrolls below the drawn grid","description":"app.rs:969 sets visible_rows = term_height - 8, a fixed chrome allowance; grid.rs:128 draws one line per column level. With several categories on Column the vertical scroll counts rows that are never drawn -- the vertical twin of improvise-gtn7.1 (a scroll must be measured where it is drawn). Derive the grid height from one function the draw also uses; test: with N column levels, moving down keeps the selected row drawn. Found by the improvise-felo.1 coupling inventory (MAX_CATEGORIES 12 -\u003e 24, PR #307; context/code/core.md section 9). Reachable at 12 too; more likely at 24.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T02:30:25Z","created_by":"Edward Langley","updated_at":"2026-09-25T02:30:25Z","labels":["tui","ux"],"dependencies":[{"issue_id":"improvise-gtn7.6","depends_on_id":"improvise-gtn7","type":"parent-child","created_at":"2026-09-25T02:30:25Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-gtn7.5","title":"TUI row header grows with every Row category until the data area is zero","description":"src/ui/grid.rs:94,102 and ui/viewport.rs:154-161: the row header is the SUM of per-level widths (4..24 cells each), so enough categories on Row shrink the data area to nothing. Needs a cap/compaction rule for the header and a test that some data column always draws. Found by the improvise-felo.1 coupling inventory (MAX_CATEGORIES 12 -\u003e 24, PR #307; context/code/core.md section 9). Reachable at 12 too; more likely at 24.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T02:30:23Z","created_by":"Edward Langley","updated_at":"2026-09-25T02:30:23Z","labels":["tui","ux"],"dependencies":[{"issue_id":"improvise-gtn7.5","depends_on_id":"improvise-gtn7","type":"parent-child","created_at":"2026-09-25T02:30:23Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-1xfb.2","title":"agent-rules: default to a new conventional commit; amend HEAD only for small unpushed fixes","description":"USER, 2026-09-25, verbatim: 'As a general rule, you shuold just add a new conventional commit. Small adjustments can ammend HEAD, but when in doubt don't.' PR #305 (merged 6427312d) wrote rule 8 and 'Commit early' as allowing an agent to 'amend, reorder or fold' unpushed commits and to 'tidy WIPs before the first push'. That is broader than the user now states: reordering and folding are out; the default is a new commit; amending is limited to HEAD, for a small adjustment, and only while unpushed; when in doubt, a new commit. Also: commit messages follow Conventional Commits (type(scope): subject) -- the recent history is mixed (docs:/ci:/chore:/test: alongside 'improvise-7mro: ...' and 'pr-reviews: ...'), so state the convention and how a ticket id is carried (e.g. in the scope or a trailer). Update agent-rules rule 8 + 'Commit early', AGENTS.md's top line, context/cloud-agents.md, the code-review skill summary line.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-25T02:19:18Z","created_by":"Edward Langley","updated_at":"2026-09-25T02:45:04Z","started_at":"2026-09-25T02:19:41Z","closed_at":"2026-09-25T02:45:04Z","close_reason":"Landed in PR #306; reviewed PASS at 94260d58, Codex P2/P3 answered","labels":["agents","tooling"],"dependencies":[{"issue_id":"improvise-1xfb.2","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T02:19:18Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-sk32","title":"Records-mode layout build is quadratic in records: RecordRow::group scans rows linearly per cell","description":"Found while measuring improvise-felo.1 (crates/improvise-core/examples/category_width_bench.rs, Apple M4 Max, release). GridLayout::with_frozen_records in records mode: 2,500 records 79-82 ms, 5,000 records 302-321 ms, i.e. ~4x for 2x records, at BOTH 12 and 24 categories (width barely moves it). Cause: view/layout.rs RecordRow::group finds a cell's row with rows.iter().position(|r| r.coords == coords) -- a linear scan per cell, so O(cells x records). Drawing the built layout is linear (3 ms / 7 ms). Fix shape: index rows by coords (HashMap\u003cCellKey, usize\u003e) while keeping first-appearance order, which gotcha 18 / core.md section 8 require; a test must pin the order is unchanged. Not caused or worsened by the category cap.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T02:14:46Z","created_by":"wave-felo1","updated_at":"2026-09-25T02:14:46Z","labels":["core"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-1xfb.1","title":"agent-rules: no squash, rebase or reset — reconcile diverging history only by merge","description":"USER RULE 2026-09-25, verbatim: 'generally there should be no squashing or rebasing in git, just merge to reconcile diverging histories.' Prompted by the improvise-cgqt agent squashing with 'git reset --soft gh/main' after #303 merged mid-session, which staged a commit that reverted #303 (caught by numstat before push) -- the same accident as improvise-diho/#196. Rule 7 of .agents/skills/wave/agent-rules.md currently treats 'reset' as a normal operation and only pins the SHA; the fix is to forbid the operation, not to make it safer. Change agent-rules (and AGENTS.md where it restates git policy): no git rebase, no squash (reset --soft, merge --squash, rebase -i fixup), no amending or force-pushing a pushed commit; take newer main with 'git merge \u003cpinned gh/main SHA\u003e'; WIP commits stay as they are. Keep rule 7's SHA pinning for merge-base and diff. Review the recovery recipes in AGENTS.md that use cherry-pick (a PR built on the wrong base; a fix pushed after merge) and say whether they stay as the one sanctioned exception or become merges. Also: bound any test loop that walks a state machine until a step.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-25T01:47:05Z","created_by":"Edward Langley","updated_at":"2026-09-25T02:15:34Z","started_at":"2026-09-25T01:48:18Z","closed_at":"2026-09-25T02:15:34Z","close_reason":"Landed in PR #305 (merge 6427312d), reviewed PASS at 9b9b1efa","labels":["agents","tooling"],"dependencies":[{"issue_id":"improvise-1xfb.1","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T01:47:05Z","created_by":"Edward Langley","metadata":"{}"}],"comments":[{"id":"8347c6b5-9936-5254-83d7-d4af2f05b388","issue_id":"improvise-1xfb.1","author":"Edward Langley","text":"USER, 2026-09-25, verbatim: 'Git operations that preserve history should generally be preserved, because the merge structure of git history is a causal record.' This is the rule's reason; state it in agent-rules. It bears on the cherry-pick recipes: a cherry-pick loses the causal link, so prefer a merge wherever one does the job.","created_at":"2026-09-25T01:49:26Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} +{"_type":"issue","id":"improvise-gtn7.3","title":"The TUI status line truncates import warnings (and every long status) to one row","description":"Per the user's rule (bd memory messages-wrap-not-truncate, 2026-09-25): a message the user needs to read wraps, never truncates. AcceptWizardModel and ImportJsonHeadless join every import warning into status_msg ('Imported with warnings: a; b; c'), and draw.rs::draw_status / fill_line cut the status line to one row -- the same truncation PR #304 fixed inside the wizard, one screen later. Any long refusal/error on the status line has the same problem. Shape to decide: a status area that grows to fit (bounded, with a way to see the rest), or long messages routed to a readable pane. Test: a status message wider than the terminal renders every word. Found by the improvise-cgqt agent.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T01:46:56Z","created_by":"Edward Langley","updated_at":"2026-09-25T01:46:56Z","labels":["tui","ux"],"dependencies":[{"issue_id":"improvise-gtn7.3","depends_on_id":"improvise-gtn7","type":"parent-child","created_at":"2026-09-25T01:46:56Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-gtn7.2","title":"TUI grid cursor scrolls off screen across columns of mixed width","description":"Found while fixing improvise-gtn7.1. With NO side panel open, a row of ten narrow columns followed by wide ones: moving right from column 9 selects columns 10..14 while the grid draws none of them (col_offset 1,3,6,9,12). Cause: App::cmd_context computes visible_cols once, at the OLD col_offset, and navigation.rs::viewport_effects then applies that count at the NEW offset (nc - (vc-1)); seven narrow columns fit from offset 0, but not seven wide ones from offset 1. A single count cannot be right across mixed widths. Fix shape: the new col_offset must be the least offset from which columns offset..=nc fit (widths + data width), the same shape as the tile bar's first_tile. Constraint: improvise-protocol resolve.rs::scrolled_to mirrors viewport_effects line for line over a column-COUNT window and tests/lockstep.rs holds host and client in agreement, so a width-aware host scroll must decide what the browser's window means first. Probe: draw.rs content_tests shape, 1 row x 16 cols (A0..A9, then 28-wide names), 80x24, panels closed.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T01:28:54Z","created_by":"agent-gtn7-1","updated_at":"2026-09-25T01:28:54Z","labels":["tui","ux"],"dependencies":[{"issue_id":"improvise-gtn7.2","depends_on_id":"improvise-gtn7","type":"parent-child","created_at":"2026-09-25T01:28:54Z","created_by":"agent-gtn7-1","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-1xfb","title":"Epic: CI, checkers and repository tooling","description":"The repository's own machinery: scripts/ checkers and their check_the_checker (7k7v, 4zei, gc9t, rv02, xebx, e958, yqnp, z4up, sxsf, mszy), GitHub workflows and the Pages deploy (864a), PR-watching scripts (qvpu, weoe), dev-shell completeness (q36k), bd setup blocks (bf0q), the doc site build (56qu), and agent identity on GitHub (xfsb). Out of scope: the release workflow and its preflight (improvise-0s6), and test gaps inside a product area, which belong to that area's epic.","status":"open","priority":2,"issue_type":"epic","owner":"el-github@elangley.org","created_at":"2026-09-25T01:04:15Z","created_by":"Edward Langley","updated_at":"2026-09-25T01:04:15Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-fhj0","title":"Epic: Browser page shell — web/, DOM, CSS and bundle","description":"Everything the browser page itself decides: web/app.js, shell.js, style.css, index.html and the wasm bundle's build flags. Covers banner, minibuffer and contrast polish (0w7y, 6acs), sticky headers and phone-width layout (8mgz, mv1d, rx8g), DOM event delegation and click surfaces (b8ot, j79v), Ctrl-S and the browser's own shortcuts (clso), history and URLs (dsn2), the page bar (ozxr), render parity (pcje, t3oh), and the handshake watchdog (a3dj, 80vh). Out of scope: key resolution and ClientState in Rust (improvise-6jk), and the worker, OPFS and multi-tab (improvise-tm6).","status":"open","priority":2,"issue_type":"epic","owner":"el-github@elangley.org","created_at":"2026-09-25T01:04:15Z","created_by":"Edward Langley","updated_at":"2026-09-25T01:04:15Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-gtn7","title":"Epic: TUI and shared front-end UX","description":"What the user sees and presses in the terminal UI, plus UX decisions both front ends share: help overlay layout (j4i9, swsi, trd3), panel rendering (n8vh, 1dzx), panel features such as rename (fz77), the keymap structure (636d global keymap), ZZ routing (rw3a), glyphs and faces as UI-agnostic data (h04z, nn4s ADR 004). Out of scope: browser DOM and CSS (the browser page-shell epic), browser key parity (improvise-6jk), and restructuring the TUI as an in-process client (improvise-ltq).","status":"open","priority":2,"issue_type":"epic","owner":"el-github@elangley.org","created_at":"2026-09-25T01:04:14Z","created_by":"Edward Langley","updated_at":"2026-09-25T01:04:14Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-8jk6","title":"Epic: Import, export and .improv persistence","description":"improvise-io's import pipeline and wizard (1uqj, bqsl, cgqt, lpzu), export formats (Parquet a5q), and .improv writer fidelity (msg2). Out of scope: the id grammar in .improv (the ADR 007 epic), SQLite as a source and SQLite export (improvise-rucf), and Storage/OPFS backends (improvise-tm6).","status":"open","priority":2,"issue_type":"epic","owner":"el-github@elangley.org","created_at":"2026-09-25T01:04:13Z","created_by":"Edward Langley","updated_at":"2026-09-25T01:04:13Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-n28d","title":"Epic: Records mode and drill","description":"Wide records mode and drill-down, on both front ends where the defect is in the shared logic: drill commit validation against concurrent removals (5mqm, ddp6, 6r2y, o9q7), records renames that overwrite an existing record (kndq), yank and paste inside records and drill (48jd, eucp, s1lx), the saved records-layout mode (gj5p), the one shared _Drill view (t219), and drill previews (zavn). Out of scope: browser key resolution onto records columns (improvise-6jk), and aggregate addressing across views (the wire epic).","status":"open","priority":2,"issue_type":"epic","owner":"el-github@elangley.org","created_at":"2026-09-25T01:04:12Z","created_by":"Edward Langley","updated_at":"2026-09-25T01:04:12Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-31oe","title":"Epic: Command layer — everything is a command (ADR 006), Cmd/Effect hygiene and the CLI","description":"improvise-command's Cmd/Effect layer and the headless CLI: ADR 006's everything-is-a-command and replay (ilvl), with undo/replay (m91) and transactional Sequence bindings (0hb); CmdContext shape (2ud); pub items with no production caller (a1ec); the refuse/abort bookkeeping and its guards (lasd, bj61, y8ym); command tests that do not discriminate (3m7b, 9trl); headless-script behaviour (ep5o, cz3o); and stale prose in context/code/command.md and cli.md. Out of scope: wire vocabulary (the wire epic), records and drill commands (the records epic), and TUI key and UX decisions (the TUI epic).","status":"open","priority":2,"issue_type":"epic","owner":"el-github@elangley.org","created_at":"2026-09-25T01:04:11Z","created_by":"Edward Langley","updated_at":"2026-09-25T01:04:11Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-5krd","title":"Epic: Concurrent edits — ADR 001 layers 1 and 2","description":"The rest of context/adr/001-concurrent-edits.md: keeping a rejected batch staged and letting force re-compose it (3al1), showing what changed when an intent cannot be re-composed (vb6l), notifying the overwritten client (2wi8), advisory reservations (layer 2, 7b8v), moving Basis onto the frame (is8c), and basis-check defects such as 4v9f. Out of scope: wire commands that are missing, and projection or cursor bookkeeping (the wire-and-projection epic), and records-mode rename collisions (the records and drill epic).","status":"open","priority":2,"issue_type":"epic","owner":"el-github@elangley.org","created_at":"2026-09-25T01:04:10Z","created_by":"Edward Langley","updated_at":"2026-09-25T01:04:10Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-tr08","title":"Epic: Security of the hosts and the wire","description":"Holds work that closes a way for an untrusted peer or page to read, write or crash something it should not: path traversal through LoadModel (owep), the ws-server's missing authentication (kpnr), and wire input that can panic or overflow the host or client (0acd). Out of scope: origin checking that has already shipped (kc7n), and preparing the public mirror, which stays under the distribution epic improvise-0s6.","status":"open","priority":2,"issue_type":"epic","owner":"el-github@elangley.org","created_at":"2026-09-25T01:04:07Z","created_by":"Edward Langley","updated_at":"2026-09-25T01:04:07Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-bqsl","title":":import in the TUI parses every file as JSON, so a CSV fails with a JSON parse error","description":"StartImportWizard (crates/improvise-command/src/ui/effect.rs, impl Effect for StartImportWizard) reads the path through app.storage and hands the bytes straight to serde_json::from_slice, so ':import sales.csv' refuses with 'JSON parse error' although the CLI's 'improvise import sales.csv' handles CSV via csv_parser. Two front doors, two answers to 'what can be imported' (design-principles §5: a derivation done twice). Fix: one shared 'bytes + file name -\u003e import records' function in improvise-io (CSV by extension, else JSON) that both the CLI's get_import_data and StartImportWizard call; csv_parser already has _bytes/_from variants over Storage (gotcha 2). Demonstrate first with a failing test: StartImportWizard over a MemoryFs holding a small CSV must open the wizard, not refuse. Consider whether :import should also take several paths (merge_csvs) — at least note the gap if not done.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T00:50:39Z","created_by":"Edward Langley","updated_at":"2026-09-25T00:50:39Z","labels":["import"],"dependencies":[{"issue_id":"improvise-bqsl","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T01:05:27Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-lpzu","title":"improvise import should accept a folder of CSV files","description":"Today 'improvise import data/*.csv' works only because the shell expands the glob: get_import_data (src/main.rs) merges several CSV paths via csv_parser::merge_csvs, which adds a 'File' field per row from the file stem. Passing the folder itself ('improvise import data/') fails, because a single path goes to read_to_string, which errors on a directory. Wanted: a directory argument expands to its *.csv entries in a DETERMINISTIC order (sorted by name, not read_dir order — see gotcha 18, the same CSV imported twice must give the same .improv), non-recursive, then goes down the existing merge path. Decide and test: an empty folder (refuse, naming the folder), a folder containing non-CSV files (skip them or refuse — state which), and a folder mixed with file arguments. The directory listing belongs behind Storage::list so the browser/OPFS host can reuse it (gotcha 2), not std::fs in main.rs.","status":"closed","priority":2,"issue_type":"feature","assignee":"agent-lpzu","owner":"el-github@elangley.org","created_at":"2026-09-25T00:50:39Z","created_by":"Edward Langley","updated_at":"2026-09-25T05:02:19Z","started_at":"2026-09-25T03:32:32Z","closed_at":"2026-09-25T05:02:19Z","close_reason":"Landed in PR #314; reviewed PASS at c43ee724","labels":["import"],"dependencies":[{"issue_id":"improvise-lpzu","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T01:05:28Z","created_by":"Edward Langley","metadata":"{}"}],"comments":[{"id":"ba36392a-bcb8-5db3-b276-13dc05ca2451","issue_id":"improvise-lpzu","author":"Edward Langley","text":"2026-09-25: now needed by improvise-1xfb.5.1 -- the user's design is that code-maat writes its CSVs into a directory and improvise processes that directory. Note code-maat's outputs have DIFFERENT schemas per analysis (coupling: entity,coupled,degree,average-revs; age: entity,age-months; abs-churn: date,added,deleted,commits), so folder import must decide what heterogeneous headers mean (one merged model with a File category and sparse measures, vs refuse, vs one model per schema). Raised P3 -\u003e P2.","created_at":"2026-09-25T03:30:03Z"},{"id":"3aafd79b-1988-54fc-860d-318579349b5a","issue_id":"improvise-lpzu","author":"Edward Langley","text":"USER DECISION 2026-09-25, verbatim: 'Records are already sparse: the model can just leave null/empty values for columns not in a given CSV file.' So heterogeneous headers MERGE into one model (with the existing File category from merge_csvs), and a record simply has no value for a column its file lacks -- not refuse, not one model per schema.","created_at":"2026-09-25T03:32:18Z"},{"id":"91707f9f-2542-533b-b5d9-41d0980ab506","issue_id":"improvise-lpzu","author":"Edward Langley","text":"USER 2026-09-25, verbatim: 'The stats model should pre-create views per file (also dir import should do this: one view filtered by the file column and projected to the columns in that file)'. So folder import creates, per source file, a view named after the file: File fixed to that file (filtered), and only that file's own columns shown -- its categories on the grid axes, its measures visible, and other files' categories/measures out of the grid (categories on None, other measures hidden). The merged default view stays.","created_at":"2026-09-25T03:38:09Z"},{"id":"1a0b8382-9330-5788-b62e-6b41c886a881","issue_id":"improvise-lpzu","author":"Edward Langley","text":"USER 2026-09-25: (1) '(none)' position: 'I think leaving it out for now is fine, maybe a UI element somewhere that shows the number of records underneath the current view?' -\u003e filed separately. (2) single-CSV folder: 'yes, we might want to have some kind of update import eventually (don't plan this yet, but we'd want file identity for this)' -\u003e a folder with ONE CSV still gets the File category and its per-file view.","created_at":"2026-09-25T04:30:53Z"},{"id":"b586e154-47ae-513c-9312-a04eef63ffaa","issue_id":"improvise-lpzu","author":"Edward Langley","text":"USER 2026-09-25, verbatim: 'yeah, adding a single file shouldn't get the File category'. Confirms the PR #314 rule: a folder (even of one CSV) always merges with File; a lone CSV named directly imports alone, without File.","created_at":"2026-09-25T04:54:23Z"}],"dependency_count":0,"dependent_count":0,"comment_count":5} +{"_type":"issue","id":"improvise-weoe","title":"pr-reviews --wait cannot see a plain PR comment, so it never wakes for the user's ADR approval","description":"scripts/pr-reviews --wait is satisfied only by a review or an inline comment whose commit is the current head (has_head_review). A thread comment (issues/{n}/comments) is attached to no commit, so by design it can never satisfy --wait. However, ADR approval arrives as exactly that: the user comments 'Approved' / answers questions on the thread (AGENTS.md 'ADR pull requests'). So a steward waiting on the approved tool sleeps through the one event it is waiting for, and must hand-poll the thread beside it. That is how the earlier #210 watcher missed the user's 19:50Z answers, and the current #210 steward is working around it the same way. Proposed: a --since \u003ciso8601\u003e (or --comments) mode in which a thread comment created after the given time satisfies the wait. Test it with the fake-gh fixture: a thread comment arriving mid-wait wakes it, and a thread comment older than --since does not.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T23:22:01Z","created_by":"Edward Langley","updated_at":"2026-09-24T23:22:01Z","dependencies":[{"issue_id":"improvise-weoe","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T01:05:50Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-m56i","title":"ADR 007 leaves formula identity unspecified: no formula-line grammar, formula id vs target item id, where a _Measure target's display lives, filter-value strings","description":"Found by the adversarial review of PR #210 (ADR 007, head 4830fa30). This is broader than improvise-i0n7 (which covers only the CategoryRow shape of _Measure formula-target rows).\n\nADR 007 does not say how a formula and its target are identified in the file or the model. An implementer hits these at once:\n\n1. **No grammar for the formula line.** Clause 5's grammar block gives header_decl, item_decl and grouped_item, but no `formula_line` or formula `target` rule for answer 5's F1 (`- Profit(profit) = revenue - cost [cat]`). `formula.pest`'s `formula = { SOI ~ target ~ \"=\" ~ expr ... }` has no id tag, and today the whole line is `formula_raw`.\n2. **Whose id is in the parentheses?** Answer 5 (Q2) is *formula* id placement. Answer 19's `Profit (profit)` is described as \"the target's id\". For a `_Measure` target these coincide (clause 1: the target's handle is its FormulaId). For a target outside `_Measure`, `Model::add_formula` creates a real item. Under answer 22 (ids globally unique), that item's ItemId and the formula's FormulaId must differ. So the file must reference the target item by id somewhere, and the ADR does not say where.\n3. **Where a `_Measure` target's display lives.** Clause 2 gives `display` to Category, Item and View, but not to Formula. km71's section says \"a target change becomes a display rename of the target\", and a `_Measure` target has no item to hold that display.\n4. **Which ItemId a formula cell's CellKey carries** for a `_Measure` target, which has no item. Also `RecordCell.measure: Option\u003cString\u003e` and `CellId::Derived { target: String }` (render.rs) name the target.\n5. **WHERE / IS filter values.** formula.pest has `filter_value = { string | pipe_quoted | bare_ident }`. Is a double-quoted `\"East Coast\"` in id-form text an id or a display? Answer 3 says every reference is an id, but a quoted string has no stated meaning under ids.\n6. **Resolving typed names at the panel edge.** If a typed display name is ambiguous (answer 19), or equals some other thing's id, which wins, or is it refused?\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T22:49:30Z","created_by":"agent-review-210","updated_at":"2026-09-24T22:49:30Z","labels":["adversarial-review","pr-210"],"dependencies":[{"issue_id":"improvise-m56i","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T01:04:54Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-m56i","depends_on_id":"improvise-sboz","type":"discovered-from","created_at":"2026-09-24T22:49:30Z","created_by":"agent-review-210","metadata":"{}"}],"comments":[{"id":"0c2ab9a3-bfb7-5273-89bb-99fc544e5a06","issue_id":"improvise-m56i","author":"agent-adr007","text":"agent-adr007 planning. This blocks improvise-cq1q.4 and improvise-cq1q.6. Items 1, 3, 4 and 5 are readings that can go in the ADR text: a formula line is '- \u003cdisplay\u003e(\u003cformula id\u003e) = \u003cid-form expr\u003e [\u003ccategory id\u003e]'; Formula carries the target's display; a formula cell's CellKey carries the FormulaId text under _measure, which is unique by answer 22; and a filter value is an item id. Items 2 and 6 are USER QUESTIONS. Item 2: add_formula creates a real item for a target outside _Measure (types.rs add_formula), so under answer 22 that item's id and the formula's id must differ. Recommendation: treat every formula target like a _Measure one, with ItemHandle::FormulaTarget(FormulaId) and no real item, so one id in the parentheses serves in every category. Item 6: the precedence when a typed name is both a display and another thing's id. Recommendation: display first, then id; an ambiguous display is refused and the refusal lists the candidates' ids.","created_at":"2026-09-25T04:51:33Z"}],"dependency_count":0,"dependent_count":2,"comment_count":1} +{"_type":"issue","id":"improvise-le4p","title":"ADR 007: an id 'format' cannot be written as a view reference — format_line wins the ordered choice, and id_ref has no quoting arm","description":"Found by the adversarial review of PR #210 (ADR 007, head 4830fa30), against gh/main ad217cc1.\n\nADR 007 clause 5 says an id \"never needs pipe quoting in an .improv declaration or reference\", and its grammar gives references `id_ref = { id_token }` with no pipe-quoted arm. That is false for one id: `format`.\n\nIn improv.pest, `view_entry = _{ format_line | prune_line | hidden_line | collapsed_line | axis_line }` is an ordered choice, and `format_line = { \"format: \" ~ rest_of_line ~ ... }`. So the axis line for a category whose id is `format` (`format: row`, `format: page, east`) matches format_line first and is read as the view's number format. The axis assignment is lost with no error. (`prune: row` and `hidden: row` fail their own rule and backtrack to axis_line, so only `format` collides.)\n\nToday this is masked by accident: `quote::KEYWORDS` contains `FORMAT` (a formula keyword, from d00cea24), matched case-insensitively, so `format_md` writes `|format|: row`. Under ADR 007, slugify folds to lower case (answer 16(b)), so any category displayed `Format` gets the id `format`, and `id_ref` cannot quote it.\n\nThe implementation must pick one: reserve `format` from slugify (suffix it like any collision), give `id_ref` a pipe-quoted arm, or reorder or disambiguate the view-section rules. A round-trip test with a category displayed `Format` on a row axis should fail first.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T22:49:28Z","created_by":"agent-review-210","updated_at":"2026-09-24T22:49:28Z","labels":["adversarial-review","pr-210"],"dependencies":[{"issue_id":"improvise-le4p","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T01:04:54Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-le4p","depends_on_id":"improvise-sboz","type":"discovered-from","created_at":"2026-09-24T22:49:28Z","created_by":"agent-review-210","metadata":"{}"}],"comments":[{"id":"bc8717a4-50fc-52a0-86ed-84adf1f7793c","issue_id":"improvise-le4p","author":"agent-adr007","text":"agent-adr007 planning: a text fix, not a user question. In the new-version arm, try axis_line (which requires an axis keyword) before format_line. No number format is an axis keyword, so neither slugify nor id_ref needs to change. Folded into improvise-cq1q.1; the test is in improvise-cq1q.5.","created_at":"2026-09-25T04:51:35Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} +{"_type":"issue","id":"improvise-5jxt","title":"pr-reviews dies with 'jq: Argument list too long' on a PR with many comments (#210)","description":"scripts/pr-reviews passes the whole PR, inline-comment and issue-comment JSON to jq as --argjson arguments (fetch(), around line 109). On PR #210 (ADR 007, dozens of long comments) the argv exceeds ARG_MAX: ./scripts/pr-reviews 210 exits 126 with 'jq: Argument list too long', with or without --wait. So the approved review watcher cannot watch the busiest ADR PR at all, and a --wait over it fails in a way that is not exit 1 (timeout) or 0. Fix: feed the three documents via files or stdin (--slurpfile / --rawfile, or jq -s over a concatenated stream) instead of argv. Test: a fixture whose combined JSON exceeds getconf ARG_MAX.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-5jxt","owner":"el-github@elangley.org","created_at":"2026-09-24T22:07:30Z","created_by":"agent-adr007","updated_at":"2026-09-24T22:37:58Z","started_at":"2026-09-24T22:17:05Z","closed_at":"2026-09-24T22:37:58Z","close_reason":"Landed in main via #301.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-0s6.1","title":"Sanitize the repository for a public mirror (improvise/improvise)","description":"Prepare the repository for a public release, via a public mirror at improvise/improvise (user's plan, 2026-09-24).\n\nScope:\n1. Secret scan of the FULL history (gitleaks or trufflehog), every branch and tag that would be published. Rotate anything found; decide whether history must be rewritten.\n2. Content audit: list what becomes public and decide per item — .beads/ (issues.jsonl, interactions.jsonl), prolog-sessions/kb.pl, .claude/ and .agents/, context/ (agent notes), commit author emails, agent-posted PR/issue comments (these stay on the private repo; confirm nothing is copied).\n3. Choose mirror shape: true mirror of main vs. filtered/fresh history (e.g. drop .beads/, .claude/, prolog-sessions/, or squash to a single initial commit). Once public, the choice is effectively permanent.\n4. Workflows in the public repo: self-hosted runners must NOT be attached there. Disable Actions on the mirror initially, or add a GitHub-hosted-only CI. Disable or strip claude.yml (no CLAUDE_CODE_OAUTH_TOKEN there; if ever enabled, gate on author_association OWNER/MEMBER/COLLABORATOR).\n5. Mirror mechanism: push main only, private -\u003e public, via a narrowly scoped deploy key; never push agent WIP branches.\n6. Decide where Pages, releases (cargo dist), README badges and crates.io metadata (repository URL) point.\n7. Contribution flow: how outside PRs/issues on the mirror are brought into the private repo.\n\nAcceptance: a written audit (secret-scan result + public-content list with decisions), the mirror script/workflow in a PR, and the user's go-ahead before the first public push.\n","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-24T19:52:46Z","created_by":"orchestrator","updated_at":"2026-09-24T19:52:46Z","labels":["area:release","needs:user"],"dependencies":[{"issue_id":"improvise-0s6.1","depends_on_id":"improvise-0s6","type":"parent-child","created_at":"2026-09-24T19:52:46Z","created_by":"orchestrator","metadata":"{}"}],"comments":[{"id":"b8d881e5-7b7f-5471-bb86-fbe206b5b706","issue_id":"improvise-0s6.1","author":"Edward Langley","text":"Found 2026-09-25 while reading a code-maat age report: bank-info.improv was tracked and deleted in 1c2361b4 (2026-09-19) but is still in history, reachable from many branches; foo.improv (deleted 359c1577) and whatever.improv likewise. Check bank-info.improv's content in history before any public mirror -- if it holds real financial data it needs a history rewrite or a mirror cut from a clean point, which is the user's decision. Also on gh/main: a generated TAGS file (stale; still says MAX_CATEGORIES = 12) and DOLT_REMOTE.md on some branch.","created_at":"2026-09-25T03:18:02Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} +{"_type":"issue","id":"improvise-ddp6","title":"A drill commit validates the row's categories but not its items, so a concurrent RemoveItem lets it recreate an orphan cell","description":"Codex P2 on #298, originally on #222 (https://github.com/fiddlerwoaroof/improvise/pull/222#discussion_r4090492870). RecordRewrite::of (crates/improvise-command/src/ui/effect.rs) refuses the commit when a category the row names is gone (improvise-qz9p), but it never checks that the row's coordinate ITEMS, or an edited measure item, still exist. Model::remove_item deletes every cell carrying the item, so a RemoveItem that lands between StartDrill and ApplyAndClearDrill is undone by the commit: SetCell writes a key naming an item its category no longer has. That is an orphan cell that no layout draws and that save writes out. Reachable today when a connected client removes an item while the host TUI user has a drill open with staged edits. The same gap is on gh/main fb3358a0, so #298 did not introduce it. Fix: resolve every item a row names (coordinates and edited measures) before composing, or refuse as unwritable. Add one regression that removes a coordinate item and one that removes a measure item. Pick fixtures where the removed item is not at index 0.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T19:05:15Z","created_by":"agent-review-298","updated_at":"2026-09-24T19:05:15Z","labels":["adversarial-review","pr-222","pr-298"],"dependencies":[{"issue_id":"improvise-ddp6","depends_on_id":"improvise-n28d","type":"parent-child","created_at":"2026-09-25T01:05:22Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-3h9w","title":"A captured editor's Enter is refused by the cursor's cell: refused_here runs before the capture is consulted (#274 after merging #286)","description":"Codex review of PR #274 at 8c28360f (P2), confirmed by the adversarial reviewer. The merge of #286 into #274 introduced it; it is a regression against 8d9de94e.\n\n`step()` answers `declined(name).or_else(|| refused_here(state, name))` BEFORE `committed()`/`commit()`. For the two commit keys, `refused_here` judges the **cursor's** cell (`declined_at` or `unprojected`) and ignores `state.opened`. So an editor that captured a still-valid stored cell is refused whenever a relayed reframe leaves the cursor on an aggregate, a derived cell, or an unprojected cell.\n\n**Probe** (real Host, `host()` fixture, plus a formula `Profit = Revenue - Cost`):\n1. A presses `l i 4 2`, so the capture is `Target(North, Value{Cost})`.\n2. B sets Region's axis to None. The relays are delivered, and the reset puts A's cursor on (0,0), which is now `Aggregate`.\n3. A presses Enter.\n- At **8c28360f**: no frame. The status is \"Cannot write to an aggregate…\", the buffer is still 42, and North/Cost is None. Every retry does the same, so the only way out is Esc, which loses the text.\n- At **8d9de94e**: `Accepted`, and North/Cost = 42.\n\nThis is 8xq0's TOCTOU returned as a false refusal, the shape of Codex's first P2 on this PR (dispatch from the cursor). The author's `a_keyboard_edit_whose_rows_all_vanished_writes_the_cell_it_opened_on` does not see it, because an empty grid is exempt from `unprojected` (51943d1b) and has no aggregate.\n\n**Fix direction:** apply the `declined_at`/`unprojected` guard for commit keys only when `state.opened` is None (an uncaptured editor), or derive it from `opened.target()`. Add the regression: a valid capture, with the cursor's cell changed to an aggregate or derived cell.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-8xq0","owner":"el-github@elangley.org","created_at":"2026-09-24T02:51:47Z","created_by":"agent-review-221-251","updated_at":"2026-09-24T21:44:51Z","started_at":"2026-09-24T03:03:50Z","closed_at":"2026-09-24T21:44:51Z","close_reason":"Landed in main via #274 (reviewed; merged 2026-09-24).","labels":["adversarial-review","pr-274"],"dependencies":[{"issue_id":"improvise-3h9w","depends_on_id":"improvise-7mro","type":"related","created_at":"2026-09-24T02:51:55Z","created_by":"agent-review-221-251","metadata":"{}"},{"issue_id":"improvise-3h9w","depends_on_id":"improvise-8xq0","type":"related","created_at":"2026-09-24T02:51:54Z","created_by":"agent-review-221-251","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-2zw9","title":"Browser: keys typed before a cell is projected diverge from the TUI's value (held i 7 Enter writes 7 where the TUI writes 47)","description":"After improvise-7mro (#286), a key resolved on a cell outside the held window never names the wrong record: i opens an editor awaiting its seed, and a commit/x/yy/p waits out loud. But with the host's replies held, the user types into an empty editor and the retry after the answer writes what they typed (7), where the TUI, which showed the seed, writes 47. Exact agreement needs the client to DEFER keys resolved on an unprojected cell until the projection arrives, which means on_frame handing queued frames back to web/app.js (a page-surface change) - or rejecting that as UX and documenting it. Pinned today by keys_sent_before_a_scroll_is_answered_agree_with_the_tui's Replies::Held arm, which compares written cells, not values.","notes":"Raised to P2 per review of #286: typed-ahead text accumulates across out-loud waits (i 5 Tab 3 Tab 8 Tab -\u003e next Tab writes 38 where TUI writes 8). Same design question as improvise-n4i9 (user decision pending): defer keys until the cell arrives.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T02:35:44Z","created_by":"agent-7mro","updated_at":"2026-09-24T02:46:28Z","labels":["browser","pr-286"],"dependencies":[{"issue_id":"improvise-2zw9","depends_on_id":"improvise-6jk","type":"parent-child","created_at":"2026-09-25T01:04:39Z","created_by":"Edward Langley","metadata":"{}"}],"comments":[{"id":"dcfa7281-9233-574a-9d68-d1490c1c98f5","issue_id":"improvise-2zw9","author":"agent-review-277","text":"From the adversarial review of #286 at 6b604a9e. This is a second symptom with the same fix (deferring keys), and I think it is worth P2 rather than P3.\n\nThe case is type-ahead data entry across a row wider than the window, with replies held (any socket latency). The fixture is `_Measure` rows Revenue/Profit/Cost and Months Jan/Feb, window 2 rows × 1 col, cursor on Revenue/Jan. Keys `i 5 Tab 3 Tab 8 Tab`:\n- TUI: Jan/Revenue = 105, Feb/Revenue = 8.\n- Browser at 6b604a9e: Jan/Revenue = 105. The second Tab is refused (\"waiting for the host to show this cell\"), and the refusal keeps the typed text. So the next digit appends, and the editor ends open with buffer **\"38\"**. The next Tab after the answer writes 38 to Feb.\n\nA refusal that keeps the buffer turns fast typing into concatenated values from two intended cells. At 6942e53e the same keys lost the text silently, and neither head writes a wrong record. The out-loud refusal is better than silence, but under latency it happens on every Tab at the window's edge.\n","created_at":"2026-09-24T02:45:34Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} +{"_type":"issue","id":"improvise-dndu","title":"PR #245's merge of #228 dropped the check-harness-lock --allow-network CI step; CI now runs only the checker's self-test","description":"## Summary\nFound reviewing PR #245 at head f41a946b, the merge of #228 `1bec7c0f` into the zs2r branch.\n\nThe merge message says \"The --allow-network step is unchanged\". It is gone. `git diff 1bec7c0f f41a946b -- .github/workflows/ci.yml` is **empty**. The `test` job step that #245 added after \"Every workspace root is formatted\" is absent at f41a946b:\n\n```\n # A manifest-only change to a crate the harness reaches by path stales\n # its own lock, which only the slow `build` job saw (improvise-zs2r).\n - name: The harness lock matches the manifests it resolves against\n run: ./scripts/check-harness-lock.sh --allow-network\n```\n\nThat step was at lines 147-150 of 23b8dbf5. `grep harness-lock` on f41a946b's ci.yml finds nothing.\n\n## Effect\nCI now runs only `check-harness-lock-test.sh`, the checker's self-test, through the `kb` row of `ci_suites`. It never runs the lock check itself against the real `harness/opfs-contract/Cargo.lock`, which was the whole point of zs2r.\n\nNo gate notices:\n- `check-ci-graph.py` exits 0 (\"all 10 suites run\"). It counts suites, not this step, and the step does not mention `checkers.sh`.\n- `context/code/tooling.md` at f41a946b still says \"CI runs with `--allow-network`\" (around lines 952 and 977), so the doc now misstates CI.\n\n## Fix\nRestore the four lines above in the `test` job, after the fmt-workspaces step. The `kb` row addition stays. Then check `git diff 1bec7c0f \u003chead\u003e -- .github/workflows/ci.yml` shows exactly that step.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T02:15:10Z","created_by":"agent-review-tooling-delta","updated_at":"2026-09-24T21:46:00Z","closed_at":"2026-09-24T21:46:00Z","close_reason":"Landed in main via #245 (reviewed; merged 2026-09-24).","labels":["adversarial-review","pr-245"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-xpzh","title":"check-ci-graph splits lines on \\n only; YAML also breaks at U+0085/2028/2029, so a job if: after one is invisible","description":"## Summary\nFound in adversarial delta review of PR #228 at head 7ae871cb (the 0wn3 value allowlist, `9dc8a643..7ae871cb`).\n\n`ci_steps` splits the workflow on `\\n` only. Text-mode `open()` also folds `\\r` and `\\r\\n`, so those are safe (verified). But YAML 1.1 parsers also break lines at NEL (U+0085), LS (U+2028) and PS (U+2029). A line such as ` runs-on: ubuntu-latest\u003cU+2028\u003e if: false` is therefore two lines to YAML and one line to the gate. To the gate it is key `runs-on`, whose value is a plain scalar starting with `u`, which the allowlist accepts. The job's `if:` never reaches `if_jobs`.\n\n## Reproduce (real ci.yml)\nAdd a job `gate` with that line (`runs-on`, then U+0085/2028/2029, then `if: false`), plus `steps: [- run: echo]`. Give kb `needs: gate`, and add `gate` to pages-deploy's `needs`.\n\n- go-yaml (yq): `.jobs.gate | has(\"if\") == true`, `.if == false`. Same for U+2028.\n- PyYAML 6.0.3 and Ruby Psych 5.2.2 (libyaml): `gate.if == false` for all three characters.\n- `check-ci-graph.py` **exits 0**, \"all 9 checkers.sh suites run in CI\". kb is skipped on a PR.\n- Controls:\n - a bare CR (`\\r`) at the same spot: exit 1, caught, because text-mode `open` folds it;\n - CRLF, and a whole-file CRLF: exit 1;\n - plain LF: exit 1.\n\nThe same mechanism hides kb's own `needs:` behind its `name:` line. GitHub's workflow parser is built on YamlDotNet, which also treats U+0085/2028/2029 as breaks. I have not verified that against GitHub itself.\n\n## Fix\nRefuse any line containing U+0085, U+2028 or U+2029. \"Refuse, don't parse\": these characters have no business in a workflow. Splitting on them would also work, but it is a second definition of a line. Add one case per character, and a CR control.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-228-j49r","owner":"el-github@elangley.org","created_at":"2026-09-24T01:50:56Z","created_by":"agent-review-tooling-delta","updated_at":"2026-09-24T21:44:46Z","started_at":"2026-09-24T01:54:11Z","closed_at":"2026-09-24T21:44:46Z","close_reason":"Landed in main via #228 (reviewed; merged 2026-09-24).","labels":["adversarial-review","pr-228"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-0dbk","title":"#266 and #279 each add ClientState::notify: their merge does not compile, and #266's rebase() skips #279's settle_status","description":"Found reviewing #266 (57b713cf, improvise-xd3q/n0pb) against #279 (9eb83538, improvise-1x9h). I materialised the merge of the two heads.\n\n1. **The merge does not compile.** Git merges `client.rs` cleanly, but each side adds its own `ClientState::notify`, so the result has E0592 (duplicate definitions). The only textual conflict is in `context/test-evidence/clients.md`. A clean merge of the Rust files is exactly what makes this easy to miss.\n - #266's version queues the sentence through `fold_outgoing` (at the tail of `in_flight`).\n - #279's version writes `view.status_msg` directly and calls `settle_status`.\n\n2. **Keep #266's `notify`; #279 should adapt.** #279's direct write is the shape n0pb removed: a rebase erases it. #266's version already records an owner: `fold_outgoing` → `apply` → `settle_status`. The owner lives on `ClientState`, not per slice, so settling the queued entry later does not lose it.\n - With #279's `notify` deleted, the merged tree passes the whole workspace, including #279's `a_rejection_that_arrives_in_an_open_editor_stands_beside_it`.\n\n3. **After the merge, `rebase()` must also call `settle_status()`.** #266's `rebase()` calls `replay`, `settle_mode` and `settle_editor`, but not #279's `settle_status`. So a rebase that moves the view out of the editor a message was written in does not clear `status_owner`. Returning to an equal editor then makes the message stand again, which contradicts #279's rule that \"an equal editor opened later is another opening\".\n - Probe, in the merged tree:\n 1. `SetSelected(1,1)` → `ChangeMode(editing)` → `SetStatus`; the message stands.\n 2. Send an awaited frame.\n 3. `fold(ResetCursor)`, which rebases.\n 4. `fold_outgoing([SetSelected(1,1)])`.\n - Result: `status_stands()` is true, and should be false.\n - Adding `self.settle_status();` to `rebase()` makes the probe pass, and the whole workspace stays green.\n\nWhichever of the two PRs lands second owns both repairs. #279 is the one that has to adapt: it drops its `notify` and adds the `settle_status` call to `rebase`.\n","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T01:37:59Z","created_by":"agent-review-246b","updated_at":"2026-09-24T21:45:59Z","closed_at":"2026-09-24T21:45:59Z","close_reason":"Landed in main via #279 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-y5eb","title":"Browser settle_declined_editor reads the pre-scroll cache: a Down-wrap from a scrolled window closes an editor the TUI opens, naming a formula not under the cursor (#282)","description":"Found in adversarial review of PR #282 (improvise-5m9z), head 942f57e5. PR #282 introduces this; PR #277 alone (aab62a36) does not have it.\n\n`ClientState::settle_declined_editor` runs after every fold, including the fold of the client's OWN outgoing frame. The frame for an advance is `scrolled_to` (SetSelected, SetRowOffset/SetColOffset) and then the open (SetBuffer, SetOpenedEditor, ChangeMode). By the time ChangeMode(Editing) is folded, the view's offsets are the NEW ones, but the GridCache still holds the OLD window, because the host has not answered yet. `declined_at` -\u003e `cell_at` subtracts the new offsets and indexes the old cache, so it reads a different cell from the one under the cursor.\n\nA one-step scroll happens to read the cell just committed, which is editable, so it is harmless. A Down that WRAPS from the bottom row to (0, col+1) does not. It jumps the row offset back to 0, and cache row 0 is then the old window's top row.\n\nRepro (reviewer's scratch test on 942f57e5, not pushed). `_Measure` on rows [Revenue, Profit, Cost], where Profit is an explicit item AND `Profit = Revenue - Cost`. Month [Jan, Feb] on columns. Client window (2, 6), placed at (2,0) with row_offset 1, so the window shows Profit and Cost. Keys `i`, `Enter`:\n- TUI: lands on (0,1) Revenue/Feb in Editing.\n- Client, straight after fold_outgoing(Enter): (0,1), mode Normal, status \"Profit is computed by a formula — edit the formula, not the cell\". Profit is not the cell under the cursor.\n- Delivering the host's reply does not repair it, because the settle only ever closes an editor.\n\nThis also holds on a materialised merge of #282 with #272 (which includes #227). There the awaiting-seed editor is untouched too, so the settle closes it the same way.\n\nA fix direction, not a prescription: settle only against a cache framed for the view's current offsets (the row/col frames' `first` equal to the cursor offsets), or skip while this client's own scroll is unanswered.\n\nAC: a lockstep test with a Down-wrap from the bottom of a scrolled window onto an editable cell whose stale-window counterpart is declined. The client and the TUI must agree on the mode. A rival that removes the guard must fail it.\n","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T01:29:00Z","created_by":"agent-review-277","updated_at":"2026-09-24T21:46:02Z","closed_at":"2026-09-24T21:46:02Z","close_reason":"Landed in main via #282 (reviewed; merged 2026-09-24).","labels":["adversarial-review","browser","pr-282"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-0wn3","title":"check-ci-graph: a multi-line flow value with an open string is not refused, and a key-shaped closer makes its content read as a counted run:","description":"## Summary\nFound in adversarial delta review of PR #228 at head 9dc8a643 (the 6zf1 fix, `588094b7..9dc8a643`). That fix leaves \"flow values spanning lines\" unrefused, on the argument that they fail closed. They do not, when the flow value's closing line is spelled like a step key.\n\nA step key whose value opens a flow collection (`[a, \"b`, or `{a: \"b`) with a quoted string that stays open is a plain inline value to the gate. `_opens_quote` answers only for a value that *starts* with a quote. The owner rule then owns only lines deeper than the step's key column, 8. So the lines *inside* the flow value at indent 8 are read as step keys, including an exact `run: …checkers.sh --ci kb`.\n\n## Reproduce (real ci.yml)\nReplace kb's last step with:\n```\n - name: [a, \"b\n run: nix shell nixpkgs#trealla --command ./scripts/checkers.sh --ci kb\n c: x\"]\n```\n- go-yaml (yq), PyYAML 6.0.3 and Ruby Psych 5.2.2 all read the step as `{\"name\": [\"a\", \"b run: nix shell … --ci kb c: x\"]}`, a step with **no `run:`**.\n- `check-ci-graph.py` **exits 0**, \"all 9 checkers.sh suites run in CI\". kb's checkers step never runs.\n- The same with a single-quoted string: exit 0.\n- The same as a flow map, `{a: \"b` … `c: x\"}`: exit 0.\n- Controls:\n - a closer that is not key-shaped (`c\"]`): exit 1, refused as unreadable. This is the case the fail-closed argument covered.\n - a closer whose value is quoted (`c: \"x\"]`): exit 1. It is also invalid YAML.\n - the no-quote form `[a,` … `c: x]`: all three parsers reject it.\n\n## Fix\nIn keeping with \"refuse, don't parse\": refuse any inline value that begins a flow collection (`[` or `{`) and does not close it on the same line. A bracket count outside quotes is enough, or simply require the value to end in the matching bracket. The one-line `needs: [a, b]` form stays readable. Add one case each for `[`, for `{`, and for the single-quoted form.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-228-j49r","owner":"el-github@elangley.org","created_at":"2026-09-24T01:25:52Z","created_by":"agent-review-tooling-delta","updated_at":"2026-09-24T21:44:42Z","started_at":"2026-09-24T01:32:11Z","closed_at":"2026-09-24T21:44:42Z","close_reason":"Landed in main via #228 (reviewed; merged 2026-09-24).","labels":["adversarial-review","pr-228"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-4k1j","title":"Browser H under nested row categories hides a group name: hidden_row_item's fold test is the nearest group, not the row's fold","description":"Found reviewing PR #275 (improvise-fo0w) at cdefeeb9. It is the client-side return of improvise-yk2s, which was fixed on the host. resolve.rs::hidden_row_item refuses on a fold only when frame.group_of(idx) is collapsed. But group_of is the NEAREST group header. With two row categories, where the outer one (Region) has a collapsed group Coast and the inner one (Product) has its own group Kit, the fold rows (Coast, Gadget) and (Coast, Gizmo) have Kit as their nearest header. Kit is expanded, so the client does not refuse. It composes HideItem{Region, item: \"Coast\"}, a group name, and the twin host accepts it and writes 'hidden: Region/Coast'. The host (HideSelectedRowItem) asks layout.row_folds_a_group, which reads the DataItem's own collapsed flag, and refuses with FOLD_REFUSAL. Reproduced by sweeping H over every row of four grouped fixtures in lockstep: 2 of 30 rows diverge. The divergence appears with a 2-row window at an offset and with a whole-grid window. It survives a merge with #263 (the e265/uv9e stack). hiding_on_a_fold_refuses_as_the_host_does uses one row category, so its fixture lacks the structure. Fix direction: the frame must carry the fold fact itself, as the per-item collapsed flag the layout already has, rather than the client inferring it from the nearest header.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T01:15:13Z","created_by":"agent-review-264","updated_at":"2026-09-24T21:46:01Z","closed_at":"2026-09-24T21:46:01Z","close_reason":"Landed in main via #275 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-6zf1","title":"check-ci-graph: the quote tracker closes a multi-line string on an escaped \\\" or doubled '', so a later quote reopens it over a real if:","description":"## Summary\nFound in adversarial delta review of PR #228 at head 588094b7 (the j49r fix, `5738e8e0..588094b7`). The bug predates that delta: it also exits 0 at 5738e8e0, and it has been present since the quote tracker landed (st8q).\n\nWhile a multi-line quoted value is open, `ci_steps` closes it on **any** line that contains the quote character (`if quote in raw`). YAML does not close it on an escaped quote (`\\\"` inside double quotes) or on a doubled quote (`''` inside single quotes). So the tracker closes early, and the rest of the string is read as structure. A later content line such as `b: \"` then looks like a key that opens a new quote, and that phantom string swallows the job's real keys until the next quote character.\n\n## Reproduce (real ci.yml)\nInsert before `kb:`, give kb `needs: gate`, and add `gate` to pages-deploy's `needs`:\n```\n gate:\n runs-on: x\n name: \"a\n it\\\" is\n b: \"\n if: false\n steps:\n - run: echo \"z\"\n```\n- yq-go: `gate.name == 'a it\" is b: '`, `gate.if == false`, `kb.needs == \"gate\"`.\n- `check-ci-graph.py` **exits 0**, \"all 9 checkers.sh suites run in CI\". kb is skipped on a PR.\n- The single-quoted form also exits 0: `'a` / `it''s` / `b: '`.\n- The same with the reopening line at indent 4: exits 0.\n- Control, a plain two-line quoted name: exit 1, caught as conditional.\n\n## Fix\nClose the tracker only on an unescaped closing quote: ignore `\\\\.` for a double-quoted value, and `''` for a single-quoted one. This mirrors `_opens_quote`. With exactly that change, both plants exit 1 and all 47 self-test cases still pass, so no existing case covers the hole. Add one case per quote style. Alternatively, refuse any multi-line quoted value, which is in keeping with \"refuse, don't parse\".","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-228-j49r","owner":"el-github@elangley.org","created_at":"2026-09-24T01:10:40Z","created_by":"agent-review-tooling-delta","updated_at":"2026-09-24T21:44:45Z","started_at":"2026-09-24T01:13:33Z","closed_at":"2026-09-24T21:44:45Z","close_reason":"Landed in main via #228 (reviewed; merged 2026-09-24).","labels":["adversarial-review","pr-228"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-8phw","title":"A keyboard value-edit retry after a crossing LayoutMoved writes the cell now under the cursor (8xq0's crossing twin, 9ww8 for values)","description":"Found reviewing PR #274 at head 7bf8debd (improvise-8xq0's fix). This is the value-cell twin of improvise-9ww8.\n\n#274 captures the opened cell once the host has answered. The 8xq0 test delivers B's HideItem to A BEFORE A presses Enter, so A's capture from when the editor opened is used, and it is correct. But when A's Enter crosses B's HideItem in flight (ordinary latency):\n- A's frame is refused `LayoutMoved`, because hiding a row moves the layout tier.\n- `refused()` replays the settled slice. The editor is back at (0,1) with buffer \"42\", and `settle_opened` recaptures what is now under the cursor, which is **South**/Cost at its current version.\n- The retry Enter is accepted, so 42, typed for North/Cost, lands on South/Cost.\n\nProbe (real Host, `host()` fixture, the keys `l i 4 2`, B's relays held until A's Enter is answered):\n- first answer `Rejected(LayoutMoved)`;\n- after the refusal, `opened.record` is South's hash (it was North's before);\n- retry `Accepted`;\n- cells after: North/Cost None, **South/Cost 42**.\n\nThe force control holds: if B writes North/Revenue instead, the refusal is `RecordMoved(north)` and the retry writes North/Cost (it is a force on the same cell).\n\nFix direction: #251's `Opened::Retry(r)` rule (`ff8ea239`) already solves this for renames. Its `renamed()` only matches `RenameRecordCoordinate`, and its comparison is on the record only. When #274 and #251 are merged:\n- `renamed()` should also match `SetCell`/`ClearCell`, as the record plus the measure;\n- `Retry` should compare the whole `OpenedTarget` (record + measure, or record + category) before it accepts the recapture.\n\nA test must hold B's relays until after A's Enter.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-8xq0","owner":"el-github@elangley.org","created_at":"2026-09-24T01:04:42Z","created_by":"agent-review-221-251","updated_at":"2026-09-24T21:44:51Z","started_at":"2026-09-24T01:14:45Z","closed_at":"2026-09-24T21:44:51Z","close_reason":"Landed in main via #274 (reviewed; merged 2026-09-24).","labels":["adversarial-review","pr-274"],"dependencies":[{"issue_id":"improvise-8phw","depends_on_id":"improvise-8xq0","type":"related","created_at":"2026-09-24T01:04:52Z","created_by":"agent-review-221-251","metadata":"{}"},{"issue_id":"improvise-8phw","depends_on_id":"improvise-9ww8","type":"related","created_at":"2026-09-24T01:04:53Z","created_by":"agent-review-221-251","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-eahe","title":"shipped_documents_load test binary exceeds the 2s tripwire: one child process per env setting","description":"Measured on #276 (M4 Max, load ~120): tests/shipped_documents_load.rs takes 2.6-3.7s, over design-principles §7's 2s per-binary tripwire. Cause: child-process reruns added by 53zz/vwjj/pr3i/a0i3/2iws — a0i3 alone spawns ten (five settings x two tests). Fix: set all the hostile settings in ONE child per test (the rivals already show each setting breaks the walk on its own, so a combined child still discriminates per-setting via rivals), or otherwise reduce child count. Measure before/after naming the machine; report the ratio to the run's median binary.","status":"closed","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-24T01:02:49Z","created_by":"orchestrator","updated_at":"2026-09-24T21:45:09Z","started_at":"2026-09-24T01:03:22Z","closed_at":"2026-09-24T21:45:09Z","close_reason":"Landed in main via #283 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-n0pb","title":"A relay heard while a frame is in flight erases the rejection sentence (PR #266 rebases view from settled, and the sentence is only in view)","description":"Found in adversarial review of PR #266 (improvise-xd3q) at e4b5ce90. The PR introduces it: at its base, 790a33ba, the same probe passes.\n\n`Client::fold_frame` (crates/improvise-wasm-client/src/socket.rs) handles `ServerFrame::Rejected` by writing the rejection sentence directly into `state.view.status_msg`, after `refused` rolls back. That sentence lives only in `view`: it is never in `settled` and never in `in_flight`. #266 makes `ClientState::fold` rebuild `view` from `settled` plus `in_flight` whenever a relay queues behind an in-flight frame. So the next such relay erases the sentence, and the user loses the one notice that their edit was not applied. `last_rejection` survives, but the sentence is gone.\n\nReproduced through the page's own entry point, `Client::fold_frame`:\n1. Two awaited frames go out through `fold_outgoing`, each a `Command::Model`.\n2. The first is refused with `Rejected(LayoutMoved)`, and the sentence is set.\n3. A relay, `Commands([ResetCursor])`, arrives while the second is still in flight.\n\nAt e4b5ce90 the result is `status_msg == \"\"`. At 790a33ba it is the sentence. This is reachable whenever the refused frame is followed by an accepted frame that relays: a pruning commit's reset and echo, or another client's reset. (A second refusal would write a fresh sentence, which is why it has to be an accepted frame.)\n\nFix options:\n- Write the sentence into `settled` as well as `view`.\n- Or keep it outside `ViewState` and have `rebase` preserve it.\n\nThe same hazard applies to any other write into `view` that a later rebase does not reproduce. `settle_editor`'s seed is re-derived by `rebase`, so it is safe. This sentence is the one case I found.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-227-fixes","owner":"el-github@elangley.org","created_at":"2026-09-24T00:55:16Z","created_by":"agent-review-246b","updated_at":"2026-09-24T21:45:16Z","started_at":"2026-09-24T01:24:26Z","closed_at":"2026-09-24T21:45:16Z","close_reason":"Landed in main via #266 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-j49r","title":"check-ci-graph: a job header with a trailing comment is accepted but never opened, so its if: and the job itself vanish from both rules","description":"## Summary\nFound in adversarial delta review of PR #228 at head 5738e8e0 (the rjaa fix, `3eafd4de..5738e8e0`).\n\nA job header with a trailing YAML comment, such as ` gate: # skipped on purpose`, is valid YAML. GitHub reads it as job `gate`. The gate disagrees with itself about the line:\n- `_readable` is handed the comment-stripped `line`, so it accepts the header;\n- `_JOB.match(raw)` in both `ci_steps` and `read_graph` runs on the raw line with the comment, so it does not match.\n\nThe job is therefore never opened. Its keys, `if: false` included, are attributed to the previous job, and `read_graph` never lists it at all.\n\n## Reproduce (real ci.yml)\nInsert, before ` wasm:` so that the preceding job is `test`, which is not counted:\n```\n gate: # skipped on purpose\n if: false\n runs-on: ubuntu-latest\n steps:\n - run: echo\n```\nGive kb `needs: gate`. Leave pages-deploy unchanged.\n\n- yq-go: `.jobs.gate.if == false`, `.jobs.kb.needs == \"gate\"`, and 8 jobs besides pages-deploy.\n- `check-ci-graph.py` **exits 0** with \"pages-deploy needs all 7 other jobs; all 9 checkers.sh suites run in CI\".\n\nSo both rules fail open at once. On a PR, kb is skipped behind gate. And pages-deploy does not need gate (x7cq's own rule), which nothing reports.\n\n## Controls\n- A trailing tab or trailing spaces on the header: refused (exit 1), because `_readable` sees them too.\n- A comment on the `if:` line: caught.\n- A `mid: # c` header in a two-hop chain: exit 1, but only by accident. Every attempt I made at a clean version was caught by some other rule.\n\n## Fix\nMatch the job header on the same comment-stripped `line` everywhere (`ci_steps` and `read_graph`), or refuse a comment on a job header. Add a self-test case and a `read_graph` graph case.","status":"closed","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-24T00:47:23Z","created_by":"agent-review-tooling-delta","updated_at":"2026-09-24T21:44:44Z","started_at":"2026-09-24T00:53:00Z","closed_at":"2026-09-24T21:44:44Z","close_reason":"Landed in main via #228 (reviewed; merged 2026-09-24).","labels":["adversarial-review","pr-228"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-xz2z","title":"The keyboard cell commit claims its basis at commit, not open: a value another client wrote while the editor was open is silently overwritten (0j0f's other door)","description":"Found while scoping improvise-3al1. improvise-0j0f was fixed only on the MOUSE path: `typed_cell` / `EditTarget` claims the record at the version it READ when the editor opened (`BasisCache::claim_as_read`). The KEYBOARD path does not. `resolve.rs::written`, reached from `commit` (Enter/Tab in Editing), calls `state.basis.claim([record_at_cursor(state)?])`, which states the record's CURRENT counter at commit time. The fold keeps running while the editor is open, so another client's write to the same record bumps that counter, and the commit claims the fresh one and is accepted.\n\nReproduced with a scratch test (not committed): a real Host, two sessions, North/Revenue = 1200, window 2x1.\n- Client A presses `i Backspace 4 2` (buffer \"12042\").\n- Client B writes North = 999 through the host. A folds the relays; A's editor stays open.\n- A presses Enter. The host ACCEPTS it: North ends at 12042. B's 999 is gone, A gets no Rejection, and B is told nothing.\nThe result is identical on gh/main 202e7eac and on the #227+#272 stack (9872cc93).\n\nThis is 0j0f's lost update, reached through the other door. It also blocks improvise-3al1: class-1 rejection, the thing force exists to answer, is essentially unreachable from the keyboard, because a concurrent write is overwritten instead of refused. The relay race (a write that reached the host before its relay reached the client) is the only keyboard case that can be refused.\n\nLikely fix: `OpenedEditor` captures what `EditTarget` captures (the record, the version read, and the generation, per improvise-vndi) when `opened_on` opens it, and `written` claims with `claim_as_read`. An awaiting editor (improvise-3uzg) would capture them when its seed settles.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-8xq0","owner":"el-github@elangley.org","created_at":"2026-09-24T00:43:29Z","created_by":"agent-227-fixes","updated_at":"2026-09-24T21:44:53Z","started_at":"2026-09-24T00:45:02Z","closed_at":"2026-09-24T21:44:53Z","close_reason":"Landed in main via #274 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"improvise-yc9r","title":"TUI opens the editor on a NonItem cell and refuses only at commit, stranding the user in Editing; the TUI/browser NonItem declines are unpinned (#265)","description":"Found in adversarial review of PR #265 (improvise-gk51), head adb6b814. The PR introduces this.\n\n#265 adds CellId::NonItem and declines it in the TUI's value_cell_key/committable_cell_key (p, x, commit). But EditOrDrill (i / Enter) never asks, so the TUI opens an editor on a NonItem cell and refuses only at commit. The browser declines at open (resolve.rs open-editor arm), so the two front ends now disagree.\n\nProbe (reviewer's, not pushed): Region on page with its selection set to \"Mars\" (no item), Month on row, _Measure on column. At adb6b814:\n- `i` enters Editing.\n- `5`, then Enter: the mode stays Editing, the status is \"commit-cell-edit: Mars is no item of Region, …\", and no cell is stored.\n\nBecause the mode stays Editing, draw_bottom_bar draws the minibuffer instead of the status line (gotcha 21), so the user sees nothing happen. NonItem is the only CellId kind that opens and then refuses: EditOrDrill refuses Aggregate/fold and Derived at open.\n\nThe TUI declines are also unpinned. Each rival below was injected and run over the full workspace (`cargo test --workspace --no-fail-fast`), and each stays green:\n- RE: value_cell_key's NonItem arm returns the cell key, so p/x write the non-item key.\n- RG: committable_cell_key treats NonItem like Record. A commit on a stale page then writes a NEW orphan record {Region=Mars,…}, which is the gw18 shape #252 and #265 exist to stop.\n- RF: the browser's open-editor NonItem arm returns None instead of a SetStatus decline.\nThe PR body's claim that \"p, x and a commit refuse with that sentence in the terminal\" is untested.\n\nAC:\n- EditOrDrill refuses a NonItem cell at open, with non_item_refusal, as it does for Derived.\n- Tests fail under RE, RG and RF, with at least one TUI test driving a stale page or an orphan records row through `i` and Enter.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-227-fixes","owner":"el-github@elangley.org","created_at":"2026-09-24T00:39:59Z","created_by":"agent-review-252","updated_at":"2026-09-24T21:44:58Z","started_at":"2026-09-24T00:47:03Z","closed_at":"2026-09-24T21:44:58Z","close_reason":"Landed in main via #292/#265 (reviewed; merged 2026-09-24).","labels":["adversarial-review","pr-265"],"comments":[{"id":"ef641b5d-1b9c-50e1-ac19-e780bfb12869","issue_id":"improvise-yc9r","author":"agent-review-277","text":"From the adversarial review of PR #277 (head aab62a36), which conflicts with #265 in `committable_cell_key`. #277 routes that function through a new `cmd::core::uneditable`, which has no NonItem arm. I materialised the #277 x #265 merge. The exhaustive matches force a decision in both `uneditable` and `resolve::reopened_at`, and nothing forces the right one: a resolution that puts `CellId::NonItem` in `uneditable`'s editable (`None`) arm compiles, and the whole workspace passes (`cargo test --workspace --no-fail-fast`). That is this ticket's rival RG, reached through a merge. Under it a TUI commit on a stale page stores `Month=Jan, Region=Mars, _Measure=Revenue = 7`. The correct resolution is `Some(CellId::NonItem { category, item }) =\u003e Some(non_item_refusal(\u0026category, \u0026item))`, which refuses with \"Mars is no item of Region, …\". With that resolution, #277's reopen also declines NonItem, while `i` (EditOrDrill) still opens on it. That is this ticket's first AC.\n","created_at":"2026-09-24T01:29:36Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} +{"_type":"issue","id":"improvise-4daa","title":"first_non_item clones every item name per coordinate per cell: grid_of ~10x slower in pivot, ~140x in records mode (#265 regression)","description":"Found in adversarial review of PR #265 (improvise-gk51), head adb6b814, which is stacked on #252 (47b166ee). The PR introduces this.\n\n#265 makes project::cell_id call Model::first_non_item for every non-aggregate, non-derived cell, in both the pivot and records branches. first_non_item calls Model::effective_item_names(cat) once per coordinate. That clones every item name of the category into a fresh Vec\u003cString\u003e, and for _Measure it also walks the formulas, just to run one `contains`. The TUI calls grid_of over the WHOLE grid on every frame (draw.rs, gotcha 44), so the cost lands on the per-frame path.\n\nMeasured with `cargo test --release`, the same bench at both commits, on the reviewer's machine (Darwin, the orchestrator's host), mean of 5 and 3 runs:\n- pivot 150x120 (18k cells; A 150 items rows, B 6 x _Measure 20 cols, C 50 on page): 111 ms per grid_of at 47b166ee, 1086 ms at adb6b814 (~10x).\n- records mode, 2000 records x 2 measures, a Label category with 2000 items: 1.6 ms at 47b166ee, 225 ms at adb6b814 (~140x). The cost is quadratic in a per-row label category, which is exactly what import makes Label categories for.\n\nFix shape, verified: answer the same question without allocating. `self.category(cat).is_some_and(|c| c.items.contains_key(name))`, OR (for a measure category) any formula whose target_category is a measure and whose target == name. With that body the bench is 234 ms pivot / 3.1 ms records, and the improvise-command, improvise-core and improvise test binaries all pass. The residual ~2x over base on the pivot case is still worth a look. A shared helper `Model::is_item(cat, name)` would also serve App::non_item_refusal (the #252 write path, same clone per write).\n\nAC: first_non_item allocates nothing per call. A timing or allocation guard (or a documented bench) shows grid_of within noise of 47b166ee on both fixtures.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-227-fixes","owner":"el-github@elangley.org","created_at":"2026-09-24T00:39:57Z","created_by":"agent-review-252","updated_at":"2026-09-24T21:44:58Z","started_at":"2026-09-24T00:47:02Z","closed_at":"2026-09-24T21:44:58Z","close_reason":"Landed in main via #292/#265 (reviewed; merged 2026-09-24).","labels":["adversarial-review","pr-265"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-wpxr","title":"A wire write to an empty record resolves through the HOST's layout: refused RecordGone when the host sits on a view hiding it","description":"Found while reviewing PR #267 (improvise-3til) at head 2c16f7f2.\n\nApp::resolve_record tries the DataStore index first. If the record has no stored cells, it falls back to record_in_layout, which reads self.layout, and that is the HOST's active view. Since improvise-jytm, the host's view is not the client's. So a client's write to an EMPTY cell succeeds or fails depending on which view the host is on.\n\nWITNESS (a scratch test in ui/host.rs, run and then discarded). It uses host() (Region x _Measure, no stored cells) and adds a view \"Other\" that hides Region/South. The client connects and its Snapshot names view \"Default\", where South's empty cell is drawn.\n- Control: with the host on Default, the client's `host.model(SetCell(addressed(\"South\"), 3))` is Accepted.\n- Same setup with the host switched to Other (switch_to_view plus rebuild_layout): the identical write is Rejected(RecordGone). The client is still on Default and can see the cell.\n\nThis is the record-shaped twin of improvise-obtu, which covers aggregates only. gotcha 44 says a RecordHash is \"document-scoped\" and \"works from any view\". That holds only for records with stored cells. An empty record is layout-scoped, through the host's layout.\n\nLikely fix shape: resolve against a layout derived for the ASKING session's view, as obtu proposes for aggregates. Or resolve an empty record from the document's categories, independent of any layout. The second must still refuse a group, formula or aggregate cell (unwritable, ciw8/xur5).\n\nNOTE for tests: improvise-3til's test (PR #267) deliberately has no follow-up write. A fixture in which the host's two views differ in which records they show WOULD make a write discriminate the reordered rival. But the answer it would pin is this bug. Do not add such a write until this is fixed.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-wpxr","owner":"el-github@elangley.org","created_at":"2026-09-24T00:37:55Z","created_by":"agent-review-267","updated_at":"2026-09-24T21:45:25Z","started_at":"2026-09-24T00:39:12Z","closed_at":"2026-09-24T21:45:25Z","close_reason":"Landed in main via #281 (reviewed; merged 2026-09-24).","labels":["adr-001","adversarial-review","pr-267","protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-pb30","title":"The column group toggle's cursor-follow is unguarded: three rivals in landing/place_cursor pass the whole workspace","description":"Found reviewing PR #263 (improvise-uv9e) at 2ea24074. The new test a_column_toggle_names_the_group_under_the_cursors_column pins only which group toggle-col-group-under-cursor names. The rest of the is_row:false path in ToggleGroupAtCursor (cmd/grid.rs) is untested. Each of these rivals was injected and run over cargo test --workspace --no-fail-fast, and each gave 1948 passed, 0 failed: (a) place_cursor's column arm swapped to (at, ctx.selected.1); (b) landing's column arm replaced with None, so there is no cursor-follow; (c) landing reads col_tuple(0) in place of col_tuple(ctx.selected.1). Rival (c) also needs a fixture with two column levels, because with one column category the prefix is always empty. The row half's equivalent is guarded by toggling_a_group_collapses_that_group_and_the_cursor_follows_it and its siblings. Fix: a two-level column fixture with the cursor in the second outer item on a nonzero inner run. Assert the cursor's column after the toggle is applied, and that the row is unchanged, off the origin.","status":"closed","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-24T00:34:16Z","created_by":"agent-review-263","updated_at":"2026-09-24T21:45:02Z","started_at":"2026-09-24T00:35:56Z","closed_at":"2026-09-24T21:45:02Z","close_reason":"Landed in main via #273 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-p6v6","title":"An editor opened with i/a while an earlier write is unanswered seeds from the stale cache: i 9 Tab Esc i 5 Enter stores 15 where the TUI stores 195","description":"Found by the adversarial delta re-review of PR #227 at de4a7d78. This bug is PRE-EXISTING. I reproduced it against gh/main 202e7eac's protocol and command sources, with the same numbers. It is the same class as improvise-mdfc, but reached through a door that #227 does not touch.\n\nmdfc made the ADVANCE await whenever any awaited frame is in flight. The other door into the cell editor is `i`/`a`, resolved by edit_or_drill, which calls opened_on(cell.edit_text). That door still seeds from the client's cache with no in-flight check. The same holds for any other opener that reads seed_at/edit_seed while a write is unanswered, for example a click-edit, which is the improvise-ugj4 path; I did not probe that one.\n\nProbe: a real Host and ClientState. The fixture is one measure column. The cursor is on North = 1, which is the rightmost column. Keys: i 9, then Tab (writes 19 and reopens on the same cell), Esc and i, all sent before any answer arrives. Then the answers are delivered, then 5 Enter.\n- Browser: buffer \"1\" after i, and it stores North = 15.\n- TUI: buffer \"19\", and it stores 195.\nSo the browser overwrites the value the user has just committed, with a number built from the stale cache.\n\nThe reachable shape is a quick Esc and re-edit, or holding a key, over any latency. The fix is the same rule, stated once, for every editor opener rather than only for advanced(). An opener awaits the host's answer whenever `state.in_flight.iter().any(|s| s.awaited)`. The mdfc condition could move into opened_on itself, so that no opener can forget it.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-227-fixes","owner":"el-github@elangley.org","created_at":"2026-09-24T00:20:54Z","created_by":"agent-review-227c","updated_at":"2026-09-24T21:44:56Z","started_at":"2026-09-24T00:27:11Z","closed_at":"2026-09-24T21:44:56Z","close_reason":"Landed in main via #272 (reviewed; merged 2026-09-24).","labels":["adversarial-review","browser","pr-227"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-rjaa","title":"check-ci-graph: a needed job whose keys are indented past 4 is read as content, so its if:/needs: are missed and nothing is refused","description":"Found in adversarial delta review of PR #228 at head 3eafd4de (the w0j4 fix, `2aafff3b..3eafd4de`).\n\nThe w0j4 refusal checks a job's keys only at indent 4. `_readable` returns True for any line indented deeper than 4 when it is not inside `steps:` or `needs:`. A job whose keys are indented 5, 6 or more is therefore read as all content. That is legal YAML, and GitHub reads it the same way. Its `if:` never reaches `if_jobs`, its `needs:` never reaches `needs`, and nothing is refused.\n\nPlanted on the real ci.yml. In each case `gate` is also added to pages-deploy's `needs`, and yq-go confirms both the `needs` edge and gate's `if: false`:\n- **A needed job with 6-space keys.** Add `gate:` with keys at 6 (`if: false`, `runs-on`, `steps:` with an item at 8), then give kb `needs: gate`. The gate exits 0, \"all 9 checkers.sh suites run in CI\", yet kb is skipped on a PR.\n- **The same with 5-space keys** and `needs: [gate]`: exit 0.\n- **A two-hop version.** A 6-space job `mid` holds `needs: gate`, and kb has `needs: mid`. That exits 0 too, so the transitive walk is cut as well.\n\nThe counted job itself cannot use this, because its steps are then not recognised and the gate fails loudly. The hole is every job it needs, directly or transitively.\n\nFix direction, in keeping with \"refuse, don't parse\": the first key line after a job header must be at indent 4, or it is refused. Any line at 5–8 that is not inside `steps:`/`needs:` must sit under a 4-space key with an empty value. Add a case for a needed job with 6-space keys.\n\nLoud false refusals seen in the same review (not bugs, noted for the doc): the gate also refuses three plain, valid styles, each by naming the line:\n- an indentless block list (`needs:` then ` - build` at 4);\n- a block list at 8;\n- a top-level `\"on\":`, the yamllint-truthy style.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-mergetrain","owner":"el-github@elangley.org","created_at":"2026-09-24T00:09:51Z","created_by":"agent-review-tooling-delta","updated_at":"2026-09-24T21:44:45Z","started_at":"2026-09-24T00:31:21Z","closed_at":"2026-09-24T21:44:45Z","close_reason":"Landed in main via #228 (reviewed; merged 2026-09-24).","labels":["adversarial-review","pr-228"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-9ww8","title":"A coordinate-rename retry after a crossing LayoutMoved renames whichever record is under the cursor (20ek's fix pins only RecordGone)","description":"Found reviewing PR #251 at head 29d2adef (improvise-20ek's fix). The 20ek fix pins the editor only on `Rejected(RecordGone(r))` where r is the refused rename's own record. But `stale_basis` checks the layout tier BEFORE the record tier, and a coordinate rename by another client bumps `layout_version` (measured 2 -\u003e 3). So when A's rename frame crosses B's rename in flight (A composed Enter before hearing B), the refusal is `LayoutMoved`, not `RecordGone`. `refused()` does not pin; the replay reopens the editor, `settle_opened` recaptures whatever record is now under the cursor at its current version, and the retry Enter renames THAT record with text typed for another.\n\nTwo probes, real Host, three_records_host (North 1, South 2, West 3), B's relays to A held until after A's Enter is answered:\n\n1. Source gone: A edits North, types Zed; B renames North -\u003e Yak. First answer `Rejected(LayoutMoved)`; editor reopens over South; retry: South -\u003e Zed. After: North None, South None, Zed 2.0, Yak 1.0.\n2. Source survives: A edits South (row 1), types Zed; B renames North -\u003e Yak, so South shifts to row 0 and West is under the cursor. First answer `Rejected(LayoutMoved)`; editor reopens over West; retry: West -\u003e Zed. South still exists, untouched, and West's 3.0 is now Zed. So the discriminator is not \"is the source gone\" at all.\n\nThe author's test `a_retry_after_record_gone_renames_no_other_record` delivers B's rename to A BEFORE Enter, so A's layout claim is current and only the record tier can fire; the crossing case, which only needs normal network latency, is untested.\n\nSuggested direction (not verified): after any refusal, a recapture must name the same record the refused rename named; if the cursor now sits over a different record (or none), decline as `Gone` does. `RecordMoved` on the same record stays a one-keystroke force. A test must hold B's relays until after A's Enter, and include the source-survives case.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T18:32:25Z","created_by":"agent-review-221-251","updated_at":"2026-09-24T21:44:49Z","started_at":"2026-09-24T00:02:49Z","closed_at":"2026-09-24T21:44:49Z","close_reason":"Landed in main via #251 (reviewed; merged 2026-09-24).","labels":["adversarial-review","pr-251"],"dependencies":[{"issue_id":"improvise-9ww8","depends_on_id":"improvise-20ek","type":"related","created_at":"2026-09-23T18:32:31Z","created_by":"agent-review-221-251","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-w0j4","title":"check-ci-graph fails open on quoted, anchored or multi-line needs: and on quoted/spaced if:/continue-on-error keys","description":"Found in adversarial delta review of PR #228 at head 2aafff3b (fix 1501def5, improvise-bbwa/1be2).\n\nThe bbwa fix reads `needs:` names as raw text, and job/step keys only in bare `key:` form. Several legal YAML spellings of the same workflow therefore fail open: the kb job is skipped on a PR, and the gate still exits 0 with \"all 9 checkers.sh suites run in CI\".\n\nReproduce on the real ci.yml:\n1. Add a job `gate` with a job-level `if: false`.\n2. Give `kb` one of the `needs:` spellings below.\n3. Add `gate` to pages-deploy's `needs`.\n\nyq-go confirms that kb needs `gate` in every case.\n\n- `needs: [\"gate\"]`: exit 0. The check compares against `\"gate\"`, quotes included.\n- `needs: 'gate'`: exit 0.\n- A block list with `- \"gate\"`: exit 0.\n- `needs: \u0026d [gate]` (an anchor on the value): exit 0.\n- `needs: \u0026d` followed by a block list: exit 0. The owner rule swallows the list.\n- A flow list over two lines, `needs: [build,` then `gate]`: exit 0.\n- Controls: `needs: [gate]`, `[ gate ]` and `[gate] # c` all exit 1.\n\nThe same key-recognition gap applies to the disqualifiers themselves. It is pre-existing from tvte, but it is the same class. Each of these exits 0:\n- a quoted job key, `\"if\": false`, on kb or on its step;\n- `'continue-on-error': true`;\n- `if : false`, with a space before the colon;\n- an explicit key, `? if` / `: false`.\n\nAlso found: the 1be2 self-test fixture puts the continuation two columns past the key, not at the boundary. So a rival that marks multiline only when `indent \u003e owner + 1` passes all 23 cases. On the real ci.yml, a 9-space `|| true` continuation (valid YAML, which yq reads as part of run:) then exits 0.\n\nFix direction:\n- Unquote scalar needs names.\n- Refuse, or report, any `needs:` whose value is not a plain name, a flow list on one line, or a block list of plain names.\n- Treat a quoted or spaced key as its bare form, or report it.\n- Add a boundary (owner+1) continuation case.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-mergetrain","owner":"el-github@elangley.org","created_at":"2026-09-23T18:29:08Z","created_by":"agent-review-tooling-delta","updated_at":"2026-09-24T21:44:46Z","started_at":"2026-09-23T23:58:21Z","closed_at":"2026-09-24T21:44:46Z","close_reason":"Landed in main via #228 (reviewed; merged 2026-09-24).","labels":["adversarial-review","pr-228"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-xd3q","title":"A relayed ResetCursor clobbers the placement of a frame the asker sent before the reframe was answered: client cursor splits from its mirror, and the next write lands on another record","description":"Found by the adversarial delta re-review of PR #227 at head baabe1a9. It is NOT #227's bug. The path touches none of #227's changed lines: host.rs is unchanged, and client.rs changes only the editor settle. The mechanism is #230's echo (improvise-wdmp). I did not re-run it at gh/main.\n\nA frame A whose write reframes the grid (a prune under prune_empty) is answered with ResetCursor, then an echo of A's own placement (wdmp's `asker_reset \u0026\u0026 places_cursor`). A frame B that the client sent BEFORE A's answer arrived was already folded optimistically. B's SetSelected is clobbered by A's relayed reset, and it is never echoed, because the host's asker_reset is per frame and B's handling saw no reset. The host's mirror keeps B's placement.\n\nProbe: a real Host and ClientState, fixture North/South/West/East = 1/2/3/4 on one measure, prune on. Keys i Backspace Enter (unanswered), then Enter (unanswered), then both answers are delivered. The client cursor ends at (1,0) while the mirror is at (2,0). The TUI is at (2,0). The next \"5 Enter\" writes West=35, where the TUI writes East=45: a write to a different record from the one the TUI touches.\n\nRelated to improvise-jpba (a refusal replay loses relays) and improvise-3kfc, but distinct: no refusal is involved. #246's relay queueing may or may not cover it. Check it there.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-227-fixes","owner":"el-github@elangley.org","created_at":"2026-09-23T18:26:47Z","created_by":"agent-review-227c","updated_at":"2026-09-24T21:45:16Z","started_at":"2026-09-24T00:09:44Z","closed_at":"2026-09-24T21:45:16Z","close_reason":"Landed in main via #266 (reviewed; merged 2026-09-24).","labels":["adversarial-review","browser","pr-227"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-mdfc","title":"An untouched commit reopens from the cache while an earlier write is unanswered: pipelined Tab Tab at the right edge overwrites the value just committed (#227)","description":"Found by the adversarial delta re-review of PR #227 (improvise-fy9o) at head baabe1a9. It is a regression that #227 introduces. It is the 3tt4/3uzg harm again, reached by pipelining.\n\nresolve::advanced reopens the editor as OpenedEditor::awaiting() only when THIS commit writes. A commit that writes nothing (an untouched Enter or Tab) still seeds at once from the cache: `false =\u003e opened_on(seed_at(state, nr, nc), edit_mode)`. protocol.md §3 justifies this with \"Nothing changed, so the cache is current\". That is false while an EARLIER write is still unanswered: the cache still predates it. settle_awaited_seed never repairs the seed, because the editor it opened is not awaiting.\n\nReproduced in two ways:\n1. Real Chromium over the worker, harness fixture tall.improv, R01/Cost = 50 (the rightmost column), keys pressed back to back: i 9 Tab Tab 5 Enter. The browser stores 505 in 5 runs out of 5. The TUI stores 5095. So the browser overwrites the 509 the user has just committed.\n2. A Rust probe with a real Host and ClientState. The second Tab is sent before the first is answered. The browser buffer is \"1\" (stale) where the TUI's is \"19\", and North ends at 15 in the browser against 195 in the TUI. With prune_empty, a pipelined Enter Enter does the same: a stale seed at a renumbered position.\n\nEvery #227 test answers each key before the next one is sent (lockstep's sent_and_answered), so nothing pipelines. The fixture lacks the structure the hazard needs.\n\nA candidate fix, checked: await whenever any awaited frame is in flight, not only when this frame writes, i.e. `match writes || state.in_flight.iter().any(|sent| sent.awaited)`. With it, both probes' seed halves pass and the workspace stays at 1950 passed, 0 failed. protocol.md §3's \"Nothing changed\" argument and its \"never another record's number\" claim need correcting with it.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-227-fixes","owner":"el-github@elangley.org","created_at":"2026-09-23T18:26:15Z","created_by":"agent-review-227c","updated_at":"2026-09-24T21:44:55Z","started_at":"2026-09-23T18:29:18Z","closed_at":"2026-09-24T21:44:55Z","close_reason":"Landed in main via #227 (reviewed; merged 2026-09-24).","labels":["adversarial-review","browser","pr-227"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-3kfc","title":"ClientState::refused never settles the unawaited entries it leaves at the front of in_flight, so the next answer pops the wrong entry (#246 relays make it reachable)","description":"Found by the adversarial review of PR #246 (improvise-jpba) at 542dd9ce.\n\n`ClientState::refused` pops the front of `in_flight` and replays, but never calls `settle_sent`. So an UNAWAITED entry (a view-only frame of this client's, or since #246 a queued relay) left at the front after the pop stays there. The NEXT answer (`accepted` or `refused`) then pops that entry instead of the awaited frame it answers. The queue is off by one from then on: the answered frame stays in flight and is replayed, and a later answer settles it.\n\nPre-existing on gh/main 4502ed2b for this client's own view-only frames. Measured: [F1 awaited, U = SetSelected(1,1) unawaited, F2 awaited], all three sent after another client's AddItem made F1 and F2 stale. Both are refused. The second refusal pops U, not F2. Client ends at (3,1), which is refused F2's placement. The mirror is at (1,1). F2 stays in `in_flight`.\n\n#246 makes it reachable from ANY relay, because a relay heard while a frame is in flight is queued as an unawaited `Sent { relayed: true }` behind the head. Measured at 542dd9ce with a real Host and ClientState:\n- Two in-flight Model frames F1 and F2. F2 ends with SetSelected(3,1).\n- Another client's reframe (AddCategory) relays ResetCursor while both are in flight. Queue: [F1, R, F2].\n- F1 is refused (LayoutMoved). Queue: [R, F2]. R is not settled.\n- F2 is refused. It pops R, which DROPS the relay jpba exists to keep. Queue: [F2].\n- Client view at (3,1), settled at (1,0), mirror at (0,0). F2's refused commands are still queued, and the next accepted answer will settle them.\n\nOn gh/main the same relay scenario also diverges (the relay is not recorded), but the queue stays aligned. On #246 it does not.\n\nCandidate fix, verified: add `self.settle_sent();` right after `self.in_flight.pop_front();` in `refused`. With it, the probe passes, and so does every workspace test at 542dd9ce. Suggested regression test: the two-in-flight probe above (host.rs tests, `exchange`/`connected_client` shape), asserting that client.view.cursor() == the mirror's and that in_flight is empty.\n","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T17:59:34Z","created_by":"agent-review-221c-246","updated_at":"2026-09-24T21:46:03Z","closed_at":"2026-09-24T21:46:03Z","close_reason":"Landed in main via #246 (reviewed; merged 2026-09-24).","labels":["adversarial-review","browser","pr-246"],"dependencies":[{"issue_id":"improvise-3kfc","depends_on_id":"improvise-jpba","type":"related","created_at":"2026-09-23T17:59:39Z","created_by":"agent-review-221c-246","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-1be2","title":"check-ci-graph counts a --ci step whose run: is a plain multi-line scalar: a continuation line (|| true, ; exit 0) is appended to the call and never read","description":"At PR #228 head d6706774, ci_steps records a step's run: from its FIRST physical line only; the owner rule then skips deeper-indented continuation lines as content without disqualifying the step. YAML folds a plain scalar's continuation into the value: yq-go reads 'run: nix shell nixpkgs#trealla --command ./scripts/checkers.sh --ci kb' + next line ' || true' as '... --ci kb || true'. Planted on the real ci.yml, python3 scripts/check-ci-graph.py exits 0 ('all 9 checkers.sh suites run in CI') while every kb-row suite failure is swallowed. Same with '; exit 0'. The existing case 'a plain continuation line does not count' is the mirror (call on the continuation line); the call-first shape has no case. Fix: a step whose run: key owns any non-empty continuation line is not counted (report it). Also: _unrun's text says 'add it to ci.yml' but under the table design the fix is the ci_suites row in checkers.sh; the 228x245 composition prints that wrong advice.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-mergetrain","owner":"el-github@elangley.org","created_at":"2026-09-23T17:59:13Z","created_by":"agent-review-tooling4","updated_at":"2026-09-24T21:44:43Z","started_at":"2026-09-23T18:02:23Z","closed_at":"2026-09-24T21:44:43Z","close_reason":"Landed in main via #228 (reviewed; merged 2026-09-24).","labels":["adversarial-review","pr-228"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-yb4b","title":"#239's spanning-fold totals ~double the TUI keypress path (paid twice per key); at 4.8k cells a held key stops keeping up, and h6z1 as filed does not reach stored-measure totals","description":"Found in adversarial review of PR #239 (improvise-wm20), head 9948aaf5. Measured on an Apple M4 Max (16 cores, 64 GB) under shared load, with the\n`grid_projection` bench, min-of-N, alternating before and after, two runs each.\nMid-size cases were added locally.\n\nThe TUI pays `compute_col_widths` in `App::cmd_context` (for `visible_cols`) AND\na whole-grid `grid_of` in `draw`, on EVERY keypress, a pure cursor move\nincluded. The event loop draws once per key and does not coalesce. Since\n#239 each of them also evaluates a spanning fold per column. That is\n`evaluate_spanning` over every drawn row, which re-derives every member key and\nre-reads the store. So the per-keypress cost roughly doubles:\n\n| cells | per keypress before (gh/main 5932ad74) | after (9948aaf5) |\n|---|---|---|\n| 400 | ~1.3 ms | ~2.1 ms |\n| 1.2k | ~5.0 ms | ~8.2 ms |\n| 2.4k | ~14.6 ms | ~25.1 ms |\n| 4.8k | ~31.4 ms | ~52.4 ms |\n| 18k | ~200 ms | ~330 ms |\n| 90k | ~2.7 s | ~4.7 s |\n\n(col_widths + whole-grid, for example 18k: 78-91 → 135-157 ms, plus\n118-122 → 185-207 ms.) The user-visible line is key repeat, about 30 Hz, or\n33 ms. At 4.8k cells the TUI kept up with a held arrow key before (31 ms) and\nfalls behind after (52 ms): the cursor keeps moving after the key is released.\nThe threshold moves from about 5k cells to about 3k on the fastest hardware\navailable here. The browser pays the same `grid_in` on every scroll step and\nevery model change.\n\n**improvise-h6z1 as filed does not reach this.** Its memo is keyed by (fold\nkey, formula), and the bench's columns are stored measures under Sum, with no\nformulas. The added cost is `fold_stored`/`evaluate_unfolded` per member, which\nno formula memo touches. Levers that do reach it:\n\n- a per-grid-build value memo keyed by cell key and shared between the walk and\n the totals, since the spanning fold's members ARE the walk's cell keys in a\n dense grid;\n- computing the total once per frame rather than twice. `cmd_context` and\n `draw` each run it;\n- (argued against by design-principles §5, \"a derivation done twice\") combining\n the walk's row values for decomposable aggregators (Sum, Count, Min, Max),\n with the spanning fold reserved for Avg, formulas and text. If this is\n chosen it needs a property test against `evaluate_spanning`.\n\nFor comparison, the largest previously accepted keypress cost is gotcha 24:\n+2.65 ms at 18k (3.04 → 5.69 ms), with 56 ms at 90k called \"3.4 frames\".\nThis one is +20 ms at 4.8k and +130 ms at 18k.\n","notes":"USER RULING 2026-09-23: fix the speed first. #239 is held until the totals stop roughly doubling keypress cost.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-yb4b","owner":"el-github@elangley.org","created_at":"2026-09-23T16:17:05Z","created_by":"agent-review-235-239","updated_at":"2026-09-24T21:45:07Z","started_at":"2026-09-23T23:57:48Z","closed_at":"2026-09-24T21:45:07Z","close_reason":"Landed in main via #239 (reviewed; merged 2026-09-24).","labels":["adversarial-review","performance","pr-239"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-b5dp","title":"wm20 rulings (1) and (2) disagree for a formula column: a row's div/0 is drawn in the column and the total under it is a clean pooled number","description":"Found in adversarial review of PR #239 (improvise-wm20), head 9948aaf5. A ruling conflict that needs the user, not a code bug.\n\nThe wm20 notes record ruling (2) as: \"An error in any row propagates, so the\ntotal is an error.\" PR #239 implements it only for STORED values. For a formula\ncolumn it deliberately does not, and says so in `context/code/command.md`: \"a\nrow's div/0 is not an input and does not propagate. Only the fold's own error\ndoes.\"\n\nMeasured at 9948aaf5. Region rows North, South and East, with Product on None\nand `Profit = Revenue - Cost`, `Margin = Profit / Revenue`. East has Revenue 0.\nThe rows draw `0.83`, `0.90` and `ERR:div/0`. The total draws **`0.80`**, not an\nerror. The same happens for `Share = Revenue / SUM(Revenue)`.\n\nThis is what ruling (1) implies. \"The column's cell as if every drawn row were\none fold\" is 48/60 = 0.80: collapsing every row into one group reads 0.80 too. So\nrulings (1) and (2) disagree for formula columns, and the PR chose (1) without\nthe user deciding. A user sees an error in the column and a clean total under\nit.\n\nAsk the user which one wins for a formula column. If (2) wins, the total must be\nthe error whenever any drawn row's formula errors, which is an extra per-row\nevaluation that the pooled fold does not do.\n","notes":"USER RULING 2026-09-23: pooled ratio. A formula total is evaluated over the pooled inputs, so a per-row formula error doesn't poison it; stored-value errors still propagate.","status":"closed","priority":2,"issue_type":"task","assignee":"agent-yb4b","owner":"el-github@elangley.org","created_at":"2026-09-23T16:17:04Z","created_by":"agent-review-235-239","updated_at":"2026-09-24T21:45:06Z","started_at":"2026-09-23T23:57:49Z","closed_at":"2026-09-24T21:45:06Z","close_reason":"Landed in main via #239 (reviewed; merged 2026-09-24).","labels":["adversarial-review","needs-user-ruling","pr-239"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-53zz","title":"The shipped-documents scratch-repo tests inherit the caller's repository env and global config, so under a worktree hook they stage files into the real index","description":"Found reviewing PR #243 (improvise-c0aj) at head 228f130e.\n\ntests/shipped_documents_load.rs shells out to git with the caller's environment and global config. Two consequences, both measured:\n\n1. **An inherited GIT_DIR writes into another repository's index.** Git exports GIT_DIR and an absolute GIT_INDEX_FILE to hooks when run from a linked worktree (measured: a pre-commit hook in a `git worktree add` checkout sees GIT_DIR=.../.git/worktrees/wt and GIT_INDEX_FILE=.../worktrees/wt/index; a normal checkout exports only a relative GIT_INDEX_FILE, pre-push exports neither, rebase -x exports neither). Every wave agent works in a linked worktree. With GIT_DIR set, `git -C \u003cscratch\u003e init` re-initialises the outer repo and `git -C \u003cscratch\u003e add .gitignore web/demo.improv` stages the scratch files into the OUTER index, because GIT_DIR wins over -C discovery and the worktree defaults to cwd. Measured: `GIT_DIR=\u003cthrowaway\u003e/.git cargo test --test shipped_documents_load` left the throwaway index holding `.gitignore`, `docs/model.improv.gz`, `secret/forced.improv`, `web/demo.improv`, and three of the four shape tests still reported ok. A pre-commit hook that runs cargo test from a worktree would therefore replace the commit's root .gitignore with `*.improv\\n!web/demo.improv\\n` and add three junk files. Latent: no hook in this repo runs cargo test today.\n\n2. **A global core.excludesFile fails a shape test falsely.** With GIT_CONFIG_GLOBAL pointing at a config whose excludesFile contains `*.gz` (a common global ignore), `a_gzipped_document_is_walked` fails at `git add` (\"paths are ignored by one of your .gitignore files\"). commit.gpgsign and core.hooksPath do not matter: no test commits, and add fires no hooks.\n\nFix: in run_git and shipped_documents, remove the repository-local env vars (`git rev-parse --local-env-vars`: GIT_DIR, GIT_WORK_TREE, GIT_INDEX_FILE, GIT_OBJECT_DIRECTORY, GIT_COMMON_DIR, ...). For the scratch repos also set GIT_CONFIG_GLOBAL=/dev/null and GIT_CONFIG_NOSYSTEM=1. Rival: re-run with GIT_DIR pointing at a throwaway repo and assert its index stays empty.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-refuse-fu","owner":"el-github@elangley.org","created_at":"2026-09-23T16:03:57Z","created_by":"agent-review-243","updated_at":"2026-09-24T21:45:00Z","started_at":"2026-09-23T18:02:09Z","closed_at":"2026-09-24T21:45:00Z","close_reason":"Landed in main via #248 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-3uzg","title":"Under prune_empty, a commit that prunes its own row reopens the browser editor on the renumbered neighbour's stale cache value (#227 regression)","description":"#227 (improvise-fy9o, head a093d126) regression. Found by the adversarial review of the 3tt4 fix.\n\n`resolve::advanced` seeds the reopened editor from the client's pre-write cache (`seed_at`) everywhere except the cursor's own cell. `landed_seed`'s doc says: \"The cache is stale only where the frame just wrote, which is the cursor's own cell\". That is false under `prune_empty`. A commit that empties the last value in a row (or a column) prunes that row. The grid renumbers, so the cell the advance lands on is a different cell from the one the cache holds at that position. The TUI's `CommitAndAdvance` puts `RebuildLayout` in front of `EnterEditAtCursor` (gotcha 23), so the TUI seeds from the renumbered grid.\n\nRepro (lockstep harness): three regions North/South/West = 1/2/3, one measure, `prune_empty = true`, cursor (0,0), window (3,3).\n- `i Backspace Enter`: the browser buffer is \"2\" (South, the stale cache at row 1). The host buffer is \"3\" (West, which row 1 now is). Both cursors are at (1,0).\n- Then a projection arrives: a fresh cache, with the client's ViewState kept. `5 Enter` then leaves the browser document at West=25 and the host at West=35. The browser writes a value built from another record's number.\n- At gh/main 5932ad74 both ends agree (buffer \"\" on both, West=5 on both). This PR introduces the divergence. Before fy9o, the welded clear-buffer blanked both seeds.\n\nThe same mechanism applies to a column pruned by Tab. That case was not run.\n\nSame harm shape as improvise-3tt4: \"the reopened editor shows the next cell's current value\" fails wherever the write reshapes the grid. A client cannot tell from its cache that a write will prune. So the fix probably cannot be a smarter `seed_at`. Options: defer the seed to the projection (`settle_editor` already seeds an absent buffer), or seed blank whenever the frame writes.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T16:01:45Z","created_by":"agent-review-227b-240","updated_at":"2026-09-23T16:01:45Z","labels":["adversarial-review","browser","pr-227"],"dependencies":[{"issue_id":"improvise-3uzg","depends_on_id":"improvise-6jk","type":"parent-child","created_at":"2026-09-25T01:04:40Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-st8q","title":"check-ci-graph fails open on a bare checkers.sh line inside a heredoc, multi-line string, or folded/plain-multiline run: scalar","description":"Found by adversarial re-review of PR #228 at head c1417359.\n\ncheck-ci-graph.py reads a run: value one physical line at a time. It does not track a multi-line quoted string or a heredoc body, and it does not apply YAML's folded (\u003e) or plain-multiline scalar rules. So a bare `./scripts/checkers.sh` on a line of its own reads as a bare invocation, which checkers.sh --would-run answers as EVERY suite. A suite that no step runs then passes, silently. This is the same fail-open class as improvise-maax item 1 (a bare mention reads as every suite runs), reached through a different mechanism.\n\nDemonstrated against the real ci.yml, with a planted suite zz-new (scripts/zz-new plus zz-new-test.sh) that no step runs. The gate exits 1 on the plant alone. It exits 0 after either of these edits:\n1. A step-summary heredoc (a realistic step):\n run: |\n cat \u003e\u003e \"$GITHUB_STEP_SUMMARY\" \u003c\u003c'EOF'\n To reproduce locally, run\n ./scripts/checkers.sh\n from the repository root.\n EOF\n2. Folding the kb job's last step as `run: \u003e` with `./scripts/checkers.sh` on one line and `pr-watch release-preflight pr-reviews fmt-workspaces` on the next. yq confirms that the YAML value is `./scripts/checkers.sh pr-watch release-preflight pr-reviews fmt-workspaces`, so only four suites run.\n\nThe same result comes from a multi-line double-quoted string that contains the bare line, and from a plain multi-line scalar (`run: ./scripts/checkers.sh` followed by an indented `kb`). A third mechanism gives the same result: `\"--command\" in words[k:]` matches any command, so `echo use --command ./scripts/checkers.sh` also reads as every suite.\n\nThe claim in context/code/tooling.md, \"A line shlex cannot parse contributes nothing, which fails closed\", is true of that one line only. The following lines are then misread. The same paragraph also says \"a CI step that spells it anywhere is a usage error rather than a green step that ran nothing\". That is false for the first position: `checkers.sh --would-run kb` in a step exits 0 and runs nothing. The gate still catches it, because asking `--would-run --would-run kb` is exit 4, so nothing is counted. But the step itself is green.\n\nFix direction (fail closed): tokenise the whole script rather than each line, so quotes span lines. Contribute nothing from a heredoc body, or refuse a script that contains `\u003c\u003c`. Fold `\u003e` and plain-multiline values the way YAML does. Honour `--command` only when the command word is `nix`. Add each shape as a SUITE_SELF_TEST case that requires a suite run only through the shape.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T15:41:47Z","created_by":"agent-review-228b","updated_at":"2026-09-25T00:59:37Z","started_at":"2026-09-23T15:48:08Z","closed_at":"2026-09-25T00:59:37Z","close_reason":"Landed in #228 (d6706774 on gh/main: 'CI runs checkers.sh --ci \u003cjob\u003e; the gate reads a table, not shell')","labels":["adversarial-review","pr-228"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-jpba","title":"A client refusal replays from a settled slice that never folded the host's relays: after a reframe, the next refused frame splits the client cursor from its mirror","description":"Found while adversarially reviewing PR #230 (improvise-wdmp) at head 93c455db. It is pre-existing: reproduced at gh/main 194d8f52. It is not caused by #230.\n\n`ClientState::refused` rolls back by `replay()`: `view = settled.clone()`, then re-folding every in-flight frame. But `settled` only ever absorbs the client's OWN frames, in `accepted()`. A command the host RELAYS to the asker (`ResetCursor` from `adopt_cursor_epoch`, the per-client `SwitchView` from `adopt_deleted_views`, and now #230's echoed placements) is folded into `view` only. The next refusal therefore rewinds the client past every relay it has heard, while the host's mirror keeps them.\n\nReproduction, as a host test in ui/host.rs with a real `Host` and a real `ClientState` (it folds every frame addressed to the asker):\n1. The asker sends `[SetSelected(1,0)]`. It is accepted, and client, settled and mirror are all at (1,0).\n2. The asker sends `[ChangeMode(Normal), AddCategory(\"Product\")]`. The mirror is reset to (0,0) and `ResetCursor` is relayed, so the client view is (0,0). `settled` stays at (1,0).\n3. The asker sends an `AddCategory` whose basis was taken before step 2. It is refused (stale layout), and `refused()` calls `replay()`.\nResult: the client is at (1,0) and its mirror at (0,0). This is the client/mirror cursor split improvise-wdmp closes, reopened by any later refusal. With #230, the placed case also leaves `settled`'s row/col offsets un-reset: `[SetRowOffset(1), AddCategory, SetSelected]` gives settled row_offset 1 and mirror 0.\n\nThe likely fix: relayed commands must reach `settled` too. For example, `fold` for a server-originated command also reduces into `settled`, or replay composes relays in arrival order. Acceptance is the test above, together with a rival that restores the current `fold`.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-viewhist","owner":"el-github@elangley.org","created_at":"2026-09-23T15:29:18Z","created_by":"agent-review-227-230","updated_at":"2026-09-24T21:45:13Z","started_at":"2026-09-23T15:34:15Z","closed_at":"2026-09-24T21:45:13Z","close_reason":"Landed in main via #246 (reviewed; merged 2026-09-24).","labels":["adversarial-review","browser","pr-230"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-maax","title":"check-ci-graph suites_run fails open: non-command mentions, --list, and --self-test's discarded names read as suites run","description":"Found in adversarial review of PR #228 (head cee61b94). `scripts/check-ci-graph.py::suites_run` fails OPEN on four shapes, each planted and run against `suite_verdict([\"kb\",\"zz-new\",\"checkers\"], suites_run(text))` with `zz-new` run by no step:\n\n1. A bare `scripts/checkers.sh` in text that is not a command reads as \"every suite runs\" (verdict []): an inline comment `run: echo skipped # ./scripts/checkers.sh` (ChatGPT Codex's P1 on the PR), a step name `- name: Runs scripts/checkers.sh`, and `run: echo scripts/checkers.sh`. Only a line whose FIRST non-space char is `#` is skipped.\n2. `checkers.sh --list` alone returns ALL, but `--list` prints and exits without running anything (checkers.sh main: `--list) suites; return 0`).\n3. `checkers.sh --self-test zz-new kb` is counted as running checkers, zz-new and kb (verdict []), but checkers.sh sets `names=(checkers)` when --self-test is given and DISCARDS the names. Consolidating the CI steps into one `--self-test kb pr-watch ...` line would silently stop running every named suite with the gate green.\n4. `checkers.sh --list kb zz-new` counts kb and zz-new as run; nothing runs.\n\nAlso (fails closed, loud but wrong): only the first invocation on a line counts (`./scripts/checkers.sh kb \u0026\u0026 ./scripts/checkers.sh zz-new` reports zz-new missing), and quoted names (`'zz-new'`) are read with their quotes.\n\nFix direction: model checkers.sh's own argv semantics (--list =\u003e runs nothing, --self-test =\u003e {checkers} only whatever else is named, -h/unknown flag =\u003e runs nothing) instead of \"no positional names =\u003e ALL\"; strip a shell comment before matching; only look inside `run:` values. Each shape above should become a SUITE_SELF_TEST case.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T15:24:35Z","created_by":"agent-review-228-229","updated_at":"2026-09-25T00:59:39Z","started_at":"2026-09-23T15:27:36Z","closed_at":"2026-09-25T00:59:39Z","close_reason":"Landed in #228 (c1417359 on gh/main: check-ci-graph asks checkers.sh what a step runs)","labels":["adversarial-review","pr-228"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-znuk","title":"A pivot client's first write into an empty cell is RecordGone when the host sits on a records view","description":"Found by agent-collapse-fu while scoping improvise-8id2. Creating an EMPTY cell resolves its record only through the host's layout (record_in_layout). So when the host is on a records view and a pivot client writes into an empty pivot cell, the record doesn't resolve and the write is refused RecordGone. Two clients on different views of one document is supported (gotcha 29), so this is a real multi-client failure. Resolve an empty-cell write against the view the CLIENT was shown (the view the command names), not the host's. Pin it with a Host-level test with the host on records and the client on pivot.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T15:17:41Z","created_by":"orchestrator","updated_at":"2026-09-23T15:17:41Z","dependencies":[{"issue_id":"improvise-znuk","depends_on_id":"improvise-pmt0","type":"parent-child","created_at":"2026-09-25T01:05:05Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-milr","title":"RenameRecordCoordinate overwrites a destination record records_named does not name: a concurrent write to it is lost with no Rejection","description":"Found by the adversarial review of PR #221 (improvise-fdk1) at ee5cba88.\n\nModelCommand::RenameRecordCoordinate WRITES the destination record (when the new coordinate lands on an existing record, per improvise-kndq's overwrite rule) but records_named names only the source record. So the basis check never asks about the destination, and a concurrent write to it is lost silently.\n\nRepro (three_records_host: North=1, South=2, West=3, records mode, a real Host):\n1. A opens the coordinate editor on North and types \"South\".\n2. B writes South/Revenue = 999 (accepted). A folds the relay.\n3. A presses Enter: ACCEPTED. South/Revenue is now 1 (North's value). B's 999, which A was never shown when it opened the editor and never claimed a version of, is gone. No Rejection, no status.\n\nThis is the lost update ADR 001's record-level basis exists to prevent, and it is new with the wire form: before #221 the overwrite was reachable only from one TUI user's own keys, where there is no concurrent writer. improvise-kndq decides the POLICY for a collision (refuse, merge or overwrite); this ticket is narrower and stands whatever kndq decides unless kndq picks \"refuse\": a command that writes a record must name it in records_named, or the check is a force by omission (gotcha 43). The obstacle is that a client may not be able to claim the destination (it may be off-screen, unpublished, or not exist), so naming it probably means \"refuse if the destination exists and the frame's basis does not state it\", which is RecordUnstated on the destination.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-hzjo2","owner":"el-github@elangley.org","created_at":"2026-09-23T15:06:51Z","created_by":"agent-review-221","updated_at":"2026-09-24T21:44:49Z","started_at":"2026-09-23T15:13:31Z","closed_at":"2026-09-24T21:44:49Z","close_reason":"Landed in main via #251 (reviewed; merged 2026-09-24).","labels":["adversarial-review","pr-221"],"dependencies":[{"issue_id":"improvise-milr","depends_on_id":"improvise-kndq","type":"related","created_at":"2026-09-23T15:06:55Z","created_by":"agent-review-221","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-20ek","title":"A coordinate-rename retry after RecordGone renames whichever record moved under the cursor (browser, fdk1)","description":"Found by the adversarial review of PR #221 (improvise-fdk1) at ee5cba88.\n\nA browser client's coordinate-rename retry after Rejected(RecordGone) renames a DIFFERENT record: the one that has since moved under the cursor.\n\nRepro (probe in crates/improvise-wasm-client/tests/browser_round_trip.rs shape, three_records_host: North=1, South=2, West=3, records mode):\n1. A opens the editor on North's Region coordinate (row 0) and types \"Zed\".\n2. B renames North -\u003e Yak (RenameRecordCoordinate, accepted). A folds the relay: North's row is gone, so row 0 is now South.\n3. A presses Enter: correctly Rejected(RecordGone(North)).\n4. The refusal replays the settled slice, so A is editing again at (0, coordinate) with buffer \"Zed\". settle_opened sees (editing, opened=None, nothing awaited) and captures the record NOW under the cursor, South, at its current version.\n5. A presses Enter again: accepted. South is renamed to Zed. South is gone.\n\nMeasured: after the refusal, opened = South's RecordHash at version 0, cell text under the cursor \"South\", buffer \"Zed\". After the second Enter, Region=South/Revenue is None and Region=Zed/Revenue = 2.\n\nWhy it matters: context/code/clients.md (\"A retry after a refusal is a force\") argues the retry is the one-keystroke force RecordMoved promises (gotcha 43). That argument holds only when the record is still under the cursor. For RecordGone, gotcha 43 says there is nothing to force (\"force never creates structure\"), and the retry here is not a force of the refused edit at all: it applies text typed for North to South. The user did see an editor over South's row, but its buffer is the text they typed for North, so one Enter rewrites a record they never opened.\n\nCandidate fixes (not decided): keep `opened` across a refusal that named that record (so the retry is RecordGone again and the user must Esc and reopen); or clear the buffer/leave editing on RecordGone; or capture only when the cursor's record equals the refused one. Any fix must keep a_second_rename_after_a_reorder_renames_the_row_the_host_shows green, which pins the opposite capture after an ACCEPTED rename (an injected \"never clear opened\" rival makes this probe pass and that test fail, so the two pull in different directions).\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-hzjo2","owner":"el-github@elangley.org","created_at":"2026-09-23T15:06:49Z","created_by":"agent-review-221","updated_at":"2026-09-24T21:44:48Z","started_at":"2026-09-23T15:13:29Z","closed_at":"2026-09-24T21:44:48Z","close_reason":"Landed in main via #251 (reviewed; merged 2026-09-24).","labels":["adversarial-review","pr-221"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-pc3b","title":"Effect-classification Debug fingerprint is order-sensitive over formula_cache (latent flake, PR #224)","description":"Found by the adversarial review of PR #224 (improvise-tt22), head e62a289c.\n\nPR #224 moved model_fingerprint (crates/improvise-command/src/ui/effect.rs tests) from serde_json to format!(\"{:?}\", model). Debug renders Model::formula_cache, a HashMap\u003cCellKey, CellValue\u003e the serde form skipped (#[serde(skip)]). recompute_formulas clears and refills it, and the refilled map iterates in a different order.\n\nDemonstrated: a temporary test built a workbook with 8 regions x Revenue/Cost and two formulas (Profit, Margin), then applied RebuildLayout, a correct ViewOnly effect that calls recompute_formulas. The Debug fingerprint differed before/after in 90/200 and 82/200 runs. On every differing run, everything up to \"formula_cache\" was byte-identical, and the formula_cache text was a character permutation of itself. So the difference is iteration order only, not a value.\n\nToday's suite is green only because test_app has no formulas. The first formula-bearing fixture added to view_only_effects_leave_shared_state_alone fails about 45% of runs on a correct effect. context/code/formula-and-io.md says the Debug fingerprint is \"no weaker\" and renders the derived cache \"the JSON skipped\" as if that were a gain. It is order-sensitive where the serde form was not.\n\nFix: fingerprint an order-independent rendering. Either exclude the derived cache (it is recomputed, not shared state a ViewOnly effect could write) or sort it. Then correct the context paragraph. Evidence belongs keyed by the new test name.\n\nThe fault-detection half still works: injecting a measure_agg write into RebuildLayout fails view_only_effects_leave_shared_state_alone with \"RebuildLayout declares ViewOnly but changed model data\".\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-refuse-fu","owner":"el-github@elangley.org","created_at":"2026-09-23T15:04:10Z","created_by":"agent-review-224","updated_at":"2026-09-23T15:41:36Z","started_at":"2026-09-23T15:13:17Z","closed_at":"2026-09-23T15:41:36Z","close_reason":"Fixed in PR #224 (legacy JSON removed, user direction), merged after adversarial review (e62a289, then the delta at 38ec606). Follow-ups: improvise-djcn, improvise-zs2r.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-5mqm","title":"A drill commit on a record whose ITEM was removed mid-drill writes an orphan cell that no grid shows but every aggregate counts","description":"Found reviewing PR #222 (improvise-qz9p) at 8490f34f. qz9p's check is category-level only: RecordRewrite::of refuses a row naming a removed CATEGORY, but Model::remove_item also sweeps the cells naming the item (core.md §9), and a drill's snapshot still names it. Probe: two_record_drill(); workbook.remove_item(\"Type\",\"Clothing\"); stage row 1 Cost=999; ApplyAndClearDrill. Result: refused=false, Type items = [Food], store gains Clothing/Jan/Cost = 999. evaluate_aggregated({Month=Jan,_Measure=Cost}, OverHidden) returns 1001 (2 + 999): the cell is invisible in any grid that enumerates Type's items but inflates every total over Type. Silent. Same shape when a category is removed and re-added under the same name (probe: remove_category(Type); add_category(Type); measure edit writes Clothing/Jan/Cost with Type having no items; qz9p's check passes because the category exists). Pre-existing on gh/main (no check at all there); #222 neither causes nor fixes it. The natural fix is qz9p's own rule one level down: a row whose coordinate names an item its category no longer has is unwritable (renames excluded, since a rename's AddItem legitimately creates the item). Decide whether that belongs in RecordRewrite::of or in a check the wire's SetCell shares.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-uo2p","owner":"el-github@elangley.org","created_at":"2026-09-23T15:03:51Z","created_by":"agent-review-222","updated_at":"2026-09-23T15:34:33Z","started_at":"2026-09-23T15:34:33Z","lease_expires_at":"2026-09-23T15:39:33Z","heartbeat_at":"2026-09-23T15:34:33Z","labels":["adversarial-review","pr-222"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-5mqm","title":"A drill commit on a record whose ITEM was removed mid-drill writes an orphan cell that no grid shows but every aggregate counts","description":"Found reviewing PR #222 (improvise-qz9p) at 8490f34f. qz9p's check is category-level only: RecordRewrite::of refuses a row naming a removed CATEGORY, but Model::remove_item also sweeps the cells naming the item (core.md §9), and a drill's snapshot still names it. Probe: two_record_drill(); workbook.remove_item(\"Type\",\"Clothing\"); stage row 1 Cost=999; ApplyAndClearDrill. Result: refused=false, Type items = [Food], store gains Clothing/Jan/Cost = 999. evaluate_aggregated({Month=Jan,_Measure=Cost}, OverHidden) returns 1001 (2 + 999): the cell is invisible in any grid that enumerates Type's items but inflates every total over Type. Silent. Same shape when a category is removed and re-added under the same name (probe: remove_category(Type); add_category(Type); measure edit writes Clothing/Jan/Cost with Type having no items; qz9p's check passes because the category exists). Pre-existing on gh/main (no check at all there); #222 neither causes nor fixes it. The natural fix is qz9p's own rule one level down: a row whose coordinate names an item its category no longer has is unwritable (renames excluded, since a rename's AddItem legitimately creates the item). Decide whether that belongs in RecordRewrite::of or in a check the wire's SetCell shares.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T15:03:51Z","created_by":"agent-review-222","updated_at":"2026-09-25T00:59:54Z","labels":["adversarial-review","pr-222"],"dependencies":[{"issue_id":"improvise-5mqm","depends_on_id":"improvise-n28d","type":"parent-child","created_at":"2026-09-25T01:05:22Z","created_by":"Edward Langley","metadata":"{}"}],"comments":[{"id":"4dde6b51-f447-5998-98b3-a97ba870d17c","issue_id":"improvise-5mqm","author":"Edward Langley","text":"Returned to open during stale in-progress cleanup (2026-09-25). Work exists but never became a PR: branch gh/fix/5mqm-removed-item (b5cae686 fix, 3b15640a docs), not merged to gh/main. Next agent should pick it up from that branch, merge gh/main, and open a PR.","created_at":"2026-09-25T00:59:42Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} {"_type":"issue","id":"improvise-465t","title":"The touched reset on an editor reopened by commit-and-advance is unpinned on both front ends (a stale touched survives the whole suite)","description":"Found re-reviewing PR #205 at 7aff3e77. OpenedEditor.touched (improvise-2054) is reset to false on every editor OPEN, but nothing pins the reset on the reopen that follows a commit-and-advance, on either front end:\n\n- TUI: effect::EnterEditAtCursor sets OpenedEditor::on(value). Injection: carry the previous editor's `touched` into the new one. The full workspace stays green: 1869 pass, 0 fail.\n- Browser: resolve::advanced -\u003e opened_on(seed_at(..)). Injection: push a SetOpenedEditor carrying the previous `touched` after it. The full workspace stays green: 1870 pass, 0 fail.\n\nHarm under the injection (a scratch TUI test, run and then deleted):\n1. On grid_holding with West/Revenue empty, open `i` on (1,0).\n2. Backspace, then `3`, so the editor is touched.\n3. Tab: this commits and reopens on (1,1) with seed \"\".\n4. Another write sets West/Revenue to 5.\n5. Enter with nothing typed.\n\nAt the head West/Revenue stays 5. Under the injection it is CLEARED: the stale touched makes \"\" a write. This is improvise-ixxx's own bug coming back on the advance path.\n\nIt matters more once improvise-fy9o lands. Today the welded clear-buffer blanks the reopened editor, so a non-empty seed already writes \"\" (that is fy9o). After fy9o, the reopened buffer holds the seed. A stale touched would then make every untouched Tab/Enter through a column write its seed over concurrent edits, which is the records-mode bulk-entry path.\n\nAcceptance: one test per front end that pins the reset. Open, touch, commit and advance, then have a concurrent write land on the new cell. An untouched Enter must leave that write alone. Each test must fail under the injection above.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-ixxx","owner":"el-github@elangley.org","created_at":"2026-09-23T15:03:16Z","created_by":"agent-review-205b","updated_at":"2026-09-23T15:24:59Z","started_at":"2026-09-23T15:13:19Z","closed_at":"2026-09-23T15:24:59Z","close_reason":"Fixed in PR #205, merged after adversarial re-review at 7aff3e7; the later changes were an evidence-only gh/main merge plus the two 465t tests the reviewer requested. Follow-ups: improvise-jibp, improvise-ugj4.","labels":["adversarial-review","pr-205"],"dependencies":[{"issue_id":"improvise-465t","depends_on_id":"improvise-2054","type":"discovered-from","created_at":"2026-09-23T15:03:23Z","created_by":"agent-review-205b","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-jmti","title":"An advance reopens the cell editor on a formula-owned or aggregate cell that edit-or-drill refuses","description":"Found on improvise-fy9o. CommitAndAdvance ends in EnterEditAtCursor, which opens editing on whatever cell the advance lands on; EditOrDrill refuses formula-owned cells ('edit the formula, not the cell') and drills aggregates. Measured: 2x2 pivot plus formula Profit = Revenue - Cost, i on (West,Revenue), Tab: cursor (1,2) on Profit, mode Editing, buffer \"\". With fy9o's untouched rule an Enter there writes nothing, but typing writes a raw cell under a formula target (TUI) or composes nothing and goes Upstream (browser: typed_cell_command returns None for CellId::Derived, so resolve::commit returns None, a black-hole key). The re-entry should apply EditOrDrill's refusals (or stop in Normal) on both ends, line for line. Acceptance: TUI and browser tests that advance onto a formula cell and onto an aggregate and assert the editor does not reopen there (or reopens with the refusal status).","notes":"PR #277 (improvise-enz6) meets jmti's acceptance on both ends (fold + formula entry declined in TUI and browser). Close both when #277 merges.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-ixxx","owner":"el-github@elangley.org","created_at":"2026-09-23T14:55:54Z","created_by":"agent-ixxx","updated_at":"2026-09-24T01:03:21Z","started_at":"2026-09-23T15:53:53Z","lease_expires_at":"2026-09-23T15:58:53Z","heartbeat_at":"2026-09-23T15:53:53Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-jmti","title":"An advance reopens the cell editor on a formula-owned or aggregate cell that edit-or-drill refuses","description":"Found on improvise-fy9o. CommitAndAdvance ends in EnterEditAtCursor, which opens editing on whatever cell the advance lands on; EditOrDrill refuses formula-owned cells ('edit the formula, not the cell') and drills aggregates. Measured: 2x2 pivot plus formula Profit = Revenue - Cost, i on (West,Revenue), Tab: cursor (1,2) on Profit, mode Editing, buffer \"\". With fy9o's untouched rule an Enter there writes nothing, but typing writes a raw cell under a formula target (TUI) or composes nothing and goes Upstream (browser: typed_cell_command returns None for CellId::Derived, so resolve::commit returns None, a black-hole key). The re-entry should apply EditOrDrill's refusals (or stop in Normal) on both ends, line for line. Acceptance: TUI and browser tests that advance onto a formula cell and onto an aggregate and assert the editor does not reopen there (or reopens with the refusal status).","notes":"PR #277 (improvise-enz6) meets jmti's acceptance on both ends (fold + formula entry declined in TUI and browser). Close both when #277 merges.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-ixxx","owner":"el-github@elangley.org","created_at":"2026-09-23T14:55:54Z","created_by":"agent-ixxx","updated_at":"2026-09-24T21:45:14Z","started_at":"2026-09-23T15:53:53Z","closed_at":"2026-09-24T21:45:14Z","close_reason":"Landed in main via #277 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-sjdb","title":"Wire SetCell into an empty cell beside a collapsed _Measure group is refused: aggregate_refusal judges the record at the fold's column","description":"Found in adversarial review of PR #217 (improvise-ciw8), head bc060f67.\n\nREGRESSION introduced by #217. `App::aggregate_refusal` locates an unstored record with `record_position(hash)`, which matches on `RecordHash` — the coordinate tuple with `_Measure` dropped — and then asks `project::cell_id` at the FIRST position carrying that record. When `_Measure` is on an axis and one of its groups is collapsed, that first position can be the measure-group fold, which `cell_id` calls `Aggregate`, so a legitimate write to a different measure of the same row is refused.\n\nRepro (host test, reviewer's `r5_an_empty_cell_beside_a_measure_fold_is_writable`): Month={Jan,Feb} on Row; _Measure on Column with Revenue,Cost in group Money (collapsed) and Units ungrouped; only Jan/Revenue stored. The host projects Feb x Units as `CellId::Record`. A wire `SetCell` on it (caught-up basis) is answered `Rejected(MutationRefused)`. With `unwritable` returning None (pre-#217 behaviour) it is Accepted. In the browser: typing into an empty cell of a row beside a collapsed measure group shows a rejection.\n\nFix shape: locate by the FULL cell (record + measure), i.e. the layout position whose key equals `resolve_cell(cell)`, and ask `cell_id` there.\n\nAlso, the stored-index arm (\"a stored record is always a record\") is UNPINNED: injecting \"stored records also go through cell_id\" fails nothing in #217's suite. Pin it with the reviewer's `r6_a_stored_record_the_hosts_view_aggregates_is_writable` (South/Revenue stored, then Product added on None in the host's view: the record is legit in records mode, and under the injection it is refused).\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-collapse-fu","owner":"el-github@elangley.org","created_at":"2026-09-23T14:55:33Z","created_by":"Edward Langley","updated_at":"2026-09-23T15:35:48Z","started_at":"2026-09-23T15:14:09Z","closed_at":"2026-09-23T15:35:48Z","close_reason":"Fixed in PR #217, merged as cc9bc41 after adversarial review (bc060f6, then the sjdb delta at 52fa397). Follow-ups: improvise-b114, improvise-xur5.","labels":["adversarial-review","pr-217"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-wdmp","title":"The browser cursor jumps to the origin after the client's own records rename (and any reframing browser write)","description":"Found by agent-hzjo2 on improvise-fdk1 (PR #221). The host's cursor-epoch ResetCursor is broadcast to every client, the asker included, so a browser user whose own write reframes the grid (for example a records coordinate rename that re-sorts rows) lands at the origin. The TUI instead advances. This predates fdk1 and applies to any reframing browser write. tests/lockstep.rs can't see it, because its twin host has no Sessions. Decide whether the asker should keep its position (for example addressed ResetCursor excluding the asker, with the asker's own fold placing the cursor), and pin it with a Sessions-level test.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-viewhist","owner":"el-github@elangley.org","created_at":"2026-09-23T14:55:07Z","created_by":"orchestrator","updated_at":"2026-09-23T15:58:32Z","started_at":"2026-09-23T14:55:56Z","closed_at":"2026-09-23T15:58:32Z","close_reason":"Fixed in PR #230, merged after adversarial review at 93c455d. The later changes were evidence-only merges of gh/main. Follow-ups: improvise-hv0r, improvise-jpba.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-d7aj","title":"No test pins the fold memo's key granularity: a memo keyed by formula alone passes the whole suite","description":"Found by the adversarial delta re-review of PR #203 at b3379d1a (agent-review-197-203b).\n\nThe fold memo in `Model::fold_formula_once` (crates/improvise-core/src/model/types.rs) is keyed by the full `CellKey`, which is right. **No test pins that.** I injected a rival that keys the memo by the formula's target name alone. That makes a formula reached at two different fold keys in one evaluation read the first key's value, or report `circular`. The whole workspace suite still passed: 1850+ tests, including `a_fold_evaluates_each_formula_it_reaches_once` and `a_cycle_at_a_fold_is_still_circular`. Both of those use one key context throughout, so they cannot tell the two keyings apart.\n\nThe reachable case: an `IF` whose branch refers to an item of another category. That moves the key while `_Measure` stays the same formula. Fixture: `quarter_model` with Jan/Feb × A = 3/4 and Jan/Feb × B = 100/200, and `G = Revenue * 2`, `F = IF(Revenue \u003e 50, G, G + B)`. At (Q1, A, F) the correct answer is 614: 14 for G@A plus F@B, and F@B is G@B = 600. The formula-keyed rival fails it. The test was a scratch test in the review, not committed.\n\nAlso add the diamond as a pinned case: `A1 = B2 + C3`, `B2 = D*2`, `C3 = D*3`, `D = Revenue`. It gives 35 at the fold and must not be `circular`. It passes today, and nothing pins it.\n","status":"closed","priority":2,"issue_type":"task","assignee":"agent-collapse-fu","owner":"el-github@elangley.org","created_at":"2026-09-23T14:54:17Z","created_by":"Edward Langley","updated_at":"2026-09-23T15:35:50Z","started_at":"2026-09-23T15:14:08Z","closed_at":"2026-09-23T15:35:50Z","close_reason":"Fixed in PR #232 (tests only), merged as bb5dfe5 after adversarial review at 993418f.","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -137,29 +212,29 @@ {"_type":"issue","id":"improvise-tt22","title":"Remove the legacy JSON document format: .improv is the only native format","description":"USER DIRECTION 2026-09-23, on PR #210: 'The only supported native file format is .improv, cleanup any legacy JSON format.' Today persistence auto-detects a leading '{' and loads legacy JSON (read-only). Remove that load path and its serde derives where they exist only for it, along with its tests and docs (repo-map .improv section, gotcha mentions). JSON *import* (import wizard / ImportJsonHeadless) is a different feature and stays. Decide what loading a legacy-JSON file does afterwards: a clear error naming the format, not a parse error. Independent of ADR 007, but it removes the legacy-JSON id cost (improvise-65kf).","status":"closed","priority":2,"issue_type":"task","assignee":"agent-refuse-fu","owner":"el-github@elangley.org","created_at":"2026-09-23T14:40:02Z","created_by":"orchestrator","updated_at":"2026-09-23T15:41:36Z","started_at":"2026-09-23T14:41:47Z","closed_at":"2026-09-23T15:41:36Z","close_reason":"Fixed in PR #224 (legacy JSON removed, user direction), merged after adversarial review (e62a289, then the delta at 38ec606). Follow-ups: improvise-djcn, improvise-zs2r.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-ugj4","title":"A click-edit opened from a stale paint shows old text but captures the current seed and version, so an untouched Enter overwrites the newer value","description":"Found merging #188 into #205. web/app.js repaint() returns early while an editor is open, so frames that arrive then change the cache but not the DOM. A click on another cell closes the old editor (no repaint) and calls edit(td, row, col, action.editText): the \u003cinput\u003e is seeded from the td's PAINTED edit text, while client.edit_target(row,col) captures EditTarget { version, opened.seed } from the CURRENT cache. Enter with nothing typed: input.value (stale) != opened.seed (current) so untouched() says touched, and typed_target_frame composes a write of the stale text claimed at the CURRENT version, which the host accepts: another client's value is overwritten silently. Same mismatch affects #188/0j0f's version capture (version is newer than the text shown). Fix direction: seed the \u003cinput\u003e from what the target captured (e.g. edit_target returns the seed, or the page repaints the cell before opening), so what is shown, what is compared and what is claimed are one capture. Test: Playwright, open editor A, deliver a frame changing B, click B, Enter untouched: no SetCell posted.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-ixxx","owner":"el-github@elangley.org","created_at":"2026-09-23T06:40:54Z","created_by":"agent-ixxx","updated_at":"2026-09-23T17:52:53Z","started_at":"2026-09-23T15:30:50Z","closed_at":"2026-09-23T17:52:53Z","close_reason":"Fixed in PR #240, merged as 0ff17c0 after adversarial review at 35013b6.","comments":[{"id":"65886fbc-05f9-5819-b7c0-a02f893b4583","issue_id":"improvise-ugj4","author":"agent-review-205b","text":"Reproduced end to end at PR #205 head 7aff3e77 by agent-review-205b. The rig was a real improvise-ws-server on web/demo.improv with two Chromium pages. A opened an editor on cell 0. B wrote 777 into cell 4. A's paint stayed 9,100.00, because repaint() early-returns while the editor is open. A clicked cell 4, and the input was seeded 9100. A pressed Enter with nothing typed and sent SetCell 9100 with basis records [[rec,1]], which is the CURRENT version. The host accepted it, and B's 777 was overwritten silently. This PR does not make it worse than gh/main: gh/main's typed_target_frame writes unconditionally at the captured version. On this input the stale text differs from the current seed by construction, so untouched() never fires and the path is identical. Pre-#188 main recomputed the target at commit, so it too claimed the current version. The bug predates both PRs.","created_at":"2026-09-23T15:03:35Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} {"_type":"issue","id":"improvise-65kf","title":"ADR 005 clause 9: '#[serde(transparent)]' cannot load legacy JSON's numeric ids if Q1's string ids are chosen","description":"Raised by the ChatGPT Codex review of PR #210 (ADR 005, head 9d97664c), and confirmed by the adversarial review.\n\nClause 9 step 1 says legacy JSON \"still deserializes, through `#[serde(transparent)]`\". Open question 1 recommends that ids be \"a small string internally\" so that a hand-written `(default)` round-trips. These cannot both hold. Legacy JSON is a serde-derived `Workbook`, and `Category.id`/`Item.id` are `usize` aliases today, so the files carry `\"id\": 3` as a JSON number. A `#[serde(transparent)]` newtype over a `String` rejects a number, and a numeric newtype cannot hold a word id.\n\nThe ADR must specify a representation, or a custom `Deserialize`, that accepts both the legacy numeric form and the textual form, and say how it is written (legacy JSON is never written, so possibly only for the wire). It must also require a legacy-JSON load test with numeric ids. Legacy JSON also carries `Model::next_category_id`, which the rectifying pass has to ignore or accept.\n","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T06:37:14Z","created_by":"agent-review-210","updated_at":"2026-09-23T15:41:36Z","closed_at":"2026-09-23T15:41:36Z","close_reason":"Fixed in PR #224 (legacy JSON removed, user direction), merged after adversarial review (e62a289, then the delta at 38ec606). Follow-ups: improvise-djcn, improvise-zs2r.","dependencies":[{"issue_id":"improvise-65kf","depends_on_id":"improvise-sboz","type":"discovered-from","created_at":"2026-09-23T06:37:14Z","created_by":"agent-review-210","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-i0n7","title":"ADR 005: _Measure's formula-target rows have no ItemId for CategoryRow, and ViewState's expanded_cats and view history are name-keyed too","description":"Found by the adversarial review of PR #210 (ADR 005, head 9d97664c). Two places where the ADR's projection and `ViewState` clauses do not match the code at 4b880846.\n\n1. **`CategoryRow.items` cannot become `(ItemId, String)` for `_Measure`.** `project.rs::category_rows` fills `items` from `Model::effective_item_names`, which for `_Measure` is the explicit items plus the formula targets (gotcha 7). Clause 1 says a formula target in `_Measure` \"has no item of its own to carry\" an id. So the clause 6 shape has no id to put in those rows. The category panel does act on them today: `resolve.rs::delete_at_cat_cursor` composes `RemoveItem { category, item }` for any item row, formula targets included, and fz77's rename will too. The ADR must say what a formula-target row carries (its `FormulaId`? nothing?) and what remove or rename means on it.\n\n2. **`ViewState` holds more name-keyed state than the two fields clause 6 names.** Clause 6 warns only about `active_view` and `cursors` for a future view rename. But:\n - `ViewState::expanded_cats: HashSet\u003cString\u003e` is keyed by **category** name. fz77's category rename, which this ADR unblocks now rather than later, collapses the renamed category's panel node for every client, with nothing in the stream to correct it.\n - `view_back_stack`/`view_forward_stack` hold `ViewFrame { view_name }`, which a view rename orphans exactly as it orphans `cursors`.\n\n These are per-client state the host cannot rewrite. So a rename has to be followed by a relay, like `adopt_deleted_views`, or the keys have to move to ids first. The ADR should list them, and say which applies to fz77.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T06:36:12Z","created_by":"agent-review-210","updated_at":"2026-09-23T06:36:12Z","dependencies":[{"issue_id":"improvise-i0n7","depends_on_id":"improvise-sboz","type":"discovered-from","created_at":"2026-09-23T06:36:12Z","created_by":"agent-review-210","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-gkyo","title":"ADR 005 wire clauses: in-frame removal (ChainBroken analogue), Rejection losing Copy under string ids, create variants that address a parent, and create-then-address frames are unaddressed","description":"Found by the adversarial review of PR #210 (ADR 005, head 9d97664c). The ADR's wire clauses (6 and 7) leave four things unsaid, each checked against the code at 4b880846.\n\n1. **An id removed earlier in the same frame.** `CheckedBatch::against` answers every command against the pre-frame `App`. So `[RemoveFormula(3), EditFormula(3)]` passes the check, since id 3 resolves before anything runs. The per-mutation structural re-check that catches this for records is `reduce.rs::broken_chain` (`Rejection::ChainBroken`), and it only walks `records_named`. The ADR adds `Rejection::Gone` to the pre-frame check and says nothing about the in-frame case. So whether the frame is refused loudly (via `MutationRefused`) or half-applied silently depends on how each new effect behaves on a missing id. The ADR should say that `broken_chain` gains an id arm, and that an exhaustive `ids_named(cmd)` (the analogue of `records_named`) is the server's decision about which ids a command names.\n\n2. **`Rejection` is `Copy`, and `Gone(Named)` breaks it under the Q1 recommendation.** `basis.rs` derives `Copy` on `Rejection`, and relies on it: the `SaveFailed` doc says it carries no message *because* `Rejection` is `Copy`. `Rejection::all(sample: RecordHash)` walks the variants with a `RecordHash` sample only. Q1 recommends that ids be \"a small string internally\", which makes `Named`, and so `Rejection`, non-`Copy`. Q1 does not list that cost. Nor does it define \"max id seen + 1\" (clause 3) when ids can be words.\n\n3. **\"Create by name\" operations also address a parent.** `AddItem { category }`, `AddItemInGroup { category, group }` and `AddFormula { target_category }` create one thing and address an existing one by name. Clause 6 files them under \"keep names\", while its own rule says a migrated variant takes ids for \"all of its addressing operands\". An `AddItem` racing a category remove-and-re-add lands on the namesake, which is the hazard the ADR exists to close. The fdk1 variant does the mixed form (category by id, item by name), so the ADR already accepts it. It should say which rule governs the create variants.\n\n4. **Create-then-address in one frame already ships.** `resolve.rs` \"create-and-switch-view\" sends `[CreateView(name), SwitchView(name)]`. Clause 7's \"no id exemption to write, and none should be\" holds only while `SwitchView` stays by name. Clause 6 requires `ViewState` keys to move to `ViewId` before any view rename. When they do, this frame cannot be composed, because the client cannot know the new view's id. \"The host assigns every id\" is therefore a decision, not a consequence. The alternative (a client proposes an id on create, as Q1's word ids would allow) is not presented.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T06:36:11Z","created_by":"agent-review-210","updated_at":"2026-09-23T06:36:11Z","dependencies":[{"issue_id":"improvise-gkyo","depends_on_id":"improvise-sboz","type":"discovered-from","created_at":"2026-09-23T06:36:11Z","created_by":"agent-review-210","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-rxz9","title":"ADR 005's id_token uses ASCII_ALPHANUMERIC, which the grammar generator emits as nothing: 1.4% of generated files are rejected; formula ids are unreachable by the generator","description":"Found by the adversarial review of PR #210 (ADR 005, head 9d97664c).\n\nThe ADR proposes `id_token = @{ (ASCII_ALPHANUMERIC | \"_\" | \"-\")+ }` and says that the grammar-walking generator (`persistence/parse.rs::generator`) can still produce only valid files. It cannot. `Gen::emit` knows only the built-ins `ANY`, `NEWLINE`, `SOI`, `EOI` and `ASCII_DIGIT`. It looks any other identifier up as a user rule, finds nothing, and emits nothing. So `ASCII_ALPHANUMERIC` contributes the empty string, and `id_tag` is often generated as `()`.\n\nMeasured with a scratch copy of improv.pest carrying the ADR's rules, and a copy of `Gen`: in 20,000 generated files, 1,230 contained `()` and 271 (1.4%) were rejected by the grammar, for example `- Kf__, _-_-()` (\"expected id_token\"). `generated_file_parses` would fail. In a header, `Default()` is silently accepted through `line_name` as a name containing parentheses, which is also not what the generator meant to exercise. With `id_token` spelled out as `('A'..'Z' | 'a'..'z' | '0'..'9' | \"_\" | \"-\")+`, which is what `bare_name` already does, the count drops to 0 of 20,000.\n\nA related gap: the generator cannot exercise formula ids at all. `ANY` emits only a-z, so no generated `formula_raw` contains `=`, and `walk_formulas_section` silently drops every formula line without `=` (`if raw.contains('=')`). Measured on the unchanged grammar: 36 of 2,000 generated files have a `## Formulas` line, and every one of them is dropped. The ADR's round-trip properties \"with and without ids\" are therefore vacuous for formulas under the generator, unless the generator learns to emit a formula.\n\nWhat the ADR must do: spell `id_token` with ranges, and state that the generator needs a formula production before property 3 covers formula ids.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T06:36:09Z","created_by":"agent-review-210","updated_at":"2026-09-23T06:36:09Z","dependencies":[{"issue_id":"improvise-rxz9","depends_on_id":"improvise-sboz","type":"discovered-from","created_at":"2026-09-23T06:36:09Z","created_by":"agent-review-210","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-i0n7","title":"ADR 005: _Measure's formula-target rows have no ItemId for CategoryRow, and ViewState's expanded_cats and view history are name-keyed too","description":"Found by the adversarial review of PR #210 (ADR 005, head 9d97664c). Two places where the ADR's projection and `ViewState` clauses do not match the code at 4b880846.\n\n1. **`CategoryRow.items` cannot become `(ItemId, String)` for `_Measure`.** `project.rs::category_rows` fills `items` from `Model::effective_item_names`, which for `_Measure` is the explicit items plus the formula targets (gotcha 7). Clause 1 says a formula target in `_Measure` \"has no item of its own to carry\" an id. So the clause 6 shape has no id to put in those rows. The category panel does act on them today: `resolve.rs::delete_at_cat_cursor` composes `RemoveItem { category, item }` for any item row, formula targets included, and fz77's rename will too. The ADR must say what a formula-target row carries (its `FormulaId`? nothing?) and what remove or rename means on it.\n\n2. **`ViewState` holds more name-keyed state than the two fields clause 6 names.** Clause 6 warns only about `active_view` and `cursors` for a future view rename. But:\n - `ViewState::expanded_cats: HashSet\u003cString\u003e` is keyed by **category** name. fz77's category rename, which this ADR unblocks now rather than later, collapses the renamed category's panel node for every client, with nothing in the stream to correct it.\n - `view_back_stack`/`view_forward_stack` hold `ViewFrame { view_name }`, which a view rename orphans exactly as it orphans `cursors`.\n\n These are per-client state the host cannot rewrite. So a rename has to be followed by a relay, like `adopt_deleted_views`, or the keys have to move to ids first. The ADR should list them, and say which applies to fz77.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T06:36:12Z","created_by":"agent-review-210","updated_at":"2026-09-23T06:36:12Z","dependencies":[{"issue_id":"improvise-i0n7","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T01:04:52Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-i0n7","depends_on_id":"improvise-sboz","type":"discovered-from","created_at":"2026-09-23T06:36:12Z","created_by":"agent-review-210","metadata":"{}"}],"comments":[{"id":"e512507a-b700-56ab-95a6-897a9a0ba3a2","issue_id":"improvise-i0n7","author":"agent-adr007","text":"agent-adr007 planning: revision 8 answers both items: ItemHandle::FormulaTarget, and clause 6 lists expanded_cats and the view history. One remainder, what remove and rename mean on a FormulaTarget row, goes in the text fix improvise-cq1q.1.","created_at":"2026-09-25T04:51:21Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} +{"_type":"issue","id":"improvise-gkyo","title":"ADR 005 wire clauses: in-frame removal (ChainBroken analogue), Rejection losing Copy under string ids, create variants that address a parent, and create-then-address frames are unaddressed","description":"Found by the adversarial review of PR #210 (ADR 005, head 9d97664c). The ADR's wire clauses (6 and 7) leave four things unsaid, each checked against the code at 4b880846.\n\n1. **An id removed earlier in the same frame.** `CheckedBatch::against` answers every command against the pre-frame `App`. So `[RemoveFormula(3), EditFormula(3)]` passes the check, since id 3 resolves before anything runs. The per-mutation structural re-check that catches this for records is `reduce.rs::broken_chain` (`Rejection::ChainBroken`), and it only walks `records_named`. The ADR adds `Rejection::Gone` to the pre-frame check and says nothing about the in-frame case. So whether the frame is refused loudly (via `MutationRefused`) or half-applied silently depends on how each new effect behaves on a missing id. The ADR should say that `broken_chain` gains an id arm, and that an exhaustive `ids_named(cmd)` (the analogue of `records_named`) is the server's decision about which ids a command names.\n\n2. **`Rejection` is `Copy`, and `Gone(Named)` breaks it under the Q1 recommendation.** `basis.rs` derives `Copy` on `Rejection`, and relies on it: the `SaveFailed` doc says it carries no message *because* `Rejection` is `Copy`. `Rejection::all(sample: RecordHash)` walks the variants with a `RecordHash` sample only. Q1 recommends that ids be \"a small string internally\", which makes `Named`, and so `Rejection`, non-`Copy`. Q1 does not list that cost. Nor does it define \"max id seen + 1\" (clause 3) when ids can be words.\n\n3. **\"Create by name\" operations also address a parent.** `AddItem { category }`, `AddItemInGroup { category, group }` and `AddFormula { target_category }` create one thing and address an existing one by name. Clause 6 files them under \"keep names\", while its own rule says a migrated variant takes ids for \"all of its addressing operands\". An `AddItem` racing a category remove-and-re-add lands on the namesake, which is the hazard the ADR exists to close. The fdk1 variant does the mixed form (category by id, item by name), so the ADR already accepts it. It should say which rule governs the create variants.\n\n4. **Create-then-address in one frame already ships.** `resolve.rs` \"create-and-switch-view\" sends `[CreateView(name), SwitchView(name)]`. Clause 7's \"no id exemption to write, and none should be\" holds only while `SwitchView` stays by name. Clause 6 requires `ViewState` keys to move to `ViewId` before any view rename. When they do, this frame cannot be composed, because the client cannot know the new view's id. \"The host assigns every id\" is therefore a decision, not a consequence. The alternative (a client proposes an id on create, as Q1's word ids would allow) is not presented.\n","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T06:36:11Z","created_by":"agent-review-210","updated_at":"2026-09-25T06:35:45Z","closed_at":"2026-09-25T06:35:45Z","close_reason":"Answered by accepted ADR 007 (revision 8); verified by the PR #318 review (issuecomment-5827855684)","dependencies":[{"issue_id":"improvise-gkyo","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T01:04:51Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-gkyo","depends_on_id":"improvise-sboz","type":"discovered-from","created_at":"2026-09-23T06:36:11Z","created_by":"agent-review-210","metadata":"{}"}],"comments":[{"id":"9b3b442b-340b-55a2-b820-fe7f8e6b80f2","issue_id":"improvise-gkyo","author":"agent-adr007","text":"agent-adr007 planning: revision 8 answers all four items. (1) Clause 7 adds ids_named and broken_chain's id arm. (2) Clause 3 records that Rejection loses Copy. (3) Clause 6 has create variants take the parent's id. (4) Clause 6 adds CreateView { display, switch }. Recommend closing. Implemented in improvise-cq1q.8.","created_at":"2026-09-25T04:51:19Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} +{"_type":"issue","id":"improvise-rxz9","title":"ADR 005's id_token uses ASCII_ALPHANUMERIC, which the grammar generator emits as nothing: 1.4% of generated files are rejected; formula ids are unreachable by the generator","description":"Found by the adversarial review of PR #210 (ADR 005, head 9d97664c).\n\nThe ADR proposes `id_token = @{ (ASCII_ALPHANUMERIC | \"_\" | \"-\")+ }` and says that the grammar-walking generator (`persistence/parse.rs::generator`) can still produce only valid files. It cannot. `Gen::emit` knows only the built-ins `ANY`, `NEWLINE`, `SOI`, `EOI` and `ASCII_DIGIT`. It looks any other identifier up as a user rule, finds nothing, and emits nothing. So `ASCII_ALPHANUMERIC` contributes the empty string, and `id_tag` is often generated as `()`.\n\nMeasured with a scratch copy of improv.pest carrying the ADR's rules, and a copy of `Gen`: in 20,000 generated files, 1,230 contained `()` and 271 (1.4%) were rejected by the grammar, for example `- Kf__, _-_-()` (\"expected id_token\"). `generated_file_parses` would fail. In a header, `Default()` is silently accepted through `line_name` as a name containing parentheses, which is also not what the generator meant to exercise. With `id_token` spelled out as `('A'..'Z' | 'a'..'z' | '0'..'9' | \"_\" | \"-\")+`, which is what `bare_name` already does, the count drops to 0 of 20,000.\n\nA related gap: the generator cannot exercise formula ids at all. `ANY` emits only a-z, so no generated `formula_raw` contains `=`, and `walk_formulas_section` silently drops every formula line without `=` (`if raw.contains('=')`). Measured on the unchanged grammar: 36 of 2,000 generated files have a `## Formulas` line, and every one of them is dropped. The ADR's round-trip properties \"with and without ids\" are therefore vacuous for formulas under the generator, unless the generator learns to emit a formula.\n\nWhat the ADR must do: spell `id_token` with ranges, and state that the generator needs a formula production before property 3 covers formula ids.\n","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T06:36:09Z","created_by":"agent-review-210","updated_at":"2026-09-25T06:35:48Z","closed_at":"2026-09-25T06:35:48Z","close_reason":"Answered by accepted ADR 007 (revision 8); verified by the PR #318 review (issuecomment-5827855684)","dependencies":[{"issue_id":"improvise-rxz9","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T01:04:52Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-rxz9","depends_on_id":"improvise-sboz","type":"discovered-from","created_at":"2026-09-23T06:36:09Z","created_by":"agent-review-210","metadata":"{}"}],"comments":[{"id":"1d08f7b1-5679-58de-b671-d574074237dc","issue_id":"improvise-rxz9","author":"agent-adr007","text":"agent-adr007 planning: clause 5 now spells id_token with ranges and requires a formula-line strategy. Recommend closing as answered in the text. The implementation criterion is in improvise-cq1q.5.","created_at":"2026-09-25T04:51:20Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} {"_type":"issue","id":"improvise-jibp","title":"The mouse editor has no touched flag: a click-edit typed back to its seed over a concurrent write is still dropped silently","description":"Residue of improvise-2054, found on PR #205. The keyboard paths (TUI and browser resolve) mark OpenedEditor.touched on append-char/pop-char, so untouched() requires !touched \u0026\u0026 text == seed. The browser mouse editor (web/app.js edit(), typed_cell) edits a DOM \u003cinput\u003e Rust never sees, so typed_cell_frame builds OpenedEditor::on(seed) untouched and compares content alone. Repro: click a cell holding 7, another client writes 5, select-all and type 7, Enter: no frame is sent and the cell stays 5 with no message. Fix belongs with #188's captured EditTarget (improvise-2wu3) carrying OpenedEditor: the page reports the first input event to Rust (or passes a touched flag), and the decision stays in untouched(). Acceptance: a socket.rs test where a touched mouse commit equal to the seed composes a write, and a Playwright check that an input edit reaches it.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-ixxx","owner":"el-github@elangley.org","created_at":"2026-09-23T06:32:09Z","created_by":"agent-ixxx","updated_at":"2026-09-23T17:52:53Z","started_at":"2026-09-23T15:30:51Z","closed_at":"2026-09-23T17:52:53Z","close_reason":"Fixed in PR #240, merged as 0ff17c0 after adversarial review at 35013b6.","comments":[{"id":"f27e94ea-2d4f-5ad9-a38b-cdf8a0d043ff","issue_id":"improvise-jibp","author":"agent-review-205b","text":"Confirmed end to end at PR #205 head 7aff3e77 by agent-review-205b, with a real ws-server and two Chromium pages. A click-edited cell 4 (seed 9100). B wrote 555. A cleared the input, typed 9100 and pressed Enter. A sent 0 SetCell frames, and the cell stayed 555 with no message. Note the direction: on gh/main the same keystrokes WRITE 9100, because typed_target_frame has no untouched check there. So on the mouse path this is a regression #205 introduces. It is the same trade 2054 was on the keyboard path: the common case (an untouched Enter over a concurrent write) is fixed, and the rare case (retyping exactly the old value over a concurrent write) is lost.","created_at":"2026-09-23T15:03:36Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} {"_type":"issue","id":"improvise-btyw","title":"Determinism width guard counts applied drill state, but the law compares Debug text: an order-eliding Debug blinds the law with the guard green","description":"Found in adversarial review of PR #208 (improvise-b9f2) at head 7dfa90f1.\n\nThe fix makes the width guard apply the compared `drill-into-cell` effects to a scratch App and count `drill_state.records`. The effects are the same objects that were compared: `left` is formatted from the Vec before it moves into `records_narrowed`. But the comparison does not compare those objects. It compares their **Debug text** (`format!(\"{effects:?}\")`). The guard counts the records in the applied state. Those are two different projections of one value. `context/code/command.md` claims \"The width guard measures what the comparison compared\", and that is false one level down.\n\nRival (run, not reasoned): give `StartDrill` a hand-written Debug that prints only the count (`StartDrill(4 rows)`) and remove `positions.sort_unstable()` in `DataStore::matching_positions` (gotcha 18). Result: the law **passes 300 of 300** and the guard never fires. The drills froze 4 records each in hash order, so every one of them differed between contexts, and the comparison could not see it.\n\nA cheap repair, verified locally: count a drill only if the compared text contains the frozen records, e.g. `.filter(|d| left.contains(\u0026format!(\"{:?}\", d.records)))` before `map_or`, passing `\u0026left` into `records_narrowed`. Measured on the M4 Max:\n- that rival plus gotcha 18: the guard fires 200 of 200;\n- clean: the law passes 200 of 200.\n\nRelated, and not a defect in #208: the drill is the law's **only** witness for gotcha 18 in the demo. With `drill_snapshot` switched to `iter_cells().filter(matches_partial)` and gotcha 18 injected, the law passes 200 of 200. That pass is correct, since no command then follows hash order. But no other command's compared text is sensitive to `matching_cells` order in this fixture.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-hzjo2","owner":"el-github@elangley.org","created_at":"2026-09-23T06:23:24Z","created_by":"agent-review-208","updated_at":"2026-09-23T14:56:22Z","started_at":"2026-09-23T06:25:44Z","closed_at":"2026-09-23T14:56:22Z","close_reason":"Fixed in PR #208, merged as bf2de49. Reviewed at 7dfa90f; the btyw delta was the reviewer's verified one-line repair, re-measured by the author.","dependencies":[{"issue_id":"improvise-btyw","depends_on_id":"improvise-b9f2","type":"discovered-from","created_at":"2026-09-23T06:23:37Z","created_by":"agent-review-208","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-wv3h","title":"#205's stale-seed guards are unpinned: dropping SetOpenedEditor(None) on enter-mode (TUI or browser) leaves the workspace green","description":"Found reviewing PR #205 (improvise-ixxx), head 5df8ed2c. Two of #205's stale-seed guards have no test that can fail.\n\n(1) EnterMode's SetOpenedEditor(None) in cmd/mode.rs, pushed when entering an editing mode. Deleting it leaves the whole workspace green (1829 passed, 0 failed).\n(2) The same line in resolve.rs::entered(), the browser twin. Deleting it also leaves 1829 passed, 0 failed.\n\nGuard (1) is reachable. A scratch test (not committed) does the following: grid_holding(Text(\"007\")), cursor (1,1). Press i, then Esc; opened_editor keeps seed \"007\" in Normal mode, since nothing clears it on exit. Move to (0,1), which holds Number(2.0). Type `:enter-mode editing`, Enter, then type 007 and press Enter. At 5df8ed2c this stores Number(7.0), which is correct. With guard (1) deleted it stays Number(2.0): a typed value is swallowed by an escaped editor's seed. The test passes at 5df8ed2c and at gh/main, and fails only under the injection, so it discriminates.\n\nRelated gap: a trimmed comparison in untouched() (seed.trim() == text.trim()) also passes the whole workspace. A text cell ' x' edited to 'x' would then write nothing. No test separates exact from trimmed equality.\n\nFix: add the scratch test as a TUI regression, plus a resolve.rs twin driving `entered()`, plus one untouched() case whose seed and buffer differ only by whitespace. The cheapest place is a follow-up commit on #205 before merge.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-ixxx","owner":"el-github@elangley.org","created_at":"2026-09-23T06:07:23Z","created_by":"agent-review-205","updated_at":"2026-09-23T15:24:59Z","started_at":"2026-09-23T06:09:16Z","closed_at":"2026-09-23T15:24:59Z","close_reason":"Fixed in PR #205, merged after adversarial re-review at 7aff3e7; the later changes were an evidence-only gh/main merge plus the two 465t tests the reviewer requested. Follow-ups: improvise-jibp, improvise-ugj4.","labels":["adversarial-review","pr-205","test-gap"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-2054","title":"An editor edited back to its seed writes nothing: untouched() compares content, so a deliberate re-entry over a concurrent write is silently dropped (#205)","description":"Found reviewing PR #205 (improvise-ixxx), head 5df8ed2c. untouched() judges an editor by CONTENT (buffer == seed captured at open), not by whether the user edited it. So an editor the user deliberately edited BACK to its seed writes nothing, and a concurrent write that landed while it was open wins silently.\n\nCONFIRMED with a scratch test in crates/improvise-command/src/command/cmd/commit.rs (not committed): grid_holding(Number(7.0)), cursor (1,1) = West/Revenue. Press i (seed \"7\"). Apply effect::SetCell(West/Revenue, 5.0), standing in for another client's write. Press Backspace, then 7, then Enter. At 5df8ed2c the cell stays Number(5.0), and the user who typed 7 is told nothing. At gh/main 98d10ca5 the same test stores Number(7.0). So #205 introduces this. The browser keyboard path (resolve::written) and the mouse path (typed_cell_frame) behave the same way by construction, because they share untouched().\n\nThis is the other side of #205's own an_untouched_edit_does_not_overwrite_a_write_made_while_it_was_open, which is right for a truly untouched editor. A content comparison cannot tell 'never touched' from 'touched and put back'.\n\nIt composes badly with improvise-0j0f / PR #188. Once the basis is claimed at open, a touched write over a concurrent change should come back Rejected(RecordMoved), which is loud. #205 swallows it before it is composed, so the refusal the user would have seen never happens. The TUI host has no basis check, so there the outcome flips from 'my 7 wins' to 'their 5 wins, silently'.\n\nOptions (a decision is needed):\n(a) Keep a touched flag on OpenedEditor. Any append-char or pop-char into the edit buffer sets it, and untouched() requires !touched. Both #205 tests still pass, and so does this one.\n(b) Accept content semantics and document them.\nNote: (a) lives in ViewState, so the browser's append/pop resolvers and reduce_view must set the flag too. That is one more Command, or a field SetBuffer carries.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-ixxx","owner":"el-github@elangley.org","created_at":"2026-09-23T06:07:22Z","created_by":"agent-review-205","updated_at":"2026-09-23T15:24:59Z","started_at":"2026-09-23T06:09:19Z","closed_at":"2026-09-23T15:24:59Z","close_reason":"Fixed in PR #205, merged after adversarial re-review at 7aff3e7; the later changes were an evidence-only gh/main merge plus the two 465t tests the reviewer requested. Follow-ups: improvise-jibp, improvise-ugj4.","labels":["adversarial-review","pr-205"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-sboz","title":"ADR: stable identities for categories, items and formulas (transient is acceptable)","description":"USER DIRECTION 2026-09-23: 'there would be a lot of benefit to categories/formulas having stable identities, even if they were only transient: it would help pin editing operations and distinguish them from delete/remove.'\n\nToday the name is the identity. Category.id and Item.id are usize values assigned on add, never keyed on and never persisted. Formula has no id at all and is effectively identified by (target, target_category). So an edit or rename can only be expressed as remove-plus-add, which loses data, and an edit whose target was concurrently replaced can't be told apart from an edit of the replacement.\n\nScope: write an ADR (context/adr/004 or the next free number) that decides:\n(1) What carries an identity: category, item, formula, and possibly view.\n(2) Transient or persistent. The user accepts transient, meaning assigned at load or creation, valid for the life of a hosting session, and never written to .improv. Say what breaks across a reload, a document swap (document_generation), and a rebase.\n(3) How ids cross the wire: ModelCommands name ids rather than names for edit, rename and replace, while add keeps names. Also how a client learns the ids (projection: CategoryTreeCache, FormulaCache).\n(4) How this interacts with ADR 002's RecordHash / CellId surrogates, and the basis check (ADR 001), e.g. an edit naming a formula id that no longer exists is RecordGone-like.\n(5) The migration path. The existing usize ids may be reusable.\n\nConsumers: improvise-km71 (formula edit), improvise-fz77 (category/item rename), and improvise-fdk1 (records coordinate rename).","notes":"USER DIRECTION 2026-09-23 (file format): 'It's probably worth a file format change (in an opt-in way, that gets rectified on save if the input file was missing them).' So ids ARE persisted in .improv. A file without ids stays legal and loads, with ids assigned at load. Saving writes them, so the file is rectified on save. The ADR must cover: the grammar change to improv.pest (it is the single source of truth; the grammar-walking generator must still produce valid files both with and without ids); the round-trip properties (a file without ids parses, gets them on save, and is then stable); what happens when a hand-edited file has duplicate or colliding ids; that legacy JSON stays read-only; and how ids survive the import wizard and CSV.\nUSER SYNTAX SUGGESTION 2026-09-23: 'Maybe something like View: Default(default) and the standard View: |Default (default)|(default) for quoting.' That is, the id follows the name in parentheses, immediately after a bare or pipe-quoted name. The example is a View, which implies views carry ids too.","status":"in_progress","priority":2,"issue_type":"feature","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T06:06:55Z","created_by":"orchestrator","updated_at":"2026-09-23T06:07:40Z","started_at":"2026-09-23T06:07:38Z","lease_expires_at":"2026-09-23T06:12:38Z","heartbeat_at":"2026-09-23T06:07:38Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-sboz","title":"ADR: stable identities for categories, items and formulas (transient is acceptable)","description":"USER DIRECTION 2026-09-23: 'there would be a lot of benefit to categories/formulas having stable identities, even if they were only transient: it would help pin editing operations and distinguish them from delete/remove.'\n\nToday the name is the identity. Category.id and Item.id are usize values assigned on add, never keyed on and never persisted. Formula has no id at all and is effectively identified by (target, target_category). So an edit or rename can only be expressed as remove-plus-add, which loses data, and an edit whose target was concurrently replaced can't be told apart from an edit of the replacement.\n\nScope: write an ADR (context/adr/004 or the next free number) that decides:\n(1) What carries an identity: category, item, formula, and possibly view.\n(2) Transient or persistent. The user accepts transient, meaning assigned at load or creation, valid for the life of a hosting session, and never written to .improv. Say what breaks across a reload, a document swap (document_generation), and a rebase.\n(3) How ids cross the wire: ModelCommands name ids rather than names for edit, rename and replace, while add keeps names. Also how a client learns the ids (projection: CategoryTreeCache, FormulaCache).\n(4) How this interacts with ADR 002's RecordHash / CellId surrogates, and the basis check (ADR 001), e.g. an edit naming a formula id that no longer exists is RecordGone-like.\n(5) The migration path. The existing usize ids may be reusable.\n\nConsumers: improvise-km71 (formula edit), improvise-fz77 (category/item rename), and improvise-fdk1 (records coordinate rename).","notes":"USER DIRECTION 2026-09-23 (file format): 'It's probably worth a file format change (in an opt-in way, that gets rectified on save if the input file was missing them).' So ids ARE persisted in .improv. A file without ids stays legal and loads, with ids assigned at load. Saving writes them, so the file is rectified on save. The ADR must cover: the grammar change to improv.pest (it is the single source of truth; the grammar-walking generator must still produce valid files both with and without ids); the round-trip properties (a file without ids parses, gets them on save, and is then stable); what happens when a hand-edited file has duplicate or colliding ids; that legacy JSON stays read-only; and how ids survive the import wizard and CSV.\nUSER SYNTAX SUGGESTION 2026-09-23: 'Maybe something like View: Default(default) and the standard View: |Default (default)|(default) for quoting.' That is, the id follows the name in parentheses, immediately after a bare or pipe-quoted name. The example is a View, which implies views carry ids too.","status":"closed","priority":2,"issue_type":"feature","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T06:06:55Z","created_by":"orchestrator","updated_at":"2026-09-25T00:29:09Z","started_at":"2026-09-23T06:07:38Z","closed_at":"2026-09-25T00:29:09Z","close_reason":"ADR 007 accepted by the user (comment 5824468104) and merged in PR #210 (2212d9b4)","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-km71","title":"A formula cannot be edited from the formula panel","description":"Reported by the user, 2026-09-23. The formula panel can add a formula (FormulaEdit then commit-buffer formula) and delete one (delete-formula-at-cursor), but it cannot edit an existing one. The only way to change a formula is to delete it and retype it. Expected: a key on the formula under the cursor (Enter or e, matching whatever the panels already use for 'act on this item') opens FormulaEdit with the buffer seeded from that formula's raw text. Committing replaces that formula in place, keeping its position and target, instead of adding a new one. It must work in both front ends: the browser formula panel as well as the TUI (see repo-map gotcha 47 for where the browser resolves keys). Open questions for the implementer: if the edit changes the target, is it the same formula or a new one? And what does Esc restore?","notes":"Orchestrator context, 2026-09-23: identity is the name. Category and Item have a usize 'id' (category.rs), but nothing keys on it. It is returned by add_category/add_item and never persisted in .improv. Model.categories is an IndexMap keyed by name, CellKey and DataStore symbols are names, and views, hidden/collapsed and formulas all refer by name. A Formula has no id at all: {raw, target, target_category, expr, filter} in a Vec, in effect identified by (target, target_category). So a rename must rewrite every name reference, or the design must first introduce a stable identity. Weigh the two before implementing.","status":"closed","priority":2,"issue_type":"feature","assignee":"agent-km71","owner":"el-github@elangley.org","created_at":"2026-09-23T06:03:24Z","created_by":"orchestrator","updated_at":"2026-09-23T16:00:12Z","started_at":"2026-09-23T06:05:11Z","closed_at":"2026-09-23T16:00:12Z","close_reason":"Fixed in PR #214 (formula editing, user-requested), merged after adversarial review (77f059c, then the kb1d delta at f3fb83e). Later changes were conflict-only gh/main merges, the doc-comment fix, and serde re-added for FormulaId only (orchestrator ruling, after tt22).","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-5xbq","title":"No test compares a page-scroll landing between host and browser: halving page_rows, or paging by window_cols, passes the whole workspace (lockstep.rs covers only j and G)","description":"Found reviewing PR #198 at f75b58af (delta review for improvise-5kdr).\n\n#198 moves the browser's page rule into `resolve.rs::page_rows(window_rows)`, and `every_grid_move_lands_where_its_binding_says` reads that function. So a wrong page rule moves the code and the test together. The evidence entry says whether the rule matches the TUI \"is `tests/lockstep.rs`'s question\". That is not true. `lockstep.rs` compares only `j` (`a_locally_resolved_cursor_move_lands_where_the_host_would_put_it`) and `G` against the host. `a_locally_resolved_page_turn_…` is about page-axis cycling, not `page-scroll`. No test presses PgDn/PgUp or Ctrl-d/Ctrl-u on both ends.\n\nThe TUI's rule is written a second time at `improvise-command/src/command/cmd/navigation.rs:121` (`visible_rows * 3 / 4, max 1`). The two copies agree today, but nothing holds them together.\n\nInjected at f75b58af, `cargo test --workspace --no-fail-fast`, whole workspace **green** for each:\n- `page_rows` becomes `window_rows / 2`\n- `grid_move`'s Page arm uses `page_rows(window_cols)`. This is also invisible because the test's window is square (`WINDOW = (8, 8)`), so rows and columns cannot be told apart.\n\nWanted: a lockstep test that presses PgDn, PgUp, Ctrl-d and Ctrl-u from a non-edge start in a non-square window (rows != cols) and compares the client's cursor and offsets with `reduce_key` on a real `App`. Better still, share one page rule between the two crates (design-principles §5: a derivation done twice is the defect).\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-docs-wave","owner":"el-github@elangley.org","created_at":"2026-09-23T05:58:15Z","created_by":"agent-review-191-200-198","updated_at":"2026-09-23T14:59:03Z","started_at":"2026-09-23T06:37:32Z","closed_at":"2026-09-23T14:59:03Z","close_reason":"Fixed in PR #215, merged after adversarial review at a8737f3. Follow-up: improvise-mv1d.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-q9eg","title":"PR #197: TUI p in records mode refuses a record that lacks a coordinate (e.g. after +cat) as an 'aggregate'; the browser pastes","description":"Found in the adversarial review of PR #197 at head 097533e5. It is a regression that #197 introduces, and TUI and browser now disagree.\n\nRepro: records mode, where every regular category sits on None. Add a category (the `+cat` column, or `wb.add_category(\"Product\")`), so the existing records do not carry it yet. Yank a value and press `p` on any measure cell.\n- gh/main (98d10ca5): the value is written (West/Cost 400 -\u003e 42).\n- PR #197: nothing is written, and the status reads \"Cannot write to an aggregate — it combines several records\". `x` on the same cell still clears it. The browser still composes the SetCell, because `cell_id` says `Record`.\n\nCause: `value_cell_key` resolves the records position to the real record key and hands it to `PasteCell`. `paste_into` then re-runs its own `aggregates(ctx, key)` check. That check asks whether the key leaves out a regular None-axis category, and in records mode every regular category is on None, so any record that lacks one coordinate reads as an aggregate. Before #197 the cursor path passed the synthetic key, which took `paste_into_record` and never reached that check. context/code/command.md claims that \"the checks in `paste_into` are reached only by a key a script spells out\". That claim is false: the cursor path reaches them too, so there is still a second derivation beside `cell_id`, and it disagrees with it.\n\nFix direction: keep the key-shape checks off a key the door has already resolved, for example by skipping `aggregates` in records mode or giving the door path its own constructor. Add a lockstep case for `p` on a sparse record.\n","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T05:55:50Z","created_by":"agent-review-197","updated_at":"2026-09-23T15:19:01Z","closed_at":"2026-09-23T15:19:01Z","close_reason":"Fixed in PR #197, merged as 705c92d after adversarial re-review at 087aa85. Later changes were evidence-only merges of gh/main. Follow-ups: improvise-ywj2, improvise-ciw8 (#217).","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-phg1","title":"RecordRewrite: the lone-cell, sparse-row and read-before-clear branches have no test that can fail","description":"Found by adversarial review of PR #204 (improvise-uo2p) at head 92eefeec. The code at that head is correct on all three points below; what is missing is a test that can fail. Three rivals injected into effect::RecordRewrite pass the whole of improvise-command (593 tests), and the first passes the whole workspace (1832):\n\n1. Lone measure-less cell. In `RecordRewrite::of`, change `(false, true) =\u003e vec![(None, row.coords.clone())]` to `Vec::new()`. Renaming a row seeded by `o` / add-record-row then leaves `CellKey([])` behind, so the row loses its coordinates. No test in the workspace fails. A handle_key fuzz trace does catch it. This gap predates #204: the same branch lived in `rename_record_coordinate` and the test set is unchanged.\n2. Sparse drill row. In the second loop of `of` (measures the row has no cell for), change `renamed(\u0026row.cell_key(measure))` to `row.cell_key(measure)`. That reintroduces uo2p's ghost record at the old coordinates for any drill row missing a measure. Every #204 fixture row carries every measure, so the fixture lacks the structure the hazard needs.\n3. Reads before any clear. In `ApplyAndClearDrill`, apply each row's clears as soon as that row's rewrite is built, before the next row reads the model. The suite stays green. A probe fails it and passes on #204: drill rows Food{Revenue=1} and Clothing{Revenue=42, Profit=5}; stage row0 Type-\u003eClothing, row0 Profit=\"\" (a blank edit on an absent measure clears the *destination* key), row1 Type-\u003eFood; expect Food/Jan/Profit = 5.\n\nAcceptance: one test per rival that fails under it and passes at the fix, with evidence entries in context/test-evidence/command.md.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-uo2p","owner":"el-github@elangley.org","created_at":"2026-09-23T05:49:50Z","created_by":"agent-review-204","updated_at":"2026-09-23T06:39:04Z","started_at":"2026-09-23T05:54:59Z","closed_at":"2026-09-23T06:39:04Z","close_reason":"Fixed in PR #209, merged as ea119bd after adversarial review at ccb517c. q2mq was the reviewer-requested fourth test; the merge touched evidence only.","labels":["adversarial-review","pr-204"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-vndi","title":"An inline edit opened before a document swap is accepted into the new document: EditTarget does not capture document_generation","description":"Found re-reviewing PR #188 (improvise-2wu3 + improvise-0j0f) at head a5fbd84f. EditTarget is { cell, version }, and BasisCache::claim_as_read states that record version beside the CURRENT document_generation and layout_version. context/code/clients.md argues the layout tier must stay current (2wu3's renumbering moves it and the identity already makes the write safe) and that argument is right. It does not extend to document_generation, and the doc says 'the document tiers' plural. Rejection::DocumentReplaced's own doc says nothing composed against the outgoing document names anything in the incoming one, whatever tier it claimed.\n\nCONFIRMED with a scratch test against a real Host (not committed). Host over MemoryFs holding other.improv = the same shape with North/Revenue = 5555. A opens the editor on North/Revenue (1200), target version 0. B sends LoadModel(other.improv); A folds the rebase Snapshot (generation 1) without repainting, which is what web/app.js does: repaint() returns early while editing, and nothing closes the editor on a Snapshot. A commits '42'. The frame claims generation 1, record (North, 0). The host ACCEPTS it: the loaded document's 5555, which A never saw, becomes 42, and no one is told. The same RecordHash resolves in the new document because RecordHash is coordinate-derived, and the fresh DataStore's counter matches.\n\nNot introduced by #188: claiming at commit (the rival) and the pre-2wu3 position path accept too. But #188 is where the editor first captures a basis at open, so it is the natural place to capture the generation. Fix shape: EditTarget gains document_generation (or claim_as_read takes it); keep layout_version current. The test should assert Rejected(DocumentReplaced) and that the loaded value survives. The scenario is a two-client LoadModel while an editor is open, and a rename-free fixture is required, see gotcha 39's fixture warning.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-viewhist","owner":"el-github@elangley.org","created_at":"2026-09-23T05:41:56Z","created_by":"agent-review-188b","updated_at":"2026-09-23T17:52:54Z","started_at":"2026-09-23T14:44:07Z","closed_at":"2026-09-23T17:52:54Z","close_reason":"Fixed in PR #219, merged as 202e7ea after adversarial review at 5690903. Later: the agreed union with #205's EditTarget and a gh/main merge; the reviewer had verified the union.","labels":["adversarial-review","pr-188","protocol","wasm-client"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-wm20","title":"The totals strip sums a formula column's results: a ratio column totals 1.50 where the formula over the totalled inputs is the answer","description":"Found while fixing improvise-eccl, filed rather than fixed as the brief directed. viewport.rs::grid_body builds the totals strip by summing each column's cell values over every row. For a raw measure under Sum that is the column's total. For a formula target it is the sum of the formula's per-row results: Margin = (Revenue - Cost) / Revenue at 0.50 on each of three rows totals 1.50, a 150% margin. improvise-eccl ruled that a formula is evaluated over aggregated inputs, never folded itself, which is what Axis::None has always done. The collapsed-group fold now obeys that ruling and the strip does not, so the fold and the total disagree on every ratio column. Proposed answer: a formula column's total is the formula evaluated at GridLayout::column_key(col) with the row categories aggregated away, the value the cell has when the row categories sit on Axis::None. The formula cache does not hold that key when no row category is on None, so evaluate it rather than read the cache. Test with a ratio (a sum agrees under both rules) and with two rows whose ratios differ, so sum, mean and pooled ratio all differ. Mind improvise-pakc, which is about the strip's CellId, not its value.","notes":"USER RULINGS 2026-09-23: the column total equals the column's cell as if every drawn row were one fold (pooled; Margin = total Profit / total Revenue; Avg = mean over records). (1) A text column uses the unanimity rule: the text if every row agrees, otherwise 'mixed'. (2) An error in any row propagates, so the total is an error. (3) When _Measure is on the row axis, the totals strip is empty. Build on top of #203 once it merges.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-collapse-fu","owner":"el-github@elangley.org","created_at":"2026-09-23T05:22:45Z","created_by":"agent-collapse-fu","updated_at":"2026-09-23T15:31:50Z","started_at":"2026-09-23T15:31:50Z","lease_expires_at":"2026-09-23T15:36:50Z","heartbeat_at":"2026-09-23T15:31:50Z","comments":[{"id":"41d898a8-8705-580e-815d-3516f207daca","issue_id":"improvise-wm20","author":"Edward Langley","text":"Delta re-review of PR #203 at b3379d1a (agent-review-197-203b). The strip also moves on `z` for a **stored** measure whose `measure_agg` is not Sum or Count. With Month Q1 = {Jan, Feb}, Region on None and Revenue under Max (Jan N/S 5/1, Feb N/S 2/7), the column sum reads 12 expanded (5 + 7) and 7 collapsed (the max over Q1). The cause is the same as this ticket's: `viewport.rs::grid_body` sums rows whatever the aggregator is. So the fix should cover the stored non-Sum case too. PR #203's core.md §14 currently says \"the total of a **stored** measure does not move on `z`\". That holds only under Sum/Count.\n","created_at":"2026-09-23T14:54:28Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} +{"_type":"issue","id":"improvise-wm20","title":"The totals strip sums a formula column's results: a ratio column totals 1.50 where the formula over the totalled inputs is the answer","description":"Found while fixing improvise-eccl, filed rather than fixed as the brief directed. viewport.rs::grid_body builds the totals strip by summing each column's cell values over every row. For a raw measure under Sum that is the column's total. For a formula target it is the sum of the formula's per-row results: Margin = (Revenue - Cost) / Revenue at 0.50 on each of three rows totals 1.50, a 150% margin. improvise-eccl ruled that a formula is evaluated over aggregated inputs, never folded itself, which is what Axis::None has always done. The collapsed-group fold now obeys that ruling and the strip does not, so the fold and the total disagree on every ratio column. Proposed answer: a formula column's total is the formula evaluated at GridLayout::column_key(col) with the row categories aggregated away, the value the cell has when the row categories sit on Axis::None. The formula cache does not hold that key when no row category is on None, so evaluate it rather than read the cache. Test with a ratio (a sum agrees under both rules) and with two rows whose ratios differ, so sum, mean and pooled ratio all differ. Mind improvise-pakc, which is about the strip's CellId, not its value.","notes":"USER RULINGS 2026-09-23: the column total equals the column's cell as if every drawn row were one fold (pooled; Margin = total Profit / total Revenue; Avg = mean over records). (1) A text column uses the unanimity rule: the text if every row agrees, otherwise 'mixed'. (2) An error in any row propagates, so the total is an error. (3) When _Measure is on the row axis, the totals strip is empty. Build on top of #203 once it merges.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-collapse-fu","owner":"el-github@elangley.org","created_at":"2026-09-23T05:22:45Z","created_by":"agent-collapse-fu","updated_at":"2026-09-24T21:45:06Z","started_at":"2026-09-23T15:31:50Z","closed_at":"2026-09-24T21:45:06Z","close_reason":"Landed in main via #239 (reviewed; merged 2026-09-24).","comments":[{"id":"41d898a8-8705-580e-815d-3516f207daca","issue_id":"improvise-wm20","author":"Edward Langley","text":"Delta re-review of PR #203 at b3379d1a (agent-review-197-203b). The strip also moves on `z` for a **stored** measure whose `measure_agg` is not Sum or Count. With Month Q1 = {Jan, Feb}, Region on None and Revenue under Max (Jan N/S 5/1, Feb N/S 2/7), the column sum reads 12 expanded (5 + 7) and 7 collapsed (the max over Q1). The cause is the same as this ticket's: `viewport.rs::grid_body` sums rows whatever the aggregator is. So the fix should cover the stored non-Sum case too. PR #203's core.md §14 currently says \"the total of a **stored** measure does not move on `z`\". That holds only under Sum/Count.\n","created_at":"2026-09-23T14:54:28Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} {"_type":"issue","id":"improvise-5kdr","title":"every_grid_move_lands_where_its_binding_says sees only the sign of a move: 3x3-from-centre clamps every step, so Ctrl-d moving 1 row and PgDn ignoring the page pass the workspace","description":"Found in adversarial review of PR #198 at head 8772db2e (the improvise-w7qd fix).\n\nevery_grid_move_lands_where_its_binding_says presses every Normal binding from (1,1) of a 3x3 grid (window 3x3) and states each landing as clamp(1+d, 0, 2). From the centre of 3x3 every step of magnitude \u003e= 1 clamps to the edge, so the test sees a SIGN and never a MAGNITUDE. Worse, it states page-scroll's landing as 1 + arg (one row), which is not what page-scroll does (window_rows*3/4 rows); the two coincide only because both clamp.\n\nMEASURED, rivals in crates/improvise-protocol/src/resolve.rs, `cargo test --workspace --no-fail-fast`:\n- Rival B: \"page-scroll\" delta = dir (ignores the page size) AND \"scroll-rows\" Relative(num(args,0)?.signum(), 0) (Ctrl-d/u move 1 row, not 5). WHOLE WORKSPACE GREEN (38 binaries ok). No harness spec presses PgUp/PgDn/Ctrl-d/Ctrl-u either.\n- Rival A: move-selection moves 2*d. The w7qd test PASSES; only lockstep's a_locally_resolved_cursor_move_lands_where_the_host_would_put_it catches it.\n\nA discriminating fixture, prototyped and run: extent 21x21, window (8,8), start (10,10); move-selection/scroll-rows land at 10+d, page-scroll at 10+6*d, jumps at 0/20. Passes on the PR's code; fails on rival B at `PageUp page-scroll [\"-1\"]`, on the scroll-rows half alone at `Ctrl-u scroll-rows [\"-5\"]`, and on rival A at `Up move-selection [\"-1\",\"0\"]`. Cost: the page rule (3/4 of the window) is stated a second time in the test; alternatively state page-scroll's landing from a shared helper.\n\nAlso: the assertion messages still say \"from (1,1)\" and would need the start named.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-helpkeys2","owner":"el-github@elangley.org","created_at":"2026-09-23T05:21:35Z","created_by":"Edward Langley","updated_at":"2026-09-23T06:07:59Z","started_at":"2026-09-23T05:39:24Z","closed_at":"2026-09-23T06:07:59Z","close_reason":"Fixed in PR #198, merged after adversarial review (delta at f75b58a). Follow-up: improvise-5xbq.","labels":["browser","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-w1bu","title":"#196's whole-grid enumeration asserts no None, so a coordinate mis-projected as an editable Record passes","description":"Found in adversarial pre-merge review of PR #196 (head 6b3dd8c).\n\n#196 deletes `a_coordinate_column_is_projected_with_no_identity`, which pinned row 0's Region coordinate as `None`. It relaxes `a_creating_column_is_named_rather_than_left_as_no_cell` from \"exactly one None on row 0\" to \"all Some on row 0\". It adds `a_coordinate_cell_is_named_rather_than_left_as_no_cell`, which pins the exact kind at ONE cell, (1, region). The whole-grid enumeration in that test then asserts only that no cell is `None`.\n\nThat enumeration answers \"is every cell something\", not \"is every cell the right thing\". The dangerous mis-projection is a coordinate projected as `CellId::Record`: `render.rs::editable` then offers it, and a typed value becomes a `SetCell` with `measure: Some(\"Region\")`. The enumeration cannot see that, and after the PR no other test sees it off row 1.\n\nCONFIRMED by injection in `cell_id` (ui/project.rs), at 6b3dd8c:\n RecordCol::Category(c) if row == 0 =\u003e Some(c.clone()), // falls through to CellId::Record\nEvery test in `cargo test --workspace --no-fail-fast` passed, including all 26 in ui::project::cell_identity. At gh/main the same injection fails `a_coordinate_column_is_projected_with_no_identity` and the old exactly-one-None count. So the replacement is strictly less discriminating than what it replaced, in the direction that corrupts data. The same injection keyed to the last row IS caught, but only by the single-cell spot check at (1, region), because records_app has two rows.\n\nRepair shape: in the enumeration, assert each cell's kind against `layout.record_col(c)`: Category -\u003e Coordinate, Measure -\u003e Record or Derived, NewMeasure/NewCategory -\u003e Create. Do not assert `is_some`. Check it with the row-0 injection above.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-viewhist","owner":"el-github@elangley.org","created_at":"2026-09-23T05:20:27Z","created_by":"Edward Langley","updated_at":"2026-09-23T06:35:10Z","started_at":"2026-09-23T05:34:15Z","closed_at":"2026-09-23T06:35:10Z","close_reason":"Fixed in PR #206, merged as 2a52888 after adversarial review at b22f6e5. hoxy was the reviewer-requested test fix; later merges touched evidence only. Follow-up: improvise-v8ab (#207).","dependencies":[{"issue_id":"improvise-w1bu","depends_on_id":"improvise-exdg","type":"related","created_at":"2026-09-23T05:20:33Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-lxg0","title":"A fresh or rebased client's Snapshot carries a phantom back-stack frame naming \"Default\"","description":"Found in adversarial pre-merge review of PR #193 (head f8f4dd5). Pre-existing on gh/main 1a6b877; not introduced by #193, but #193's new text in context/code/command.md rests on its absence.\n\n`project.rs::fresh_view` builds a new client's slice as `ViewState::default()` and then folds `Command::SwitchView(workbook.active_view)`. `ViewState::default().active_view` is the literal \"Default\". `reduce_view::switched` pushes a frame whenever the name it leaves differs from the one it enters. So any document whose active view is not \"Default\" hands every newly connected client a back-stack frame `{view_name: \"Default\", mode: Normal}` that the client never visited. `Sessions::rebase` goes through `reseed` -\u003e `fresh_view`, so a document swap does the same. The doc comment says the slice is \"empty\", so the intent is clear.\n\nCONFIRMED with a probe test in ui/project.rs tests (not committed): `app_with_two_views()`, connect, then `install_workbook(other_document())` with \"Default\" deleted, then `rebase`. The snapshot's `view_back_stack` is `[ViewFrame { view_name: \"Default\", mode: Normal }]`. That frame names a view the incoming document does not have.\n\nConsequences:\n- If the document has a view named \"Default\" (every `Workbook::new` does) but opens elsewhere, `\u003c` in the browser moves the client to \"Default\", which it never visited. That is improvise-lrrx's symptom on the client side.\n- If it has no \"Default\", the client and its server mirror both step onto a dead name (`reduce_view::stepped` does not check). `resolve_view` falls back to the default, so `\u003c` visibly does nothing and pushes a forward frame.\n- The TUI host does not have this. `App::with_storage` sets `active_view` directly, so host and client histories differ from the first frame.\n- #193's new command.md paragraph says the client's stacks \"cannot hold a dead name: ForgetView purges them, and a load arrives as a fresh Snapshot\". This is the counterexample. That claim is the stated reason `reduce_view::stepped` may stay unchecked while `effect::stepped_to` refuses a dead frame.\n\nRepair shape: build fresh_view with `ViewState { active_view: workbook.active_view.clone(), ..ViewState::default() }`, as App::with_storage does. Do not fold a SwitchView. Test: connect and rebase against a document whose active view is NOT \"Default\" and that HAS a \"Default\" view. Assert the snapshot's back stack is empty. A fixture opening on \"Default\" is right by accident.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-viewhist","owner":"el-github@elangley.org","created_at":"2026-09-23T05:20:16Z","created_by":"Edward Langley","updated_at":"2026-09-23T06:35:10Z","started_at":"2026-09-23T05:34:12Z","closed_at":"2026-09-23T06:35:10Z","close_reason":"Fixed in PR #206, merged as 2a52888 after adversarial review at b22f6e5. hoxy was the reviewer-requested test fix; later merges touched evidence only. Follow-up: improvise-v8ab (#207).","dependencies":[{"issue_id":"improvise-lxg0","depends_on_id":"improvise-lrrx","type":"related","created_at":"2026-09-23T05:20:33Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-e1sm","title":"Determinism law passes about 14% of the time with gotcha 18 re-injected, and cannot see nondeterminism inside Effect::apply","description":"Found in adversarial review of PR #187 (improvise-hzjo) at head fc0b13c, merged locally with gh/main da2e5ab.\n\n1. **The law catches gotcha 18 only about 86% of the time.** I re-injected gotcha 18 by commenting out `positions.sort_unstable()` in `DataStore::matching_positions`. `every_command_gives_equal_effects_in_independently_built_equal_contexts` then **passed 55 times in 400 runs**, about 14%. Over 60 failing runs, exactly one (command, args) pair ever diverged: `drill-into-cell [\"Product/Latte\", \"Quarter/Q2\"]`. That drill is one record with two values, so each other instance agrees with probability 1/2, and (1/2)^3 is 12.5%. `fit-measure` never diverged, although the evidence entry says the injection is \"reached through `drill-into-cell` and `fit-measure`'s record order\". The \"four instances make it negligible\" argument holds for the 21-entry buffer rival. It does not hold for the store, where the fixture's orderable set is two cells.\n With `vec![\"Product/Latte\"]` added to `args_the_document_names` (4 records × 2 measures), the injected law failed **400 of 400**. Without the injection it passed 400 of 400, and so did the companion test. No spurious failures.\n Fix: add a drill argument that matches several records. Correct the evidence entry, which names fit-measure. State the residual rate as measured rather than argued.\n\n2. **Scope the law says it has but does not have.** It compares the `Debug` of the effects `execute` returns. Many effects are unit structs that do their work in `apply`: `ApplyAndClearDrill`, `SortData`, `CleanEmptyRecords`, `Save`, `RebuildLayout`, `AcceptWizardModel`. Hash-order nondeterminism inside an `apply` is therefore invisible to the law, and improvise-uo2p is a live P1 instance of it. `context/code/command.md` says the law guards \"the one kind of nondeterminism this repo has actually shipped\". Its \"What it cannot see\" paragraph should add that the law covers `execute` and not `Effect::apply`.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-hzjo2","owner":"el-github@elangley.org","created_at":"2026-09-23T05:09:48Z","created_by":"agent-review-182","updated_at":"2026-09-23T05:52:25Z","started_at":"2026-09-23T05:16:55Z","closed_at":"2026-09-23T05:52:25Z","close_reason":"Fixed in PR #187, merged after adversarial review (content at fc0b13c, fixture delta at 0825f99; later changes were an evidence-only merge and doc corrections). Follow-up: improvise-b9f2.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-0j0f","title":"The inline editor claims its basis at commit, not open: a value another client wrote while the editor was open is silently overwritten","description":"Found reviewing PR #188 (improvise-2wu3), head 384e04ac. That PR captures the edited cell's IDENTITY when the editor opens (edit_target), which fixes the misdirected write. It does not capture the BASIS: typed_record_frame calls self.state().basis.claim([cell.record]) at COMMIT time, and the page's fold goes on while the screen is frozen, so the claim states the version the user was never shown.\n\nCONFIRMED with a scratch test against a real Host at 384e04ac (not committed). Two clients; North/Revenue = 1200. A opens the editor on it (edit_target(0,0)). B writes 999 to the same cell; A folds the relayed frames without repainting, as the page does. A commits '42' via typed_cell(target). The frame claims [(North, 1)], the post-write version, and the host ACCEPTS it: the cell ends at 42, B's 999 is gone, and nobody is told. Same result when B CLEARS the cell: A's commit re-creates it at 42, accepted.\nControl, same fixture: replacing the frame's records with the claim taken at open ([(North, 0)]) gets Rejected(RecordMoved) and 999 survives. So the host's check is sound; the client asks it the wrong question.\n\nThis contradicts the PR's own new text in context/code/clients.md: 'if the record is written by someone else meanwhile, the basis tier refuses it (RecordMoved)'. It does not.\n\nHeld in the same probe: rows renumbered above the editor (ShowItem of a row above) -\u003e the write lands on the opened record; the opened record's item removed (RemoveItem) -\u003e Rejected(RecordGone), nothing resurrected.\n\nPre-existing on gh/main (typed_cell also claimed at commit), but 2wu3's diagnosis ('the stale thing is the screen, and nothing measures that') is only half closed without it. Fix: edit_target returns the record's version (or the whole claim) alongside the RecordCell, and typed_cell claims that. Guard: the scenario above, asserting Rejected(RecordMoved) and the other client's value intact. The keyboard editor (improvise-8xq0) likely wants the same answer.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-wire2","owner":"el-github@elangley.org","created_at":"2026-09-23T05:04:00Z","created_by":"agent-review-183","updated_at":"2026-09-23T06:35:13Z","started_at":"2026-09-23T05:10:33Z","closed_at":"2026-09-23T06:35:13Z","close_reason":"Fixed in PR #188, merged as de70373 after adversarial re-review at a5fbd84. au6c and 4img were the review-requested fixes; later merges touched evidence only. Follow-up: improvise-vndi.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-uv9e","title":"toggle-group-under-cursor ignoring the cursor passes the whole workspace: every test sits at selected (0,0)","description":"Found reviewing PR #183 (improvise-y1av), head a4910eb4, which closed the cursor-at-0 class for the formula/view panel commands. The class is not closed in grid.rs.\n\nToggleGroupAtCursor::execute reads ctx.layout.row_group_for(ctx.selected.0) / col_group_for(ctx.selected.1). Its three tests (toggling_at_the_cursor_names_the_group_the_cursor_sits_in_and_collapses_it, a_second_toggle_at_the_same_cursor_names_the_next_group_not_the_collapsed_one, a_fully_collapsed_row_axis_leaves_the_command_nothing_to_address) all run with the cursor at (0,0), and the fixture puts the first group at row 0, so the group under the cursor and the group at row 0 are the same group in every case. The second test re-collapses at row 0 too.\n\nCONFIRMED by injection at a4910eb4: row_group_for(0) and col_group_for(0) in place of the cursor reads. cargo test --workspace --no-fail-fast: 1771 passed, 0 failed (same as baseline). Reverted.\n\nThe column variant (toggle-col-group-under-cursor, is_row: false) has no command-level test at all.\n\nFix: seat selected.0 on a row in the SECOND group (e.g. Apr, row 2) and assert Q2 is named and Q1 is not; add the column twin with selected.1 nonzero. Checked the neighbours while here: HideSelectedRowItem with .nth(0) is caught by headless_cli; OpenItemAddAtCursor/ToggleCatExpand reading entry 0 are caught only by wasm-client lockstep (the_category_panel_bindings_agree_with_the_host), with no command-level unit test.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T04:57:26Z","created_by":"agent-review-183","updated_at":"2026-09-23T23:54:35Z","started_at":"2026-09-23T23:54:35Z","lease_expires_at":"2026-09-23T23:59:35Z","heartbeat_at":"2026-09-23T23:54:35Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-uv9e","title":"toggle-group-under-cursor ignoring the cursor passes the whole workspace: every test sits at selected (0,0)","description":"Found reviewing PR #183 (improvise-y1av), head a4910eb4, which closed the cursor-at-0 class for the formula/view panel commands. The class is not closed in grid.rs.\n\nToggleGroupAtCursor::execute reads ctx.layout.row_group_for(ctx.selected.0) / col_group_for(ctx.selected.1). Its three tests (toggling_at_the_cursor_names_the_group_the_cursor_sits_in_and_collapses_it, a_second_toggle_at_the_same_cursor_names_the_next_group_not_the_collapsed_one, a_fully_collapsed_row_axis_leaves_the_command_nothing_to_address) all run with the cursor at (0,0), and the fixture puts the first group at row 0, so the group under the cursor and the group at row 0 are the same group in every case. The second test re-collapses at row 0 too.\n\nCONFIRMED by injection at a4910eb4: row_group_for(0) and col_group_for(0) in place of the cursor reads. cargo test --workspace --no-fail-fast: 1771 passed, 0 failed (same as baseline). Reverted.\n\nThe column variant (toggle-col-group-under-cursor, is_row: false) has no command-level test at all.\n\nFix: seat selected.0 on a row in the SECOND group (e.g. Apr, row 2) and assert Q2 is named and Q1 is not; add the column twin with selected.1 nonzero. Checked the neighbours while here: HideSelectedRowItem with .nth(0) is caught by headless_cli; OpenItemAddAtCursor/ToggleCatExpand reading entry 0 are caught only by wasm-client lockstep (the_category_panel_bindings_agree_with_the_host), with no command-level unit test.","status":"closed","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T04:57:26Z","created_by":"agent-review-183","updated_at":"2026-09-24T21:45:01Z","started_at":"2026-09-23T23:54:35Z","closed_at":"2026-09-24T21:45:01Z","close_reason":"Landed in main via #263 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-w7qd","title":"every_grid_move_lands_where_its_binding_says omits scroll-rows/page-scroll and its floor lets the jumps vanish: reversed Ctrl-d/u and PgUp/PgDn pass the whole workspace","description":"Found in re-review of PR #180 at head 544744cf, the follow-up to improvise-6562.\n\n`every_grid_move_lands_where_its_binding_says` derives the expected landing of each `move-selection` binding from its own delta args. The four jumps come from a hand-kept table. Two holes remain.\n\n1. **`scroll-rows` and `page-scroll` are not in it**, although both are advertised: `Ctrl-d`/`u` as `scroll-rows ±5`, and `PgDn`/`PgUp` as `page-scroll ±1`.\n - Injected: negate the arg in both resolver arms (`Relative(-num(args,0)?, 0)` and `Page(-num(args,0)?)`), so all four keys scroll the WRONG way.\n - `cargo test --workspace --no-fail-fast`: **0 failures.**\n - The advertised-key guard cannot see it: from (1,1) a reversed key still moves, so it counts as Acts.\n - The args are deltas like `move-selection`'s, so they can be derived the same way. From the centre of a 3x3 grid, ±5 clamps to row 0 or row 2 and ±1 page does the same, so direction is still visible.\n2. **The floor does not protect the jumps.** The walk presses 22 sequences against `pressed \u003e= 14`. The `move-selection` keys alone make 16. Injected: drop the jump table's `expected.push(..)` AND apply the `$` → `ToStart(false)` rival. **The whole workspace is green.**\n\nFix:\n- Derive `scroll-rows` and `page-scroll` from their args as `move-selection` is.\n- Replace the numeric floor with a per-entry assertion: every expected `(command, args)` presses at least one sequence.\n- Then re-run both injections.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-helpkeys2","owner":"el-github@elangley.org","created_at":"2026-09-23T04:51:46Z","created_by":"agent-review-180","updated_at":"2026-09-23T06:07:59Z","started_at":"2026-09-23T05:03:25Z","closed_at":"2026-09-23T06:07:59Z","close_reason":"Fixed in PR #198, merged after adversarial review (delta at f75b58a). Follow-up: improvise-5xbq.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-0v8l","title":"23 of 27 refusal conversions in #185 are unpinned: reverting any one to set_status leaves the suite green","description":"Found by the adversarial review of PR #185 (improvise-c5ip/udt2), head 8d51ad7.\n\nThe classification of each writer as \"refusal\" (App::refuse / effect::refuse) or \"report\" (set_status) is what the headless exit code reads, but most of it is unpinned. A mutation sweep reverted each of the 27 conversions one at a time (effect::refuse -\u003e effect::set_status, app.refuse(x) -\u003e app.view_state.status_msg = x) and ran improvise-command's lib tests plus tests/headless_cli.rs.\n\n4 were caught: fit.rs:86, commit.rs:839 (CommitSave), effect.rs:108 (AddItem), effect.rs:742 (report_file_write).\n\n23 survived, i.e. could silently go back to exiting 0:\n- cmd/commit.rs:510 (rename_record_coordinate empty), :863 (CommitExport empty path)\n- cmd/mode.rs:337, :344 (EditOrDrill nothing-at-cursor, formula-owned cell)\n- cmd/grid.rs:401, :407 (ViewNavigate), :548, :576 (AddRecordRow)\n- cmd/text_buffer.rs:382 (ExecuteCommand parse error)\n- cmd/search.rs:130 (no matches)\n- ui/effect.rs:86 (AddCategory — note this one used to exit 1 via its abort; the abort is still there, but the exit code no longer reads it, so the refuse call is now the only thing keeping add-category at the limit exiting 1, and nothing pins it)\n- ui/effect.rs:129 (AddItemInGroup), :195 (AddFormula), :672 (apply_coordinate_rename), :894, :898 (StartImportWizard), :938 (LoadModel), :996, :1007, :1015, :1022, :1029, :1068 (ImportJsonHeadless)\n\nSuggested shape, not a per-site test each: one enumerating test (or a table) that drives each refusal's failure input through apply_effects and asserts last_batch_refused(), with a paired success input asserting it is not — the pairing is what stops a \"refuse everywhere\" rival from passing. At minimum pin AddCategory at MAX_CATEGORIES headless, since that is a pre-existing exit-1 case that lost its only mechanism.\n","status":"closed","priority":2,"issue_type":"task","assignee":"agent-refuse-fu","owner":"el-github@elangley.org","created_at":"2026-09-23T04:40:47Z","created_by":"agent-review-185","updated_at":"2026-09-23T14:56:21Z","started_at":"2026-09-23T05:16:54Z","closed_at":"2026-09-23T14:56:21Z","close_reason":"Fixed in PR #213, merged as 345a358 after adversarial review at 3929196. Follow-ups: improvise-we1a, improvise-rtud.","labels":["adversarial-review","area:cli","pr-185"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-8xq0","title":"The keyboard editor's commit writes whatever record is under the cursor at Enter, not the one being edited (2wu3's sibling)","description":"Found by agent-wire2 while fixing improvise-2wu3. CONFIRMED with a scratch probe in improvise-wasm-client/tests/browser_round_trip.rs (not committed). Two clients share one Host, and client A has announced its viewport. A presses i on (0,0) (North/Revenue) and types 42, so the buffer is seeded from North. Client B hides North, and A folds the relayed frames. A then presses Enter. resolve.rs::commit composes SetCell against record_at_cursor(state) AT ENTER TIME, which is South's record (RecordHash 246296...), when North's (161215...) was expected. The basis claim is satisfied because A folded the fresh Projection::Basis, so the host accepts the write, which lands on a row the user was not editing. Unlike 2wu3 the screen is not frozen in this path, but the edit buffer still holds North's value. The 2wu3 fix, capturing the RecordCell when the editor opens, does not reach it, because the keyboard editor's state lives in ViewState (mode plus the edit buffer) and names no cell. A fix probably means recording the edited cell's identity when Editing is entered: a ViewState field or a mode payload, which is a design question, touching improvise-ktlk's settle_editor, gotcha 30 membership and gotcha 25 serde.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-8xq0","owner":"el-github@elangley.org","created_at":"2026-09-23T04:39:29Z","created_by":"agent-wire2","updated_at":"2026-09-24T00:16:06Z","started_at":"2026-09-24T00:16:06Z","lease_expires_at":"2026-09-24T00:21:06Z","heartbeat_at":"2026-09-24T00:16:06Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-8xq0","title":"The keyboard editor's commit writes whatever record is under the cursor at Enter, not the one being edited (2wu3's sibling)","description":"Found by agent-wire2 while fixing improvise-2wu3. CONFIRMED with a scratch probe in improvise-wasm-client/tests/browser_round_trip.rs (not committed). Two clients share one Host, and client A has announced its viewport. A presses i on (0,0) (North/Revenue) and types 42, so the buffer is seeded from North. Client B hides North, and A folds the relayed frames. A then presses Enter. resolve.rs::commit composes SetCell against record_at_cursor(state) AT ENTER TIME, which is South's record (RecordHash 246296...), when North's (161215...) was expected. The basis claim is satisfied because A folded the fresh Projection::Basis, so the host accepts the write, which lands on a row the user was not editing. Unlike 2wu3 the screen is not frozen in this path, but the edit buffer still holds North's value. The 2wu3 fix, capturing the RecordCell when the editor opens, does not reach it, because the keyboard editor's state lives in ViewState (mode plus the edit buffer) and names no cell. A fix probably means recording the edited cell's identity when Editing is entered: a ViewState field or a mode payload, which is a design question, touching improvise-ktlk's settle_editor, gotcha 30 membership and gotcha 25 serde.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-8xq0","owner":"el-github@elangley.org","created_at":"2026-09-23T04:39:29Z","created_by":"agent-wire2","updated_at":"2026-09-24T21:44:52Z","started_at":"2026-09-24T00:16:06Z","closed_at":"2026-09-24T21:44:52Z","close_reason":"Landed in main via #274 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-9fpl","title":"Eight of bok4's refusals (AddItemInGroup, AddFormula parse, ImportJsonHeadless) fail no test when reverted","description":"Found by the adversarial review of PR #181 (improvise-bok4) at head 34ef280.\n\n#181 converts 13 narrated failures in `crates/improvise-command/src/ui/effect.rs` to `refused(app, …)`. I reverted each call site on its own to a status-only write and ran `cargo test --workspace --no-fail-fast`. These reversions **fail no test**:\n\n- `AddItemInGroup` (the `add_item_in_group` Err arm)\n- `AddFormula` (the parse-error arm)\n- all six `ImportJsonHeadless` arms: cannot read (CSV and JSON), CSV error, JSON parse error, array-path error, import error\n\nThese reversions each fail exactly one test: `on_view`, `AddItem`, `DeleteView` and `LoadModel`.\n\nSo eight of the thirteen claimed conversions are unwitnessed. That matters now because PR #185 (c5ip/udt2) rewrites the same lines to `app.refuse(…)`, which does not abort. A conflict resolution that takes #185's side of these eight lines silently drops #181's fix, and nothing turns red. Wanted: a Host-level frame test per effect, or one table-driven test. Each should pair a failing command with a second command that lands, and assert `Rejected(MutationRefused)` and that the landed half is undone.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-wire2","owner":"el-github@elangley.org","created_at":"2026-09-23T04:37:32Z","created_by":"agent-review-181","updated_at":"2026-09-23T05:14:41Z","started_at":"2026-09-23T04:40:05Z","closed_at":"2026-09-23T05:14:41Z","close_reason":"Fixed in PR #181, merged as 810077a. Adversarial review passed at 12171fb; the only later changes were merges of gh/main, touching evidence files only (verified at 2619d17). Follow-ups: improvise-514o, and improvise-v4hg (formula-edit parse arm).","labels":["adversarial-review","pr-181"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-40j4","title":"A client DeleteView of an absent view, and AddFormula into an absent category, are still accepted (bok4 residue)","description":"Found by the adversarial review of PR #181 (improvise-bok4) at head 34ef280. These are the same class bok4 closes: a client frame is reported Accepted after one of its commands did nothing, or did something the client did not ask for. PR #181 missed these two sites.\n\n1. **DeleteView of a view the document does not have.** `Workbook::delete_view` returns `Ok(())` for an absent name when there are two or more views, because `shift_remove` is a no-op. So `effect::DeleteView` refuses only the last-view case. Measured through `Host::handle`: with views Default/Scratch/Other, the frame `[DeleteView(\"Scratch\"), DeleteView(\"Scratch\")]` answers `Accepted`, and a single `DeleteView(\"Ghost\")` is also `Accepted`. `on_view`'s own premise is that \"a view the document no longer has is a failure to report\", and it applies that to nine effects but not to DeleteView.\n\n2. **AddFormula into a category the document does not have.** For a non-`_Measure` target, `effect::AddFormula` runs `let _ = app.model_state.workbook.add_item(\u0026formula.target_category, \u0026formula.target)` and then `add_formula` anyway. Measured: `[RemoveCategory(\"Region\"), AddFormula { raw: \"Total = 1\", target_category: \"Region\" }]` answers `Accepted`, and the document now holds a formula `Total [Region]` for a category that does not exist. It persists: `format_md` writes `- Total = 1 [Region]` and `parse_md` reloads it with no Region category. A single `AddFormula { target_category: \"Ghost\" }` is also Accepted. PR #181 converted AddFormula's parse-error arm but missed this `let _`.\n\nFix shape: DeleteView refuses when the named view is absent. AddFormula refuses when `add_item` errs, before `add_formula` runs. Each fix needs a Host-level frame test like the ones #181 added.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-wire2","owner":"el-github@elangley.org","created_at":"2026-09-23T04:37:30Z","created_by":"agent-review-181","updated_at":"2026-09-23T05:14:41Z","started_at":"2026-09-23T04:40:05Z","closed_at":"2026-09-23T05:14:41Z","close_reason":"Fixed in PR #181, merged as 810077a. Adversarial review passed at 12171fb; the only later changes were merges of gh/main, touching evidence files only (verified at 2619d17). Follow-ups: improvise-514o, and improvise-v4hg (formula-edit parse arm).","labels":["adversarial-review","pr-181"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-v29m","title":"wk18's guard reads only HelpLine::Literal: a hand-built HelpLine::Keys naming an unbound key passes the whole workspace","description":"Found in adversarial review of PR #180 at head 949810cd.\n\nwk18's claim is that a help-page key is looked up, never typed, and that `no_help_row_spells_a_key_by_hand` enforces it. The test reads only `HelpLine::Literal`. `HelpLine::Keys { keys: Vec\u003cString\u003e, .. }` is a public variant any builder can construct directly, and `help_bindings()` records only rows that go through the `keys` parameter, so a hand-built one is invisible to every guard, jhdf's included.\n\nInjected into `panels()` and run:\n\n Some(HelpLine::Keys { keys: vec![\"Q\".to_string()], description: \"Quit instantly\" }),\n\n`Q` is bound to nothing. `cargo test --workspace --no-fail-fast`: **0 failures.** The TUI and the browser (`wasm-client/render.rs`) would both draw it.\n\nA smaller path, also run green: `ex!(\"q or press Q\", ..)`. `every_ex_command_the_help_pages_show_is_one_the_parser_accepts` passes it, because `parse_line` accepts trailing words.\n\nFix options:\n- (a) Make a `Keys` row unconstructible outside `keys()`, e.g. a private field or a private constructor type. That is the repo's \"unrepresentable, not validated\" answer.\n- (b) Assert that the number of `Keys` rows in `pages()` equals the number of rows `help_bindings()` recorded. All of those rows resolve, per `every_keys_row_the_pages_ask_for_finds_its_keys`.\n\nEither way, re-inject the `Q` row and confirm it fails.\n","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T04:35:07Z","created_by":"agent-review-180","updated_at":"2026-09-23T05:44:50Z","closed_at":"2026-09-23T05:44:50Z","close_reason":"Fixed in PR #180, merged after adversarial review (content reviewed at 544744c; the tip-removal delta and the evidence-only merges were reviewed at f248827). Follow-ups: improvise-1c5m, improvise-17ak.","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -177,8 +252,8 @@ {"_type":"issue","id":"improvise-uj4k","title":"wave agent-rules hand-back drops branch, head SHA, PR number and the base-commit check (lost in PR #175's move)","description":"Found reviewing PR #175 at b6d2c47 (adversarial pre-merge review).\n\nPR #175 makes `.agents/skills/wave/agent-rules.md` the one home of the agent-side rules and cuts AGENTS.md's \"Every wave agent's brief must include\" list to a pointer. Two items of the old lists survive in neither new file:\n\n1. **The hand-back's identifiers.** Old AGENTS.md item 7: \"branch and commits, what changed per ticket\". Old wave SKILL.md item 7: \"branch and head SHA, PR number, what changed per ticket\". New agent-rules \"Hand-back, per ticket\" lists claim result, baseline, reproduced, rival, unverifiable, proposed text, not-predicted. No branch, no head SHA, no PR number, no per-ticket change summary. New SKILL.md item 6 re-asks only for \"what the ticket did not predict\". The orchestrator needs the head SHA to dispatch review (SKILL \"Shepherding\": \"give it the head SHA\") and the PR number to watch it (`scripts/pr-watch \u003cpr\u003e --expect \u003csha\u003e`).\n\n2. **The base-commit check.** Old AGENTS.md item 1: \"Verify your base commit first ... `git log --oneline -1` against a named commit\". New SKILL.md item 1 still has the brief name an expected base commit, and agent-rules' intro says the brief supplies it, but no rule tells the agent to compare against it. SKILL item 1's own argument (\"only noticed because it was told to check\") now describes a check nobody is told to make.\n\nNeither was in /tmp/salvage/standing-rules.md either. The loss is in merging the AGENTS/SKILL lists into a rules file that never had them.\n\n## Acceptance Criteria\n- agent-rules.md \"Hand-back, per ticket\" asks for branch, head SHA, PR number and what changed per ticket.\n- agent-rules.md tells the agent what to do with the brief's expected base commit (e.g. check `gh/main` is at or after it after fetching), or SKILL.md item 1 is dropped with a reason.\n","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T04:12:28Z","created_by":"agent-review-175","updated_at":"2026-09-23T04:22:20Z","closed_at":"2026-09-23T04:22:20Z","close_reason":"PR #175 MERGED (confirmed at 9f3f530), adversarially reviewed then re-reviewed after fixes (merge). The agent standing rules now live in the repo at .agents/skills/wave/agent-rules.md, replacing the non-durable /tmp/salvage/standing-rules.md. .claude/skills/wave is a symlink to .agents/skills/wave, so the two drifted copies are one. SKILL.md is the brief checklist's only home, and AGENTS.md points to it. The review confirmed every rule from the /tmp file landed, and the fix restored the two items the move had dropped: hand-back identifiers (branch, head SHA, PR number) and the base-commit check.","labels":["adversarial-review","docs","pr-175"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-c5ip","title":"A scripted commit-cell-edit whose advance aborts exits 1 silently after saving the write","description":"Found by the adversarial review of PR #171 (improvise-2bac) at head d9b10905. Pre-existing, not introduced by #171; #171 NARROWS it.\n\n`improvise cmd 'move-selection 2 0 . commit-cell-edit editing 9 Region/South'` on a 3-row document (cursor ends on the last row) writes South=9, SAVES the document, prints nothing to stderr, and exits 1. Same for `add-category Foo . commit-cell-edit editing 5 Foo/x` on an empty document.\n\nCause: CommitAndAdvance's advance is EnterAdvance, which returns AbortChain when the cursor is already bottom-right (navigation.rs, \"the advance premise no longer holds\"). The CLI maps `last_batch_aborted()` to exit 1 (context/code/cli.md section 3). But the abort here is not a refusal: the write already landed, and the document is saved because `changed` is true.\n\nMeasured with real binaries:\n- pre-#151 (f7573bf): EVERY scripted commit-cell-edit exited 1 with the write saved, because the pure arm built CursorState::default() (row_count 0), so the advance always aborted.\n- #171 head: exits 0 when the cursor is not bottom-right, 1 when it is. The write lands and is saved in both cases.\n\nWhy it matters: a caller that trusts the exit code believes the document was not written, retries or rolls back, and has nothing on stderr to explain it. It is the mirror image of improvise-udt2 (a refusal that exits 0): here a success exits 1.\n\nLikely fix direction: an advance that has nowhere to go is not an abort of the user's intent in a headless run; either EnterAdvance clamps instead of aborting when it is the tail of a commit, or the CLI distinguishes \"a chain stopped after its mutation landed\" from a refusal. Needs a decision about what AbortChain means in the tail position, since the TUI relies on it to skip trailing effects.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-cli","owner":"el-github@elangley.org","created_at":"2026-09-23T04:10:06Z","created_by":"agent-review-171","updated_at":"2026-09-23T04:42:37Z","started_at":"2026-09-23T04:13:02Z","closed_at":"2026-09-23T04:42:37Z","close_reason":"PR #185 MERGED (confirmed at 8d51ad7), adversarially reviewed (merge); CI 13/0. up8g: headless runs pin the extent to 80x24 (HEADLESS_EXTENT), so a script writes the same record whatever terminal ran it. The reviewer restored the terminal-size read and the test failed with R13 vs R40, both in a non-tty shell and under a real 50x150 pty. c5ip/udt2: the exit code reads whether the request was REFUSED (App::refuse sets a per-batch batch_refused flag, in AppSnapshot) rather than whether a chain ABORTED, so a success that aborts exits 0 and a refusal that narrates exits 1. That was the basis for improvise-cz8d, which reworks #181 on top of it. Follow-up: improvise-0v8l (23 of 27 refusal conversions are not pinned by a test, AddCategory most importantly).","labels":["adversarial-review","area:cli","pr-171"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-yf7p","title":"A registry contextualize may overwrite a parsed field on any command but commit-cell-edit, and nothing fails","description":"Found by the adversarial review of PR #171 (improvise-2bac) at head d9b10905.\n\nPR #171 makes the registry's interactive arm `parse` then `contextualize(C, \u0026CmdContext) -\u003e C`, and states (context/code/cli.md section 8) that one hole stays representable: a `contextualize` can OVERWRITE a field `parse` set. It says the hole \"has its own test\". That test, `commit_cell_edit_honours_an_explicit_value_and_cell_over_the_cursor`, covers commit-cell-edit only.\n\nInjected, run, full workspace suite: four rival contextualizers on OTHER commands, each overwriting a parsed field:\n- yank: `|c, ctx| YankCell { key: cursor_key(ctx).unwrap_or(c.key) }`\n- paste: same shape\n- drill-into-cell: same shape\n- toggle-panel-visibility: `panel: Panel::Formula` (ignores the parsed panel)\n- move-panel-cursor: `delta: cmd.delta.signum()` (clamps an explicit delta)\nResult: 1756 passed, 0 failed. Every one of them is the #151/2bac demotion class (explicit args overridden by context) and nothing in the tree sees it. `every_argument_list_the_gate_accepts_the_run_accepts_too` cannot see it by design: it checks acceptance, not content.\n\nSo the class is closed structurally only for \"the interactive arm refuses what parse accepts\" (contextualize is infallible) and for \"a fallback shadows parse\" (rival b). Overwrite is closed by review discipline plus one command's test.\n\nOptions: (1) make it unrepresentable - split each registered command into a parsed part and a context part, so contextualize returns only the context part and cannot name a parsed field; (2) a registry-wide test: for every name x candidate args that parse accepts, build interactive(args) under two contexts that differ in cursor cell, panel flags, panel cursors and edit buffer, and require the Debug of every arg-derived field to equal parse(args)'s. (1) is the repo's usual answer (doctrine: an invariant is made unrepresentable, not validated).\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-registry","owner":"el-github@elangley.org","created_at":"2026-09-23T04:10:01Z","created_by":"agent-review-171","updated_at":"2026-09-23T05:15:07Z","started_at":"2026-09-23T04:12:56Z","closed_at":"2026-09-23T05:15:07Z","close_reason":"Fixed in PR #182, merged after adversarial review at 0a6e940. Follow-up: improvise-3m7b.","labels":["adversarial-review","pr-171"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-l4mp","title":"PasteCell writes SetCell onto an aggregate cell: a None-axis aggregate or a collapsed group's fold","description":"Found planning improvise-6qet. PasteCell (command/cmd/cell.rs ~l.170) emits SetCell(self.key, value) with no aggregate check. That was already true for a cell aggregated over a regular category on Axis::None (the write lands on a partial key); since 6qet it also reaches a collapsed group's fold row, whose key carries the group name (Month=Q1) as a coordinate, so p writes a cell at a non-item. The settability answer already has a home: project.rs::cell_id / GridLayout::folds_a_group plus the none-cats check EditOrDrill uses. Decline (status message) rather than write. Test must fail on the None-axis arm and the fold arm separately.","notes":"None-axis half fixed by PR #184 (merged). Collapsed-group half still open: assigned to the collapse follow-ups agent, which is doing it after #184 via a folds_a_group guard on the cursor path.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-6p8q","owner":"el-github@elangley.org","created_at":"2026-09-23T04:06:17Z","created_by":"agent-collapse3","updated_at":"2026-09-23T04:51:03Z","started_at":"2026-09-23T04:14:58Z","lease_expires_at":"2026-09-23T04:19:58Z","heartbeat_at":"2026-09-23T04:14:58Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-enz6","title":"[bug] Enter-to-advance onto a collapsed group's row opens the editor and commits a write to the group coordinate","description":"Found planning improvise-6qet (not fixed there; outside its ground). Since 6qet a collapsed group is ONE data row whose tuple is the group name (Month=Q1), an aggregate with no record (context/code/core.md §14). EditOrDrill declines on GridLayout::folds_a_group, but CommitAndAdvance (command/cmd/commit.rs ~l.713) re-enters edit through the EnterEditAtCursor effect, which has no such guard. So in Editing, Enter that advances the cursor onto a fold opens the editor there, and the next commit writes SetCell with Month=Q1 — a coordinate that is not an item. Fix belongs with the edit-entry path: ask folds_a_group (the one home) rather than re-deriving 'is this a group row'. Test must seat the fold off row 0 and advance onto it through apply_effects.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T04:05:58Z","created_by":"agent-collapse3","updated_at":"2026-09-23T04:05:58Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-l4mp","title":"PasteCell writes SetCell onto an aggregate cell: a None-axis aggregate or a collapsed group's fold","description":"Found planning improvise-6qet. PasteCell (command/cmd/cell.rs ~l.170) emits SetCell(self.key, value) with no aggregate check. That was already true for a cell aggregated over a regular category on Axis::None (the write lands on a partial key); since 6qet it also reaches a collapsed group's fold row, whose key carries the group name (Month=Q1) as a coordinate, so p writes a cell at a non-item. The settability answer already has a home: project.rs::cell_id / GridLayout::folds_a_group plus the none-cats check EditOrDrill uses. Decline (status message) rather than write. Test must fail on the None-axis arm and the fold arm separately.","notes":"None-axis half fixed by PR #184 (merged). Collapsed-group half still open: assigned to the collapse follow-ups agent, which is doing it after #184 via a folds_a_group guard on the cursor path.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T04:06:17Z","created_by":"agent-collapse3","updated_at":"2026-09-25T01:00:02Z","closed_at":"2026-09-25T01:00:02Z","close_reason":"Landed in #184 (on gh/main): p declines aggregates","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-enz6","title":"[bug] Enter-to-advance onto a collapsed group's row opens the editor and commits a write to the group coordinate","description":"Found planning improvise-6qet (not fixed there; outside its ground). Since 6qet a collapsed group is ONE data row whose tuple is the group name (Month=Q1), an aggregate with no record (context/code/core.md §14). EditOrDrill declines on GridLayout::folds_a_group, but CommitAndAdvance (command/cmd/commit.rs ~l.713) re-enters edit through the EnterEditAtCursor effect, which has no such guard. So in Editing, Enter that advances the cursor onto a fold opens the editor there, and the next commit writes SetCell with Month=Q1 — a coordinate that is not an item. Fix belongs with the edit-entry path: ask folds_a_group (the one home) rather than re-deriving 'is this a group row'. Test must seat the fold off row 0 and advance onto it through apply_effects.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T04:05:58Z","created_by":"agent-collapse3","updated_at":"2026-09-24T21:45:55Z","closed_at":"2026-09-24T21:45:55Z","close_reason":"Landed in main via #277 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-x1uk","title":"AGENTS.md bd-managed Session Completion block prescribes git merge origin/main and a bare git push (the #145 trap)","description":"AGENTS.md's bd-generated block (\u003c!-- BEGIN BEADS INTEGRATION v:1 profile:minimal hash:ca08a54f --\u003e ... END) contains a \"Session Completion\" protocol. It says `git merge origin/main`, then a bare `git push`, and that \"work is NOT complete until git push succeeds\".\n\nFor a wave agent's PR branch, both commands are wrong in this repo:\n- `git merge origin/main` is the PR #145 trap. origin is a mirror that runs ahead of gh/main, so it pulls unmerged commits into a branch based on gh/main. AGENTS.md \"Shipping a wave\" already says this in prose, which leaves the block contradicting its own file.\n- A bare `git push` is refused, because a branch cut from gh/main tracks gh/main. The right form is `git push gh HEAD:refs/heads/\u003cbranch\u003e`.\n\nIt was not fixed by hand because the text sits inside bd's generated, hash-stamped markers. A `bd setup`/upgrade regeneration would silently revert a hand edit, and a hand edit also breaks the recorded hash. Mitigation landed in the wave skill: .agents/skills/wave/agent-rules.md, \"Base, branch, push\" rule 5, tells wave agents the block does not apply to them.\n\nFix options:\n(a) Configure bd with a custom recipe or profile whose template names gh/main and an explicit refspec (see `bd setup --add`, `bd setup -o`), then regenerate.\n(b) Switch the block to a profile that omits the push protocol. The `minimal` profile description in CLAUDE.md's copy already defers git policy to active instructions.\n(c) Upstream: make the template's remote and branch configurable.\n\nAcceptance: the Session Completion text that bd writes into AGENTS.md no longer prescribes `git merge origin/main` or a bare `git push` for PR branches, and survives regeneration.\n","status":"closed","priority":2,"issue_type":"task","assignee":"agent-docs-wave","owner":"el-github@elangley.org","created_at":"2026-09-23T04:03:49Z","created_by":"agent-tooling2","updated_at":"2026-09-23T06:29:42Z","started_at":"2026-09-23T05:01:10Z","closed_at":"2026-09-23T06:29:42Z","close_reason":"Fixed in PR #201, merged after adversarial review (31b5acd, then the delta at 141be9f). Follow-ups: improvise-28ri, improvise-oga4, improvise-bf0q.","comments":[{"id":"c6c76952-d674-540c-8e6b-7bb3f92de1be","issue_id":"improvise-x1uk","author":"agent-tooling2","text":"The wave-skill mitigation is now agent-rules.md 'Base, branch, push' rule 6, not rule 5. improvise-uj4k added a base-check rule at position 1.","created_at":"2026-09-23T04:15:20Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} {"_type":"issue","id":"improvise-j4qt","title":"A read-only browser tab accepts edits, paints them, and loses them","description":"Found while re-checking improvise-i0v9 (agent-webpage, 2026-09-22, at 942211b). Measured in Chromium AND Firefox, two tabs in one context on one document: the second tab opens read-only (correct, 1wft). Pressing i, typing 4242, Enter in the second tab APPLIES the edit to its in-memory App and paints it (18,400.00 -\u003e 184,004,242.00), and only then does the persist fail, replacing the read-only status with 'NOT SAVED ... Cannot write ...: another tab or window is holding this document'. The first tab never sees the edit. After the first tab closes, a further edit in the second tab still reports NOT SAVED (no upgrade path; that half is i0v9's gap 2). So 'read-only' is a claim about storage, not about the session: the tab is a local scratch copy that looks writable. Candidate fix: in a read-only boot the worker refuses Command::Model frames (a Rejection to the asker, as ADR 001 already routes refusals) so the optimistic fold rolls back (improvise-nxah) and nothing un-persistable is ever painted. That needs a Rejection variant in improvise-protocol/src/basis.rs, which is why it was not done in the i0v9 lane.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-wire2","owner":"el-github@elangley.org","created_at":"2026-09-23T03:58:55Z","created_by":"agent-webpage","updated_at":"2026-09-23T04:36:06Z","started_at":"2026-09-23T04:04:43Z","closed_at":"2026-09-23T04:36:06Z","close_reason":"PR #177 MERGED (confirmed at ef7775e), adversarially reviewed (merge once gh/main was merged in and CI was green, which it now is: 13/0, MERGEABLE/CLEAN). A read-only browser tab (another tab holds the OPFS handle) refuses every Command::Model with Rejection::ReadOnly before the host sees it, so an unsaveable edit is never applied or painted. The reviewer's stronger rival (send the frame through the host, then append the rejection) fails both the Rust test and the Playwright grid assertion on the painted value alone. A mixed frame is refused whole. Firefox passes (run locally by the reviewer; CI had not run at all while the PR conflicted).","labels":["browser","opfs"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-ih3k","title":"kb completeness guard cannot see one of two clauses sharing a key (work_done/3, decision/3, work_followup/3)","description":"Found reviewing PR #166 (improvise-usus) at a55b3c7.\n\n`kb-merge.pl complete` compares HEAD and the candidate as SETS of keys (`ground_keys` uses `sort/2`, which dedups). `work_done/3`, `decision/3` and `work_followup/3` are keyed on their first two arguments (`key_arity`) but are NOT `functional/1`, so `kb_consistent` accepts two clauses sharing a key with different third arguments. `kb add` refuses that on one branch, but two branches that each record `work_done(T, bug_fix, ...)` for the same ticket merge into a HEAD holding both (a git conflict at the end of the work_done block whose natural resolution is keep-both). Once HEAD has such a pair, losing either clause from the working tree is invisible to the guard.\n\nReproduced (fixture copies only, via KB_FILE / KB_BASELINE):\n- baseline = HEAD kb.pl + `work_done('improvise-zzzz',bug_fix,'first fix, merged from branch A')` + `work_done('improvise-zzzz',bug_fix,'review fix, merged from branch B')`. `scripts/kb check` on it: `kb_consistent: TRUE`.\n- working copy = the same minus the B clause.\n- `KB_FILE=copy KB_BASELINE=baseline scripts/kb add \u003ca new doctrine\u003e` -\u003e exit 0, `kb-merge: complete -- baseline 811, result 811, 0 removal(s) recorded`. The B clause is gone and the message reports equal totals.\n- Control: dropping a uniquely keyed clause is refused (kb-test case 18; also run by hand).\n\nNo such pair exists in HEAD today (checked all 809 clauses), so this is latent. Possible shapes of a fix: compare keys as a multiset (msort, and require each key's multiplicity in the candidate to be at least HEAD's, less ledger entries), or compare whole clauses (variants) for predicates that are not functional.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-kbguard2","owner":"el-github@elangley.org","created_at":"2026-09-23T03:56:07Z","created_by":"agent-review-kb","updated_at":"2026-09-23T04:29:53Z","started_at":"2026-09-23T04:01:20Z","closed_at":"2026-09-23T04:29:53Z","close_reason":"PR #179 MERGED (confirmed at 9472989), adversarially reviewed (merge); CI 14/0 including the kb job on Linux with GNU stat. ih3k: the completeness guard counts clauses PER KEY, so losing one clause of a pair that shares a key is refused. yea8: the kb rm ledger carries a stamp (sha256 + inode + ns-mtime) of the file its write published, and any rewrite outside the tool voids it. A content hash alone would not work: kb.pl is canonical, so grep -v of a clause is byte-identical to kb rm output. In the reviewer's repro the in-place rewrite even keeps the inode, so the mtime is the part doing the work. za97: the refusal no longer offers committing the loss, and the kb skill documents the guard. Follow-up: improvise-4zei (P4, case 25 cannot see a content-only stamp; case 24 still guards it).","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -188,46 +263,46 @@ {"_type":"issue","id":"improvise-90ea","title":"fmt-workspaces-test never dirties the root package: a script that skips improvise's own src/ passes 17/17","description":"Found by post-merge adversarial review of PR #159 (improvise-b4ks), head 3d143011. This is a fourth fixture vacuity in scripts/fmt-workspaces-test.sh, the same shape as the three its evidence records.\n\nThe fixture's outer root is a package (`root-pkg`) as well as a workspace, which is right. But no case ever dirties the ROOT PACKAGE's own `src/lib.rs`. Only `a/src/lib.rs` (a member) and `nested/src/lib.rs` get dirtied. So a script that formats every member EXCEPT the root package passes the whole suite. In this repo the root package is `improvise`: `src/`, `tests/`, `examples/`, the whole TUI.\n\nCONFIRMED at 40b8913. Rival injected into members_of's python:\n names = [p[\"name\"] for p in doc[\"packages\"] if len(doc[\"packages\"]) == 1 or os.path.dirname(p[\"manifest_path\"]) != doc[\"workspace_root\"]]\n- `./scripts/checkers.sh fmt-workspaces` -\u003e 17 passed, 0 failed (exit 0).\n- `fmt-workspaces.sh --list` shows `improvise` missing from the outer root.\n- A misformatted fn appended to src/ui/text.rs was NOT reported by the gate. Control: with the rival reverted, the same fault IS reported (`Diff in .../src/ui/text.rs:72`).\nAll reverted.\n\nFix: add a case that dirties `$work/repo/src/lib.rs` and requires it to be named (exit 1).\n\nChecked and held: the harness-lint job's `cargo fmt -p opfs-contract -- --check` (working-directory harness/opfs-contract) and this script check the same crate under the same rule. Same stable toolchain, no rustfmt.toml anywhere, so the check is redundant but consistent.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-tooling2","owner":"el-github@elangley.org","created_at":"2026-09-23T03:46:48Z","created_by":"agent-review-wire","updated_at":"2026-09-23T04:19:30Z","started_at":"2026-09-23T03:49:28Z","closed_at":"2026-09-23T04:19:30Z","close_reason":"PR #170 MERGED (confirmed), adversarially reviewed (merge after the ximf one-line fix, which was made); CI green at d11d2ac per pr-watch --expect. scripts/fmt-workspaces.sh: the fixture now misformats EVERY package it finds (discovered independently of the script) and requires each to be named exactly once, and the gate itself refuses (exit 2) if any [package] in the tree belongs to no workspace root. That structural check is what stops a fifth fixture gap; the reviewer added a fourth fixture package and it got its own case automatically. usage() now says exit 2 means NO VERDICT was produced.","labels":["adversarial-review","ci","pr-159","testing","tooling"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-lrrx","title":"View history survives a document swap: install_workbook clears cursors but not the back/forward stacks, so \u003c walks the old document's views in the new one","description":"Found in a post-merge adversarial review of PR #154 (improvise-swzk), head 5de61e48. improvise-8nd9 says ViewBack/ViewForward are \"correct today\" because `ViewState::forget_view` purges both stacks when a view is deleted, and says the class returns \"the moment a second path can put a dead name on a stack\". That second path already exists.\n\n`App::install_workbook` (reached from LoadModel, AcceptWizardModel, and the third WholeWorkbook effect) resets `view_state.active_view` and clears `view_state.cursors`, because \"the incoming workbook's view names have nothing to do with the outgoing one's\" (gotcha 24). It does NOT clear `view_back_stack` or `view_forward_stack`. So the history of the old document survives into the new one, and `\u003c`/`,` walk frames that name the OUTGOING document's views.\n\nCONFIRMED with the headless CLI at gh/main 40b8913:\n- B.improv has views Default and Y, with `Initial View: Y`, and Region North/South/East on row. A.improv has views Default and X.\n- Control: `switch-view X . load B.improv . hide-selected-row-item . w ctl.improv` -f A.improv writes `hidden: Region/North` under `## View: Y`, the view B opened on.\n- Subject: `switch-view X . load B.improv . view-back . hide-selected-row-item . w out.improv` -f A.improv writes `hidden: Region/North` under `## View: Default`. `\u003c` walked A's history into B's Default view, which this client never visited in B. Exit 0 and no status.\n\nWhen the old frame names a view the new document lacks, ViewBack pushes the current view onto the forward stack and then `switch_to_view` refuses. That is 8nd9's phantom frame, and a `\u003c` that visibly does nothing. This follows from the same code (PLAUSIBLE, not run separately).\n\nRepair shape: clear both stacks in `install_workbook` beside `cursors.clear()`, for the same reason. Or give ViewBack/ViewForward swzk's observe-after shape, which is 8nd9. Either way, the regression test needs a fixture where the frame's name EXISTS in the incoming document but is not where it opened (as above). Otherwise a refused switch looks the same as \"nothing happened\".\n\nConnected-client mirrors are not affected: `Sessions::rebase` sends a fresh Snapshot, which replaces ViewState whole. This is the host/TUI's own slice.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-viewhist","owner":"el-github@elangley.org","created_at":"2026-09-23T03:38:04Z","created_by":"agent-review-cmd","updated_at":"2026-09-23T05:38:01Z","started_at":"2026-09-23T04:38:07Z","closed_at":"2026-09-23T05:38:01Z","close_reason":"Fixed in PR #193, merged as a2f3c8d. Adversarial review passed at f8f4dd5; later changes were a gh/main merge touching evidence only, plus a docs correction pointing at improvise-lxg0.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-6qet","title":"[bug] z collapses the neighbouring group and can never re-expand one: a GroupHeader is drawn but has no data row to address it from","description":"Found while replacing improvise-ub4o's vacuous involution test, which is why\nnothing said so: the old test compared execute() against an UNCHANGED context,\nso no assertion in the suite had ever run the command twice over applied state.\n\nMEASURED. Fixture: Month with Q1={Jan,Feb}, Q2={Apr,May} on Row, Type on Column,\ncursor at data row 0. Pressing `z` (toggle-group-under-cursor) three times:\n\n 1. names Q1, collapses it\n 2. names Q2, collapses it \u003c- NOT the group just collapsed\n 3. returns no effects at all \u003c- row_count() is 0\n\nMechanism. expand_category skips every item of a collapsed group, so the group\ncontributes zero AxisEntry::DataItem. ToggleGroupAtCursor resolves through\nGridLayout::row_group_for(ctx.selected.0), which is indexed by DATA row and walks\nback to the nearest preceding GroupHeader. A collapsed group therefore has no data\nrow from which its own header is the nearest preceding one -- so the cursor can\nname it only while it is expanded.\n\nTwo consequences, and the second is the one a user hits:\n\n - Pressing `z` twice collapses two groups rather than restoring one. The cursor\n did not move; the grid moved under it.\n - A collapsed group can be re-expanded only by `:toggle-group`, which names the\n group in text. From the grid there is no way back.\n\nThis is the third bead in a family about exactly that way back -- improvise-lhi4\n(prune_empty dropped the header) and improvise-0o6x (a leading header was never\ndrawn) both fixed the header's VISIBILITY. AxisEntry's doc comment says a\nGroupHeader is always visible \"so its label and collapse state show\". Both of\nthose are now true and neither makes the header ADDRESSABLE, which is the half\nleft over.\n\nNot fixed under ub4o, which is a test bead: the three tests landed there pin the\nbehaviour above as it stands (a_second_toggle_at_the_same_cursor_names_the_next_\ngroup_not_the_collapsed_one, a_fully_collapsed_row_axis_leaves_the_command_\nnothing_to_address), so whichever answer is chosen here must edit them, which is\nthe point -- they are where the decision will be visible.\n\nOPTIONS, none obviously right:\n\n (a) Let the cursor address a header. That means a cursor position that is not a\n data row, which row_group_for's whole signature (data_row -\u003e visual) is built\n to avoid, and which every other cursor-addressed command would then have to\n answer for. Expensive and wide.\n (b) Make ToggleGroupAtCursor remember: if the view has a collapsed group whose\n header is the nearest preceding one in the VISUAL entry stream at the\n cursor's visual position, expand that instead. Keeps the data-row cursor and\n reads the entry stream, which is how lhi4 was resolved -- from the entries,\n with no \u0026View.\n (c) A separate expand-all / cycle-collapse binding, leaving `z` alone.\n (d) Decide it is acceptable because `:toggle-group` exists, and record that here\n plus in the gotcha, so the next person stops re-finding it. The family of\n three beads is evidence somebody keeps re-finding it.\n\nWhichever is chosen, the guard must inject a rival that collapses a neighbour:\n\"some group is named\" is satisfied by the current behaviour.","notes":"USER RULING 2026-09-23, and it reframes the bug. \"When collapsing a group, I'd generally expect the cursor to move to whichever place the group collapsed into. In some cases, the right thing to do might be to attach an argument to the command that is used to invalidate later commands.\"\n\nSo this is NOT \"the header is not addressable\". It is \"THE CURSOR DID NOT FOLLOW THE CONTENT IT WAS ON\". The four options above are all answers to the wrong question, and the chosen shape is a FIFTH that none of them listed.\n\nDECIDED (user, from a presented fork): A COLLAPSED GROUP CONTRIBUTES ONE DATA ROW, WHOSE CELL IS AN AGGREGATE OVER THE GROUP.\n\nThe change is one statement. layout.rs:755 today reads:\n\n // Skip the data item if its group is collapsed.\n if item_group.is_some_and(|g| view.is_group_collapsed(cat_name, g)) { continue; }\n\nA collapsed group emits its GroupHeader and then skips EVERY item, contributing zero AxisEntry::DataItem -- that `continue` is the whole mechanism of this bug. Instead it should emit ONE DataItem for the group.\n\nWHY THIS SHAPE RATHER THAN THE OTHERS. It dissolves the problem instead of working around it:\n\n- The cursor lands on the collapsed group BY ARITHMETIC, with no special case. Fixture Q1={Jan,Feb}, Q2={Apr,May}, cursor at data row 0 (Jan). Collapse Q1: data rows become [Q1, Apr, May], so data row 0 IS Q1 -- exactly \"the place the group collapsed into\". Pressing z again re-expands it, so z is an involution again without anything being taught about involution.\n- NOTHING about ViewCursor, row_group_for or any cursor-addressed command changes. The alternative that literally delivered the ruling (a cursor that names a visual entry) would have changed row_group_for's signature and forced every cursor-addressed command to define what it does on a header row.\n- It is what a pivot table MEANS by a collapsed group: the group's totals in one row. Collapsing currently hides the rows and shows nothing in their place, which is the part that reads as broken.\n- Settability is already answered. ADR 002 puts CellId on the projected cell and grid_of decides it, so the row comes back as CellId::Aggregate and is non-editable in BOTH front ends with no second derivation. Do NOT re-derive editability from \"is this a group row\".\n\nTHE ORDERING CONSTRAINT THAT WILL DECIDE THE IMPLEMENTATION, and it is easy to miss. ToggleGroup is EffectKind::Model(LayoutGeometry) (verified, effect.rs:444). Gotcha 24: since improvise-ni6k the cursor is invalidated by OBSERVING the grid's frame, and apply_effects diffs the frame after every model effect INSIDE the loop. So a collapse RESETS the cursor unless the batch places it afterwards. The placement must therefore come AFTER the ToggleGroup effect in the emitted vec -- the same shape as improvise-o9uq, where \"the order is the entire fix\" and the injected rival was the SWAP rather than the omission. A test that only checks the cursor ends up right must be shown to fail with the two effects reversed.\n\nPART 2 OF THE RULING, FOLDED IN HERE rather than filed as a general mechanism (user's choice): ToggleGroup may carry the group it acted on, so a later command in the same batch can be invalidated by it. State honestly what that does and does not buy. A Binding::Sequence flattens into ONE effect vec (gotcha 21), so `z z` bound as a sequence composes both toggles against the SAME pre-batch context -- that case the argument would fix. TWO SEPARATE KEYPRESSES are a different batch each and were never affected. AND UNDER THE CHOSEN DESIGN THE SAME-BATCH CASE MAY ALSO STOP MATTERING, because the second toggle resolves against a grid where the collapsed group is an addressable row. So: implement the aggregate row FIRST, then check whether the invalidation argument still buys anything. If it does not, say so here and do not add it -- a mechanism with no reachable case is the guard-one-level-away shape this repo keeps paying for.\n\nWHAT ELSE MOVES, since row_count() grows by one per collapsed group:\n- CSV export. persistence/export.rs::render_csv builds with with_frozen_records, NOT new (improvise-iot6, gotcha 1) -- a collapsed group will now appear as a row in exports. Decide whether that is wanted and say so; it is arguably correct and is certainly a behaviour change with round-trip tests to update.\n- Column totals, the browser grid and the viewport arithmetic all read the same GridLayout, so they follow for free -- that is the argument for making the change THERE rather than in a renderer.\n- The three tests PR #156 landed pin the CURRENT behaviour (a_second_toggle_at_the_same_cursor_names_the_next_group_not_the_collapsed_one, a_fully_collapsed_row_axis_leaves_the_command_nothing_to_address). They MUST be edited -- that is deliberate, they are where this decision becomes visible.\n- collapsed_group_has_header_but_no_data_items in layout.rs asserts the premise being changed and must be inverted, not deleted.\n\nGUARD. The rival to inject is a collapse that names a NEIGHBOUR: \"some group is named\" is satisfied by the current broken behaviour. Assert the cursor's position after a collapse of a group that is NOT first on the axis, because a group at data row 0 is right by accident under both the old and new behaviour -- the same trap as a fixture whose expected count is 1.\nIMPLEMENTED (agent-collapse3, branch fix/6qet-collapse-row). PART 2 NOT ADDED: nothing binds z as a Binding::Sequence, and if something did, both toggles resolve against one pre-batch context and name the same group, so the pair is a no-op, which is what z z means. No reachable case. Follow-ups filed: improvise-enz6 (CommitAndAdvance onto a fold), improvise-l4mp (PasteCell onto an aggregate), improvise-b5zh (close_pending_run arm now unreachable). Closes improvise-6eqi's concern: every toggle test starts off data row 0.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-collapse3","owner":"el-github@elangley.org","created_at":"2026-09-23T02:23:31Z","created_by":"agent-cmd2","updated_at":"2026-09-23T04:37:06Z","started_at":"2026-09-23T03:11:52Z","closed_at":"2026-09-23T04:37:06Z","close_reason":"PR #176 MERGED (confirmed at 216b148), adversarially reviewed (merge) and re-reviewed after a real browser-harness failure (a Playwright spec pinned the old collapsed-group row count; fixed, with a new assertion that the collapsed row is not editable). This implements the user's ruling that a collapsed group becomes ONE data row whose cell is an aggregate over the group, so the cursor lands on the collapsed group by arithmetic and z is an involution again. GridLayout::folds_a_group is the one predicate, asked by project::cell_id (CellId::Aggregate) and EditOrDrill. The toggle is emitted before the cursor placement, with the landing row computed from the layout after the toggle. Every toggle test now starts off row 0, which closes 6eqi. Follow-ups from review: improvise-gw18 (\u003e drills into a collapsed row, and o then writes a non-item coordinate), improvise-eccl (a ratio formula on the collapsed row shows 1.00 where None-axis shows 0.50), improvise-f2oo (hidden members make the total move on z), improvise-yk2s (H on a collapsed row saves a phantom hide), plus enz6, l4mp's collapsed half and b5zh.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-obtu","title":"The aggregate-addressing seam: a view-naming command resolved against that view's layout","description":"The remaining improvise-gd28 work, now that the blocker is correctly diagnosed (see improvise-gd28 and gotcha 44).\n\nAn AggregateHash is layout-scoped and cannot be otherwise, so App::resolve_aggregate answers only for the host's active view -- which since improvise-jytm is not the client's. A browser holding CellId::Aggregate(h) therefore cannot get it resolved.\n\nTHE SHAPE, and it is not a new mechanism: let the command NAME ITS VIEW, exactly as ModelCommand::SetAxis { view: String, .. } already does, and resolve the surrogate against a layout derived for that name rather than against app.layout. grid_in already derives a layout for a session on a view the host is not on, so the machinery exists.\n\nExplicitly NOT the alternative: projecting the coordinates to the client. That is precisely what ADR 002 exists to prevent -- an opaque surrogate stops being opaque and the client starts holding document coordinates.\n\nExplicitly NOT needing a SessionId: the mutation a drill entry performs is addressed to NO ONE session -- it configures the shared _Drill view exactly as CreateView/SetAxis already do unaddressed. Only the RESOLUTION of its operand is session-scoped. That asymmetry is the whole finding.\n\nAlso not needed: improvise-fdk1. edit_or_drill declines records-mode editing out loud before reaching a cell, so a browser cannot edit inside a drill grid at all, and the user's \"staging scheme should be identical\" ruling is kept by the browser getting no edits rather than by a second scheme.\n\nNote the contention issue filed separately: _Drill is one view in Workbook::views, so two clients drilling contend.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-23T01:36:28Z","created_by":"Edward Langley","updated_at":"2026-09-23T01:36:28Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-obtu","title":"The aggregate-addressing seam: a view-naming command resolved against that view's layout","description":"The remaining improvise-gd28 work, now that the blocker is correctly diagnosed (see improvise-gd28 and gotcha 44).\n\nAn AggregateHash is layout-scoped and cannot be otherwise, so App::resolve_aggregate answers only for the host's active view -- which since improvise-jytm is not the client's. A browser holding CellId::Aggregate(h) therefore cannot get it resolved.\n\nTHE SHAPE, and it is not a new mechanism: let the command NAME ITS VIEW, exactly as ModelCommand::SetAxis { view: String, .. } already does, and resolve the surrogate against a layout derived for that name rather than against app.layout. grid_in already derives a layout for a session on a view the host is not on, so the machinery exists.\n\nExplicitly NOT the alternative: projecting the coordinates to the client. That is precisely what ADR 002 exists to prevent -- an opaque surrogate stops being opaque and the client starts holding document coordinates.\n\nExplicitly NOT needing a SessionId: the mutation a drill entry performs is addressed to NO ONE session -- it configures the shared _Drill view exactly as CreateView/SetAxis already do unaddressed. Only the RESOLUTION of its operand is session-scoped. That asymmetry is the whole finding.\n\nAlso not needed: improvise-fdk1. edit_or_drill declines records-mode editing out loud before reaching a cell, so a browser cannot edit inside a drill grid at all, and the user's \"staging scheme should be identical\" ruling is kept by the browser getting no edits rather than by a second scheme.\n\nNote the contention issue filed separately: _Drill is one view in Workbook::views, so two clients drilling contend.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-23T01:36:28Z","created_by":"Edward Langley","updated_at":"2026-09-23T01:36:28Z","dependencies":[{"issue_id":"improvise-obtu","depends_on_id":"improvise-pmt0","type":"parent-child","created_at":"2026-09-25T01:05:04Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-t0ma","title":"records_steps is guarded by one incidental test","description":"records_steps (improvise-core/src/view/layout.rs) is guarded by exactly ONE test in the whole workspace, and that test is incidental: injecting is_index_or_dim() -\u003e is_measure() into records_steps failed only save_with_unfinished_record_is_readable, which is a persistence test that happens to enter records mode. Measured during PR #140.\n\nThat is thin for the spine GridLayout auto-detects records mode by (_Index on Row, _Dim on Column). A rival that got the spine wrong in a way that persistence does not observe would ship.\n\nWanted: a step-equivalence test asserting records_steps(model) produces the spine plus exactly one Axis::None step per non-index/dim category, and drill_steps likewise. It must be a test OF THE STEPS, not of the resulting effects -- the effects are cmd/grid.rs's translation and are already covered; the point of the split is that the steps are the model-side answer a browser will also consume.\n\nInject the is_measure() rival and show it fails the new test. Asked for by the ChatGPT Codex review on PR #140.","status":"closed","priority":2,"issue_type":"task","assignee":"agent-collapse-fu","owner":"el-github@elangley.org","created_at":"2026-09-23T01:36:26Z","created_by":"Edward Langley","updated_at":"2026-09-23T06:07:58Z","started_at":"2026-09-23T04:38:25Z","closed_at":"2026-09-23T06:07:58Z","close_reason":"Fixed in PR #191, merged after adversarial review at 31fd2f5.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-up8g","title":"Viewport extent is an external input on BOTH front ends, and it changes which record an edit writes","description":"The viewport extent is an EXTERNAL INPUT that reaches the document, and it is not a browser problem -- the TUI has exactly the same hole and always did.\n\nrun_headless_commands reads crossterm::terminal::size() into app.term_{width,height}, and CmdContext::{visible_rows, visible_cols} derive from it. Measured: term 80x24 -\u003e visible_rows=16; term 200x60 -\u003e visible_rows=52.\n\nAND IT REACHES THE DOCUMENT. Probe: a 40-row single-category grid, identical command sequence, one PageDown then clear-cell at the cursor:\n term 80x24 -\u003e selected=(12,0) -\u003e cleared Row=R12\n term 200x60 -\u003e selected=(39,0) -\u003e cleared Row=R39\nSame commands, DIFFERENT RECORD DELETED.\n\nSo Client::set_viewport is not a browser-introduced external input; it is the browser's SPELLING of an input the TUI has had all along. Gotcha 47's \"not derivable\" is true of BOTH front ends. Any ADR that treats extent as a browser problem will produce a log that replays in a browser and not in a terminal.\n\nTHE INTERACTION THAT MAKES THIS URGENT: today this hazard is INVISIBLE in `improvise script`, because the script path discards the cursor entirely (improvise-6uva). Fixing 6uva makes this live. The extent must be in the log -- or pinned by the replay harness -- IN THE SAME CHANGE that restores context-aware script parsing, or the repair will look like it works and then be flaky.\n\nThis is the cheapest of the five external inputs to close: it is one pair of integers and it already has a wire spelling (ClientFrame::Viewport). Make it a logged command (set-extent \u003crows\u003e \u003ccols\u003e) emitted on resize. The TUI has no such command today -- that is the only new vocabulary needed, and gotcha 42 already argues the extent is deployment-independent.\n\nA regression test must drive TWO sizes and assert the edit lands on the same record. One size cannot see this.\n\nFound by the improvise-ilvl replay audit.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-cli","owner":"el-github@elangley.org","created_at":"2026-09-23T01:24:54Z","created_by":"Edward Langley","updated_at":"2026-09-23T04:42:36Z","started_at":"2026-09-23T04:12:59Z","closed_at":"2026-09-23T04:42:36Z","close_reason":"PR #185 MERGED (confirmed at 8d51ad7), adversarially reviewed (merge); CI 13/0. up8g: headless runs pin the extent to 80x24 (HEADLESS_EXTENT), so a script writes the same record whatever terminal ran it. The reviewer restored the terminal-size read and the test failed with R13 vs R40, both in a non-tty shell and under a real 50x150 pty. c5ip/udt2: the exit code reads whether the request was REFUSED (App::refuse sets a per-batch batch_refused flag, in AppSnapshot) rather than whether a chain ABORTED, so a success that aborts exits 0 and a refusal that narrates exits 1. That was the basis for improvise-cz8d, which reworks #181 on top of it. Follow-up: improvise-0v8l (23 of 27 refusal conversions are not pinned by a test, AddCategory most importantly).","comments":[{"id":"43b16194-af3a-50b5-8265-105b872d2cde","issue_id":"improvise-up8g","author":"agent-review-cmd","text":"NOW LIVE, confirmed by a post-merge review of PR #151 (improvise-6uva, merged as 57be9144). #151 restored context-aware script parsing without logging or pinning the extent in the same change, which is exactly what this ticket asked for.\n\nProbe: a document with one category Region and 60 items, run under a pty through `script -q /dev/null` with `stty rows N`:\n improvise cmd 'page-scroll 1 . hide-selected-row-item' -f doc.improv\n- gh/main 40b8913, 24 rows: hidden: Region/R13\n- gh/main 40b8913, 50 rows: hidden: Region/R32\n- pre-#151 f7573bff, 50 rows: hidden: Region/R01. The cursor was always reset to the origin, so the result did not depend on the terminal.\nThe same script run against the same document writes a different document depending on the height of the terminal that ran it. With no tty (CI, cron), crossterm::terminal::size() fails and the app keeps 80x24, so CI and an interactive shell also disagree.\n","created_at":"2026-09-23T03:36:23Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} -{"_type":"issue","id":"improvise-ilvl","title":"ADR 006: everything is a command, and a recorded sequence replays the whole system","description":"USER RULING 2026-09-22: \"No action anywhere should happen without firing a\ncommand: like with emacs, everything is a command. But I should be able to\nreproduce any state of the whole system by spinning up a fresh copy of an\napplication and replaying a recorded sequence of commands.\"\n\nTWO CLAUSES, and the second is much stronger than the first. \"Everything is a\ncommand\" is a discipline. \"Replay reproduces any state\" is a TESTABLE PROPERTY,\nand it is the one that makes the discipline enforceable rather than aspirational.\n\nWHAT THE TREE ALREADY GIVES US, measured:\n - The Cmd/Effect pipeline IS the Emacs shape: a named command in a registry,\n resolved against a read-only CmdContext, returning effects that are the only\n mutation site. Nothing about that needs inventing.\n - `improvise script \u003cfile\u003e` ALREADY REPLAYS a command sequence into a fresh\n process (src/main.rs::run_headless_script), and PR #131 made it and\n `improvise cmd` one function so they cannot diverge. So half the machinery\n exists and is shipped.\n - Determinism work is largely done and was done for this reason without\n knowing it: gotcha 18 (DataStore iteration order, analyze_records first-seen\n order) and gotcha 37b (a gp fit records seed AND FitConfig, because the same\n seed under a different config lands on a different tree).\n - THERE IS NO RECORDER. parse.rs parses a script; nothing writes one.\n\nSO THE WORK IS (a) the recording side, and (b) closing the paths that bypass the\ncommand layer.\n\nTHE HARD QUESTION, WHICH THE ADR MUST ANSWER FIRST: WHICH VOCABULARY IS RECORDED?\nThere are THREE command-ish layers and they are not interchangeable:\n 1. `Cmd` -- a NAME plus args, resolved against a CmdContext. The Emacs\n analogue, and what `improvise script` already replays.\n 2. `Effect` -- the resolved mutation, the only thing that touches App.\n 3. `Command`/`ModelCommand` -- the WIRE vocabulary, derived from the effects.\n\nGotcha 38 draws the line that matters: A KEYMAP LOOKUP CANNOT RETURN A `Command`,\nbecause a binding names an INTENT resolved against a CmdContext (\"move-cursor\ndown\") while a Command is the RESOLVED MUTATION (\"SetSelected{row,col}\").\nimprovise-gxi's AC 4 asked for something that split forbids.\n\nThat cuts both ways for replay:\n - Recording layer 1 is small, human-readable and editable, and replays only if\n the CONTEXT evolves identically -- which is the Emacs model and is fine from\n a known start state.\n - Recording layer 2 or 3 is robust to context drift but is not \"commands\" in\n the user's sense, and a log of SetSelected{3,4} is not something a person\n reads or edits.\nThe ADR should probably choose layer 1 and say what that costs.\n\nWHAT BREAKS REPLAY TODAY -- enumerate before designing:\n - ANY ACTION THAT DOES NOT GO THROUGH A COMMAND. Audit for it: the browser's\n click surfaces compose Commands (Click::PageItem does), but viewport/resize,\n the ResizeObserver, and the OPFS persist trigger are observations rather than\n commands. Decide whether an observation must become a command or is outside\n the log by construction.\n - EXTERNAL INPUT. LoadModel(path) and the import wizard read files; replay\n reproduces the state only given the same inputs. Does the log reference them,\n or must it capture them?\n - TIME AND RANDOMNESS. A gp fit already records seed + config; anything else\n that samples a clock or an RNG has to.\n - MULTI-CLIENT. \"The state of the whole SYSTEM\" with several sessions means the\n log has to carry WHO acted, which is ADR 001's addressed-command question\n from a new direction -- and gotcha 29 says the model is shared and the UI is\n not, so a replay reproducing every client's UI needs per-session logs.\n\nWHAT THE PROPERTY BUYS, and why it is worth the ADR: a replay harness is the\nstrongest possible regression test for this codebase -- record a session, replay\nit, diff the resulting document. It would have caught the whole class this repo\nkeeps rediscovering (a test that passes while the thing it names is broken),\nbecause a replay diff cannot be vacuous.\n\nDO NOT START BY WRITING A RECORDER. Start by auditing what currently bypasses the\ncommand layer, because that list decides whether the property is reachable at all.","notes":"TWO REFINEMENTS FROM THE USER, 2026-09-22, and the second changes what this bead\nis:\n\n \"(Ignoring nondeterminism like rng seeding)\"\n \"This was how the app used to work, but maybe the web refactor broke it\"\n\nSO RNG/CLOCK DETERMINISM IS OUT OF SCOPE. Strike that bullet from the enumeration\nabove; a gp fit's seed+config recording (gotcha 37b) is a separate concern and\nalready handled.\n\nAND THIS IS ARCHAEOLOGY, NOT DESIGN. The property existed. The first task is to\nfind WHEN and WHERE it stopped holding, not to invent a mechanism. That reframes\nthe whole bead: start with `git log` and a bisect-shaped question, not with a\nrecorder.\n\nWHAT THE WEB WORK INTRODUCED THAT IS A CANDIDATE, found in a first pass:\n\nThe browser refactor deliberately replaced several hand-kept tables with\nOBSERVATIONS, and each is a state change that happens WITHOUT A COMMAND FIRING:\n - App::document_generation += 1 (app.rs:444, bumped by install_workbook)\n - App::cursor_epoch += 1 (app.rs:653, on cursor invalidation)\n - App::layout_version += 1 (app.rs:945, per LayoutGeometry effect)\n - view_frame / view_frames (app.rs:73-124) -- the cursor is invalidated\n by OBSERVING the grid's frame (gotcha 24, improvise-ni6k), which is why no\n effect calls reset_cursor any more\n - model_state.dirty raised in apply_effects off EffectKind (gotcha 45), which\n is why MarkDirty was DELETED as an effect\n\nEACH OF THOSE WAS A DELIBERATE MOVE AND THE ARGUMENT FOR IT IS GOOD: a table of\n\"which effects renumber the grid\" is a second description free to disagree with\nthe first, so the repo chose to observe instead. Gotchas 24, 39 and 45 all say\n\"observed, not predicted\" in as many words.\n\nSO THERE IS A REAL TENSION TO RESOLVE IN THE ADR, and it is the interesting part:\n\"everything is a command\" and \"observe rather than predict\" pull in opposite\ndirections.\n\nBUT PROBABLY NOT A CONTRADICTION, and the ADR should test this claim first\nbecause it may dissolve the whole problem: AN OBSERVATION IS A PURE FUNCTION OF\nSTATE. If replaying the commands reproduces the state, every observation\nre-derives identically and nothing is lost. Observations are DERIVED, not\nprimitive, so they do not need to be in the log -- they need to be REPRODUCIBLE\nFROM it. That is a weaker and much cheaper requirement.\n\nWHAT WOULD GENUINELY BREAK REPLAY is an action whose result is NOT determined by\nthe command sequence. The candidates to audit, in order:\n - VIEWPORT EXTENT. Client::set_viewport records what the PAGE announced, off\n the browser window size and a ResizeObserver. That is external input, not a\n command, and gotcha 47 says it is \"not derivable\". A replay on a different\n window size gets a different extent -- and therefore a different projected\n grid.\n - EXTERNAL FILES. LoadModel(path) and the import wizard read the filesystem;\n replay reproduces state only given the same inputs.\n - MULTI-CLIENT ORDERING. \"The state of the whole system\" with several sessions\n needs the log to carry WHO acted and in what order -- ADR 001's addressed-\n command question from a new direction.\n\nFIRST CONCRETE STEP: `improvise script` already replays a command sequence into a\nfresh process (src/main.rs::run_headless_script, one function with\nrun_headless_commands since PR #131). So the check is cheap -- take a document,\ndrive it with a script, and see whether the result is reproducible. If it is, the\nTUI path still holds the property and the regression is browser-side only, which\nnarrows the search to the list above.\nAUDIT COMPLETE 2026-09-22 (read-only, base a9cad15). Baseline measured on the auditing host: 1699 passed / 0 failed / 7 ignored over 38 binaries, longest single binary 0.70s.\n\nTHE USER'S HYPOTHESIS IS WRONG FOR THE MAIN DEFECT. Replay is broken, but not by the web refactor: see improvise-6uva, broken by 35946af on 2026-04-04, five months before the first wasm-client commit.\n\nTWO DEFECTS FOUND, FILED SEPARATELY: improvise-6uva (script cannot move the cursor) and improvise-up8g (viewport extent is external on both front ends). THE SECOND IS MASKED BY THE FIRST -- fix 6uva and up8g becomes live, so they must be closed together or the repair will look like it works and then be flaky.\n\nTHE TICKET'S STATED SAFETY ARGUMENT IS FALSE, and the false version is dangerous. The ticket claims \"an observation is a pure function of state\". IT IS NOT -- it is a pure function of the TRAJECTORY. Measured, two routes to an IDENTICAL document:\n no commands -\u003e layout_version 0, cursor_epoch 0, dirty false\n set-axis Product column, then set-axis Product row -\u003e layout_version 2, cursor_epoch 2, dirty true\nThe weaker true claim (\"reproducible from the log\") is all replay needs. But the stronger false one is exactly what you would cite to justify SNAPSHOT-PLUS-SUFFIX LOG COMPACTION, and that would be wrong, silently, in the direction that looks like it works: Basis puts document_generation and layout_version ON THE WIRE and clients compare them for equality, so a compacted replay is not basis-compatible with any client holding a claim. model_state.dirty is the same shape and is the worker's OPFS persist trigger (gotcha 45), so a replay reaching the same document by a different route persists a different number of times.\n\nGOTCHA 40 IS STALE, and the truth is stronger than the doc. It says layout_version bumps \"per ModelChange::LayoutGeometry effect\". It actually bumps from an OBSERVED view_shapes diff (observe_reshape, app.rs:938), so three idempotent set-axis calls bump nothing and batching two real changes into one line or two gives the same 2. The observed form is what the ADR should lean on. Correction applied to context/repo-map.md.\n\nDETERMINISM OTHERWISE CHECKS OUT: no clock or RNG is reachable from any command or effect except App::last_autosave (Instant::now(), debounce only, writes to a separate autosave_path and never the document), and fit-measure takes its seed as an explicit argument with a fixed default. One genuine clause-1 violation that costs replay nothing: autosave_if_needed writes a file on a wall-clock timer with NO COMMAND FIRING, so \"everything is a command\" is literally false there.\n\nTHE FIVE GENUINE EXTERNAL INPUTS, ranked by cost to close:\n1. Viewport extent (improvise-up8g) -- cheapest, one pair of integers, already has a wire spelling.\n2. Document identity at startup (get_initial_workbook, -f \u003cpath\u003e) -- cheap: the log header names the starting document by content hash.\n3. External files mid-session (LoadModel, ExportCsv, SaveAs, ImportJsonHeadless, StartImportWizard) -- moderate. Storage is ALREADY the seam (gotcha 2), so a Storage that replays recorded bytes is a clean mechanism needing no effect change.\n4. Import-wizard interaction -- moderate/hard: the keys go through a command but the wizard state is session-local residue with no Projection. Replayable in principle; nothing tests it.\n5. Multi-client ordering and authorship -- HARDEST, and partly a design question. apply_model's basis check means a differently-interleaved replay can REJECT a batch the original accepted, so the log must carry SessionId AND the arrival order across sessions.\n\nWHICH VOCABULARY TO RECORD -- the ADR's central question. The auditor's reading, offered for the ADR to accept or reject: the user's ruling has two clauses wanting different layers. Clause 1 (\"everything is a command, like Emacs\") is a LAYER-1 discipline about what a user can type, bind and read. Clause 2 (\"replay reproduces any state\") is a LOG property, and LAYER 3 (Command/ModelCommand) is strictly better at it -- already Serialize/Deserialize, already carries a Basis, already has one door and one interpreter, already transactional via ClientFrame::Commands, and ALREADY WHAT THE BROWSER EMITS. Note a layer-1 log CANNOT BE PRODUCED BY THE BROWSER AS BUILT: gotcha 38 says a keymap lookup cannot return a Command, so the browser resolves keys to layer 3 and never constructs a layer-1 name.\nThe two are not in conflict if the log records layer 3 and layer 1 keeps its status as the human surface, with ExecuteCommand being just another producer of layer-3 traffic. WHAT THAT COSTS is closing layer 3's three DELIBERATE holes -- ApplyAndClearDrill and AcceptWizardModel off the wire (gotcha 38), ClientFrame::Save a frame not a command (gotcha 45), SwitchView carrying no basis -- each of which has a written argument that would have to be REOPENED. That is the real price and belongs in the ADR rather than being discovered later.\nLayer 2 (Effect) declined: 58 types, no text syntax, no parser, no Serialize, and a log of effects cannot be edited by a person, which is most of what the user asked for.\n\nNOT VERIFIED: the browser half was not driven (no Playwright, no wasm build) -- everything about Client::set_viewport is read, not run, though the TUI analogue WAS run and is the same mechanism. Multi-client interleaving was not exercised. The import wizard was not replayed. No bisect between 35946af and HEAD for ADDITIONAL regressions -- a second archaeology pass after 6uva is fixed is cheap and is where to look next. Only two terminal sizes probed.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-23T00:55:57Z","created_by":"Edward Langley","updated_at":"2026-09-23T01:25:18Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-ilvl","title":"ADR 006: everything is a command, and a recorded sequence replays the whole system","description":"USER RULING 2026-09-22: \"No action anywhere should happen without firing a\ncommand: like with emacs, everything is a command. But I should be able to\nreproduce any state of the whole system by spinning up a fresh copy of an\napplication and replaying a recorded sequence of commands.\"\n\nTWO CLAUSES, and the second is much stronger than the first. \"Everything is a\ncommand\" is a discipline. \"Replay reproduces any state\" is a TESTABLE PROPERTY,\nand it is the one that makes the discipline enforceable rather than aspirational.\n\nWHAT THE TREE ALREADY GIVES US, measured:\n - The Cmd/Effect pipeline IS the Emacs shape: a named command in a registry,\n resolved against a read-only CmdContext, returning effects that are the only\n mutation site. Nothing about that needs inventing.\n - `improvise script \u003cfile\u003e` ALREADY REPLAYS a command sequence into a fresh\n process (src/main.rs::run_headless_script), and PR #131 made it and\n `improvise cmd` one function so they cannot diverge. So half the machinery\n exists and is shipped.\n - Determinism work is largely done and was done for this reason without\n knowing it: gotcha 18 (DataStore iteration order, analyze_records first-seen\n order) and gotcha 37b (a gp fit records seed AND FitConfig, because the same\n seed under a different config lands on a different tree).\n - THERE IS NO RECORDER. parse.rs parses a script; nothing writes one.\n\nSO THE WORK IS (a) the recording side, and (b) closing the paths that bypass the\ncommand layer.\n\nTHE HARD QUESTION, WHICH THE ADR MUST ANSWER FIRST: WHICH VOCABULARY IS RECORDED?\nThere are THREE command-ish layers and they are not interchangeable:\n 1. `Cmd` -- a NAME plus args, resolved against a CmdContext. The Emacs\n analogue, and what `improvise script` already replays.\n 2. `Effect` -- the resolved mutation, the only thing that touches App.\n 3. `Command`/`ModelCommand` -- the WIRE vocabulary, derived from the effects.\n\nGotcha 38 draws the line that matters: A KEYMAP LOOKUP CANNOT RETURN A `Command`,\nbecause a binding names an INTENT resolved against a CmdContext (\"move-cursor\ndown\") while a Command is the RESOLVED MUTATION (\"SetSelected{row,col}\").\nimprovise-gxi's AC 4 asked for something that split forbids.\n\nThat cuts both ways for replay:\n - Recording layer 1 is small, human-readable and editable, and replays only if\n the CONTEXT evolves identically -- which is the Emacs model and is fine from\n a known start state.\n - Recording layer 2 or 3 is robust to context drift but is not \"commands\" in\n the user's sense, and a log of SetSelected{3,4} is not something a person\n reads or edits.\nThe ADR should probably choose layer 1 and say what that costs.\n\nWHAT BREAKS REPLAY TODAY -- enumerate before designing:\n - ANY ACTION THAT DOES NOT GO THROUGH A COMMAND. Audit for it: the browser's\n click surfaces compose Commands (Click::PageItem does), but viewport/resize,\n the ResizeObserver, and the OPFS persist trigger are observations rather than\n commands. Decide whether an observation must become a command or is outside\n the log by construction.\n - EXTERNAL INPUT. LoadModel(path) and the import wizard read files; replay\n reproduces the state only given the same inputs. Does the log reference them,\n or must it capture them?\n - TIME AND RANDOMNESS. A gp fit already records seed + config; anything else\n that samples a clock or an RNG has to.\n - MULTI-CLIENT. \"The state of the whole SYSTEM\" with several sessions means the\n log has to carry WHO acted, which is ADR 001's addressed-command question\n from a new direction -- and gotcha 29 says the model is shared and the UI is\n not, so a replay reproducing every client's UI needs per-session logs.\n\nWHAT THE PROPERTY BUYS, and why it is worth the ADR: a replay harness is the\nstrongest possible regression test for this codebase -- record a session, replay\nit, diff the resulting document. It would have caught the whole class this repo\nkeeps rediscovering (a test that passes while the thing it names is broken),\nbecause a replay diff cannot be vacuous.\n\nDO NOT START BY WRITING A RECORDER. Start by auditing what currently bypasses the\ncommand layer, because that list decides whether the property is reachable at all.","notes":"TWO REFINEMENTS FROM THE USER, 2026-09-22, and the second changes what this bead\nis:\n\n \"(Ignoring nondeterminism like rng seeding)\"\n \"This was how the app used to work, but maybe the web refactor broke it\"\n\nSO RNG/CLOCK DETERMINISM IS OUT OF SCOPE. Strike that bullet from the enumeration\nabove; a gp fit's seed+config recording (gotcha 37b) is a separate concern and\nalready handled.\n\nAND THIS IS ARCHAEOLOGY, NOT DESIGN. The property existed. The first task is to\nfind WHEN and WHERE it stopped holding, not to invent a mechanism. That reframes\nthe whole bead: start with `git log` and a bisect-shaped question, not with a\nrecorder.\n\nWHAT THE WEB WORK INTRODUCED THAT IS A CANDIDATE, found in a first pass:\n\nThe browser refactor deliberately replaced several hand-kept tables with\nOBSERVATIONS, and each is a state change that happens WITHOUT A COMMAND FIRING:\n - App::document_generation += 1 (app.rs:444, bumped by install_workbook)\n - App::cursor_epoch += 1 (app.rs:653, on cursor invalidation)\n - App::layout_version += 1 (app.rs:945, per LayoutGeometry effect)\n - view_frame / view_frames (app.rs:73-124) -- the cursor is invalidated\n by OBSERVING the grid's frame (gotcha 24, improvise-ni6k), which is why no\n effect calls reset_cursor any more\n - model_state.dirty raised in apply_effects off EffectKind (gotcha 45), which\n is why MarkDirty was DELETED as an effect\n\nEACH OF THOSE WAS A DELIBERATE MOVE AND THE ARGUMENT FOR IT IS GOOD: a table of\n\"which effects renumber the grid\" is a second description free to disagree with\nthe first, so the repo chose to observe instead. Gotchas 24, 39 and 45 all say\n\"observed, not predicted\" in as many words.\n\nSO THERE IS A REAL TENSION TO RESOLVE IN THE ADR, and it is the interesting part:\n\"everything is a command\" and \"observe rather than predict\" pull in opposite\ndirections.\n\nBUT PROBABLY NOT A CONTRADICTION, and the ADR should test this claim first\nbecause it may dissolve the whole problem: AN OBSERVATION IS A PURE FUNCTION OF\nSTATE. If replaying the commands reproduces the state, every observation\nre-derives identically and nothing is lost. Observations are DERIVED, not\nprimitive, so they do not need to be in the log -- they need to be REPRODUCIBLE\nFROM it. That is a weaker and much cheaper requirement.\n\nWHAT WOULD GENUINELY BREAK REPLAY is an action whose result is NOT determined by\nthe command sequence. The candidates to audit, in order:\n - VIEWPORT EXTENT. Client::set_viewport records what the PAGE announced, off\n the browser window size and a ResizeObserver. That is external input, not a\n command, and gotcha 47 says it is \"not derivable\". A replay on a different\n window size gets a different extent -- and therefore a different projected\n grid.\n - EXTERNAL FILES. LoadModel(path) and the import wizard read the filesystem;\n replay reproduces state only given the same inputs.\n - MULTI-CLIENT ORDERING. \"The state of the whole system\" with several sessions\n needs the log to carry WHO acted and in what order -- ADR 001's addressed-\n command question from a new direction.\n\nFIRST CONCRETE STEP: `improvise script` already replays a command sequence into a\nfresh process (src/main.rs::run_headless_script, one function with\nrun_headless_commands since PR #131). So the check is cheap -- take a document,\ndrive it with a script, and see whether the result is reproducible. If it is, the\nTUI path still holds the property and the regression is browser-side only, which\nnarrows the search to the list above.\nAUDIT COMPLETE 2026-09-22 (read-only, base a9cad15). Baseline measured on the auditing host: 1699 passed / 0 failed / 7 ignored over 38 binaries, longest single binary 0.70s.\n\nTHE USER'S HYPOTHESIS IS WRONG FOR THE MAIN DEFECT. Replay is broken, but not by the web refactor: see improvise-6uva, broken by 35946af on 2026-04-04, five months before the first wasm-client commit.\n\nTWO DEFECTS FOUND, FILED SEPARATELY: improvise-6uva (script cannot move the cursor) and improvise-up8g (viewport extent is external on both front ends). THE SECOND IS MASKED BY THE FIRST -- fix 6uva and up8g becomes live, so they must be closed together or the repair will look like it works and then be flaky.\n\nTHE TICKET'S STATED SAFETY ARGUMENT IS FALSE, and the false version is dangerous. The ticket claims \"an observation is a pure function of state\". IT IS NOT -- it is a pure function of the TRAJECTORY. Measured, two routes to an IDENTICAL document:\n no commands -\u003e layout_version 0, cursor_epoch 0, dirty false\n set-axis Product column, then set-axis Product row -\u003e layout_version 2, cursor_epoch 2, dirty true\nThe weaker true claim (\"reproducible from the log\") is all replay needs. But the stronger false one is exactly what you would cite to justify SNAPSHOT-PLUS-SUFFIX LOG COMPACTION, and that would be wrong, silently, in the direction that looks like it works: Basis puts document_generation and layout_version ON THE WIRE and clients compare them for equality, so a compacted replay is not basis-compatible with any client holding a claim. model_state.dirty is the same shape and is the worker's OPFS persist trigger (gotcha 45), so a replay reaching the same document by a different route persists a different number of times.\n\nGOTCHA 40 IS STALE, and the truth is stronger than the doc. It says layout_version bumps \"per ModelChange::LayoutGeometry effect\". It actually bumps from an OBSERVED view_shapes diff (observe_reshape, app.rs:938), so three idempotent set-axis calls bump nothing and batching two real changes into one line or two gives the same 2. The observed form is what the ADR should lean on. Correction applied to context/repo-map.md.\n\nDETERMINISM OTHERWISE CHECKS OUT: no clock or RNG is reachable from any command or effect except App::last_autosave (Instant::now(), debounce only, writes to a separate autosave_path and never the document), and fit-measure takes its seed as an explicit argument with a fixed default. One genuine clause-1 violation that costs replay nothing: autosave_if_needed writes a file on a wall-clock timer with NO COMMAND FIRING, so \"everything is a command\" is literally false there.\n\nTHE FIVE GENUINE EXTERNAL INPUTS, ranked by cost to close:\n1. Viewport extent (improvise-up8g) -- cheapest, one pair of integers, already has a wire spelling.\n2. Document identity at startup (get_initial_workbook, -f \u003cpath\u003e) -- cheap: the log header names the starting document by content hash.\n3. External files mid-session (LoadModel, ExportCsv, SaveAs, ImportJsonHeadless, StartImportWizard) -- moderate. Storage is ALREADY the seam (gotcha 2), so a Storage that replays recorded bytes is a clean mechanism needing no effect change.\n4. Import-wizard interaction -- moderate/hard: the keys go through a command but the wizard state is session-local residue with no Projection. Replayable in principle; nothing tests it.\n5. Multi-client ordering and authorship -- HARDEST, and partly a design question. apply_model's basis check means a differently-interleaved replay can REJECT a batch the original accepted, so the log must carry SessionId AND the arrival order across sessions.\n\nWHICH VOCABULARY TO RECORD -- the ADR's central question. The auditor's reading, offered for the ADR to accept or reject: the user's ruling has two clauses wanting different layers. Clause 1 (\"everything is a command, like Emacs\") is a LAYER-1 discipline about what a user can type, bind and read. Clause 2 (\"replay reproduces any state\") is a LOG property, and LAYER 3 (Command/ModelCommand) is strictly better at it -- already Serialize/Deserialize, already carries a Basis, already has one door and one interpreter, already transactional via ClientFrame::Commands, and ALREADY WHAT THE BROWSER EMITS. Note a layer-1 log CANNOT BE PRODUCED BY THE BROWSER AS BUILT: gotcha 38 says a keymap lookup cannot return a Command, so the browser resolves keys to layer 3 and never constructs a layer-1 name.\nThe two are not in conflict if the log records layer 3 and layer 1 keeps its status as the human surface, with ExecuteCommand being just another producer of layer-3 traffic. WHAT THAT COSTS is closing layer 3's three DELIBERATE holes -- ApplyAndClearDrill and AcceptWizardModel off the wire (gotcha 38), ClientFrame::Save a frame not a command (gotcha 45), SwitchView carrying no basis -- each of which has a written argument that would have to be REOPENED. That is the real price and belongs in the ADR rather than being discovered later.\nLayer 2 (Effect) declined: 58 types, no text syntax, no parser, no Serialize, and a log of effects cannot be edited by a person, which is most of what the user asked for.\n\nNOT VERIFIED: the browser half was not driven (no Playwright, no wasm build) -- everything about Client::set_viewport is read, not run, though the TUI analogue WAS run and is the same mechanism. Multi-client interleaving was not exercised. The import wizard was not replayed. No bisect between 35946af and HEAD for ADDITIONAL regressions -- a second archaeology pass after 6uva is fixed is cheap and is where to look next. Only two terminal sizes probed.","status":"in_progress","priority":2,"issue_type":"task","assignee":"agent-adr006","owner":"el-github@elangley.org","created_at":"2026-09-23T00:55:57Z","created_by":"Edward Langley","updated_at":"2026-09-25T04:45:22Z","started_at":"2026-09-25T04:45:22Z","lease_expires_at":"2026-09-25T04:50:22Z","heartbeat_at":"2026-09-25T04:45:22Z","dependencies":[{"issue_id":"improvise-ilvl","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T01:05:08Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-f9qf","title":"Pin the shell: macOS bash 3.2 hid two bugs that failed 17/19 in CI on the same commit","description":"USER RULING 2026-09-22: \"Bash really should be gotten from nix: macOS bash is old\nand deprecated, or just use zsh for scripts. You can use a nix shebang line\n(nix-shell manpage has details here).\"\n\nWHY, MEASURED RATHER THAN ASSERTED. improvise-xgra (PR #133) shipped two bugs that\nwere invisible on the machine that wrote them:\n - `local n=\"$1\" name=\"$2\" f=\"...$n...\"` is NOT left-to-right on bash 5 -- every\n name is created before the assignments run, so $n is unbound under set -u.\n - macOS ships bash 3.2 (2007, GPLv2, frozen), where it works.\nRESULT: scripts/pr-reviews-test.sh passed 19/19 LOCALLY AND 2/19 IN CI ON THE SAME\nCOMMIT. Both bugs surfaced as exit 2 with no output, because a dead command\nsubstitution hands jq an empty --argjson and jq answers with a USAGE error.\n\nTHE INVENTORY, as of 22ab58f: 16 scripts, with 11 `#!/usr/bin/env bash`,\n4 `#!/bin/sh` and 1 `#!/usr/bin/env sh`. So the bash/sh split is already\ninconsistent, and the sh ones are running as macOS bash-in-sh-mode locally and\ndash in CI -- a SECOND undetected platform difference of the same kind.\n\nTHE TWO OPTIONS THE USER NAMED:\n a. A NIX SHEBANG, e.g.\n #!/usr/bin/env nix-shell\n #!nix-shell -i bash -p bash\n which pins ONE bash for every developer and CI alike. Cost: nix-shell\n startup on every invocation, which matters for scripts CI runs per job and\n for anything in a tight loop -- MEASURE IT before committing, and say what it\n costs on a cold and a warm store.\n b. ZSH, which macOS ships current and which is the login shell there. No startup\n cost, but it is a different language in the places that bite (word splitting,\n array indexing from 1, `setopt` needed for sh-compatible globbing), so\n PORTING IS NOT A SHEBANG CHANGE -- every script would need reading.\n\nDECIDE ONE AND APPLY IT UNIFORMLY. A per-script choice reintroduces exactly the\ninconsistency above.\n\nCHECK BEFORE CHOOSING, because it may decide it:\n - Does CI have nix on every job that runs a script? The kb job uses\n `nix shell nixpkgs#trealla`, so nix is present there; confirm for build, test\n and browser.\n - Does the git merge driver (scripts/beads-merge-driver.sh) tolerate a\n nix-shell startup? It runs inside git's merge machinery.\n - Does .envrc / direnv already put a nix bash on PATH? If so, `#!/usr/bin/env\n bash` may already be correct INSIDE the dev shell and wrong only outside it --\n which would make the fix \"run scripts through direnv\" rather than a shebang\n change, and would be cheaper than either option.\n\nWHATEVER LANDS, THE GUARD IS THE POINT: the bug this closes was invisible to a\nlocal run, so the fix must be verified by something that RUNS BOTH SHELLS, or by\npinning so there is only one. A checker wired into CI in the same change is the\nminimum -- improvise-xgra's lesson exactly.","notes":"FIXED in PR #137 (merge a9cad15).\n\nTHE ENVIRONMENT, NOT THE INTERPRETER, WAS THE LOAD-BEARING HALF -- and this\nticket, the originating commit, and the agent's own first hypothesis all blamed\nthe interpreter.\n\nDIRENV DOES NOT SOLVE IT, and the reason is the finding. .envrc puts nix bash\n5.3.15 on PATH, which IS CI's semantics. But pkgs.mkShell ALSO EXPORTS ~30\nGENERICALLY-NAMED LOWERCASE VARIABLES THE RUNNER DOES NOT HAVE -- name, out,\noutputs, system, shell, builder, stdenv, phases, patches, buildInputs, doCheck,\nstrictDeps and more. A script reading one by accident under set -u is SILENT IN\nTHE DEV SHELL AND FATAL IN CI.\n\nThat is exactly what happened. pr-reviews' broken third clause read $name, and in\nthe dev shell $name is \"nix-shell-env\". Reproduced at 051f541^, one machine, one\nsitting:\n macOS /bin/bash 3.2 19 passed, 0 failed\n direnv exec . (nix bash 5.3) 19 passed, 0 failed\n the same, with `name` unset 2 passed, 17 failed \u003c- matches CI exactly\nTWO DIFFERENT REASONS FOR GREEN, ONE OF THEM THE CORRECT INTERPRETER.\n\nNote also: on CI it is $name that is unbound, NOT $n -- `fetch` declares `local\nn`, so dynamic scoping keeps n bound. A fix aimed only at the shell would have\nshipped.\n\nVERIFIED BY THE ORCHESTRATOR, and my first check was confounded: `nix shell\nnixpkgs#bash` appeared to set `name` too, until I re-ran with `env -u name` and\nit came back UNSET while `direnv exec .` still gave nix-shell-env. My check had\ninherited the variable from my own direnv-loaded shell -- it answered \"does name\nend up set\" where the question is \"does nix shell set it\". The agent's control\nwas right and mine was not.\n\nTHE NIX SHEBANG WAS DECLINED ON AVAILABILITY, NOT SPEED. Measured warm, 20\ninvocations: ambient `bash -c true` 5.1ms; `nix shell nixpkgs#bash --command`\n61.8ms; `nix-shell -p bash --run` (THE MANPAGE FORM) 675.2ms, 132x. 675ms inside\ngit's merge machinery for beads-merge-driver.sh is bad -- but the decider is that\nNIX IS ON ONLY THE kb JOB. build, test, wasm, js, harness-lint, browser and\npages-deploy have none, so a nix shebang needs install-nix-action added to three\nmore jobs, paid every run, to reach a bash those runners already ship. zsh\ndeclined too: not a shebang change, sixteen scripts to read, and no zsh on the\nrunner either.\n\nWHAT LANDED: scripts/checkers.sh, the ONE entry point for every check_the_checker,\ncalled verbatim by CI and by the local gate. It pins a bash \u003e= 5 (ambient first,\nnix only when the ambient one is older, exit 3 when neither), SCRUBS THE\nENVIRONMENT TO AN ALLOWLIST, and refuses any shebang in scripts/ that is not\n#!/usr/bin/env bash. CI logs `checkers: bash 5.2 at /usr/bin/bash` -- ambient, no\nnix, zero startup.\n\nTHE INJECTED FAULT, which is the proof: restoring the multi-assignment `local`\ngives 22 passed under the OLD local gate, 22 passed under direnv, and\n2 passed / 20 failed / exit 1 under checkers.sh.\n\nTHE OLD GATE WAS ITSELF THE BUG. `for t in scripts/*-test.sh; do sh \"$t\"; done`\nOVERRIDES EVERY SHEBANG with /bin/sh, so on macOS it ran every suite under bash\n3.2 in sh-mode regardless of what the file said. The gate in my brief was the\ndisease. Seven other sites ran a repo script through an explicit `sh`, and one of\nthem is dist-workspace.toml -- which a first-line shebang gate STRUCTURALLY CANNOT\nSEE. The shebang rule closes declarations, never invocations.\n\nUNRUN, STATED: scripts/build-web-release.sh runs only under dist in\nbuild-global-artifacts, which no branch push exercises, so its new shebang and two\nchanged lines are mechanical and untested.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T22:31:17Z","created_by":"Edward Langley","updated_at":"2026-09-22T23:16:23Z","closed_at":"2026-09-22T23:16:23Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-clso","title":"Ctrl-S saves and also opens the browser's own save dialog","description":"Found by the improvise-jz6w agent (PR #134) as a consequence of its own fix, and\ncorrectly NOT fixed there because every available fix crosses a doctrine line.\n\nNOT A REGRESSION -- verified by the orchestrator. web/app.js:786 calls\npreventDefault only when `!e.ctrlKey \u0026\u0026 !e.altKey`, with the deliberate comment\n\"Ctrl/Alt combinations are left alone: they are the browser's\". So Ctrl-S ALWAYS\nopened Chrome's \"Save page as\" dialog; before improvise-jz6w it did that and\nnothing else. Now it saves the document AND opens the dialog. The save is\ncorrect; the dialog is the wart, and it is newly VISIBLE rather than newly\nintroduced.\n\nWHY IT WAS NOT FIXED IN #134, and this is the useful part:\n - Parsing the frame in app.js gives the page PROTOCOL KNOWLEDGE\n (design-principles section 5).\n - A `e.key === \"s\" \u0026\u0026 e.ctrlKey` test puts a KEYBINDING FACT in the page.\n - Widening the wasm surface so Rust can say \"this key is claimed exclusively\"\n is a new decision the ticket did not authorise.\n\nTHE HONEST SHAPE: the DECISION -- \"is this key exclusively claimed by the app?\" --\nbelongs in Rust as a third thing a resolver already knows, beside Local and\nRequest. That is a natural follow-up to the KeyOutcome variant #134 added, not a\npatch to app.js.\n\nWHATEVER LANDS, THE ASSERTION GOES IN harness/tests/shell.spec.js (gotcha 48: a\nchange to web/*.js belongs in that spec directory). Note harness/node_modules does\nnot exist in a fresh worktree, so `npx playwright test` fails with an error about\nthe PACKAGE rather than the harness -- run npm install in harness/ first.\n\nCHECK BEFORE BUILDING: whether any other Ctrl chord is now in the same position.\nThe preventDefault rule is blanket, so the answer is \"every Ctrl binding the\nresolver claims\", and today that is one.","notes":"SECOND INSTANCE, AND IT IS WORSE: Ctrl-W. Found by the improvise-htl6 agent while hunting for a free key; verified by the orchestrator at keymap.rs:611 --\n\n normal.bind_args(KeyCode::Char('w'), ctrl, \"enter-mode\", vec![\"save-prompt\".into()])\n\nwith the comment \"Ctrl-S saves to the known path; Ctrl-W asks for one\" (improvise-lae).\n\nIN A BROWSER, Ctrl-W CLOSES THE TAB. Same mechanism as the Ctrl-S wart -- web/app.js calls preventDefault only when !e.ctrlKey \u0026\u0026 !e.altKey -- but the consequence is not a spurious dialog the user dismisses, it is THE DOCUMENT'S TAB CLOSING. Whatever the save prompt does is invisible, because the surface it would draw on is gone.\n\nTHE GENERALISATION, which is why this belongs here rather than in its own ticket: THE KEYMAP IS SHARED BETWEEN THE TWO FRONT ENDS (improvise-bpq2, gotcha 47), so a Ctrl- chord chosen for a terminal is one the browser must live with, and the browser has a reserved set the terminal does not. Ctrl-R (reload), Ctrl-S (save page), Ctrl-W (close tab), Ctrl-T, Ctrl-N are all browser-owned. So the fix for this ticket should be stated over the CLASS -- what happens to a browser-reserved chord in a shared keymap -- rather than per key, or a third instance arrives the next time somebody needs a free Ctrl- binding.\n\nNote the constraint that makes this hard is already recorded above: every available fix crosses a doctrine line, because preventDefault-ing selectively needs the page to know which chords the app binds, which is protocol knowledge (design-principles section 5). The shared keymap is where that knowledge already lives, which is a hint at where the answer is.\n\nALSO WORTH RECORDING: Ctrl-R is NOT bound today, and the htl6 agent deliberately did not take it for view-forward for exactly this reason -- it chose ',' instead. So this ticket has already changed one decision.\nANALYSIS 2026-09-22 by agent-helpkeys2 (NOT fixed; this needs a ruling). Read at gh/main 4d71c01.\n\nTHE DECISION IS ALREADY IN RUST. web/app.js calls preventDefault when on_key_event returns a frame, and a frame exists only when resolve() claimed the key (Local/Request/Upstream; Ignored gives none). The ONLY keybinding knowledge in the page is the extra rule `if (!e.ctrlKey \u0026\u0026 !e.altKey)` at app.js:786. So the obvious fix is to DELETE page knowledge, not add it: preventDefault whenever a frame comes back, whatever the modifiers. That fixes Ctrl-S (and Ctrl-d/Ctrl-u, which today scroll the grid AND whatever the browser does) with no protocol knowledge in the page. It is not safe to ship alone, for the reason below.\n\nTHE CLASS HAS THREE MEMBERS, not one, and they want different answers:\n\n1. PREVENTABLE chords the app binds on purpose (Ctrl-S, Ctrl-d, Ctrl-u; Ctrl-R/Ctrl-P/Ctrl-F if ever bound). Answer: the page honours the claim, per above. Rust already decides.\n\n2. UNPREVENTABLE chords (Ctrl-W, Ctrl-T, Ctrl-N, Ctrl-Shift-T/N/W, Ctrl-Tab; Ctrl-Q on Linux Chrome). Believed, NOT measured here: Chromium does not deliver these to a normal tab's keydown at all, and preventDefault cannot stop them. So no page code fixes Ctrl-W. Its consequence is also narrower than the note above suggests: `enter-mode save-prompt` is a mode `declined` refuses in the browser anyway, it is advertised nowhere the browser paints (not in advertised(), not in help pages), and the worker persists every accepted command to OPFS, so a closed tab loses nothing. The rule this member needs is a KEYMAP rule: a command bound only to a browser-reserved chord is unreachable in a browser, so it must have a second key or be one the browser declines. That is checkable in resolve.rs's tests over the shared keymap, given a named list of reserved chords (a fact about browsers, living beside BrowserKey).\n\n3. ACCIDENTAL claims through the AnyChar fallback. Keymap::lookup_local retries a modified Char without its modifiers and then falls to AnyChar, so in every text-entry mode Ctrl-C, Ctrl-V, Ctrl-R, Ctrl-L TYPE A LETTER — pinned on purpose by resolve::tests::a_modified_character_now_types_because_the_fallback_is_the_shared_one (Ctrl-x types 'x'). Today, in a browser minibuffer, Ctrl-V types 'v' AND pastes; Ctrl-R types 'r' AND reloads. Applying fix 1 alone would make it type 'v' and SUPPRESS the paste — strictly worse. So fix 1 is blocked on a ruling about member 3.\n\nTHE RULING NEEDED (the user's, not an agent's): should a Ctrl/Alt-modified character reach AnyChar at all? Options:\n (A) No: lookup_local stops retrying Ctrl/Alt-modified Chars as bare/AnyChar. Shared table, so the TUI changes too (Ctrl-x in the TUI minibuffer stops typing x — probably correct there as well). Risk: AltGr on Windows arrives as Ctrl+Alt, so international characters (e.g. @ on a German layout) would stop typing in the browser; BrowserKey would need to treat ctrl\u0026\u0026alt as AltGr and drop both. Then fix 1 is safe and the page loses its modifier rule.\n (B) Browser-only: BrowserKey (wasm-client/keys.rs) drops Ctrl-modified printable characters before resolve, the way it already drops Meta. No shared-table change; the TUI keeps today's behaviour; the page's rule moves into Rust where it is testable. Then fix 1 is safe.\n (C) Keep typing modified chars, keep the page rule, and fix only Ctrl-S by making Request outcomes claim regardless of modifiers. Smallest, but it is per-key again, which is what this ticket asks not to do.\nRecommended by this agent: (B) + fix 1 + the member-2 keymap guard; (A) is cleaner but changes the TUI and needs an AltGr decision.\n\nWhatever lands, the assertion belongs in harness/tests/shell.spec.js (gotcha 48) and needs `npm install` in harness/ first.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T20:59:53Z","created_by":"Edward Langley","updated_at":"2026-09-23T04:22:55Z","labels":["browser"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-clso","title":"Ctrl-S saves and also opens the browser's own save dialog","description":"Found by the improvise-jz6w agent (PR #134) as a consequence of its own fix, and\ncorrectly NOT fixed there because every available fix crosses a doctrine line.\n\nNOT A REGRESSION -- verified by the orchestrator. web/app.js:786 calls\npreventDefault only when `!e.ctrlKey \u0026\u0026 !e.altKey`, with the deliberate comment\n\"Ctrl/Alt combinations are left alone: they are the browser's\". So Ctrl-S ALWAYS\nopened Chrome's \"Save page as\" dialog; before improvise-jz6w it did that and\nnothing else. Now it saves the document AND opens the dialog. The save is\ncorrect; the dialog is the wart, and it is newly VISIBLE rather than newly\nintroduced.\n\nWHY IT WAS NOT FIXED IN #134, and this is the useful part:\n - Parsing the frame in app.js gives the page PROTOCOL KNOWLEDGE\n (design-principles section 5).\n - A `e.key === \"s\" \u0026\u0026 e.ctrlKey` test puts a KEYBINDING FACT in the page.\n - Widening the wasm surface so Rust can say \"this key is claimed exclusively\"\n is a new decision the ticket did not authorise.\n\nTHE HONEST SHAPE: the DECISION -- \"is this key exclusively claimed by the app?\" --\nbelongs in Rust as a third thing a resolver already knows, beside Local and\nRequest. That is a natural follow-up to the KeyOutcome variant #134 added, not a\npatch to app.js.\n\nWHATEVER LANDS, THE ASSERTION GOES IN harness/tests/shell.spec.js (gotcha 48: a\nchange to web/*.js belongs in that spec directory). Note harness/node_modules does\nnot exist in a fresh worktree, so `npx playwright test` fails with an error about\nthe PACKAGE rather than the harness -- run npm install in harness/ first.\n\nCHECK BEFORE BUILDING: whether any other Ctrl chord is now in the same position.\nThe preventDefault rule is blanket, so the answer is \"every Ctrl binding the\nresolver claims\", and today that is one.","notes":"SECOND INSTANCE, AND IT IS WORSE: Ctrl-W. Found by the improvise-htl6 agent while hunting for a free key; verified by the orchestrator at keymap.rs:611 --\n\n normal.bind_args(KeyCode::Char('w'), ctrl, \"enter-mode\", vec![\"save-prompt\".into()])\n\nwith the comment \"Ctrl-S saves to the known path; Ctrl-W asks for one\" (improvise-lae).\n\nIN A BROWSER, Ctrl-W CLOSES THE TAB. Same mechanism as the Ctrl-S wart -- web/app.js calls preventDefault only when !e.ctrlKey \u0026\u0026 !e.altKey -- but the consequence is not a spurious dialog the user dismisses, it is THE DOCUMENT'S TAB CLOSING. Whatever the save prompt does is invisible, because the surface it would draw on is gone.\n\nTHE GENERALISATION, which is why this belongs here rather than in its own ticket: THE KEYMAP IS SHARED BETWEEN THE TWO FRONT ENDS (improvise-bpq2, gotcha 47), so a Ctrl- chord chosen for a terminal is one the browser must live with, and the browser has a reserved set the terminal does not. Ctrl-R (reload), Ctrl-S (save page), Ctrl-W (close tab), Ctrl-T, Ctrl-N are all browser-owned. So the fix for this ticket should be stated over the CLASS -- what happens to a browser-reserved chord in a shared keymap -- rather than per key, or a third instance arrives the next time somebody needs a free Ctrl- binding.\n\nNote the constraint that makes this hard is already recorded above: every available fix crosses a doctrine line, because preventDefault-ing selectively needs the page to know which chords the app binds, which is protocol knowledge (design-principles section 5). The shared keymap is where that knowledge already lives, which is a hint at where the answer is.\n\nALSO WORTH RECORDING: Ctrl-R is NOT bound today, and the htl6 agent deliberately did not take it for view-forward for exactly this reason -- it chose ',' instead. So this ticket has already changed one decision.\nANALYSIS 2026-09-22 by agent-helpkeys2 (NOT fixed; this needs a ruling). Read at gh/main 4d71c01.\n\nTHE DECISION IS ALREADY IN RUST. web/app.js calls preventDefault when on_key_event returns a frame, and a frame exists only when resolve() claimed the key (Local/Request/Upstream; Ignored gives none). The ONLY keybinding knowledge in the page is the extra rule `if (!e.ctrlKey \u0026\u0026 !e.altKey)` at app.js:786. So the obvious fix is to DELETE page knowledge, not add it: preventDefault whenever a frame comes back, whatever the modifiers. That fixes Ctrl-S (and Ctrl-d/Ctrl-u, which today scroll the grid AND whatever the browser does) with no protocol knowledge in the page. It is not safe to ship alone, for the reason below.\n\nTHE CLASS HAS THREE MEMBERS, not one, and they want different answers:\n\n1. PREVENTABLE chords the app binds on purpose (Ctrl-S, Ctrl-d, Ctrl-u; Ctrl-R/Ctrl-P/Ctrl-F if ever bound). Answer: the page honours the claim, per above. Rust already decides.\n\n2. UNPREVENTABLE chords (Ctrl-W, Ctrl-T, Ctrl-N, Ctrl-Shift-T/N/W, Ctrl-Tab; Ctrl-Q on Linux Chrome). Believed, NOT measured here: Chromium does not deliver these to a normal tab's keydown at all, and preventDefault cannot stop them. So no page code fixes Ctrl-W. Its consequence is also narrower than the note above suggests: `enter-mode save-prompt` is a mode `declined` refuses in the browser anyway, it is advertised nowhere the browser paints (not in advertised(), not in help pages), and the worker persists every accepted command to OPFS, so a closed tab loses nothing. The rule this member needs is a KEYMAP rule: a command bound only to a browser-reserved chord is unreachable in a browser, so it must have a second key or be one the browser declines. That is checkable in resolve.rs's tests over the shared keymap, given a named list of reserved chords (a fact about browsers, living beside BrowserKey).\n\n3. ACCIDENTAL claims through the AnyChar fallback. Keymap::lookup_local retries a modified Char without its modifiers and then falls to AnyChar, so in every text-entry mode Ctrl-C, Ctrl-V, Ctrl-R, Ctrl-L TYPE A LETTER — pinned on purpose by resolve::tests::a_modified_character_now_types_because_the_fallback_is_the_shared_one (Ctrl-x types 'x'). Today, in a browser minibuffer, Ctrl-V types 'v' AND pastes; Ctrl-R types 'r' AND reloads. Applying fix 1 alone would make it type 'v' and SUPPRESS the paste — strictly worse. So fix 1 is blocked on a ruling about member 3.\n\nTHE RULING NEEDED (the user's, not an agent's): should a Ctrl/Alt-modified character reach AnyChar at all? Options:\n (A) No: lookup_local stops retrying Ctrl/Alt-modified Chars as bare/AnyChar. Shared table, so the TUI changes too (Ctrl-x in the TUI minibuffer stops typing x — probably correct there as well). Risk: AltGr on Windows arrives as Ctrl+Alt, so international characters (e.g. @ on a German layout) would stop typing in the browser; BrowserKey would need to treat ctrl\u0026\u0026alt as AltGr and drop both. Then fix 1 is safe and the page loses its modifier rule.\n (B) Browser-only: BrowserKey (wasm-client/keys.rs) drops Ctrl-modified printable characters before resolve, the way it already drops Meta. No shared-table change; the TUI keeps today's behaviour; the page's rule moves into Rust where it is testable. Then fix 1 is safe.\n (C) Keep typing modified chars, keep the page rule, and fix only Ctrl-S by making Request outcomes claim regardless of modifiers. Smallest, but it is per-key again, which is what this ticket asks not to do.\nRecommended by this agent: (B) + fix 1 + the member-2 keymap guard; (A) is cleaner but changes the TUI and needs an AltGr decision.\n\nWhatever lands, the assertion belongs in harness/tests/shell.spec.js (gotcha 48) and needs `npm install` in harness/ first.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T20:59:53Z","created_by":"Edward Langley","updated_at":"2026-09-23T04:22:55Z","labels":["browser"],"dependencies":[{"issue_id":"improvise-clso","depends_on_id":"improvise-fhj0","type":"parent-child","created_at":"2026-09-25T01:05:37Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-udt2","title":"A status message carries no severity, so a headless refusal cannot set a non-zero exit","description":"Left open deliberately by improvise-q914/j6x8 (PR for fix/cli-honesty). The\nreasoning is context/code/cli.md section 3; this is the ticket it points at.\n\nimprovise cmd now PRINTS everything a command reports, to stderr, so a refusal\nis no longer indistinguishable from a success. What it still cannot do is put\na refusal in the EXIT CODE. Exit 1 means a parse error or an aborted batch;\na refusal that only writes a status exits 0.\n\nWHY IT WAS NOT CLOSED THERE. ViewState::status_msg is a bare String. Both\nclassifiers available in the tree are disproved by code in the tree:\n\n - \"a refusal is a batch that aborted\" -- only three effects set\n abort_effects (gotcha 21). effect::AddItem writes Workbook::add_item's\n Err straight to the status and does NOT abort, and that is q914's own\n worked example of a refusal. This classifier calls a refusal a success.\n\n - \"a refusal is anything that spoke\" -- cmd/fit.rs sets a status on\n SUCCESS, carrying the seed that reproduces the fit. effect::Save does\n too. This classifier calls a deliverable a failure.\n\nSo the CLI cannot guess, and guessing would be the adjacent-check defect\ndesign-principles section 7 names.\n\nWHAT WOULD CLOSE IT. A severity beside status_msg -- the smallest honest\nshape is probably an enum on the same write (Report | Refusal), set at the\n~60 sites that assign status_msg. That is a ViewState change, so it is a wire\nchange (gotcha 25): it must derive Serialize/Deserialize, it reaches the\nbrowser client's Snapshot, and the TUI can then colour the status line by it,\nwhich is arguably the bigger win. Scope it as a protocol ticket, not a CLI one.\n\nACCEPTANCE\n- A refusal that only writes a status exits non-zero from improvise cmd.\n- effect::Save on success and cmd/fit.rs on success still exit 0 -- these are\n the two rivals; a fix that fails either is the wrong fix.\n- tests/headless_cli.rs::a_command_that_succeeds_while_reporting_still_exits_zero\n must still pass unchanged.\n- README's CLI contract section is updated; it currently states the hole.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-cli","owner":"el-github@elangley.org","created_at":"2026-09-22T15:20:31Z","created_by":"Edward Langley","updated_at":"2026-09-23T04:42:38Z","started_at":"2026-09-23T04:13:05Z","closed_at":"2026-09-23T04:42:38Z","close_reason":"PR #185 MERGED (confirmed at 8d51ad7), adversarially reviewed (merge); CI 13/0. up8g: headless runs pin the extent to 80x24 (HEADLESS_EXTENT), so a script writes the same record whatever terminal ran it. The reviewer restored the terminal-size read and the test failed with R13 vs R40, both in a non-tty shell and under a real 50x150 pty. c5ip/udt2: the exit code reads whether the request was REFUSED (App::refuse sets a per-batch batch_refused flag, in AppSnapshot) rather than whether a chain ABORTED, so a success that aborts exits 0 and a refusal that narrates exits 1. That was the basis for improvise-cz8d, which reworks #181 on top of it. Follow-up: improvise-0v8l (23 of 27 refusal conversions are not pinned by a test, AddCategory most importantly).","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-j6x8","title":"improvise cmd discards every status message and exits 0, so a refusal is indistinguishable from success","description":"Found by the improvise-pv3g agent, while checking whether that P1's own fix was\nobservable through the binary the P1 was demonstrated with. It is not.\n\nrun_headless_commands (src/main.rs:331) applies effects and NEVER PRINTS\napp.view_state.status_msg. Measured on clean fixtures:\n\n document outcome stdout/stderr exit\n Score 1000/4000 fit succeeds, formula stored nothing 0\n Score 1e20/4e20 fit refused, nothing stored nothing 0\n\nSo a refused command is INDISTINGUISHABLE FROM A SUCCESSFUL ONE. The user gets\nsilence and exit 0 whether the command worked, refused, or did nothing at all.\nexit_code is set to 1 only for PARSE errors, so a refused command cannot signal\nfailure through the exit status either.\n\nWHY THIS MATTERS MORE THAN IT LOOKS: improvise-pv3g's acceptance asked for an\nactionable refusal naming the cause (\"too large for the 32-bit arithmetic ...\nscale the measure and fit that\"). That wording was written, tested and is\ncorrect -- and reaches NOBODY through `improvise cmd`. The TUI renders the status\nline, so the criterion is genuinely met there; the headless path is where it\nevaporates. This is the repo's characteristic defect one layer out: the fix is\nright and the surface that would show it is adjacent.\n\nWHY NO TEST SEES IT: a_refusal_names_what_went_wrong_and_stores_nothing asserts\nthe EFFECT's status. The effect is correct. Nothing asserts that the headless\nbinary emits it.\n\nA SECOND, SMALLER TRAP in the same path, worth fixing or documenting: `improvise\ncmd -f \u003cfile\u003e` REWRITES THE DOCUMENT EVEN WHEN THE COMMAND CHANGED NOTHING,\nexpanding 1e20 to 100000000000000000000. A fixture edited by string-replacing a\nvalue after one run silently becomes a no-op. It cost the reporting agent two\nmisleading test results before it was caught.\n\nWHAT TO DECIDE, not assume:\n - Which stream? A status message is not an error; stdout is probably right,\n with stderr for a refusal. Say which and why.\n - Should a refusal set a non-zero exit? It is the difference between `improvise\n cmd ... \u0026\u0026 next-step` working and silently proceeding on a no-op. Note this\n is a CLI contract change, so it belongs in README's CLI section too.\n - Is silence ever right? A script running a hundred commands may not want a\n hundred lines. If so the answer is a flag, and the DEFAULT should be the\n honest one.\n - `improvise script` shares this path -- check whether it has the same hole.\n\nNOTE improvise-q914 is adjacent (\"improvise cmd advertises JSON it does not\naccept, and swallows every effect error at exit 0\") and may subsume or be\nsubsumed by this. READ IT IN FULL FIRST and merge the two rather than filing a\nthird view of one problem.","notes":"FIXED in PR #131 (merge 9d2bdd3) as one fix with improvise-q914 -- see that ticket for the full reasoning, including why the stdout/stderr split I proposed in the brief cannot be built honestly and why the exit code is narrower than q914's AC. improvise script shares the hole exactly, being the same function. The per-command dirty accumulation also closes the no-op-rewrite trap noted here.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T06:26:41Z","created_by":"Edward Langley","updated_at":"2026-09-22T15:38:58Z","closed_at":"2026-09-22T15:38:58Z","close_reason":"Closed","dependencies":[{"issue_id":"improvise-j6x8","depends_on_id":"improvise-q914","type":"blocks","created_at":"2026-09-22T06:26:52Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-n6t8","title":"Header layout must be reserved, not fitted: the mode pill, the hint and the status all reflow","description":"USER 2026-09-22: \"the top bar jumps around a lot because the layout isn't fixed\nenough: The messages like 'the command line is not available in the browser yet'\nshould be toasts that do not cause relayout and should disappear eventually\n(maybe where the minibuffer is?)\"\n\nHOW IT WORKS TODAY, read before filing:\n - web/shell.js::composeStatus joins TWO independent facts into #status with\n \" . \": `shell` (the transport -- \"in-page worker\") and `model`\n (RenderModel::status, which is ViewState::status_msg). They are composed\n rather than overwritten deliberately, so a repaint cannot silently erase a\n connection error (gotcha 46).\n - #hint also carries variable-width content, one line per mode, off\n help::hint_line.\n - Both live in the flex \u003cheader\u003e. So the bar reflows when EITHER a status\n message arrives or the MODE changes -- the second is worth naming because it\n reflows even with no message at all.\n\nTHE TRAP IN THE OBVIOUS FIX, and it is the thing to get right:\nViewState::status_msg is STATE, not an event. web/app.js's repaint() calls\nshell.setModel(model.status) unconditionally, and repaint() runs on every\nkeydown. So a toast dismissed on a timer RESURRECTS on the next keypress, because\nnothing cleared the underlying string -- and Rust only replaces status_msg when\nsome later command sets one. A naive setTimeout(hide) produces a message that\nflickers back whenever the user does anything, which is worse than a message that\nsimply stays.\n\nSo dismissal must be keyed to MESSAGE IDENTITY (\"I have already shown this\nstring\") rather than to a timer alone, or the same string arriving genuinely\ntwice must be distinguishable from a repaint of the old one. Decide which, and\nsay so -- if two identical messages in a row should both be shown, identity alone\nis not enough and something must carry a sequence.\n\nSCOPE -- PRESENTATION ONLY, and that is a deliberate limit. Do NOT split\nstatus_msg into transient-vs-persistent in ViewState. That is a protocol change\nwith a portability argument attached (gotcha 30), the TUI shows status_msg in its\nbottom bar where it costs no layout at all, and nothing here forecloses it later.\nThe browser's problem is where it draws the string, not what the string is.\n\nTHREE THINGS THE FIX OWES:\n 1. THE HEADER MUST STOP REFLOWING ON ITS OWN, independently of toasts. #hint\n changes per mode, so a fixed/reserved layout is needed regardless. Reserving\n space is the fix; making the text shorter is not.\n 2. THE TOAST MUST NOT TAKE PART IN LAYOUT AT ALL -- out of flow, so it cannot\n push anything.\n 3. THE CONNECTION-ERROR FACT MUST SURVIVE. composeStatus exists because a\n repaint used to erase it. A toast that auto-hides must not take a transport\n failure with it: \"disconnected\" is not transient and must stay visible.\n That is the mirror of the bug gotcha 46 records.\n\nPLACEMENT: the user suggests near the minibuffer. PR #120 put the minibuffer as a\nraised bordered card over the grid, so that region is consistent -- but DECIDE\nWHAT HAPPENS WHEN BOTH ARE PRESENT. A user typing in the minibuffer who triggers\na status message must not have the prompt covered or displaced. Say what you\nchose.\n\nTESTABLE, and therefore required: harness/tests/ can assert that the header's\nbounding box does not change when a status message arrives and when the mode\nchanges (the two reflow causes above), and that the toast overlaps rather than\ndisplaces. gotcha 48: a change to web/*.js belongs in that spec directory. The\ncolour and the animation are not testable there and should be argued instead.\n\nSEQUENCING: branch ui/page-menu is in flight and owns web/app.js, web/shell.js\nand web/style.css. START THIS AFTER IT LANDS, or expect a conflict in all three.","notes":"USER 2026-09-22, widening this ticket: \"the other display annoyance is that the\nnormal/categories/etc. pill changes sizes constantly: it should have a fixed\nallocated size and not move the layout around. As a general rule, there shouldn't\nbe motion of layout elements I expect to be fixed.\"\n\nSO THIS TICKET IS NOW ABOUT HEADER LAYOUT STABILITY GENERALLY, and the toast is\none instance of it. THE RULE IS THE DELIVERABLE, not three separate patches.\n\nTHREE KNOWN MOVERS, all in the flex \u003cheader\u003e:\n 1. THE MODE PILL. Its label comes from AppMode::label() -- NORMAL, CATEGORIES,\n FORMULA, INSERT, RECORDS, ... -- and the pill is sized by its text, so every\n mode change resizes it and shifts everything after it.\n 2. #hint. One line per mode off help::hint_line, so it reflows on the same\n event as the pill, compounding it.\n 3. #status. Composed from the transport fact and ViewState::status_msg (see\n the original description).\n\nTHE OBVIOUS FIX IS THE FRAGILE ONE, AND IT WILL BE CHOSEN BY DEFAULT: a\nhardcoded min-width in ch, sized by eye to today's longest label. That silently\nresumes the jitter the day an AppMode variant with a longer label is added.\nAppMode's per-variant facts are exhaustive-match methods, so the COMPILER makes\nyou fill in label() for a new variant (repo-map, AppMode) -- but nothing anywhere\nchecks that the CSS still fits it. The gap between \"the compiler caught the Rust\"\nand \"the pixel is wrong\" is exactly where this regresses.\n\nSO THE GUARD IS THE POINT, and it is genuinely testable: a Playwright spec that\nwalks every reachable mode and asserts the header's bounding box -- and the left\nedge of the element AFTER the pill -- do not change. That catches a new long\nlabel, not just today's set. Prove it discriminates by injecting a mode label\nlonger than the reserved width and watching it fail. Without that injection this\nis a test that passes because nothing moved anyway.\n\nNote which modes a browser can actually reach: improvise-6acs and improvise-y11g\nmean some text-entry modes are not reachable there yet, so a spec that walks\n\"every mode\" may be walking four. Say how many it really covered -- an\noverstated scope is worse than a narrow one honestly described.\n\nTHE GENERAL RULE TO WRITE DOWN, since the user stated it as one: an element whose\nCONTENT varies but whose POSITION the reader treats as fixed must have its space\nRESERVED, not fitted. That covers the pill, the hint, the status, the document\ntitle and the view chip, and it is the reason a toast must be out of flow rather\nthan merely short.\nFIXED in PR #127 (merge d46ec59).\n\nTHE TICKET'S FRAMING WAS WRONG ABOUT WHICH ELEMENT COMPOUNDED, and the spec found\nit rather than the author. The ticket (and the user's report) named the mode pill.\nOn its first run the new spec failed on the header's HEIGHT: #status was\nflex: 0 1 auto beside a flex: 1 1 auto hint, so a longer hint took the shrink out\nof #status, which WRAPPED TO TWO AND THEN THREE LINES -- 52.5px to 71.0px. THE\nOLD HEADER WAS TWO LINES TALL AT NORMAL WITH NO MESSAGE AT ALL.\n\nSo reserving the pill alone would have left the bar moving and the PR would have\nshipped looking correct. Fixed with flex: 1 1 0 on the hint (zero basis, absorbs\nthe remainder, contributes nothing to overflow) and nowrap + flex: 0 0 auto on\n#status.\n\nTHE RESERVE IS A FLOOR, NOT A CAP, and that is the load-bearing choice: a width\nor max-width absorbs an over-long value inside a box that never grows, so every\nbounding-box measurement stays identical and the regression is INVISIBLE. min-width\nlets an over-long mode label grow the box, which is the only way the guard can see\nit. Proved by injection: removing the 21ch reserve fails \"the bar moved in\nCATEGORIES\" AND the 16-char check-the-checker.\n\nDISMISSAL IS KEYED ON WHAT WAS LAST ACCEPTED, not on a timer, because\nViewState::status_msg is STATE re-offered by every repaint. Consequence, stated\nrather than hidden: TWO IDENTICAL MESSAGES IN A ROW SHOW ONCE. That is forced --\nnothing beside status_msg carries a sequence, model.revision moves for a cursor\nkey as readily as for a re-issued command, and of the two readings of an ambiguous\noffer only one avoids reintroducing the reported bug. A sequence number is the\nprotocol change the scope forbade.\n\nGOTCHA 46 IS NOW STRUCTURAL RATHER THAN CONVENTIONAL. composeStatus is DELETED:\nthe transport fact and the document fact go to two ELEMENTS, not one string joined\nwith a separator, so a repaint reaches onModel and CANNOT ADDRESS onShell AT ALL.\nThe join's unit test was REPLACED, not dropped -- \"a repaint's model status cannot\nreach the transport's sink at all\", verified present by the orchestrator. A\nseparation with no guard is how one regresses into a single sink.\n\nNINE MODES WALKED, not the four I guessed: NORMAL, CATEGORIES, FORMULAS, VIEWS,\nTILES, HELP, INSERT, NEW CATEGORY, FORMULA. Excluded with reasons:\nSavePrompt/ExportPrompt/CommandMode refused by resolve::stranding, records mode\ndeclines out loud, ImportWizard has no browser entry, Quit is not a screen. Each\niteration asserts #mode BEFORE measuring, so a key that failed to reach its mode\ncannot make the comparison vacuous.\n\nMINIBUFFER AND TOAST COEXIST: #bottom-dock is position: fixed and anchored at its\nBOTTOM, stacking upward. The minibuffer's box is byte-identical with and without a\ntoast. A top-anchored stack, or an offset from the minibuffer's height, would both\nhave moved the prompt.\n\nTHE TOAST KEEPS ITS TEXT WHEN IT HIDES, deliberately: the element is the record of\nwhat was last said, and drill.spec.js's discrimination between two refusals reads\nit -- an emptied node would satisfy not.toContainText(\"aggregate\") VACUOUSLY the\nmoment the timer fired.\n\nNOT VERIFIED: prefers-color-scheme is exercised nowhere (dark was screenshotted by\nhand; pre-existing, now stated in clients.md). Colour, contrast, spacing and the\ngrouped/multi-level header DOM remain argued. Text ALIGNMENT inside a fixed box is\ninvisible to a bounding-box guard -- honest scope, not a gap. Firefox and WebKit\ndo not run header.spec.js (Chromium-only, the config's existing rule).","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T06:09:15Z","created_by":"Edward Langley","updated_at":"2026-09-22T07:45:56Z","closed_at":"2026-09-22T07:45:56Z","close_reason":"Closed","labels":["browser"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-rw6l","title":"Page bar: clicking a page category should open a menu of its items, with a down-arrow affordance","description":"USER 2026-09-22, pointing at the page bar (the \"Quarter = Q1 (1/4)\" chip) on the\ndeployed page: \"The page items here (e.g. Quarter) should open a menu when clicked\nso you can pick a different option. Also have a down-arrow indicating there's a\nmenu.\"\n\nTHE GOOD NEWS, VERIFIED BEFORE FILING: almost everything needed already exists.\nThis is NOT a protocol change and needs no new derivation.\n\n - improvise_core::view::layout::PageAxis ALREADY carries\n `items: Option\u003cVec\u003cString\u003e\u003e` -- Some exactly for Axis::Page, None for a\n Filter axis, which constrains without being navigable (gotcha 27). So the\n ITEM NAMES ARE ALREADY PROJECTED: GridCache::page_coords is Vec\u003cPageAxis\u003e.\n - ModelCommand::SetPageSelection { .. } ALREADY EXISTS on the wire\n (improvise-protocol/src/reduce.rs:127), and is in records_named's match at\n :185. So the mutation half is done.\n\nTHE ONE GAP is that the browser THROWS THE NAMES AWAY. wasm-client/src/render.rs\nRenderPageAxis (:157) keeps `cat`, `selected` and `cycle: Option\u003cRenderPageCycle\u003e`,\nand RenderPageCycle (:168) is `{ at: usize, count: usize }` -- a POSITION and a\nCOUNT. That was enough for improvise-p1ym, which asked \"can [ and ] cycle\"; it is\nnot enough for \"can the user pick\", which needs the names.\n\nSo: widen RenderPageAxis to carry the item names it is already handed. That is a\npass-through, not a second derivation -- the names come from the projection, and\nnothing in the browser may compute them (design-principles section 5).\n\nTHEN THE UI, which is the actual work:\n - a down-arrow affordance on a page chip that HAS a menu. Note the affordance\n must follow `items.is_some()`, NOT the presence of a page bar entry: a Filter\n axis appears in page_coords and must NOT get an arrow, because there is\n nothing to pick. Getting that wrong offers a menu that cannot do anything.\n - click opens a menu of the items; choosing one composes\n Command::Model(SetPageSelection{..}).\n - the currently selected item should be marked in the menu.\n\nEVENT HANDLING: the user has already ruled on the shape (improvise-b8ot) -- one\ndelegated handler per surface on a stable root, not one listener per node, and\nIDs/classes/data attributes as needed to disambiguate. A menu built with per-node\nlisteners would be new work in exactly the style b8ot exists to remove. Read b8ot\nbefore starting, and prefer landing this in the shape b8ot wants rather than\nadding another per-node listener to migrate later.\n\nKEYBOARD: the menu must be operable without a mouse, and must not swallow keys\nthe grid needs. web/app.js's window keydown handler returns early when `editing`\nis true; a menu is a third mode of that kind and needs the same treatment or it\nwill eat hjkl. Say which you chose.\n\nNOT IN SCOPE, adjacent, filed separately or noted:\n - the TILE bar above it (the \"Quarter Pag\" chips) is a different widget. Its\n three-character labels come from Axis::short_label(), which exists so the\n TUI's tile bar has fixed-width columns; in a browser that reads as a\n truncation bug. Axis::plain_label() already says \"Page\". That is a separate\n change and NOT this ticket.","notes":"FIXED in PR #123 (merge 7470b61).\n\nTHE DESIGN DECISION WORTH KEEPING: `items` is relayed INSIDE `cycle`, not beside\nit. One predicate, not two. A Filter axis has no cycle, so it gets no arrow, no\nmenu, no items, is painted as a \u003cspan\u003e rather than a \u003cbutton\u003e, and\nClick::PageItem composes nothing if a click reaches it -- FOUR answers off\nPageAxis::cycle, the one home gotcha 27 already named. `items.is_some()` appears\nnowhere. That is stronger than the guard I asked for in the brief, which was a\ntest that a Filter axis gets no arrow: here the wrong state is not constructible.\n\nEvery test carries a Filter axis beside the Page one, and the pass-through fault\n(items: Vec::new()) is caught by\nrender::tests::a_navigable_page_axis_relays_the_items_it_may_be_fixed_to.\n\nKEYBOARD: the menu is the third state of the kind `editing` already was --\napp.js's window keydown returns early into shell.js::menuKey. Same mechanism, not\na new one, because the menu hangs over the grid and hjkl reaching through would\nscroll the thing it points at out from under it. menuKey answers EVERY key with\n`ignore` as the default (swallowed, never passed on), and count: 0 closes rather\nthan wrapping modulo zero.\n\nCODEX FOUND A REAL BUG and it was correct: the pageMenu branch originally sat\nABOVE app.js's metaKey guard, so with the menu open Cmd-R was answered `ignore`\nand then preventDefault'ed -- reload blocked -- and Ctrl-j/Alt-Home moved the\nmenu, two lines below a comment saying Ctrl/Alt stay the browser's. Fixed by\nordering plus one bail. VERIFIED BY THE ORCHESTRATOR on the merged branch: the\nmetaKey guard is above the branch, and pageMenuKeydown returns on\nctrlKey || altKey before claiming anything.\n\nBOUNDARY CROSSING, flagged and accepted: resolve.rs gained Click::PageItem and\nlib.rs a click_page_item. Unavoidable -- the ticket asks that choosing an item\nCOMPOSES SetPageSelection, the page holds no Basis, and design-principles\nsection 5 forbids it deriving one. No wire change: Command, ModelCommand and the\nframe pair are untouched. Indices, matching every existing Click variant, so the\npage never spells a category or item name.\n\nNOT VERIFIED: Firefox and WebKit run none of pages.spec.js (the config is\nChromium-only outside opfs/shell). The menu's position: fixed placement off\ngetBoundingClientRect is the part that could plausibly differ by engine.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T06:07:10Z","created_by":"Edward Langley","updated_at":"2026-09-22T06:41:45Z","closed_at":"2026-09-22T06:41:45Z","close_reason":"Closed","labels":["browser"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-nn4s","title":"ADR 004: an Emacs-like face system, so a theme is UI-agnostic data the front ends apply","description":"USER DIRECTION 2026-09-22: \"I think we want to iterate towards having a way to\ntheme the app like emacs does: faces, etc. described in a UI-agnostic format that\nthe different frontends can apply (there's probably a swift version down the road\nand maybe Qt, etc.)\"\n\nNote \"iterate towards\". This is a DIRECTION, not a feature request, and the first\ndeliverable is an ADR (004 is free) rather than code.\n\nWHERE THE REPO ACTUALLY IS TODAY, measured:\n - AxisColor { Row, Column, Page, Filter, Inactive } in improvise-core/view/axis.rs\n -- names ROLES. This is the pattern working.\n - MinibufColor { Green, Cyan, Yellow } in improvise-protocol/lib.rs -- names\n COLOURS. draw.rs::minibuf_fg is an IDENTITY MAP (Green -\u003e Color::Green). Its\n doc comment says \"semantic palette only\" and the type makes that false: there\n is nothing to theme. The roles exist at the call sites (green is editing,\n yellow is the prompt/destructive family, cyan is search) and were spelled as\n colours.\n - 124 `Color::` literals across 10 TUI files (draw.rs, grid.rs, every panel,\n help, the wizard). No theme, palette or color_scheme concept anywhere.\n - PR #120 just built a CSS custom-property palette for the browser. So the two\n front ends are AT A FORK: either colour roles become shared vocabulary each\n end maps to its own palette, or the web palette and the TUI's 124 literals\n drift permanently and one document looks unrelated in two clients.\n\nSEVEN QUESTIONS THE ADR MUST ANSWER. They are listed because each is a place this\ngets decided by accident otherwise.\n\n1. THE TERMINAL-PALETTE TENSION, WHICH IS THE HARD ONE. A face's colour may want\n to be \"the terminal's green\" rather than an RGB value. Today Color::Green means\n whatever the USER'S TERMINAL calls green, so their terminal theme applies. A\n format carrying only RGB would LOSE that -- a regression, not a gap, and one\n that would be discovered late and be expensive to undo. Emacs answers it with\n named colours plus tty approximation. Decide whether a face attribute can be\n a NAMED terminal colour, an RGB value, or either, and what a browser does with\n \"terminal green\".\n\n2. INHERITANCE is what makes a face system tractable rather than 124 entries.\n Emacs derives most faces from a few. Decide whether faces inherit, and if so\n whether the resolution happens in shared code (once, correctly) or per front\n end (three times, differently).\n\n3. A THEME IS CLIENT-LOCAL. Gotcha 29 / the user's own ruling: the model is shared\n and the UI is not. So a theme must NOT be a Command, must NOT live in\n ViewState, and must NOT be projected -- otherwise one client's theme reaches\n another client's screen, which is the exact bug improvise-g0f is about. State\n this explicitly in the ADR; it is cheap now and a protocol change later.\n\n4. WHERE THE VOCABULARY LIVES. AxisColor is in improvise-core, which is supposed\n to have no UI; MinibufColor is in improvise-protocol. That inconsistency\n predates this and the ADR should resolve it rather than inherit it.\n\n5. WHICH ATTRIBUTES ARE PORTABLE. Foreground, background, weight, slant,\n underline map onto ratatui Modifier, CSS, Swift and Qt. Box, shadow and\n rounded corners do not reach a terminal. Decide the minimum common set, and\n decide what a front end does with an attribute it cannot render -- Emacs\n ignores it, which is probably right and should be SAID.\n\n6. THE FORMAT. A Swift or Qt front end means the theme is DATA, not Rust -- so it\n needs a file format. TOML, JSON, or a small pest grammar in this repo's usual\n style. It is NOT part of .improv: a theme is configuration, not document data,\n and putting it there would make a document dictate a reader's colours.\n\n7. HOW IT ARRIVES WITHOUT A FLAG DAY. A big-bang refactor of 124 literals is how\n this dies. The ADR needs a migration path where the vocabulary lands first and\n the literals move opportunistically.\n\nTHE INCREMENTAL STEPS THAT FORECLOSE NOTHING, cheapest first:\n a. Rename MinibufColor's variants to roles. Pure vocabulary, no behaviour, and\n it converts \"semantic palette\" from a false doc comment into a true type.\n b. Name PR #120's CSS custom properties after the same roles, so the web\n palette is already a theme in the making rather than a second vocabulary.\n c. Let the 124 literals migrate as files are touched for other reasons.\n\nNOT IN SCOPE YET: a theme picker, user-supplied theme files, or live reloading.\nThose follow the vocabulary; none of them is the hard part.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T05:44:55Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:44:55Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-oujb","title":"A release should carry the web app too: today a tag captures only the native binaries","description":"USER RULING 2026-09-22, asked whether the release workflow builds a package for the\nweb version and told it does not: \"Attach the site to releases too.\"\n\nWHAT IS TRUE TODAY. .github/workflows/release.yml has ZERO references to wasm, web,\npkg, pages or assemble -- verified by grep. It builds four native targets\n(aarch64/x86_64 x macOS/Linux) plus a shell installer, per dist-workspace.toml.\nThe web app ships by a completely separate route: ci.yml's pages-deploy, gated on\n`github.event_name == 'push' \u0026\u0026 github.ref == 'refs/heads/main'`. Continuous\ndeployment from the default branch, not a release artifact.\n\nFOUR CONSEQUENCES, none of them written down anywhere:\n - A TAG DOES NOT DEPLOY THE SITE. pages-deploy fires only on a push to main, so\n tagging leaves the site wherever main last was. Tag from main's tip and they\n coincide BY ACCIDENT; tag an older commit and they diverge silently.\n - There is no web version of a release to download, self-host or roll back to.\n - THE PAGE NAMES NO VERSION AT ALL. web/assemble.sh stamps the cache-bust token\n with $GITHUB_SHA, but nothing in web/index.html shows a version or commit, so\n a visitor cannot tell what they are running and neither can a bug report.\n - A binary user and a site user are running different code, potentially months\n apart: v0.1.0-rc2's binaries are from 2026-04-11 and the site is from whatever\n merged most recently. README's Installation section does not say so.\n\nTHE ASK: a tagged release also carries the assembled site as an artifact, so a\nrelease has a self-hostable web bundle and a rollback target.\n\nFIVE TRAPS, each of which is how this gets done wrong:\n\n1. release.yml IS AUTOGENERATED BY cargo-dist and says so on line 1. A hand edit\n is clobbered by the next `dist init`. Use dist's own supported mechanism for\n extra artifacts (see its `extra-artifacts` / custom build configuration in\n dist-workspace.toml) so the addition SURVIVES REGENERATION. If no supported\n mechanism fits, say so explicitly and argue for the hand edit rather than\n making it silently -- and leave a comment in dist-workspace.toml so the next\n `dist init` has a warning.\n2. DO NOT WRITE A SECOND WAY TO BUILD THE BUNDLE. ci.yml's `build` job carries a\n long comment explaining that everything downstream DOWNLOADS from one\n wasm-pack run precisely so \"the harness runs against the tree Pages publishes\"\n is true by construction. A release job that builds its own bundle a different\n way reintroduces exactly what that comment prevents. Use web/build.sh and\n web/assemble.sh, the same scripts a developer runs.\n3. THE CACHE-BUST STAMP TAKES AN ARGUMENT AND THE RIGHT ONE DIFFERS. CI passes\n $GITHUB_SHA; a local run defaults to `dev`. A release should almost certainly\n pass the TAG. Decide, and note scripts/assemble-stamp.sh verifies each stamped\n reference independently -- check it still passes with whatever you choose.\n4. THE RELEASE JOB NEEDS A TOOLCHAIN IT DOES NOT HAVE: wasm32-unknown-unknown,\n a PINNED wasm-pack (ci.yml pins v0.15.0 and explains that pinning is what makes\n a local measurement evidence about a CI run), and node for the prose site.\n Reuse those pins rather than picking fresh ones.\n5. wasm-opt REJECTS LLVM's memory.copy WITHOUT --enable-bulk-memory, and the\n worker bundle ALSO needs --enable-nontrapping-float-to-int because it carries\n the whole App and so chrono's date arithmetic (gotcha 45). Those live in the\n crates' Cargo.toml metadata; do not re-specify them.\n\nALSO IN SCOPE, because it is the cheap half of the same problem: GIVE THE PAGE A\nVISIBLE VERSION OR COMMIT. A site that cannot say what it is makes every bug\nreport about it unanswerable. Small, and worth more than the tarball.\n\nDECIDE AND STATE, do not default: whether tagging should ALSO deploy Pages, or\nwhether the site keeps tracking main and a release merely captures a copy. The\nuser chose \"attach the site to releases\", which does not by itself answer this.\nBoth are defensible; supporting both by accident is not.\n\nNOT IN SCOPE: bumping a version or cutting a tag (improvise-3tj / improvise-l36,\nboth need the user), and RELEASING.md, which improvise-gx5d owns on another branch.","notes":"USER RULING 2026-09-22, settling the open decision this ticket flagged: \"I like\nPages showing the latest main for now.\"\n\nSO: pages-deploy is UNCHANGED. It keeps its `github.event_name == 'push' \u0026\u0026\ngithub.ref == 'refs/heads/main'` gate; a tag does not deploy the site. A release\nmerely CAPTURES a copy of the assembled site as an artifact.\n\nNote the words \"for now\". This is a current preference, not a permanent\narchitecture decision, and whoever reads it later should be able to tell the\ndifference. Do not record it as settled forever.\n\nTHREE THINGS THAT FOLLOW, and they change the emphasis of the work:\n\n1. THE TWO LINEAGES DIVERGING IS NOW DELIBERATE, so it gets DOCUMENTED rather than\n fixed. A binary user runs whatever was tagged; a site visitor runs latest main.\n One sentence in README's Installation section.\n\n2. THE VERSION STAMP IS THE MOST VALUABLE PART OF THIS TICKET, not the tarball.\n With the lineages deliberately different, a page that cannot say what it is\n makes every bug report about it unanswerable. web/index.html names no version\n at all today. If only one thing lands from this bead, it is the stamp.\n\n3. THE ARTIFACT'S PURPOSE IS NARROWER THAN IT SOUNDS. With Pages tracking main,\n the release tarball is NOT what anyone visits -- it is a self-hostable copy and\n a rollback target. Saying \"the web version of v0.1.0\" invites a reader to\n assume the tarball is what is deployed, which is false and costs an afternoon.\n State the narrow claim.\n\nThe cache-bust token question narrows too: since pages-deploy is untouched, the\nrelease capture is the only new caller of web/assemble.sh, so the tag is the\nobvious token. Check scripts/assemble-stamp.sh still passes with it.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T05:31:13Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:31:44Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-gx5d","title":"The tag-push release workflow: diagnosis, a preflight guard, and the missing procedure","description":"USER 2026-09-22: \"we need to fix the tag push release workflow\" ... then, on being\nshown the diagnosis: \"do nothing for now, just make sure the workflow will work\"\nand yes to a preflight guard.\n\nTHE WORKFLOW WAS NEVER BROKEN, and the evidence is stronger than a local check.\nv0.1.0-rc1 and v0.1.0-rc2 (2026-04-11) BOTH have full published GitHub Releases:\ntarballs for all four targets, their .sha256 files, dist-manifest.json,\nsource.tar.gz and improvise-installer.sh. So the workflow has run end to end and\nproduced everything it is supposed to produce.\n\nWHAT ACTUALLY HAPPENED: v0.1.0-rc3 was pushed to gh, origin and gitea while every\ncrate still said 0.1.0-rc2. dist matches the tag against the ROOT PACKAGE version\n(dist-workspace.toml says members = [\"cargo:.\"], so only the root package counts\nand gp-tree sitting at 0.1.0 is not a finding). It refused with \"This workspace\ndoesn't have anything for dist to Release!\" and named the right answer itself, and\nthe four jobs behind `plan` skipped.\n\nReproduced both directions locally: `dist plan --tag=v0.1.0-rc2` gets past the\ncheck; `--tag=v0.1.0-rc3` gives the CI error verbatim.\n\nA CORRECTION WORTH KEEPING, because it nearly became a false fact in context/: I\nfirst concluded rc1/rc2 had never triggered the workflow, inferring it from an\nempty `gh run list`. GitHub Actions logs AGE OUT AT 90 DAYS and those tags are\nfive months old. An absent run and a discarded run look identical from the run\nlist, which is the same shape as doctrine(a_missing_loose_ref_is_not_a_deleted_branch).\nCheck `gh release view \u003ctag\u003e --json assets` -- the artifacts outlive the logs.\n\nTHE REAL ROOT CAUSE IS DOCUMENTATION. There is no release procedure anywhere:\n - .github/workflows/release.yml is cargo-dist's autogenerated file -- a\n mechanism, not a procedure.\n - README section Installation describes the current state and is accurate.\n - context/plan.md Phase 3 has one-time SETUP steps from the launch plan,\n including \"test the workflow by tagging v0.1.0-rc1 ... Delete the rc tag\n afterward\" -- which was not done; all three rc tags are still on all three\n remotes.\nNothing anywhere says BUMP THE VERSION BEFORE YOU TAG.\n\nIN FLIGHT on branch fix/release-preflight: scripts/release-preflight.sh (refuses a\ndirty tree, compares tag to root version, runs `dist plan` as a second opinion and\nsays when it could not), its check_the_checker, a CI step in the `test` job, the\nreasoning in context/code/tooling.md, and a short RELEASING procedure.\n\nNOTE THE TEST LESSON, which is the transferable part: the preflight's first test\nasserted only \"did it refuse\", and every negative case still passed with the tag\ncomparison replaced by `if false;` -- because the script refuses a DIRTY WORKING\nTREE first and this repo's tree is almost always dirty from bd's auto-export. Four\nof five checks were answering \"is the tree dirty\" while claiming to answer \"does\nit catch a bad tag\". It now runs against a clean throwaway checkout AND asserts the\nrefusal names the version mismatch.\n\nNO VERSION IS BUMPED AND NO TAG IS CUT. Cutting v0.1.0 is improvise-3tj and\npublishing is improvise-l36; both need the user.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T05:26:50Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:26:50Z","dependency_count":0,"dependent_count":1,"comment_count":0} -{"_type":"issue","id":"improvise-owep","title":"Path traversal: a connected client can make the server read any file it can read (LoadModel)","description":"USER RULING 2026-09-22: \"We'll rely on sufficient containerization, but we should\nprevent path traversal attacks mainly: the server should have a documents\ndirectory and make sure the path canonicalizes to that directory.\"\n\nTHIS IS LIVE TODAY, NOT A FUTURE CONCERN. Verified by the orchestrator while\nscoping the ruling:\n\n - ModelCommand::LoadModel(PathBuf) is ON THE WIRE (protocol/src/reduce.rs:159).\n - effect::LoadModel (command/src/ui/effect.rs:914) calls\n persistence::load_from(\u0026*app.storage, \u0026self.0) with the CLIENT'S path.\n - PhysicalFs (io/src/storage.rs:94) has NO root and NO confinement:\n File::open(path) and File::create(path) on whatever arrives.\n - improvise-ws-server builds its App with App::new(workbook, path), which\n supplies that unconfined PhysicalFs.\n\nSo ANY CONNECTED WEBSOCKET CLIENT CAN ASK THE HOST TO READ ANY FILE THE SERVER\nPROCESS CAN READ. A .improv-shaped file loads wholesale and is then projected\nback to the client. A file that does NOT parse is still a leak risk: the error\ngoes into view_state.status_msg as \"Load error: {e}\", and pest errors quote the\noffending input.\n\nWRITE IS NOT YET CLIENT-REACHABLE -- Save/SaveAs are ViewOnly and off the wire --\nBUT IT IS ABOUT TO BE. improvise-y11g (the : command line, a core feature per the\nuser) reaches `:w \u003cpath\u003e`, and improvise-2mqz adds the export/download frames.\nLanding either without this is how arbitrary file WRITE ships.\n\nPAIR WITH improvise-kc7n: the Origin check answers what PAGE is driving a client,\nnever WHO is connecting, and improvise-kpnr (authentication) is open. Confinement\nis the control that does not depend on either.\n\nWHERE IT BELONGS: THE BACKEND, NOT THE CALL SITES. Gotcha 2 -- every document read\nor write goes through App::storage, and the host picks the backend. So this is a\nROOTED PhysicalFs (e.g. PhysicalFs::rooted(dir)), and every caller is covered by\nconstruction rather than by each remembering to check. That is\ndoctrine(an_invariant_is_made_unrepresentable_not_validated), the same answer\nprune_axis_items and gp-tree's Row\u003cT\u003e got.\n\nCRITICALLY, THIS IS A PROPERTY OF THE SERVER, NOT OF Storage. A TUI user may open\nany file they own; confining App::new would be WRONG. The server constructs the\nrooted backend; the TUI keeps the unrooted one. Gotcha 2 already says the host\npicks, so this fits without a trait change.\n\nSIX IMPLEMENTATION TRAPS, each of which is the standard way this fix is got wrong:\n\n1. std::fs::canonicalize REQUIRES THE PATH TO EXIST. A write to a NEW document\n fails it. Canonicalize the PARENT and join the final component -- and reject a\n final component that is itself `..` or contains a separator.\n2. REJECT, DO NOT SANITIZE. Stripping or collapsing `..` is the classic broken\n fix; `....//` and percent/unicode variants defeat it. Canonicalize, then\n compare.\n3. COMPARE COMPONENT-WISE, NOT AS STRINGS. `/srv/docs-evil`.starts_with(\"/srv/docs\")\n is TRUE as a string and false as a path. Use Path::starts_with, which is\n component-wise, and canonicalize the ROOT once at construction (a relative or\n symlinked root defeats the comparison otherwise).\n4. SYMLINKS: canonicalize resolves them, so a link inside the directory pointing\n out IS caught -- but only because the check runs on the resolved path. Do not\n \"optimise\" it into a lexical normalisation.\n5. TOCTOU IS A STATED RESIDUAL, NOT A SOLVED PROBLEM. Between the check and the\n open, a component can be replaced. openat2(RESOLVE_BENEATH) is the real answer\n and portable Rust does not have it. The user has ruled that containerization\n carries this; SAY SO in the code's context/ prose rather than implying the\n check is total.\n6. ABSOLUTE PATHS INSIDE THE ROOT must be allowed or the feature is unusable, and\n absolute paths outside it refused -- so the rule is \"canonicalizes under the\n root\", never \"is relative\".\n\nWHAT THE SERVER'S CLI GAINS: a --documents-dir (name it as you like), replacing or\nconstraining the current `files: Vec\u003cPathBuf\u003e` list. Decide and state whether the\nexisting multi-file form survives as \"these files, which must be under the root\"\nor is replaced by \"this directory\". Both are defensible; silently supporting both\nis not.\n\nTESTS -- and the check_the_checker discipline applies, because a confinement test\nthat passes against an unconfined backend is worse than none:\n - ../ escapes, absolute escapes, a symlink inside the root pointing out, a root\n that is itself a symlink, and the `/srv/docs-evil` string-prefix case.\n - A NEW file inside the root must be WRITABLE -- the case trap 1 breaks.\n - Drive it through ModelCommand::LoadModel over Sessions, not just against\n PhysicalFs directly: the vulnerability is reachable from the wire and the test\n should be too.\n - INJECT THE FAULT: point the rooted backend's root at `/` and watch the escape\n tests fail. A confinement test that cannot fail is the characteristic defect\n of this repo.\n - Consider whether io/src/storage.rs::contract::storage_contract should gain a\n confinement clause. Note OpfsFs is confined by the browser already and\n MemoryFs is a map, so this may be a PhysicalFs property rather than a contract\n one -- decide explicitly and write down which.","notes":"DEFERRED BY USER RULING 2026-09-22: \"The server isn't running anywhere yet so this\ncan be deferred.\"\n\nThat is an EXPOSURE judgement, not a correctness one. Everything in the analysis\nabove stands: the path really is client-supplied, PhysicalFs really is unrooted,\nand the read really would succeed. What is absent is a deployment -- nobody is\nrunning improvise-ws-server, so there is no process for a client to reach and no\nfilesystem to read. Priority dropped P0 -\u003e P2 and the bead set BLOCKED so it stays\noff `bd ready`, NOT closed.\n\nTHE DEFERRAL IS CONDITIONAL, AND THE CONDITION IS ALREADY WRITTEN DOWN AS A\nDEPENDENCY. improvise-y11g (the : command line) and improvise-2mqz (SaveAs /\nExportCsv) are both `blocked-by` this ticket, and that edge is now MORE load-\nbearing rather than less: it is what stops the exposure arriving before the fix.\nDo not remove those edges to unblock either ticket. If one of them becomes urgent,\nthis one comes with it.\n\nTHREE THINGS THAT REOPEN IT, so the condition is testable rather than remembered:\n 1. improvise-ws-server being deployed or demoed anywhere reachable, including a\n tunnel for a Show HN.\n 2. improvise-y11g or improvise-2mqz landing, either of which makes a\n client-supplied path reach a WRITE.\n 3. Any new ModelCommand or frame that carries a path.\n\nWHAT DOES NOT REOPEN IT: the browser worker deployment. Its Storage is OpfsFs,\nconfined by the browser to the origin's private filesystem, and the document is\nthe user's own. This ticket is about improvise-ws-server only.\n\nNOTE FOR WHOEVER PICKS IT UP: the analysis above was written against a live\nreading of the tree at d17ad0f. Re-verify rather than trust it -- a deferred\nsecurity finding whose premises have quietly changed is worse than an open one,\nbecause the priority says it was judged and the judgement may no longer hold.","status":"blocked","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T05:24:28Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:25:03Z","labels":["protocol"],"dependency_count":0,"dependent_count":2,"comment_count":0} -{"_type":"issue","id":"improvise-0w7y","title":"Browser UI polish: dismissable banner, a minibuffer a web user will see, and more contrast","description":"USER, 2026-09-22, looking at the deployed page at fiddlerwoaroof.github.io/improvise/app/:\n\n 1. \"I just noticed the minibuffer is at the bottom of the screen, that's a bit\n surprising and not really 'in your face' for a web page.\"\n 2. \"we need to do a frontend design polish for the UI. The general aesthetics\n are pretty good, but it should 'pop' a bit\"\n 3. \"we should make the giant 'Everything runs here in this tab ...' thing\n dismissable\"\n\nWHAT THE SCREENSHOT SHOWS. The banner is ~10 dense lines of prose ABOVE THE FOLD,\npushing the tile bar, category tree, formula panel and grid down. It explains\n?doc=, ?seed=, ?ws=, wss:// vs ws:// and reverse proxies -- reference material,\non first paint, every visit. The minibuffer was showing \"Display a menu\" in small\ntext at the very bottom-left.\n\nSCOPE IS PRESENTATION ONLY. RenderMinibuffer is built in wasm-client/src/render.rs\nfrom state.view.mode.minibuffer(), and both front ends read the same semantic\nvalue. Moving or restyling it must NOT add a second derivation of what it says\n(design-principles section 5; improvise-edp is the worked example). The bottom\nline stays right for the TUI -- this is about the web page.\n\nNOT THIS TICKET: making the minibuffer REACHABLE. That is improvise-6acs (verify\nthe \"typing blind\" report) and improvise-y11g (the : command line). Seeing it and\nreaching it are different bugs and the tracker should not let one stand in for\nthe other.\n\nTHE CONSTRAINT THAT SHAPES THE WORK: harness/tests/shell.spec.js is the only thing\nthat executes web/*.js, and it CANNOT see CSS or the grouped/multi-level DOM\n(gotcha 48). So a dismissed banner surviving a reload is testable and most of the\npolish is not -- which means the untestable part must be ARGUED, and the split\nbetween the two must be stated rather than blurred.\n\nBanner dismissal is per-viewer browser storage, which is what localStorage is\nlegitimately for: wrap every read and write in try/catch and render correctly when\nit throws or returns nothing.\n\nIN FLIGHT on branch ui/shell-polish.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T05:21:27Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:21:27Z","labels":["browser"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-6acs","title":"Verify the browser minibuffer report: painting is tested, reaching the mode is not","description":"USER REPORT 2026-09-21: \"the 'minibuffer' for things like typing in a new category\nname, etc. doesn't actually show up anywhere so you're typing blind.\"\n\nTHIS WAS NEVER FILED. It is filed now as a VERIFY-FIRST ticket, because the obvious\ndiagnosis is wrong and the real cause may already be fixed.\n\nWHY IT IS NOT A PAINTING BUG. web/app.js (:247-251) paints the minibuffer, and\nrender.rs::every_minibuffer_mode_paints_what_is_being_typed asserts all four\ntext-entry modes paint their prompt and the typed value. That test exists because\npainting WAS the bug once (improvise-fdt6, evidence in\ncontext/test-evidence/clients.md) and it was fixed.\n\nWHY THE TEST CANNOT SEE THE REPORTED SYMPTOM, which is the interesting part: it\nfolds Command::ChangeMode(mode) DIRECTLY. It never presses a key. So it proves the\npaint works GIVEN the mode, and says nothing about whether a keypress can REACH the\nmode. That is this repo's characteristic defect -- a test that passes while the\nthing it names is broken -- sitting in the exact place the symptom points.\n\nSO THE HYPOTHESIS TO TEST IS REACHABILITY, not rendering: does a browser keypress\nactually put the client into AppMode::CategoryAdd? resolve.rs::stranding() refuses\nonly SavePrompt, ExportPrompt and CommandMode, so category-add is not stranded --\nbut `search-or-category-add` sat on the_command_names_that_still_need_the_host_are_these\nuntil PR #114 moved it off (improvise-koh6). The report predates that merge.\n\nSO IT MAY ALREADY BE FIXED. Verify against current main FIRST and close on evidence\nif so -- do not build anything before reproducing.\n\nIF IT REPRODUCES: the fix is whatever makes the key reach the mode, and the test\nthat earns its place is one that goes from a KEYPRESS to a painted minibuffer, not\nfrom a folded ChangeMode. Add it beside the existing one rather than replacing it;\nthey answer different questions and both are worth having.\n\nIF IT DOES NOT REPRODUCE: say so on the ticket with the keypress you used, and\nstrengthen the existing test anyway -- a keypress-to-paint test is the guard that\nwould have caught this class, and its absence is why a user found it instead.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T05:14:30Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:14:30Z","labels":["browser"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-1ohv","title":"add-record-row cannot be named on the wire: a client cannot create a record","description":"Found by the improvise-h3y7 completeness audit.\n\nAddRecordRow (cmd/grid.rs:492) builds a CellKey from the active view's\nfiltering_cats() page selections (gotcha 27: Page union Filter, NOT\ncategories_on(Axis::Page)) and writes SetCell(key, Number(0.0)).\n\nA client cannot compose it, for the same reason the coordinate rename cannot be\ncomposed: ModelCommand::SetCell takes a RecordCell whose RecordHash is an opaque\nsurrogate the server resolves against cells that ALREADY EXIST (App::resolve_record,\nui/app.rs:516). The record this command creates does not exist, so there is nothing\nto name it with.\n\nThe client DOES have the operands -- GridCache::page_coords carries the page\nselections (gotcha 33), and page_coords is framed by nothing and survives a reshape.\nWhat it lacks is a variant that TAKES them.\n\nWHY THIS IS NOT A ONE-LINE ADDITION. A ModelCommand that names coordinates rather\nthan a surrogate is a NEW NAMING MODE in the vocabulary. ADR 002 contemplated two:\n\"names its target by name\" (a category, an item, a view) and \"names a record that\nalready exists by a position-free surrogate\". A third -- \"names a record that does\nnot exist yet, by coordinates\" -- wants the ADR conversation improvise-hkbc's guard\nis designed to force. DO NOT SLIP IT IN AS A VARIANT.\n\nCHEAPEST HONEST INTERIM: leave add-record-row declined and land the coordinate-rename\nticket first, which answers most of the same question and may settle the naming mode\non a case where the record's identity is at least partly known.\n\nTHE CLASS THIS BELONGS TO, worth writing into ADR 002 or context/code/protocol.md\nbecause it will be rediscovered otherwise: a client cannot name a record it is about\nto create. That is ADR 002 working as designed, and it is INVISIBLE from the\nModelCommand list, because every existing variant either names things by name or\nnames a record that already exists. `will_be_created_by_the_frame` (ui/reduce.rs:284)\nexempts such a record from the basis check, which reads as though the naming problem\nwere solved. It is not, and the two are easy to confuse.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T04:47:53Z","created_by":"Edward Langley","updated_at":"2026-09-22T04:47:53Z","labels":["protocol"],"dependencies":[{"issue_id":"improvise-1ohv","depends_on_id":"improvise-fdk1","type":"blocks","created_at":"2026-09-22T04:48:02Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-fo0w","title":"Three black-hole keys are resolvable today over vocabulary that already exists","description":"Found by the improvise-h3y7 completeness audit. THE HEADLINE OF THAT AUDIT is that the\nvocabulary is far more complete than assumed: of thirteen gaps examined, EIGHT are\nclosed in the vocabulary and open only in the resolver. These three need NO new wire\nsurface at all.\n\nEach sits on the_command_names_that_still_need_the_host_are_these (resolve.rs:2114)\nand is therefore a BLACK HOLE -- gotcha 47: Host::handle answers ClientFrame::Key with\nan explicit empty vec, so these keys do nothing at all in a browser rather than being\nmerely slower.\n\n1. `search` -- EnterSearchMode::execute (cmd/mode.rs:386) emits exactly\n [SetSearchMode(true), SetSearchQuery(\"\")]. Both are Commands. Composable in one\n line; the cheapest single win in the audit.\n\n BUT IT MUST NOT SHIP ALONE. search-navigate scans the model for matching cells and\n the client has only a viewport-windowed GridCache, so it is NOT composable --\n shipping `/` without `n` gives a browser user a search box that finds nothing,\n which is a WORSE failure than the current silence. Either land both (search-navigate\n needs a results projection or a host request frame -- file separately once scoped)\n or decline `/` out loud in the meantime.\n\n2. `enter-advance` -- EnterAdvance::execute (cmd/navigation.rs:144) is pure cursor\n arithmetic returning viewport_effects(...), i.e. SetSelected/SetRowOffset/\n SetColOffset, all on the wire. Its row_count/col_count are GridCache::extent, which\n improvise-bpq2 added for precisely this. The bottom-right AbortChain becomes\n KeyOutcome::Ignored. NOTE improvise-bd9o measures this as the black hole that\n swallows a records-mode commit.\n\n3. `hide-selected-row-item` -- ModelCommand::HideItem { view, category, item } exists.\n The operands are AxisFrame::cats.first() and the selected row's label tuple [0],\n both on GridCache.row_frame (gotcha 44).\n\nRULES THAT APPLY TO ALL THREE:\n- Each arm mirrors its Cmd LINE FOR LINE, including where siblings disagree\n (doctrine(mirror_the_cmd_line_for_line), gotcha 47). Tidying them into one rule is\n what makes the client a second derivation.\n- Each must come OFF the host list in the same commit. That list is where closing a\n gap has to come and say so.\n- tests/lockstep.rs is where the evidence goes.\n\nNOT IN THIS TICKET: force-quit, which is in the same file and is a product question\n(what does Ctrl-Q mean in a browser tab?). It should stop being an upstream black hole\neither way, but which way is a ruling.\n\nCAVEAT FROM THE AUDIT, stated so nobody treats the sizes as measured: the auditor read\neach Cmd::execute and confirmed every effect it emits has a Command, but did not write\nthe arms. Operand availability in GridCache is read off the types, not exercised.","status":"in_progress","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-22T04:47:16Z","created_by":"Edward Langley","updated_at":"2026-09-24T00:23:57Z","started_at":"2026-09-24T00:23:57Z","lease_expires_at":"2026-09-24T00:28:57Z","heartbeat_at":"2026-09-24T00:23:57Z","labels":["browser","protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-nn4s","title":"ADR 004: an Emacs-like face system, so a theme is UI-agnostic data the front ends apply","description":"USER DIRECTION 2026-09-22: \"I think we want to iterate towards having a way to\ntheme the app like emacs does: faces, etc. described in a UI-agnostic format that\nthe different frontends can apply (there's probably a swift version down the road\nand maybe Qt, etc.)\"\n\nNote \"iterate towards\". This is a DIRECTION, not a feature request, and the first\ndeliverable is an ADR (004 is free) rather than code.\n\nWHERE THE REPO ACTUALLY IS TODAY, measured:\n - AxisColor { Row, Column, Page, Filter, Inactive } in improvise-core/view/axis.rs\n -- names ROLES. This is the pattern working.\n - MinibufColor { Green, Cyan, Yellow } in improvise-protocol/lib.rs -- names\n COLOURS. draw.rs::minibuf_fg is an IDENTITY MAP (Green -\u003e Color::Green). Its\n doc comment says \"semantic palette only\" and the type makes that false: there\n is nothing to theme. The roles exist at the call sites (green is editing,\n yellow is the prompt/destructive family, cyan is search) and were spelled as\n colours.\n - 124 `Color::` literals across 10 TUI files (draw.rs, grid.rs, every panel,\n help, the wizard). No theme, palette or color_scheme concept anywhere.\n - PR #120 just built a CSS custom-property palette for the browser. So the two\n front ends are AT A FORK: either colour roles become shared vocabulary each\n end maps to its own palette, or the web palette and the TUI's 124 literals\n drift permanently and one document looks unrelated in two clients.\n\nSEVEN QUESTIONS THE ADR MUST ANSWER. They are listed because each is a place this\ngets decided by accident otherwise.\n\n1. THE TERMINAL-PALETTE TENSION, WHICH IS THE HARD ONE. A face's colour may want\n to be \"the terminal's green\" rather than an RGB value. Today Color::Green means\n whatever the USER'S TERMINAL calls green, so their terminal theme applies. A\n format carrying only RGB would LOSE that -- a regression, not a gap, and one\n that would be discovered late and be expensive to undo. Emacs answers it with\n named colours plus tty approximation. Decide whether a face attribute can be\n a NAMED terminal colour, an RGB value, or either, and what a browser does with\n \"terminal green\".\n\n2. INHERITANCE is what makes a face system tractable rather than 124 entries.\n Emacs derives most faces from a few. Decide whether faces inherit, and if so\n whether the resolution happens in shared code (once, correctly) or per front\n end (three times, differently).\n\n3. A THEME IS CLIENT-LOCAL. Gotcha 29 / the user's own ruling: the model is shared\n and the UI is not. So a theme must NOT be a Command, must NOT live in\n ViewState, and must NOT be projected -- otherwise one client's theme reaches\n another client's screen, which is the exact bug improvise-g0f is about. State\n this explicitly in the ADR; it is cheap now and a protocol change later.\n\n4. WHERE THE VOCABULARY LIVES. AxisColor is in improvise-core, which is supposed\n to have no UI; MinibufColor is in improvise-protocol. That inconsistency\n predates this and the ADR should resolve it rather than inherit it.\n\n5. WHICH ATTRIBUTES ARE PORTABLE. Foreground, background, weight, slant,\n underline map onto ratatui Modifier, CSS, Swift and Qt. Box, shadow and\n rounded corners do not reach a terminal. Decide the minimum common set, and\n decide what a front end does with an attribute it cannot render -- Emacs\n ignores it, which is probably right and should be SAID.\n\n6. THE FORMAT. A Swift or Qt front end means the theme is DATA, not Rust -- so it\n needs a file format. TOML, JSON, or a small pest grammar in this repo's usual\n style. It is NOT part of .improv: a theme is configuration, not document data,\n and putting it there would make a document dictate a reader's colours.\n\n7. HOW IT ARRIVES WITHOUT A FLAG DAY. A big-bang refactor of 124 literals is how\n this dies. The ADR needs a migration path where the vocabulary lands first and\n the literals move opportunistically.\n\nTHE INCREMENTAL STEPS THAT FORECLOSE NOTHING, cheapest first:\n a. Rename MinibufColor's variants to roles. Pure vocabulary, no behaviour, and\n it converts \"semantic palette\" from a false doc comment into a true type.\n b. Name PR #120's CSS custom properties after the same roles, so the web\n palette is already a theme in the making rather than a second vocabulary.\n c. Let the 124 literals migrate as files are touched for other reasons.\n\nNOT IN SCOPE YET: a theme picker, user-supplied theme files, or live reloading.\nThose follow the vocabulary; none of them is the hard part.","status":"in_progress","priority":2,"issue_type":"task","assignee":"agent-faces","owner":"el-github@elangley.org","created_at":"2026-09-22T05:44:55Z","created_by":"Edward Langley","updated_at":"2026-09-25T04:45:30Z","started_at":"2026-09-25T04:45:30Z","lease_expires_at":"2026-09-25T04:50:30Z","heartbeat_at":"2026-09-25T04:45:30Z","dependencies":[{"issue_id":"improvise-nn4s","depends_on_id":"improvise-gtn7","type":"parent-child","created_at":"2026-09-25T01:05:33Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-oujb","title":"A release should carry the web app too: today a tag captures only the native binaries","description":"USER RULING 2026-09-22, asked whether the release workflow builds a package for the\nweb version and told it does not: \"Attach the site to releases too.\"\n\nWHAT IS TRUE TODAY. .github/workflows/release.yml has ZERO references to wasm, web,\npkg, pages or assemble -- verified by grep. It builds four native targets\n(aarch64/x86_64 x macOS/Linux) plus a shell installer, per dist-workspace.toml.\nThe web app ships by a completely separate route: ci.yml's pages-deploy, gated on\n`github.event_name == 'push' \u0026\u0026 github.ref == 'refs/heads/main'`. Continuous\ndeployment from the default branch, not a release artifact.\n\nFOUR CONSEQUENCES, none of them written down anywhere:\n - A TAG DOES NOT DEPLOY THE SITE. pages-deploy fires only on a push to main, so\n tagging leaves the site wherever main last was. Tag from main's tip and they\n coincide BY ACCIDENT; tag an older commit and they diverge silently.\n - There is no web version of a release to download, self-host or roll back to.\n - THE PAGE NAMES NO VERSION AT ALL. web/assemble.sh stamps the cache-bust token\n with $GITHUB_SHA, but nothing in web/index.html shows a version or commit, so\n a visitor cannot tell what they are running and neither can a bug report.\n - A binary user and a site user are running different code, potentially months\n apart: v0.1.0-rc2's binaries are from 2026-04-11 and the site is from whatever\n merged most recently. README's Installation section does not say so.\n\nTHE ASK: a tagged release also carries the assembled site as an artifact, so a\nrelease has a self-hostable web bundle and a rollback target.\n\nFIVE TRAPS, each of which is how this gets done wrong:\n\n1. release.yml IS AUTOGENERATED BY cargo-dist and says so on line 1. A hand edit\n is clobbered by the next `dist init`. Use dist's own supported mechanism for\n extra artifacts (see its `extra-artifacts` / custom build configuration in\n dist-workspace.toml) so the addition SURVIVES REGENERATION. If no supported\n mechanism fits, say so explicitly and argue for the hand edit rather than\n making it silently -- and leave a comment in dist-workspace.toml so the next\n `dist init` has a warning.\n2. DO NOT WRITE A SECOND WAY TO BUILD THE BUNDLE. ci.yml's `build` job carries a\n long comment explaining that everything downstream DOWNLOADS from one\n wasm-pack run precisely so \"the harness runs against the tree Pages publishes\"\n is true by construction. A release job that builds its own bundle a different\n way reintroduces exactly what that comment prevents. Use web/build.sh and\n web/assemble.sh, the same scripts a developer runs.\n3. THE CACHE-BUST STAMP TAKES AN ARGUMENT AND THE RIGHT ONE DIFFERS. CI passes\n $GITHUB_SHA; a local run defaults to `dev`. A release should almost certainly\n pass the TAG. Decide, and note scripts/assemble-stamp.sh verifies each stamped\n reference independently -- check it still passes with whatever you choose.\n4. THE RELEASE JOB NEEDS A TOOLCHAIN IT DOES NOT HAVE: wasm32-unknown-unknown,\n a PINNED wasm-pack (ci.yml pins v0.15.0 and explains that pinning is what makes\n a local measurement evidence about a CI run), and node for the prose site.\n Reuse those pins rather than picking fresh ones.\n5. wasm-opt REJECTS LLVM's memory.copy WITHOUT --enable-bulk-memory, and the\n worker bundle ALSO needs --enable-nontrapping-float-to-int because it carries\n the whole App and so chrono's date arithmetic (gotcha 45). Those live in the\n crates' Cargo.toml metadata; do not re-specify them.\n\nALSO IN SCOPE, because it is the cheap half of the same problem: GIVE THE PAGE A\nVISIBLE VERSION OR COMMIT. A site that cannot say what it is makes every bug\nreport about it unanswerable. Small, and worth more than the tarball.\n\nDECIDE AND STATE, do not default: whether tagging should ALSO deploy Pages, or\nwhether the site keeps tracking main and a release merely captures a copy. The\nuser chose \"attach the site to releases\", which does not by itself answer this.\nBoth are defensible; supporting both by accident is not.\n\nNOT IN SCOPE: bumping a version or cutting a tag (improvise-3tj / improvise-l36,\nboth need the user), and RELEASING.md, which improvise-gx5d owns on another branch.","notes":"USER RULING 2026-09-22, settling the open decision this ticket flagged: \"I like\nPages showing the latest main for now.\"\n\nSO: pages-deploy is UNCHANGED. It keeps its `github.event_name == 'push' \u0026\u0026\ngithub.ref == 'refs/heads/main'` gate; a tag does not deploy the site. A release\nmerely CAPTURES a copy of the assembled site as an artifact.\n\nNote the words \"for now\". This is a current preference, not a permanent\narchitecture decision, and whoever reads it later should be able to tell the\ndifference. Do not record it as settled forever.\n\nTHREE THINGS THAT FOLLOW, and they change the emphasis of the work:\n\n1. THE TWO LINEAGES DIVERGING IS NOW DELIBERATE, so it gets DOCUMENTED rather than\n fixed. A binary user runs whatever was tagged; a site visitor runs latest main.\n One sentence in README's Installation section.\n\n2. THE VERSION STAMP IS THE MOST VALUABLE PART OF THIS TICKET, not the tarball.\n With the lineages deliberately different, a page that cannot say what it is\n makes every bug report about it unanswerable. web/index.html names no version\n at all today. If only one thing lands from this bead, it is the stamp.\n\n3. THE ARTIFACT'S PURPOSE IS NARROWER THAN IT SOUNDS. With Pages tracking main,\n the release tarball is NOT what anyone visits -- it is a self-hostable copy and\n a rollback target. Saying \"the web version of v0.1.0\" invites a reader to\n assume the tarball is what is deployed, which is false and costs an afternoon.\n State the narrow claim.\n\nThe cache-bust token question narrows too: since pages-deploy is untouched, the\nrelease capture is the only new caller of web/assemble.sh, so the tag is the\nobvious token. Check scripts/assemble-stamp.sh still passes with it.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T05:31:13Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:31:44Z","dependencies":[{"issue_id":"improvise-oujb","depends_on_id":"improvise-0s6","type":"parent-child","created_at":"2026-09-25T01:04:33Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-gx5d","title":"The tag-push release workflow: diagnosis, a preflight guard, and the missing procedure","description":"USER 2026-09-22: \"we need to fix the tag push release workflow\" ... then, on being\nshown the diagnosis: \"do nothing for now, just make sure the workflow will work\"\nand yes to a preflight guard.\n\nTHE WORKFLOW WAS NEVER BROKEN, and the evidence is stronger than a local check.\nv0.1.0-rc1 and v0.1.0-rc2 (2026-04-11) BOTH have full published GitHub Releases:\ntarballs for all four targets, their .sha256 files, dist-manifest.json,\nsource.tar.gz and improvise-installer.sh. So the workflow has run end to end and\nproduced everything it is supposed to produce.\n\nWHAT ACTUALLY HAPPENED: v0.1.0-rc3 was pushed to gh, origin and gitea while every\ncrate still said 0.1.0-rc2. dist matches the tag against the ROOT PACKAGE version\n(dist-workspace.toml says members = [\"cargo:.\"], so only the root package counts\nand gp-tree sitting at 0.1.0 is not a finding). It refused with \"This workspace\ndoesn't have anything for dist to Release!\" and named the right answer itself, and\nthe four jobs behind `plan` skipped.\n\nReproduced both directions locally: `dist plan --tag=v0.1.0-rc2` gets past the\ncheck; `--tag=v0.1.0-rc3` gives the CI error verbatim.\n\nA CORRECTION WORTH KEEPING, because it nearly became a false fact in context/: I\nfirst concluded rc1/rc2 had never triggered the workflow, inferring it from an\nempty `gh run list`. GitHub Actions logs AGE OUT AT 90 DAYS and those tags are\nfive months old. An absent run and a discarded run look identical from the run\nlist, which is the same shape as doctrine(a_missing_loose_ref_is_not_a_deleted_branch).\nCheck `gh release view \u003ctag\u003e --json assets` -- the artifacts outlive the logs.\n\nTHE REAL ROOT CAUSE IS DOCUMENTATION. There is no release procedure anywhere:\n - .github/workflows/release.yml is cargo-dist's autogenerated file -- a\n mechanism, not a procedure.\n - README section Installation describes the current state and is accurate.\n - context/plan.md Phase 3 has one-time SETUP steps from the launch plan,\n including \"test the workflow by tagging v0.1.0-rc1 ... Delete the rc tag\n afterward\" -- which was not done; all three rc tags are still on all three\n remotes.\nNothing anywhere says BUMP THE VERSION BEFORE YOU TAG.\n\nIN FLIGHT on branch fix/release-preflight: scripts/release-preflight.sh (refuses a\ndirty tree, compares tag to root version, runs `dist plan` as a second opinion and\nsays when it could not), its check_the_checker, a CI step in the `test` job, the\nreasoning in context/code/tooling.md, and a short RELEASING procedure.\n\nNOTE THE TEST LESSON, which is the transferable part: the preflight's first test\nasserted only \"did it refuse\", and every negative case still passed with the tag\ncomparison replaced by `if false;` -- because the script refuses a DIRTY WORKING\nTREE first and this repo's tree is almost always dirty from bd's auto-export. Four\nof five checks were answering \"is the tree dirty\" while claiming to answer \"does\nit catch a bad tag\". It now runs against a clean throwaway checkout AND asserts the\nrefusal names the version mismatch.\n\nNO VERSION IS BUMPED AND NO TAG IS CUT. Cutting v0.1.0 is improvise-3tj and\npublishing is improvise-l36; both need the user.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T05:26:50Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:26:50Z","dependencies":[{"issue_id":"improvise-gx5d","depends_on_id":"improvise-0s6","type":"parent-child","created_at":"2026-09-25T01:07:40Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"improvise-owep","title":"Path traversal: a connected client can make the server read any file it can read (LoadModel)","description":"USER RULING 2026-09-22: \"We'll rely on sufficient containerization, but we should\nprevent path traversal attacks mainly: the server should have a documents\ndirectory and make sure the path canonicalizes to that directory.\"\n\nTHIS IS LIVE TODAY, NOT A FUTURE CONCERN. Verified by the orchestrator while\nscoping the ruling:\n\n - ModelCommand::LoadModel(PathBuf) is ON THE WIRE (protocol/src/reduce.rs:159).\n - effect::LoadModel (command/src/ui/effect.rs:914) calls\n persistence::load_from(\u0026*app.storage, \u0026self.0) with the CLIENT'S path.\n - PhysicalFs (io/src/storage.rs:94) has NO root and NO confinement:\n File::open(path) and File::create(path) on whatever arrives.\n - improvise-ws-server builds its App with App::new(workbook, path), which\n supplies that unconfined PhysicalFs.\n\nSo ANY CONNECTED WEBSOCKET CLIENT CAN ASK THE HOST TO READ ANY FILE THE SERVER\nPROCESS CAN READ. A .improv-shaped file loads wholesale and is then projected\nback to the client. A file that does NOT parse is still a leak risk: the error\ngoes into view_state.status_msg as \"Load error: {e}\", and pest errors quote the\noffending input.\n\nWRITE IS NOT YET CLIENT-REACHABLE -- Save/SaveAs are ViewOnly and off the wire --\nBUT IT IS ABOUT TO BE. improvise-y11g (the : command line, a core feature per the\nuser) reaches `:w \u003cpath\u003e`, and improvise-2mqz adds the export/download frames.\nLanding either without this is how arbitrary file WRITE ships.\n\nPAIR WITH improvise-kc7n: the Origin check answers what PAGE is driving a client,\nnever WHO is connecting, and improvise-kpnr (authentication) is open. Confinement\nis the control that does not depend on either.\n\nWHERE IT BELONGS: THE BACKEND, NOT THE CALL SITES. Gotcha 2 -- every document read\nor write goes through App::storage, and the host picks the backend. So this is a\nROOTED PhysicalFs (e.g. PhysicalFs::rooted(dir)), and every caller is covered by\nconstruction rather than by each remembering to check. That is\ndoctrine(an_invariant_is_made_unrepresentable_not_validated), the same answer\nprune_axis_items and gp-tree's Row\u003cT\u003e got.\n\nCRITICALLY, THIS IS A PROPERTY OF THE SERVER, NOT OF Storage. A TUI user may open\nany file they own; confining App::new would be WRONG. The server constructs the\nrooted backend; the TUI keeps the unrooted one. Gotcha 2 already says the host\npicks, so this fits without a trait change.\n\nSIX IMPLEMENTATION TRAPS, each of which is the standard way this fix is got wrong:\n\n1. std::fs::canonicalize REQUIRES THE PATH TO EXIST. A write to a NEW document\n fails it. Canonicalize the PARENT and join the final component -- and reject a\n final component that is itself `..` or contains a separator.\n2. REJECT, DO NOT SANITIZE. Stripping or collapsing `..` is the classic broken\n fix; `....//` and percent/unicode variants defeat it. Canonicalize, then\n compare.\n3. COMPARE COMPONENT-WISE, NOT AS STRINGS. `/srv/docs-evil`.starts_with(\"/srv/docs\")\n is TRUE as a string and false as a path. Use Path::starts_with, which is\n component-wise, and canonicalize the ROOT once at construction (a relative or\n symlinked root defeats the comparison otherwise).\n4. SYMLINKS: canonicalize resolves them, so a link inside the directory pointing\n out IS caught -- but only because the check runs on the resolved path. Do not\n \"optimise\" it into a lexical normalisation.\n5. TOCTOU IS A STATED RESIDUAL, NOT A SOLVED PROBLEM. Between the check and the\n open, a component can be replaced. openat2(RESOLVE_BENEATH) is the real answer\n and portable Rust does not have it. The user has ruled that containerization\n carries this; SAY SO in the code's context/ prose rather than implying the\n check is total.\n6. ABSOLUTE PATHS INSIDE THE ROOT must be allowed or the feature is unusable, and\n absolute paths outside it refused -- so the rule is \"canonicalizes under the\n root\", never \"is relative\".\n\nWHAT THE SERVER'S CLI GAINS: a --documents-dir (name it as you like), replacing or\nconstraining the current `files: Vec\u003cPathBuf\u003e` list. Decide and state whether the\nexisting multi-file form survives as \"these files, which must be under the root\"\nor is replaced by \"this directory\". Both are defensible; silently supporting both\nis not.\n\nTESTS -- and the check_the_checker discipline applies, because a confinement test\nthat passes against an unconfined backend is worse than none:\n - ../ escapes, absolute escapes, a symlink inside the root pointing out, a root\n that is itself a symlink, and the `/srv/docs-evil` string-prefix case.\n - A NEW file inside the root must be WRITABLE -- the case trap 1 breaks.\n - Drive it through ModelCommand::LoadModel over Sessions, not just against\n PhysicalFs directly: the vulnerability is reachable from the wire and the test\n should be too.\n - INJECT THE FAULT: point the rooted backend's root at `/` and watch the escape\n tests fail. A confinement test that cannot fail is the characteristic defect\n of this repo.\n - Consider whether io/src/storage.rs::contract::storage_contract should gain a\n confinement clause. Note OpfsFs is confined by the browser already and\n MemoryFs is a map, so this may be a PhysicalFs property rather than a contract\n one -- decide explicitly and write down which.","notes":"DEFERRED BY USER RULING 2026-09-22: \"The server isn't running anywhere yet so this\ncan be deferred.\"\n\nThat is an EXPOSURE judgement, not a correctness one. Everything in the analysis\nabove stands: the path really is client-supplied, PhysicalFs really is unrooted,\nand the read really would succeed. What is absent is a deployment -- nobody is\nrunning improvise-ws-server, so there is no process for a client to reach and no\nfilesystem to read. Priority dropped P0 -\u003e P2 and the bead set BLOCKED so it stays\noff `bd ready`, NOT closed.\n\nTHE DEFERRAL IS CONDITIONAL, AND THE CONDITION IS ALREADY WRITTEN DOWN AS A\nDEPENDENCY. improvise-y11g (the : command line) and improvise-2mqz (SaveAs /\nExportCsv) are both `blocked-by` this ticket, and that edge is now MORE load-\nbearing rather than less: it is what stops the exposure arriving before the fix.\nDo not remove those edges to unblock either ticket. If one of them becomes urgent,\nthis one comes with it.\n\nTHREE THINGS THAT REOPEN IT, so the condition is testable rather than remembered:\n 1. improvise-ws-server being deployed or demoed anywhere reachable, including a\n tunnel for a Show HN.\n 2. improvise-y11g or improvise-2mqz landing, either of which makes a\n client-supplied path reach a WRITE.\n 3. Any new ModelCommand or frame that carries a path.\n\nWHAT DOES NOT REOPEN IT: the browser worker deployment. Its Storage is OpfsFs,\nconfined by the browser to the origin's private filesystem, and the document is\nthe user's own. This ticket is about improvise-ws-server only.\n\nNOTE FOR WHOEVER PICKS IT UP: the analysis above was written against a live\nreading of the tree at d17ad0f. Re-verify rather than trust it -- a deferred\nsecurity finding whose premises have quietly changed is worse than an open one,\nbecause the priority says it was judged and the judgement may no longer hold.","status":"blocked","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T05:24:28Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:25:03Z","labels":["protocol"],"dependencies":[{"issue_id":"improvise-owep","depends_on_id":"improvise-tr08","type":"parent-child","created_at":"2026-09-25T01:04:25Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":2,"comment_count":0} +{"_type":"issue","id":"improvise-0w7y","title":"Browser UI polish: dismissable banner, a minibuffer a web user will see, and more contrast","description":"USER, 2026-09-22, looking at the deployed page at fiddlerwoaroof.github.io/improvise/app/:\n\n 1. \"I just noticed the minibuffer is at the bottom of the screen, that's a bit\n surprising and not really 'in your face' for a web page.\"\n 2. \"we need to do a frontend design polish for the UI. The general aesthetics\n are pretty good, but it should 'pop' a bit\"\n 3. \"we should make the giant 'Everything runs here in this tab ...' thing\n dismissable\"\n\nWHAT THE SCREENSHOT SHOWS. The banner is ~10 dense lines of prose ABOVE THE FOLD,\npushing the tile bar, category tree, formula panel and grid down. It explains\n?doc=, ?seed=, ?ws=, wss:// vs ws:// and reverse proxies -- reference material,\non first paint, every visit. The minibuffer was showing \"Display a menu\" in small\ntext at the very bottom-left.\n\nSCOPE IS PRESENTATION ONLY. RenderMinibuffer is built in wasm-client/src/render.rs\nfrom state.view.mode.minibuffer(), and both front ends read the same semantic\nvalue. Moving or restyling it must NOT add a second derivation of what it says\n(design-principles section 5; improvise-edp is the worked example). The bottom\nline stays right for the TUI -- this is about the web page.\n\nNOT THIS TICKET: making the minibuffer REACHABLE. That is improvise-6acs (verify\nthe \"typing blind\" report) and improvise-y11g (the : command line). Seeing it and\nreaching it are different bugs and the tracker should not let one stand in for\nthe other.\n\nTHE CONSTRAINT THAT SHAPES THE WORK: harness/tests/shell.spec.js is the only thing\nthat executes web/*.js, and it CANNOT see CSS or the grouped/multi-level DOM\n(gotcha 48). So a dismissed banner surviving a reload is testable and most of the\npolish is not -- which means the untestable part must be ARGUED, and the split\nbetween the two must be stated rather than blurred.\n\nBanner dismissal is per-viewer browser storage, which is what localStorage is\nlegitimately for: wrap every read and write in try/catch and render correctly when\nit throws or returns nothing.\n\nIN FLIGHT on branch ui/shell-polish.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T05:21:27Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:21:27Z","labels":["browser"],"dependencies":[{"issue_id":"improvise-0w7y","depends_on_id":"improvise-fhj0","type":"parent-child","created_at":"2026-09-25T01:05:35Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-6acs","title":"Verify the browser minibuffer report: painting is tested, reaching the mode is not","description":"USER REPORT 2026-09-21: \"the 'minibuffer' for things like typing in a new category\nname, etc. doesn't actually show up anywhere so you're typing blind.\"\n\nTHIS WAS NEVER FILED. It is filed now as a VERIFY-FIRST ticket, because the obvious\ndiagnosis is wrong and the real cause may already be fixed.\n\nWHY IT IS NOT A PAINTING BUG. web/app.js (:247-251) paints the minibuffer, and\nrender.rs::every_minibuffer_mode_paints_what_is_being_typed asserts all four\ntext-entry modes paint their prompt and the typed value. That test exists because\npainting WAS the bug once (improvise-fdt6, evidence in\ncontext/test-evidence/clients.md) and it was fixed.\n\nWHY THE TEST CANNOT SEE THE REPORTED SYMPTOM, which is the interesting part: it\nfolds Command::ChangeMode(mode) DIRECTLY. It never presses a key. So it proves the\npaint works GIVEN the mode, and says nothing about whether a keypress can REACH the\nmode. That is this repo's characteristic defect -- a test that passes while the\nthing it names is broken -- sitting in the exact place the symptom points.\n\nSO THE HYPOTHESIS TO TEST IS REACHABILITY, not rendering: does a browser keypress\nactually put the client into AppMode::CategoryAdd? resolve.rs::stranding() refuses\nonly SavePrompt, ExportPrompt and CommandMode, so category-add is not stranded --\nbut `search-or-category-add` sat on the_command_names_that_still_need_the_host_are_these\nuntil PR #114 moved it off (improvise-koh6). The report predates that merge.\n\nSO IT MAY ALREADY BE FIXED. Verify against current main FIRST and close on evidence\nif so -- do not build anything before reproducing.\n\nIF IT REPRODUCES: the fix is whatever makes the key reach the mode, and the test\nthat earns its place is one that goes from a KEYPRESS to a painted minibuffer, not\nfrom a folded ChangeMode. Add it beside the existing one rather than replacing it;\nthey answer different questions and both are worth having.\n\nIF IT DOES NOT REPRODUCE: say so on the ticket with the keypress you used, and\nstrengthen the existing test anyway -- a keypress-to-paint test is the guard that\nwould have caught this class, and its absence is why a user found it instead.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T05:14:30Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:14:30Z","labels":["browser"],"dependencies":[{"issue_id":"improvise-6acs","depends_on_id":"improvise-fhj0","type":"parent-child","created_at":"2026-09-25T01:05:35Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-1ohv","title":"add-record-row cannot be named on the wire: a client cannot create a record","description":"Found by the improvise-h3y7 completeness audit.\n\nAddRecordRow (cmd/grid.rs:492) builds a CellKey from the active view's\nfiltering_cats() page selections (gotcha 27: Page union Filter, NOT\ncategories_on(Axis::Page)) and writes SetCell(key, Number(0.0)).\n\nA client cannot compose it, for the same reason the coordinate rename cannot be\ncomposed: ModelCommand::SetCell takes a RecordCell whose RecordHash is an opaque\nsurrogate the server resolves against cells that ALREADY EXIST (App::resolve_record,\nui/app.rs:516). The record this command creates does not exist, so there is nothing\nto name it with.\n\nThe client DOES have the operands -- GridCache::page_coords carries the page\nselections (gotcha 33), and page_coords is framed by nothing and survives a reshape.\nWhat it lacks is a variant that TAKES them.\n\nWHY THIS IS NOT A ONE-LINE ADDITION. A ModelCommand that names coordinates rather\nthan a surrogate is a NEW NAMING MODE in the vocabulary. ADR 002 contemplated two:\n\"names its target by name\" (a category, an item, a view) and \"names a record that\nalready exists by a position-free surrogate\". A third -- \"names a record that does\nnot exist yet, by coordinates\" -- wants the ADR conversation improvise-hkbc's guard\nis designed to force. DO NOT SLIP IT IN AS A VARIANT.\n\nCHEAPEST HONEST INTERIM: leave add-record-row declined and land the coordinate-rename\nticket first, which answers most of the same question and may settle the naming mode\non a case where the record's identity is at least partly known.\n\nTHE CLASS THIS BELONGS TO, worth writing into ADR 002 or context/code/protocol.md\nbecause it will be rediscovered otherwise: a client cannot name a record it is about\nto create. That is ADR 002 working as designed, and it is INVISIBLE from the\nModelCommand list, because every existing variant either names things by name or\nnames a record that already exists. `will_be_created_by_the_frame` (ui/reduce.rs:284)\nexempts such a record from the basis check, which reads as though the naming problem\nwere solved. It is not, and the two are easy to confuse.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T04:47:53Z","created_by":"Edward Langley","updated_at":"2026-09-22T04:47:53Z","labels":["protocol"],"dependencies":[{"issue_id":"improvise-1ohv","depends_on_id":"improvise-fdk1","type":"blocks","created_at":"2026-09-22T04:48:02Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-1ohv","depends_on_id":"improvise-pmt0","type":"parent-child","created_at":"2026-09-25T01:04:59Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-fo0w","title":"Three black-hole keys are resolvable today over vocabulary that already exists","description":"Found by the improvise-h3y7 completeness audit. THE HEADLINE OF THAT AUDIT is that the\nvocabulary is far more complete than assumed: of thirteen gaps examined, EIGHT are\nclosed in the vocabulary and open only in the resolver. These three need NO new wire\nsurface at all.\n\nEach sits on the_command_names_that_still_need_the_host_are_these (resolve.rs:2114)\nand is therefore a BLACK HOLE -- gotcha 47: Host::handle answers ClientFrame::Key with\nan explicit empty vec, so these keys do nothing at all in a browser rather than being\nmerely slower.\n\n1. `search` -- EnterSearchMode::execute (cmd/mode.rs:386) emits exactly\n [SetSearchMode(true), SetSearchQuery(\"\")]. Both are Commands. Composable in one\n line; the cheapest single win in the audit.\n\n BUT IT MUST NOT SHIP ALONE. search-navigate scans the model for matching cells and\n the client has only a viewport-windowed GridCache, so it is NOT composable --\n shipping `/` without `n` gives a browser user a search box that finds nothing,\n which is a WORSE failure than the current silence. Either land both (search-navigate\n needs a results projection or a host request frame -- file separately once scoped)\n or decline `/` out loud in the meantime.\n\n2. `enter-advance` -- EnterAdvance::execute (cmd/navigation.rs:144) is pure cursor\n arithmetic returning viewport_effects(...), i.e. SetSelected/SetRowOffset/\n SetColOffset, all on the wire. Its row_count/col_count are GridCache::extent, which\n improvise-bpq2 added for precisely this. The bottom-right AbortChain becomes\n KeyOutcome::Ignored. NOTE improvise-bd9o measures this as the black hole that\n swallows a records-mode commit.\n\n3. `hide-selected-row-item` -- ModelCommand::HideItem { view, category, item } exists.\n The operands are AxisFrame::cats.first() and the selected row's label tuple [0],\n both on GridCache.row_frame (gotcha 44).\n\nRULES THAT APPLY TO ALL THREE:\n- Each arm mirrors its Cmd LINE FOR LINE, including where siblings disagree\n (doctrine(mirror_the_cmd_line_for_line), gotcha 47). Tidying them into one rule is\n what makes the client a second derivation.\n- Each must come OFF the host list in the same commit. That list is where closing a\n gap has to come and say so.\n- tests/lockstep.rs is where the evidence goes.\n\nNOT IN THIS TICKET: force-quit, which is in the same file and is a product question\n(what does Ctrl-Q mean in a browser tab?). It should stop being an upstream black hole\neither way, but which way is a ruling.\n\nCAVEAT FROM THE AUDIT, stated so nobody treats the sizes as measured: the auditor read\neach Cmd::execute and confirmed every effect it emits has a Command, but did not write\nthe arms. Operand availability in GridCache is read off the types, not exercised.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-22T04:47:16Z","created_by":"Edward Langley","updated_at":"2026-09-24T21:45:11Z","started_at":"2026-09-24T00:23:57Z","closed_at":"2026-09-24T21:45:11Z","close_reason":"Landed in main via #275 (reviewed; merged 2026-09-24).","labels":["browser","protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-cfly","title":"The wave ships view history with no key that reaches it: declined() still refuses view-back","description":"Found by the wave's integration agent at preview 30560e0f, and it is a WAVE-LEVEL\nresidue rather than a defect in any branch -- which is why nobody could see it.\n\nPR #115 makes the view history genuinely work for a connected client:\nViewBack/ViewForward join the view half of Command, reduce_view folds all three\nview effects line for line, each client's own view_back_stack is maintained, and\na deleted view is pruned from every client's history via Command::ForgetView.\n\nBUT resolve.rs's composed() has NO view-back or view-forward arm, and declined()\nstill answers \"stepping back to the previous view is not available in the browser\nyet\" -- byte-identical to base.\n\nSo the merged wave contains the CAPABILITY and not the KEY. A browser user\npresses `\u003c` and is told the feature does not exist, over machinery that now\nfully supports it.\n\nWHY NEITHER AGENT COULD SEE IT: #115 was told not to touch resolve.rs (another\nagent owned it) and correctly reported the exact lines instead. #114 owned\nresolve.rs and had no reason to look at declined() at all. Reading #115 alone you\nwould conclude browser view-history works; reading #114 alone you would never\nlook.\n\nTHE EXACT LINES, from #115's report and confirmed by the integration agent as\nstill byte-identical to base:\n\n crates/improvise-protocol/src/resolve.rs\n 239: // NOT \"not built\": it commits a drill first, so composing it would\n 240: // commit another client's typing. Needs addressed commands -- clients.md.\n 241: \"view-back\" =\u003e \"stepping back to the previous view\",\n\nDELETE THE COMMENT WITH THE ARM. Its stated reason is WRONG as of #115: composing\nview-back does not commit another client's typing, because a browser session has\nno drill state to commit. Leaving a false reason behind is worse than leaving the\ndecline.\n\nWHAT TO ADD: a composed() arm returning vec![Command::ViewBack], and the forward\nsibling. Note gotcha 47's rule -- each resolver mirrors its Cmd LINE FOR LINE,\nincluding where siblings disagree -- so check ViewNavigate's own execute() rather\nthan assuming the two directions are symmetric.\n\nPRECONDITION: #115 merged. Nothing else; #115 named this explicitly.\n\nNOT IN SCOPE: line 234, \"drill-into-cell\" =\u003e \"drill\". That one still needs the\n_Drill view session-local and the staging design, and the user has since ruled\nthat browser staging must be identical to the TUI's. See improvise-gd28.\n\nALSO: make sure the wave's PR body does not claim browser view-history works.\n","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T04:17:26Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:22:26Z","closed_at":"2026-09-22T05:22:26Z","close_reason":"Closed","labels":["browser","protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-en4s","title":"No guard that a client's formula panel loses a formula its target item's deletion removed","description":"Found while reviewing improvise-g3no; the reasoning is in\ncontext/code/command.md beside the other Dirty notes.\n\nDirty::of(ModelChange::CellData) reports formulas: true. That was written as\ndeliberate over-reporting in the cheap direction -- a cell write cannot change\na formula, so the entry cost one redundant Projection and bought nothing.\n\nIt is now load-bearing. improvise-g3no made Model::remove_item drop the\nformulas targeting the item, and effect::RemoveItem is CellData, so a delete\nreally can take a formula out of the document. Tightening that arm would leave\nevery connected client's formula panel showing a formula the document no longer\nhas, forever, with nothing in the stream to correct it -- gotcha 35's expensive\ndirection, and exactly the failure that put `formulas` on the LayoutGeometry arm\nin the first place.\n\nNothing guards it. removing_a_category_empties_the_clients_formula_panel covers\nthe LayoutGeometry twin only. The missing test is its item-level sibling: a\nsession whose formula panel names a _Measure formula target, a\nModelCommand::RemoveItem for that target, and an assertion that the client's\nFormulaCache no longer names it. Inject the fault by setting formulas: false on\nthe CellData arm of Dirty::of.\n\nLives in ui/project.rs, which is why it is filed rather than written.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-cmd2","owner":"el-github@elangley.org","created_at":"2026-09-22T02:27:48Z","created_by":"Edward Langley","updated_at":"2026-09-23T02:43:14Z","started_at":"2026-09-23T02:29:32Z","closed_at":"2026-09-23T02:43:14Z","close_reason":"PR #157. Dirty::of(CellData) now reports formulas: true -- RemoveItem is CellData and Model::remove_item drops the formulas targeting the item, so a client's formula panel kept showing a formula the document no longer had. WHAT THE TICKET DID NOT PREDICT, and it changed the test: written the obvious way (connect, AddFormula, RemoveItem) the test fails under the rival AT THE SETUP ASSERTION, not the property -- because AddFormula is ITSELF CellData, so with the arm tightened the formula never reaches the client at all. Red for the neighbouring question where the property is about a deletion. The formula is therefore seeded BEFORE Sessions::connect, so the client holds it from the bootstrap seed, which no Dirty gates. TWO CORRECTIONS TO THE TICKET'S PREMISE: 'nothing guards it' read greener than it was -- the rival also takes down two other tests, but at setup and incidentally, which is what made it look both unguarded and covered depending which way you ran it; and the LayoutGeometry sibling is fine and was left alone, because only the CellData twin shares an arm with its own setup.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-xolb","title":"The wave brief's gate list omitted the node-test jobs, the only gate that sees an unpainted RenderModel field","description":"Found by the wave's integration agent diffing my wave brief against .github/workflows/ci.yml.\n\nMY GATE LIST OMITTED A REAL CI JOB:\n\n ./scripts/node-test.sh 'web/tests/*.test.js' ci.yml:239 \"Browser JS unit tests (node --test)\"\n web/tests/bundle/*.test.js ci.yml:371 (needs a built bundle)\n\nAt base: 36 tests over 5 files, 0 fail. Neither my brief nor context/repo-map.md's \"Testing -- the short version\" mentions either.\n\nWHY IT MATTERS, and it is not a formality: web/tests/render_surface.test.js is the MECHANICAL guard at the RenderModel -\u003e DOM boundary. It parses the pub fields off crates/improvise-wasm-client/src/render.rs and requires model.\u003cfield\u003e to appear in web/app.js + web/shell.js.\n\nPROVEN BY INJECTION: adding `pub injected_unpainted_field: u64` to RenderModel fails that gate with actual: ['injected_unpainted_field'], expected: []. cargo test --workspace is COMPLETELY INDIFFERENT to it.\n\nSo an agent working from a gate list that omits it can expose a projected field, never paint it, pass every gate it was told to run, and ship the defect. That is precisely the cross-branch shape a wave produces: one agent adds the field, another owns the painting.\n\nTWO BLIND SPOTS THE GUARD STATES ABOUT ITSELF, confirmed by reading, so it should not be over-trusted either:\n - it is a SUBSTRING search (page.includes(\"model.\" + field)), so a field read only inside a function nothing calls reads as painted\n - its field regex requires exactly four-space indentation and a lowercase name\n\nTHE GENERALISABLE PART: a gate list carried in a brief is a COPY of CI, and a copy drifts. The brief is written from memory and the repo-map, and both were stale here. Checking the brief against ci.yml is a once-per-wave action, not a once-per-agent one -- no single agent would find this, because q and s never touch RenderModel and r and t each individually pass cargo test.\n\nWHAT TO DO: add both node jobs to the repo-map's testing section and to the wave brief template, so the next brief inherits CI rather than memory.\n","notes":"WIDENED 2026-09-22 by the improvise-rw6l agent, which hit the gap from the other\nside and lost a CI cycle to it.\n\nTHE GATE LIST OMITS A SECOND NODE SUITE, not just one. web/tests/bundle/ does NOT\nmatch the glob web/tests/*.test.js, so an agent given the standard gate list never\nruns it. wasm_surface.test.js is a ratchet on \"the handful of calls the page\nmakes\" and needs a built web/pkg, so it also has a PRECONDITION the other suite\ndoes not. It ran only in CI and failed there (commit 0a920f1).\n\nSo the standard brief needs BOTH lines, and the second needs its precondition\nstated:\n\n ./scripts/node-test.sh 'web/tests/*.test.js'\n web/build.sh \u0026\u0026 ./scripts/node-test.sh 'web/tests/bundle/*.test.js'\n\nTWO MORE ENVIRONMENT NOTES FROM THE SAME AGENT, both of which cost time and\nneither of which is written anywhere:\n\n 1. harness/node_modules DOES NOT EXIST IN A FRESH WORKTREE, and `npx playwright\n test` then fails with \"Cannot find package '@playwright/test'\" -- an error\n about the package, not about the harness, so it reads as a broken checkout.\n `npm install` in harness/ first. (AGENTS.md says npm ci, never npm install,\n for the repo's own dependency discipline; reconcile the two before writing\n this into a brief.)\n 2. The shell guard refuses a heredoc'd Python script for reasons not\n reproducible from its content: the same `python3 - \u003c\u003c'PY'` shape ran\n repeatedly and was then refused twice on a block containing only Rust test\n text. `python3 /dev/stdin \u003c\u003c'PYEOF'` worked. Worth a line in AGENTS.md's\n environment notes beside the `gh` and `bd` ones.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-docs-wave","owner":"el-github@elangley.org","created_at":"2026-09-22T00:49:15Z","created_by":"Edward Langley","updated_at":"2026-09-23T06:29:42Z","started_at":"2026-09-23T05:01:12Z","closed_at":"2026-09-23T06:29:42Z","close_reason":"Fixed in PR #201, merged after adversarial review (31b5acd, then the delta at 141be9f). Follow-ups: improvise-28ri, improvise-oga4, improvise-bf0q.","labels":["docs","testing","tooling"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-piyp","title":"every_bound_command_name_is_registered is blind to prefix sub-bindings and F-keys","description":"PROVEN BY INJECTION 2026-09-21 by the wave's integration agent.\n\nevery_bound_command_name_is_registered (improvise-command/src/command/keymap.rs:417) has two blind spots:\n\n - the walk does `Some(Binding::Prefix(_)) | None =\u003e {}` at line 458 -- it NEVER descends into a prefix sub-keymap\n - its private probe_keys() (line 491) covers 0x20..0x7f plus 14 named keys but NO F-keys, while the neighbouring public probe_keys_pub() (line 905) DOES include F(1)/F(2)\n\nINJECTION: two command names bound that no registry entry provides -- one under the `g` prefix, one on F(5) in Normal.\n - every_bound_command_name_is_registered -\u003e ok\n - cargo test --workspace -\u003e 1595 passed, 0 failed, 7 ignored, 37 binaries, BYTE-IDENTICAL to baseline\n\nPOSITIVE CONTROL, which is what separates \"untested path\" from \"broken test\": the same bogus name on Char('~'), a probed non-prefix key -\u003e the test FAILS. So the guard works; its reach is the defect.\n\nCONSEQUENCE: a command name bound only under g/y/Z, or on a function key, can be unregistered -- OR have args that will not parse, since improvise-s13 moved enter-mode into the args-carrying class -- and the whole suite stays green. That is the exact failure the guard was written for, surviving in the region it does not reach.\n\ndump_every_binding DOES descend into prefixes via which_key, but it is a DUMP gated on IMPROVISE_BINDING_DUMP, not an assertion -- inert in CI, so it does not cover the gap.\n\nTHE CONTRAST THAT MAKES THIS WORTH FIXING RATHER THAN NOTING: nothing_declined_is_also_composed in improvise-protocol/src/resolve.rs is the STRONGER guard -- its bound_commands helper DOES recurse into Binding::Prefix and into parents. So the resolution side is guarded where the registration side is not, and an agent reading either test alone would reasonably conclude its bindings are checked. Only comparing the two shows which half is real.\n\nFIX SHAPE: descend into Binding::Prefix in the walk, and make probe_keys cover what probe_keys_pub already does. Both are small. The acceptance is the injection above failing -- test it with a name behind a prefix AND one on an F-key, because they are two different blind spots and one fix may not close both.\n\nRelevant: gotcha 13 (prefixes are stored as an address; KeymapSet::prefix_keymap walks parents so RecordsNormal reaches Normal's g/y/Z) and gotcha 15 (the guard checks BOTH halves of dispatch -- the name resolves and the args parse).\n","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T00:49:14Z","created_by":"Edward Langley","updated_at":"2026-09-22T02:27:16Z","closed_at":"2026-09-22T02:27:16Z","close_reason":"Fixed in PR #112 (wave/q-keymap, head b4bd6bc), both blind spots closed and each injection fails SEPARATELY -- which was the acceptance, because a fix for one need not close the other.\n\n - PREFIX: reachable_names now recurses into Binding::Prefix. Reported via RecordsNormal, which confirms the INHERITED-prefix path rather than only a locally-bound one.\n - F-KEY: probe_keys now DELEGATES to probe_keys_pub, which covers F(1)..=F(12).\n\nTHE FINDING THE BEAD DID NOT PREDICT, and it is the better half: the F-key gap was not an oversight about F-keys. probe_keys and probe_keys_pub were DUPLICATES differing by two lines, and the gap WAS the drift between them. Adding F-keys to both would have closed the symptom and left the mechanism intact. So the duplication doctrine applies to TEST SCAFFOLDING, not only to production derivations -- two copies of a test's probe key space is the same disease as two keymap spines, and it failed the same way.","labels":["command","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-b8ot","title":"Delegate DOM events: one handler per surface on the stable root, not one per node","description":"USER RULING 2026-09-21: \"I want one handler outside the area that gets redrawn\nand dispatch events there.\"\n\nTODAY: one listener PER NODE, created inside the render loops and reallocated on\nevery repaint. Only `window` keydown (app.js:509) and the transport listeners\nare delegated.\n\n app.js:361 td mousedown ONE PER GRID CELL, inside the cell loop\n app.js:436 li click one per panel row\n app.js:476 span click one per tile\n app.js:493 li click one per category row\n app.js:414 input keydown the editor\n app.js:424 input blur the editor\n\nEight replaceChildren sites rebuild the DOM wholesale, so each closure is\ndiscarded and reallocated per frame, and repaints happen roughly per keystroke.\n\nTHE ROOTS ARE ALREADY STABLE, which is what makes this cheap. replaceChildren\nreplaces an element's CHILDREN, not the element, so every `el.*` registry entry\n(app.js:30-43) survives every repaint:\n\n el.grid \u003c- replaceChildren at :214 delegate td mousedown here\n el.categories \u003c- :218 delegate li click here\n el.tiles \u003c- :479 delegate span click here\n el.formulas / el.views \u003c- via `host` at :439 delegate li click here\n\nSo each surface already has exactly the persistent ancestor the ruling asks for.\nNo new wrapper element is needed. Read indices off data- attributes\n(data-row/data-col on a cell, data-index on a row or tile) with\nevent.target.closest(..).\n\nFOUR THINGS A NAIVE REWRITE BREAKS. All are documented and load-bearing:\n\n1. THE GRID USES mousedown, NOT click, DELIBERATELY (improvise-0x9u). From an\n editing state the input's blur repaints first, so the \u003ctd\u003e is gone before a\n click could fire. Delegation does not change that -- the target is detached\n before the event -- so it must stay mousedown.\n\n2. IT CALLS preventDefault(). Dropping that reintroduces focus/selection\n behaviour the editor depends on.\n\n3. IT CONDITIONALLY SKIPS THE REPAINT. On an editable cell it calls edit(..) and\n does NOT repaint, because a repaint would take the \u003cinput\u003e away. The comment\n at :356-360 says so. A delegated handler must keep that branch.\n\n4. IT CLOSES A LIVE EDITOR FIRST (`if (editing) editing.close(false)`), before\n composing the click. Order matters.\n\nTHE EDITOR'S OWN TWO LISTENERS (keydown, blur at :414/:424) are a different\ncase: one transient node, one at a time, attached at creation and gone with it.\nDelegating those buys nothing and would need the blur/keydown to find the input\nthrough a root that does not own it. Leave them direct unless there is a reason;\nsay so in the change rather than silently keeping them.\n\nWHY THIS IS NOT PRIMARILY A PERFORMANCE TICKET, and should not be argued as one:\nthe cell count is bounded by the VIEWPORT, not the document -- set_viewport\nannounces a real window and GridCache is viewport-indexed for the browser\n(gotcha 47) -- so it is a few hundred closures per repaint, not thousands. And\nit does not leak: replaceChildren drops the nodes and their listeners with them.\nimprovise-i5tk's rule applies -- do not claim a performance win without a\nbenchmark, and \"it is fine\" is exactly as unevidenced as \"this dominates\".\n\nThe real argument is the repo's own: design-principles section 5 wants web/app.js to\ntrend to \"paint and wire\", and one handler per surface is wire where one handler\nper node is bookkeeping that grows with the data.\n\nTESTING: a change to web/*.js belongs in harness/tests/shell.spec.js (gotcha 48),\nnot a new ad-hoc surface. The assertions that matter are the four above, each of\nwhich is a behaviour a rewrite can silently lose: a click from an OPEN EDITOR\nstill lands (that is 0x9u's own case), the editor is not taken away when opening\none, and a click on the panel/tile surfaces still dispatches after a repaint.\nPure decision logic (target -\u003e indices) can go in web/shell.js, which is\nnode --test-able with no DOM.\n","notes":"USER LATITUDE 2026-09-21: \"We can use IDs, classes or data attributes as necessary to disambiguate the action to be taken.\"\n\nThe mechanism is open. One observation that should shape which is used, because the plumbing already exists and a delegated handler that ignores it would be a second derivation.\n\nTHE CELL ALREADY CARRIES ITS OWN IDENTITY ACROSS. web/shell.js gridPlan (lines 88-108) builds each cell with row, col, text, editText, editable, and a classes map of numeric/error/selected/typing/editable. web/app.js line 352 does nothing but toggle those classes onto the node. So row, col and editable are ALREADY decided upstream and already reach the node -- and row deliberately counts DATA rows rather than trusting position, because a group header is not a data row (shell.js lines 72-74).\n\nCONSEQUENCE: a delegated handler should read what gridPlan already decided rather than re-derive it from DOM structure. Writing data-row / data-col / an editable class is carrying an existing answer. Walking up the table counting rows to work out which data row was clicked would be a SECOND derivation of the very thing shell.js 72-74 exists to get right, and it would silently disagree the moment a group header appears.\n\nWHERE THE DECISION BELONGS: gridPlan, and the panel/tile equivalents. It is pure, it has no DOM, and it is already node --test-able -- so \"which action does this node mean\" can be unit-tested without a browser, leaving the delegated handler as a lookup. That is design-principles section 5's paint-and-wire applied to the dispatch as well as to the paint.\n\nPICKING BETWEEN THE THREE, given the latitude:\n - data attributes for the OPERANDS (data-row, data-col, data-index). They are values, and an attribute is where a value goes.\n - classes for the PREDICATES that already exist. editable is already a class; reusing it costs nothing and keeps one answer.\n - IDs only for the surfaces themselves, which they already are. Not for rows or cells -- an id must be unique per document and these are rebuilt every frame.\n\nA node's action is then: surface from the delegation root, operands from data attributes, branch from the class. No structural inference anywhere.\nUSER LATITUDE 2026-09-21: 'We can use IDs, classes or data attributes as necessary to disambiguate the action to be taken.'\n\nSo the mechanism is open. One observation that should shape which is used, because\nthe plumbing already exists and a delegated handler that IGNORES it would be a\nsecond derivation:\n\nTHE CELL ALREADY CARRIES ITS OWN IDENTITY ACROSS. web/shell.js::gridPlan (:88-108)\nbuilds each cell as:\n\n { row: dataRow, col, text, editText, editable,\n classes: { numeric, error, selected, typing, editable } }\n\nand web/app.js:352 does nothing but over that map.\nSo , and are ALREADY decided upstream and already reach the\nnode -- deliberately counts DATA rows rather than trusting position,\nbecause a group header is not a data row (shell.js:72-74).\n\nCONSEQUENCE: a delegated handler should read what gridPlan already decided rather\nthan re-derive it from DOM structure. Writing data-row / data-col / an \nclass is carrying an existing answer; walking up the table counting \u003ctr\u003es to work\nout which data row was clicked would be a SECOND derivation of the very thing\nshell.js:72-74 exists to get right, and it would silently disagree the moment a\ngroup header appears.\n\nWHERE THE DECISION BELONGS: gridPlan, and the panel/tile equivalents. It is pure,\nit has no DOM, and it is already node --test-able -- so 'which action does this\nnode mean' can be unit-tested without a browser, leaving the delegated handler as\na lookup. That is design-principles section 5's 'paint and wire' applied to the\ndispatch as well as the paint.\n\nPICKING BETWEEN THE THREE, given the latitude:\n - data attributes for the OPERANDS (data-row, data-col, data-index). They are\n values, and an attribute is where a value goes.\n - classes for the PREDICATES that already exist ( is already a class).\n Reusing it costs nothing and keeps one answer.\n - IDs only for the surfaces themselves, which they already are (#grid,\n #categories, #tiles). Not for rows or cells -- an id must be unique per\n document and these are rebuilt per frame.\n\nA cell's action is then (surface from the root, operands from data-, branch from\nthe class), with no structural inference anywhere.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T00:36:44Z","created_by":"Edward Langley","updated_at":"2026-09-23T03:14:47Z","labels":["browser","ux"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-636d","title":"Introduce a global keymap that almost all modes inherit from","description":"User ask, 2026-09-21: confirm keymap inheritance works and the keymaps are\nactually shared, and put `?` in a base keymap overridden only in insert mode.\n\nVERIFIED at 523d571. Three findings, and the first is good news.\n\n## 1. THE KEYMAPS ARE GENUINELY SHARED. No drift is possible.\n\n browser: resolve.rs:53 Keymaps::shared().lookup(..)\n TUI: improvise-command/src/command/keymap.rs:127 Keymaps::default_keymaps()\n\nDifferent constructors, but `shared()` (keymap.rs:446) is a OnceLock over\n`default_keymaps` itself:\n\n static KEYMAPS: OnceLock\u003cKeymaps\u003e = OnceLock::new();\n KEYMAPS.get_or_init(Keymaps::default_keymaps)\n\nSo there is ONE definition and two callers -- a cached copy and a fresh one of\nthe same function. Content is identical by construction, not by a test holding\ntwo copies in step. This is the shape design-principles section 5 asks for and it\nis already right.\n\n## 2. INHERITANCE WORKS AND IS USED TWICE IN SEVENTEEN MODES.\n\nThe mechanism is real -- Keymap::with_parent, and lookup() walks parents. But\n`with_parent` is called at exactly two sites:\n\n keymap.rs:656 RecordsNormal inherits Normal\n keymap.rs:834 RecordsEditing inherits Editing (via TextEntry::with_parent)\n\nEvery other mode is a ROOT keymap. PanelKeymap::new and TextEntry::new both\nbuild with Keymap::new(), so the three panel modes, TileSelect, SearchMode,\nHelp, ImportWizard and the seven other text-entry modes inherit from nothing.\n\nThere is no base keymap. What looks like sharing across modes is actually\nSPINES -- PanelKeymap::new and TextEntry::new each re-bind a common set into a\nfresh map. That is duplication-by-builder rather than inheritance: correct\ntoday, and nothing makes it stay correct.\n\n## 3. `?` RESOLVES IN 2 OF 17 MODES.\n\nIt is bound once, keymap.rs:551, on `normal`:\n\n normal.bind_args(KeyCode::Char('?'), none, \"enter-mode\", vec![\"help\"])\n\nSo it works in Normal and -- via the one inheritance edge -- RecordsNormal.\nIt does NOT resolve in: CategoryPanel, FormulaPanel, ViewPanel, TileSelect,\nSearchMode, ImportWizard, or Help itself. Those are exactly the modes a user is\nmost likely to be lost in.\n\nIt correctly does not resolve in the eight text-entry modes, where `?` must type\na literal `?`.\n\n## THE PROPOSED FIX IS FREE, and this is the load-bearing detail\n\nPut `?` in a base keymap and let every non-insert mode inherit it. **Text-entry\nmodes need NO explicit override**, because of the existing lookup order:\n\n lookup_local: exact(key,mods) -\u003e Char(NONE) -\u003e AnyChar -\u003e Any\n lookup: lookup_local(..) THEN parent\n\n`lookup_local` is exhausted BEFORE the parent is consulted (keymap.rs:261-264).\nSo a text-entry child's own `AnyChar` -\u003e append-char shadows a parent's exact\n`?` automatically. The override the user asked for already falls out of the\nfallback order; it does not have to be written.\n\n## WHAT TO DECIDE WHILE DOING IT\n\n - Which keys belong in the base besides `?`. `:` (command mode) is already\n re-bound by both PanelKeymap::new and normal, and is a candidate. Do not\n sweep everything in: a base keymap that binds too much makes every mode's\n keys harder to reason about, which is the opposite of the ask.\n - Whether `?` in Help CLOSES the overlay. Today Help has no `?` at all. Toggling\n is the obvious reading and is not what the current binding does anywhere.\n - Whether the panel spines should BECOME inheritance rather than re-binding.\n That is the larger change and can follow; it is what would stop the\n duplication from drifting.\n\n## GUARD TO ADD\n\nThere is no test that a key resolves in every mode that should have it.\n`every_hint_key_resolves_in_its_mode` checks each mode's HINT against its own\nmap, which cannot see a key that no hint mentions. `prefix_keymaps_are_flat`\nand `every_bound_command_name_is_registered` are the other two and neither\ncovers this. So the acceptance is a test naming the base keys and asserting they\nresolve in every non-text-entry ModeKey -- and, in the other direction, that\nthey resolve to append-char in the text-entry ones.\n\nRelated: improvise-uln5 records that the help OVERLAY advertises eight keys that\ndo nothing in the browser. `?` being unreachable from most modes is the other\nhalf of the same complaint -- the help is hard to reach and then wrong.\n","notes":"USER RULING 2026-09-21: 'Almost all the command modes should inherit from a global mode.'\n\nThat supersedes this bead's original framing. It is no longer 'put ? in a base\nkeymap' -- ? is one symptom. The work is the global keymap itself, and the panel\nand text-entry SPINES become inheritance rather than re-binding.\n\nWHAT IS DUPLICATED TODAY, which is the concrete scope:\n\n bind_panel_toggles(\u0026mut km) is called TWICE -- keymap.rs:367 (PanelKeymap::new)\n and :555 (normal). It binds one toggle-panel-and-focus per Panel::ALL. So the\n panel toggles are already written once and APPLIED twice into two roots; that\n is the duplication-by-builder this ruling removes.\n\n ':' -\u003e enter-mode command bound in PanelKeymap::new (:369) and again in normal.\n '?' -\u003e enter-mode help bound ONLY in normal (:551). The asymmetry between\n these two is itself evidence nothing is keeping the spines in agreement.\n\nWHY THIS IS SAFE FOR 'ALMOST ALL' RATHER THAN NEEDING EXCEPTIONS -- the existing\nfallback order does the work:\n\n lookup_local: exact(key,mods) -\u003e Char(NONE) -\u003e AnyChar -\u003e Any\n lookup: lookup_local(..) THEN parent (keymap.rs:261-264)\n\nLocal is exhausted BEFORE the parent. Two consequences that make the ruling\ncheap:\n - a TEXT-ENTRY mode inheriting the global still types a literal '?', because its\n own AnyChar -\u003e append-char is found locally first. No override needed.\n - IMPORT WIZARD binds KeyPattern::Any, which catches everything locally, so it\n inherits harmlessly -- nothing ever reaches the parent.\n\nSo the modes that must NOT inherit are those where a global key would be actively\nwrong AND is not already shadowed. Name them explicitly rather than assuming;\nthe honest answer may be 'none', which would make it 'all' rather than 'almost\nall' and is worth saying out loud if so.\n\nWHAT STILL NEEDS DECIDING, and these are the product calls:\n - Which keys are global. '?' and ':' and the panel toggles are the candidates\n the duplication already nominates. Resist sweeping more in: a global that\n binds too much makes every mode harder to reason about, which is the opposite\n of the ask.\n - Whether '?' in Help CLOSES the overlay. Today Help binds no '?' at all.\n - Esc is NOT a candidate: it means something different in nearly every mode.\n\nGUARDS THE CHANGE OWES. There is no test that a key resolves in every mode that\nshould have it -- every_hint_key_resolves_in_its_mode checks a mode's hint\nagainst its own map and cannot see a key no hint mentions. So:\n - every global key resolves in every mode that inherits the global\n - in the eight text-entry modes it resolves to append-char instead\n - bind_panel_toggles has ONE call site afterwards, which is the mechanical\n check that the duplication is actually gone\n\nNote gotcha 13: prefix keys are stored as an ADDRESS and resolved via\nKeymaps::prefix_keymap, which walks parents -- so RecordsNormal already reaches\nNormal's g/y/Z that way. prefix_keymaps_are_flat must still pass; a global\nkeymap must not introduce a nested prefix.\n\nThe good news from the original investigation stands and is not affected: the\nkeymaps are genuinely SHARED between the two front ends. Keymaps::shared() is a\nOnceLock over default_keymaps itself, so there is one definition and two callers,\nnot two copies held in step by a test.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T00:33:38Z","created_by":"Edward Langley","updated_at":"2026-09-22T00:33:55Z","labels":["protocol","ux"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-uln5","title":"The help overlay advertises 8 black-hole keys and a stranded command page, and no guard sees it","description":"MEASURED under Chromium at 523d571 from a fresh page, one key at a time, with\nthree controls (C, P, T) that DID move -- so \"nothing happened\" is a measurement\nand not a blind probe:\n\n NOTHING / search\n NOTHING n search-navigate\n NOTHING N search-or-category-add\n NOTHING H hide-selected-row-item\n NOTHING z toggle-group-under-cursor\n NOTHING Enter enter-advance\n NOTHING Control+s save\n NOTHING Control+q force-quit\n MOVED C / P / T (controls)\n\nEight of the eleven names in the_command_names_that_still_need_the_host_are_these\nare advertised in the help overlay the BROWSER RENDERS. Help page 5 additionally\nadvertises the entire ':' command line (:add-cat, :w, :q, :import, :export...),\nand CommandMode is stranding()'d, so that whole page is unreachable too.\n\nWHY THE EXISTING GUARD CANNOT SEE THIS. every_advertised_command_answers_in_its_own_mode\nreads help::advertised, which is the HINT-LINE table only. search-or-category-add\nis correctly absent from AppMode::Normal's hint -- the guard is right about what\nit checks. The help OVERLAY (help.rs::pages()) is a SECOND, INDEPENDENT body of\nadvertising in the same file, and nothing cross-references it against resolve.\n\nclients.md's own severity rule -- \"a key named in the mode's own hint line doing\nnothing reads as BROKEN\" -- applies with full force to the overlay and was never\nextended to it. Two bodies of advertising, one guarded.\n\nTHE STRUCTURAL OBSTACLE, which is the real cost and is why this is not trivial:\nhelp.rs::keys(mode, command, args, description) knows the command name AT\nCONSTRUCTION and then THROWS IT AWAY -- HelpLine::Keys { keys: Vec\u003cString\u003e,\ndescription } keeps only rendered key labels. So the analogous guard cannot be\nwritten without first carrying command/mode/args onto the variant. Small, but\nnot zero, and it has to happen before any check can exist.\n\nNote improvise-wk18 is the sibling already filed for help.rs's literal() lines,\nwhich the same guard cannot see for a related reason (a key string with no\ncommand name). Whoever takes either should read both -- they are one change to\nHelpLine's shape.\n\nSee also improvise-koh6 (P1): this is not merely cosmetic. Following the overlay's\nadvertised N corrupts a data cell, because the keystrokes that follow a dead key\nland on live NORMAL bindings and `i` opens an editor.\n","notes":"BLOCKED AND RE-SCOPED 2026-09-21 by the keymap agent (PR #112), which took improvise-636d and deliberately did NOT half-do this one. Its reasoning and its proposed design, which is worth more than the ticket had:\n\nWHY IT COULD NOT BE DONE FROM THE KEYMAP GROUND:\n - the guard needs resolve() AND the `furnished` fixture, both private to resolve.rs's mod tests. Duplicating the fixture elsewhere is the disease this repo names repeatedly.\n - the HelpLine reshape both tickets call for breaks the struct patterns at improvise-wasm-client/src/render.rs:346 and src/ui/help.rs:72 -- NEITHER uses `..`, so the reshape is a compile error in a file another agent owns.\n\nTHE PROPOSED SHAPE, designed but not written: keep HelpLine FLAT and PUBLIC, and introduce an internal\n\n HelpRow {\n Keys { mode, command, args, description },\n Prefix { mode, prefix, then, .. },\n Ex { line, .. },\n Prose (HelpLine),\n }\n\nthat pages() RENDERS into HelpLine. One source, no renderer edit, and the guard walks the rows rather than the rendered lines.\n\nThat satisfies uln5's stated blocking cost -- \"carry command/mode/args onto the variant\" -- WITHOUT touching render.rs, which is what made it look expensive.\n\nIT ALSO SPLITS wk18's 17 literal() lines honestly rather than treating them as one lump:\n - gg / yy / ZZ are checkable via Keymaps::prefix_keymap\n - the 14 ex-commands are checkable against the registry from improvise-command's tests\n\nSo the two beads are one change to HelpRow plus two different guards, not one guard over a uniform list.\n\nNOTE THE GROUND THIS NEEDS: resolve.rs's test module, help.rs, and a test that can reach the registry. That is a different partition from the one this wave used, so schedule it with whoever owns resolve.rs rather than with the keymap.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-22T00:22:25Z","created_by":"Edward Langley","updated_at":"2026-09-24T00:07:20Z","started_at":"2026-09-24T00:07:20Z","lease_expires_at":"2026-09-24T00:12:20Z","heartbeat_at":"2026-09-24T00:07:20Z","labels":["browser","testing","ux"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-b8ot","title":"Delegate DOM events: one handler per surface on the stable root, not one per node","description":"USER RULING 2026-09-21: \"I want one handler outside the area that gets redrawn\nand dispatch events there.\"\n\nTODAY: one listener PER NODE, created inside the render loops and reallocated on\nevery repaint. Only `window` keydown (app.js:509) and the transport listeners\nare delegated.\n\n app.js:361 td mousedown ONE PER GRID CELL, inside the cell loop\n app.js:436 li click one per panel row\n app.js:476 span click one per tile\n app.js:493 li click one per category row\n app.js:414 input keydown the editor\n app.js:424 input blur the editor\n\nEight replaceChildren sites rebuild the DOM wholesale, so each closure is\ndiscarded and reallocated per frame, and repaints happen roughly per keystroke.\n\nTHE ROOTS ARE ALREADY STABLE, which is what makes this cheap. replaceChildren\nreplaces an element's CHILDREN, not the element, so every `el.*` registry entry\n(app.js:30-43) survives every repaint:\n\n el.grid \u003c- replaceChildren at :214 delegate td mousedown here\n el.categories \u003c- :218 delegate li click here\n el.tiles \u003c- :479 delegate span click here\n el.formulas / el.views \u003c- via `host` at :439 delegate li click here\n\nSo each surface already has exactly the persistent ancestor the ruling asks for.\nNo new wrapper element is needed. Read indices off data- attributes\n(data-row/data-col on a cell, data-index on a row or tile) with\nevent.target.closest(..).\n\nFOUR THINGS A NAIVE REWRITE BREAKS. All are documented and load-bearing:\n\n1. THE GRID USES mousedown, NOT click, DELIBERATELY (improvise-0x9u). From an\n editing state the input's blur repaints first, so the \u003ctd\u003e is gone before a\n click could fire. Delegation does not change that -- the target is detached\n before the event -- so it must stay mousedown.\n\n2. IT CALLS preventDefault(). Dropping that reintroduces focus/selection\n behaviour the editor depends on.\n\n3. IT CONDITIONALLY SKIPS THE REPAINT. On an editable cell it calls edit(..) and\n does NOT repaint, because a repaint would take the \u003cinput\u003e away. The comment\n at :356-360 says so. A delegated handler must keep that branch.\n\n4. IT CLOSES A LIVE EDITOR FIRST (`if (editing) editing.close(false)`), before\n composing the click. Order matters.\n\nTHE EDITOR'S OWN TWO LISTENERS (keydown, blur at :414/:424) are a different\ncase: one transient node, one at a time, attached at creation and gone with it.\nDelegating those buys nothing and would need the blur/keydown to find the input\nthrough a root that does not own it. Leave them direct unless there is a reason;\nsay so in the change rather than silently keeping them.\n\nWHY THIS IS NOT PRIMARILY A PERFORMANCE TICKET, and should not be argued as one:\nthe cell count is bounded by the VIEWPORT, not the document -- set_viewport\nannounces a real window and GridCache is viewport-indexed for the browser\n(gotcha 47) -- so it is a few hundred closures per repaint, not thousands. And\nit does not leak: replaceChildren drops the nodes and their listeners with them.\nimprovise-i5tk's rule applies -- do not claim a performance win without a\nbenchmark, and \"it is fine\" is exactly as unevidenced as \"this dominates\".\n\nThe real argument is the repo's own: design-principles section 5 wants web/app.js to\ntrend to \"paint and wire\", and one handler per surface is wire where one handler\nper node is bookkeeping that grows with the data.\n\nTESTING: a change to web/*.js belongs in harness/tests/shell.spec.js (gotcha 48),\nnot a new ad-hoc surface. The assertions that matter are the four above, each of\nwhich is a behaviour a rewrite can silently lose: a click from an OPEN EDITOR\nstill lands (that is 0x9u's own case), the editor is not taken away when opening\none, and a click on the panel/tile surfaces still dispatches after a repaint.\nPure decision logic (target -\u003e indices) can go in web/shell.js, which is\nnode --test-able with no DOM.\n","notes":"USER LATITUDE 2026-09-21: \"We can use IDs, classes or data attributes as necessary to disambiguate the action to be taken.\"\n\nThe mechanism is open. One observation that should shape which is used, because the plumbing already exists and a delegated handler that ignores it would be a second derivation.\n\nTHE CELL ALREADY CARRIES ITS OWN IDENTITY ACROSS. web/shell.js gridPlan (lines 88-108) builds each cell with row, col, text, editText, editable, and a classes map of numeric/error/selected/typing/editable. web/app.js line 352 does nothing but toggle those classes onto the node. So row, col and editable are ALREADY decided upstream and already reach the node -- and row deliberately counts DATA rows rather than trusting position, because a group header is not a data row (shell.js lines 72-74).\n\nCONSEQUENCE: a delegated handler should read what gridPlan already decided rather than re-derive it from DOM structure. Writing data-row / data-col / an editable class is carrying an existing answer. Walking up the table counting rows to work out which data row was clicked would be a SECOND derivation of the very thing shell.js 72-74 exists to get right, and it would silently disagree the moment a group header appears.\n\nWHERE THE DECISION BELONGS: gridPlan, and the panel/tile equivalents. It is pure, it has no DOM, and it is already node --test-able -- so \"which action does this node mean\" can be unit-tested without a browser, leaving the delegated handler as a lookup. That is design-principles section 5's paint-and-wire applied to the dispatch as well as to the paint.\n\nPICKING BETWEEN THE THREE, given the latitude:\n - data attributes for the OPERANDS (data-row, data-col, data-index). They are values, and an attribute is where a value goes.\n - classes for the PREDICATES that already exist. editable is already a class; reusing it costs nothing and keeps one answer.\n - IDs only for the surfaces themselves, which they already are. Not for rows or cells -- an id must be unique per document and these are rebuilt every frame.\n\nA node's action is then: surface from the delegation root, operands from data attributes, branch from the class. No structural inference anywhere.\nUSER LATITUDE 2026-09-21: 'We can use IDs, classes or data attributes as necessary to disambiguate the action to be taken.'\n\nSo the mechanism is open. One observation that should shape which is used, because\nthe plumbing already exists and a delegated handler that IGNORES it would be a\nsecond derivation:\n\nTHE CELL ALREADY CARRIES ITS OWN IDENTITY ACROSS. web/shell.js::gridPlan (:88-108)\nbuilds each cell as:\n\n { row: dataRow, col, text, editText, editable,\n classes: { numeric, error, selected, typing, editable } }\n\nand web/app.js:352 does nothing but over that map.\nSo , and are ALREADY decided upstream and already reach the\nnode -- deliberately counts DATA rows rather than trusting position,\nbecause a group header is not a data row (shell.js:72-74).\n\nCONSEQUENCE: a delegated handler should read what gridPlan already decided rather\nthan re-derive it from DOM structure. Writing data-row / data-col / an \nclass is carrying an existing answer; walking up the table counting \u003ctr\u003es to work\nout which data row was clicked would be a SECOND derivation of the very thing\nshell.js:72-74 exists to get right, and it would silently disagree the moment a\ngroup header appears.\n\nWHERE THE DECISION BELONGS: gridPlan, and the panel/tile equivalents. It is pure,\nit has no DOM, and it is already node --test-able -- so 'which action does this\nnode mean' can be unit-tested without a browser, leaving the delegated handler as\na lookup. That is design-principles section 5's 'paint and wire' applied to the\ndispatch as well as the paint.\n\nPICKING BETWEEN THE THREE, given the latitude:\n - data attributes for the OPERANDS (data-row, data-col, data-index). They are\n values, and an attribute is where a value goes.\n - classes for the PREDICATES that already exist ( is already a class).\n Reusing it costs nothing and keeps one answer.\n - IDs only for the surfaces themselves, which they already are (#grid,\n #categories, #tiles). Not for rows or cells -- an id must be unique per\n document and these are rebuilt per frame.\n\nA cell's action is then (surface from the root, operands from data-, branch from\nthe class), with no structural inference anywhere.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T00:36:44Z","created_by":"Edward Langley","updated_at":"2026-09-23T03:14:47Z","labels":["browser","ux"],"dependencies":[{"issue_id":"improvise-b8ot","depends_on_id":"improvise-fhj0","type":"parent-child","created_at":"2026-09-25T01:05:37Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-636d","title":"Introduce a global keymap that almost all modes inherit from","description":"User ask, 2026-09-21: confirm keymap inheritance works and the keymaps are\nactually shared, and put `?` in a base keymap overridden only in insert mode.\n\nVERIFIED at 523d571. Three findings, and the first is good news.\n\n## 1. THE KEYMAPS ARE GENUINELY SHARED. No drift is possible.\n\n browser: resolve.rs:53 Keymaps::shared().lookup(..)\n TUI: improvise-command/src/command/keymap.rs:127 Keymaps::default_keymaps()\n\nDifferent constructors, but `shared()` (keymap.rs:446) is a OnceLock over\n`default_keymaps` itself:\n\n static KEYMAPS: OnceLock\u003cKeymaps\u003e = OnceLock::new();\n KEYMAPS.get_or_init(Keymaps::default_keymaps)\n\nSo there is ONE definition and two callers -- a cached copy and a fresh one of\nthe same function. Content is identical by construction, not by a test holding\ntwo copies in step. This is the shape design-principles section 5 asks for and it\nis already right.\n\n## 2. INHERITANCE WORKS AND IS USED TWICE IN SEVENTEEN MODES.\n\nThe mechanism is real -- Keymap::with_parent, and lookup() walks parents. But\n`with_parent` is called at exactly two sites:\n\n keymap.rs:656 RecordsNormal inherits Normal\n keymap.rs:834 RecordsEditing inherits Editing (via TextEntry::with_parent)\n\nEvery other mode is a ROOT keymap. PanelKeymap::new and TextEntry::new both\nbuild with Keymap::new(), so the three panel modes, TileSelect, SearchMode,\nHelp, ImportWizard and the seven other text-entry modes inherit from nothing.\n\nThere is no base keymap. What looks like sharing across modes is actually\nSPINES -- PanelKeymap::new and TextEntry::new each re-bind a common set into a\nfresh map. That is duplication-by-builder rather than inheritance: correct\ntoday, and nothing makes it stay correct.\n\n## 3. `?` RESOLVES IN 2 OF 17 MODES.\n\nIt is bound once, keymap.rs:551, on `normal`:\n\n normal.bind_args(KeyCode::Char('?'), none, \"enter-mode\", vec![\"help\"])\n\nSo it works in Normal and -- via the one inheritance edge -- RecordsNormal.\nIt does NOT resolve in: CategoryPanel, FormulaPanel, ViewPanel, TileSelect,\nSearchMode, ImportWizard, or Help itself. Those are exactly the modes a user is\nmost likely to be lost in.\n\nIt correctly does not resolve in the eight text-entry modes, where `?` must type\na literal `?`.\n\n## THE PROPOSED FIX IS FREE, and this is the load-bearing detail\n\nPut `?` in a base keymap and let every non-insert mode inherit it. **Text-entry\nmodes need NO explicit override**, because of the existing lookup order:\n\n lookup_local: exact(key,mods) -\u003e Char(NONE) -\u003e AnyChar -\u003e Any\n lookup: lookup_local(..) THEN parent\n\n`lookup_local` is exhausted BEFORE the parent is consulted (keymap.rs:261-264).\nSo a text-entry child's own `AnyChar` -\u003e append-char shadows a parent's exact\n`?` automatically. The override the user asked for already falls out of the\nfallback order; it does not have to be written.\n\n## WHAT TO DECIDE WHILE DOING IT\n\n - Which keys belong in the base besides `?`. `:` (command mode) is already\n re-bound by both PanelKeymap::new and normal, and is a candidate. Do not\n sweep everything in: a base keymap that binds too much makes every mode's\n keys harder to reason about, which is the opposite of the ask.\n - Whether `?` in Help CLOSES the overlay. Today Help has no `?` at all. Toggling\n is the obvious reading and is not what the current binding does anywhere.\n - Whether the panel spines should BECOME inheritance rather than re-binding.\n That is the larger change and can follow; it is what would stop the\n duplication from drifting.\n\n## GUARD TO ADD\n\nThere is no test that a key resolves in every mode that should have it.\n`every_hint_key_resolves_in_its_mode` checks each mode's HINT against its own\nmap, which cannot see a key that no hint mentions. `prefix_keymaps_are_flat`\nand `every_bound_command_name_is_registered` are the other two and neither\ncovers this. So the acceptance is a test naming the base keys and asserting they\nresolve in every non-text-entry ModeKey -- and, in the other direction, that\nthey resolve to append-char in the text-entry ones.\n\nRelated: improvise-uln5 records that the help OVERLAY advertises eight keys that\ndo nothing in the browser. `?` being unreachable from most modes is the other\nhalf of the same complaint -- the help is hard to reach and then wrong.\n","notes":"USER RULING 2026-09-21: 'Almost all the command modes should inherit from a global mode.'\n\nThat supersedes this bead's original framing. It is no longer 'put ? in a base\nkeymap' -- ? is one symptom. The work is the global keymap itself, and the panel\nand text-entry SPINES become inheritance rather than re-binding.\n\nWHAT IS DUPLICATED TODAY, which is the concrete scope:\n\n bind_panel_toggles(\u0026mut km) is called TWICE -- keymap.rs:367 (PanelKeymap::new)\n and :555 (normal). It binds one toggle-panel-and-focus per Panel::ALL. So the\n panel toggles are already written once and APPLIED twice into two roots; that\n is the duplication-by-builder this ruling removes.\n\n ':' -\u003e enter-mode command bound in PanelKeymap::new (:369) and again in normal.\n '?' -\u003e enter-mode help bound ONLY in normal (:551). The asymmetry between\n these two is itself evidence nothing is keeping the spines in agreement.\n\nWHY THIS IS SAFE FOR 'ALMOST ALL' RATHER THAN NEEDING EXCEPTIONS -- the existing\nfallback order does the work:\n\n lookup_local: exact(key,mods) -\u003e Char(NONE) -\u003e AnyChar -\u003e Any\n lookup: lookup_local(..) THEN parent (keymap.rs:261-264)\n\nLocal is exhausted BEFORE the parent. Two consequences that make the ruling\ncheap:\n - a TEXT-ENTRY mode inheriting the global still types a literal '?', because its\n own AnyChar -\u003e append-char is found locally first. No override needed.\n - IMPORT WIZARD binds KeyPattern::Any, which catches everything locally, so it\n inherits harmlessly -- nothing ever reaches the parent.\n\nSo the modes that must NOT inherit are those where a global key would be actively\nwrong AND is not already shadowed. Name them explicitly rather than assuming;\nthe honest answer may be 'none', which would make it 'all' rather than 'almost\nall' and is worth saying out loud if so.\n\nWHAT STILL NEEDS DECIDING, and these are the product calls:\n - Which keys are global. '?' and ':' and the panel toggles are the candidates\n the duplication already nominates. Resist sweeping more in: a global that\n binds too much makes every mode harder to reason about, which is the opposite\n of the ask.\n - Whether '?' in Help CLOSES the overlay. Today Help binds no '?' at all.\n - Esc is NOT a candidate: it means something different in nearly every mode.\n\nGUARDS THE CHANGE OWES. There is no test that a key resolves in every mode that\nshould have it -- every_hint_key_resolves_in_its_mode checks a mode's hint\nagainst its own map and cannot see a key no hint mentions. So:\n - every global key resolves in every mode that inherits the global\n - in the eight text-entry modes it resolves to append-char instead\n - bind_panel_toggles has ONE call site afterwards, which is the mechanical\n check that the duplication is actually gone\n\nNote gotcha 13: prefix keys are stored as an ADDRESS and resolved via\nKeymaps::prefix_keymap, which walks parents -- so RecordsNormal already reaches\nNormal's g/y/Z that way. prefix_keymaps_are_flat must still pass; a global\nkeymap must not introduce a nested prefix.\n\nThe good news from the original investigation stands and is not affected: the\nkeymaps are genuinely SHARED between the two front ends. Keymaps::shared() is a\nOnceLock over default_keymaps itself, so there is one definition and two callers,\nnot two copies held in step by a test.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T00:33:38Z","created_by":"Edward Langley","updated_at":"2026-09-22T00:33:55Z","labels":["protocol","ux"],"dependencies":[{"issue_id":"improvise-636d","depends_on_id":"improvise-gtn7","type":"parent-child","created_at":"2026-09-25T01:05:32Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-uln5","title":"The help overlay advertises 8 black-hole keys and a stranded command page, and no guard sees it","description":"MEASURED under Chromium at 523d571 from a fresh page, one key at a time, with\nthree controls (C, P, T) that DID move -- so \"nothing happened\" is a measurement\nand not a blind probe:\n\n NOTHING / search\n NOTHING n search-navigate\n NOTHING N search-or-category-add\n NOTHING H hide-selected-row-item\n NOTHING z toggle-group-under-cursor\n NOTHING Enter enter-advance\n NOTHING Control+s save\n NOTHING Control+q force-quit\n MOVED C / P / T (controls)\n\nEight of the eleven names in the_command_names_that_still_need_the_host_are_these\nare advertised in the help overlay the BROWSER RENDERS. Help page 5 additionally\nadvertises the entire ':' command line (:add-cat, :w, :q, :import, :export...),\nand CommandMode is stranding()'d, so that whole page is unreachable too.\n\nWHY THE EXISTING GUARD CANNOT SEE THIS. every_advertised_command_answers_in_its_own_mode\nreads help::advertised, which is the HINT-LINE table only. search-or-category-add\nis correctly absent from AppMode::Normal's hint -- the guard is right about what\nit checks. The help OVERLAY (help.rs::pages()) is a SECOND, INDEPENDENT body of\nadvertising in the same file, and nothing cross-references it against resolve.\n\nclients.md's own severity rule -- \"a key named in the mode's own hint line doing\nnothing reads as BROKEN\" -- applies with full force to the overlay and was never\nextended to it. Two bodies of advertising, one guarded.\n\nTHE STRUCTURAL OBSTACLE, which is the real cost and is why this is not trivial:\nhelp.rs::keys(mode, command, args, description) knows the command name AT\nCONSTRUCTION and then THROWS IT AWAY -- HelpLine::Keys { keys: Vec\u003cString\u003e,\ndescription } keeps only rendered key labels. So the analogous guard cannot be\nwritten without first carrying command/mode/args onto the variant. Small, but\nnot zero, and it has to happen before any check can exist.\n\nNote improvise-wk18 is the sibling already filed for help.rs's literal() lines,\nwhich the same guard cannot see for a related reason (a key string with no\ncommand name). Whoever takes either should read both -- they are one change to\nHelpLine's shape.\n\nSee also improvise-koh6 (P1): this is not merely cosmetic. Following the overlay's\nadvertised N corrupts a data cell, because the keystrokes that follow a dead key\nland on live NORMAL bindings and `i` opens an editor.\n","notes":"BLOCKED AND RE-SCOPED 2026-09-21 by the keymap agent (PR #112), which took improvise-636d and deliberately did NOT half-do this one. Its reasoning and its proposed design, which is worth more than the ticket had:\n\nWHY IT COULD NOT BE DONE FROM THE KEYMAP GROUND:\n - the guard needs resolve() AND the `furnished` fixture, both private to resolve.rs's mod tests. Duplicating the fixture elsewhere is the disease this repo names repeatedly.\n - the HelpLine reshape both tickets call for breaks the struct patterns at improvise-wasm-client/src/render.rs:346 and src/ui/help.rs:72 -- NEITHER uses `..`, so the reshape is a compile error in a file another agent owns.\n\nTHE PROPOSED SHAPE, designed but not written: keep HelpLine FLAT and PUBLIC, and introduce an internal\n\n HelpRow {\n Keys { mode, command, args, description },\n Prefix { mode, prefix, then, .. },\n Ex { line, .. },\n Prose (HelpLine),\n }\n\nthat pages() RENDERS into HelpLine. One source, no renderer edit, and the guard walks the rows rather than the rendered lines.\n\nThat satisfies uln5's stated blocking cost -- \"carry command/mode/args onto the variant\" -- WITHOUT touching render.rs, which is what made it look expensive.\n\nIT ALSO SPLITS wk18's 17 literal() lines honestly rather than treating them as one lump:\n - gg / yy / ZZ are checkable via Keymaps::prefix_keymap\n - the 14 ex-commands are checkable against the registry from improvise-command's tests\n\nSo the two beads are one change to HelpRow plus two different guards, not one guard over a uniform list.\n\nNOTE THE GROUND THIS NEEDS: resolve.rs's test module, help.rs, and a test that can reach the registry. That is a different partition from the one this wave used, so schedule it with whoever owns resolve.rs rather than with the keymap.","status":"closed","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-22T00:22:25Z","created_by":"Edward Langley","updated_at":"2026-09-24T21:45:24Z","started_at":"2026-09-24T00:07:20Z","closed_at":"2026-09-24T21:45:24Z","close_reason":"Landed in main via #264 (reviewed; merged 2026-09-24).","labels":["browser","testing","ux"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-z336","title":"[bug] A page selection is never cleared, so cycling a category's axis hides a filter and cycling back restores it","description":"Reported by the user against the in-browser app: after clicking an item in the\ncategories panel there is no way to clear the filter it applies.\n\nMECHANISM, read at 523d571 in improvise-protocol/src/resolve.rs.\nclick_category_row -\u003e filter_to_item_at (resolve.rs:906) emits, for an Item row:\n\n SetAxis { category, axis: Axis::Page }\n SetPageSelection { category, item }\n SetStatus(\"Filter: {cat} = {item}\")\n\nThree separate problems, and the third is why the user could not find the way out.\n\n1. IT IS NOT A TOGGLE. Clicking the SAME item again emits the identical pair, so\n the gesture is idempotent rather than reversible. Every other click surface in\n this panel has an inverse -- a Category row calls toggle_cat_expand_at -- so\n the one gesture that changes the DOCUMENT is the one with no way back.\n\n2. IT SILENTLY DESTROYS THE CATEGORY'S PREVIOUS AXIS. SetAxis overwrites Row,\n Column or None with Page and nothing records what it was. So even a future\n \"clear\" cannot restore the prior layout; the best it could do is guess None.\n The status line says \"Filter: ...\" while what actually happened is a LAYOUT\n change, which is also why it reads as undoable when it is not.\n\n3. THE ESCAPE ROUTE EXISTS AND IS UNDISCOVERABLE. click_tile (resolve.rs:148)\n cycles a tile's axis on a SECOND click of an already-selected tile, so\n Page -\u003e None is reachable by clicking the category's tile twice. But:\n - nothing says so, and the status line points at \"Filter\", not the tile bar\n - it cycles Row-\u003eColumn-\u003ePage-\u003eNone, so \"clear\" is up to three more clicks\n - the TUI's other route, [ and ] page cycling, has no browser affordance:\n improvise-p7lh records that the browser draws NO PAGE BAR at all, and\n GridCache::page_coords does not reach RenderModel\n\nSo the feature is reachable in principle and absent in practice, which is worse\nthan absent -- the user concludes it cannot be done.\n\nWHAT A FIX HAS TO DECIDE, and it is a product call rather than a bug:\n - Should a second click on the SAME item clear the filter? That makes the\n gesture reversible and is what \"filter\" implies. It also needs an answer for\n what axis the category returns to, given (2) destroyed it.\n - Or should clicking never move a category to Page at all, and instead set a\n page SELECTION only when the category is already on Page, leaving the\n layout alone? That is the smaller, more honest gesture and it makes (2)\n moot -- but it changes what the click does for every category not already\n paged.\n - Either way the page bar wants painting (improvise-p7lh), because that is\n where a paged category is visible and cyclable in the TUI, and its absence\n is what makes the state feel trapped.\n\nNote gotcha 27: Page and Filter are different axes, and only Page is navigable.\nThis code uses Page, correctly -- but the STATUS LINE calls it a filter, which\nis the vocabulary of the non-navigable one. Worth aligning whichever way the\nproduct call goes.\n","notes":"MECHANISM CORRECTED 2026-09-21 by driving it under Chromium. My original code-read was right about the click path and WRONG about the way out, and the real defect is one neither the report nor I named.\n\nCONFIRMED: filter_to_item_at emitting SetAxis{Page} + SetPageSelection + the status is correct. Clicking an item gives 'Filter: Product = Espresso', the tile goes Row -\u003e Pag, the grid collapses 4 rows to 1. A SECOND click on the same item is a genuine no-op -- not a toggle. Clicking a DIFFERENT item switches the filter, so you can move a filter but not lift it.\n\nWRONG IN MY READ: the tile-bar double-click is NOT the only way back, and the tile bar IS visible and clickable in the shipped page (#tiles .tile, five of them). There is a second, KEYBOARD route: Space in the category panel (cycle-axis-at-cursor), which is advertised in that mode's own hint as 'Space:cycle-axis' and works directly after the click with no re-focus. So the escape is more discoverable than I claimed.\n\nTHE ACTUAL DEFECT: NEITHER ROUTE CLEARS ANYTHING. They only hide the filter, and it comes back. Measured, cycling Product all the way round:\n\n Pag(Espresso) -\u003e None (63,700, unfiltered)\n -\u003e Row (all 4 products)\n -\u003e Col\n -\u003e Page -\u003e 18,400 \u003c- Espresso is BACK, unasked\n\nView::set_axis (improvise-core/src/view/types.rs:118) does NOT touch\npage_selections. Only on_category_removed and on_item_removed ever clear one. So\nthe selection survives every axis move and is silently reinstated the moment the\ncategory returns to Page.\n\nThere is NO ClearPageSelection anywhere -- not in ModelCommand, not in the View\nAPI, AND NOT IN THE TUI. So this is a product gap at parity, not a browser\nregression. The browser is simply the first place anyone tried it with a mouse.\n\n[ and ] cannot clear it either: measured, they cycle the page selection among the\ncategory's items (18,400 -\u003e 17,100 -\u003e 15,800 -\u003e 20,300 -\u003e 24,600) with no 'All'\nentry to land on.\n\nTHIRD FINDING, and it may be what produced the original report: THE STATUS LINE\nLIES. 'Filter: Product = Espresso' stays on screen after the axis has gone to\nNone, Row and Col, and after five ] presses moved the page to a different item.\nThe one piece of feedback about the filter is stale exactly while the user is\ntrying to get rid of it -- so a user who DID clear it correctly is told for the\nrest of the session that it is still on. That mechanism can manufacture this\nexact bug report from a working code path.\n\nSilent-Space is split out as improvise-25bf.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-refuse-fu","owner":"el-github@elangley.org","created_at":"2026-09-22T00:17:00Z","created_by":"Edward Langley","updated_at":"2026-09-23T15:02:18Z","started_at":"2026-09-23T15:00:20Z","closed_at":"2026-09-23T15:02:18Z","close_reason":"Already fixed by 7a446166 (PR #114): View::set_axis forgets the page selection when a category leaves Page/Filter. Pinned by moving_a_category_off_the_constraining_axes_forgets_its_page_selection. Verified at gh/main 33cf496 by agent-refuse-fu.","labels":["browser","ux"],"dependencies":[{"issue_id":"improvise-z336","depends_on_id":"improvise-p7lh","type":"related","created_at":"2026-09-22T00:32:50Z","created_by":"Edward Langley","metadata":"{}"}],"comments":[{"id":"7a9089a4-34de-5d21-be5a-e20e249b9101","issue_id":"improvise-z336","author":"agent-refuse-fu","text":"agent-refuse-fu 2026-09-23: ALREADY FIXED at gh/main 33cf496. Fixing commit 7a446166 (\"fix(panel): five category-panel and axis defects, four of them P1\", merged via PR #114) makes View::set_axis forget the page selection when a category leaves the constraining axes (Page/Filter). Pinned by view::types::tests::moving_a_category_off_the_constraining_axes_forgets_its_page_selection and its companion moving_a_category_from_page_to_filter_keeps_its_page_selection; evidence in context/test-evidence/core.md. Confirmed 7a446166 is an ancestor of HEAD. Not closed by me; the orchestrator decides.\n","created_at":"2026-09-23T15:01:49Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} {"_type":"issue","id":"improvise-k844","title":"importance_ranking overflows to inf for a large importance: 100.0 * imp / total multiplies first","description":"improvise-zv8t forces every document category to `Forced::Categorical`, which is\nright (a `Year` category with items 2024/2025 is a coordinate, not a number).\nThe cost: a WIDE numeric category one-hots past `MAX_FEATURES` and refuses the\nwhole fit, where before it encoded as one numeric feature and a fit that never\nsplit on it succeeded.\n\nMeasured on 100 records with a 100-item numeric `Year` beside a `Cost` that\npredicts `Score` on its own:\n- pre-zv8t: fit lands on `IF(Cost \u003c 48.015...)`; `Year` never appears.\n- post-zv8t: preflight refuses at 101 features.\n\nPinned by `a_wide_numeric_category_now_refuses_a_fit_that_used_to_ignore_it`\nin crates/improvise-gp/src/fit.rs, and argued in context/code/gp.md section 11.\n\nThe workaround the refusal names works: mark the category a Label and the\npre-zv8t feature set is restored exactly. What is lost is the case where the\nuser wants neither -- a wide numeric category left alone while the fit uses the\nmeasures.\n\n## Steps to Reproduce\n\nBuild a model with a Regular category `Year` holding 100 numeric items\n(1900..1999), a Label category for record identity, and two measures `Cost` and\n`Score` where Score = 4000 if Cost \u003e 50 else 1000. Put `Year` on Axis::Row and\n_Measure on Axis::Page. Run `fit_measure(.., \"Score\", ..)`.\n\nPre-zv8t it lands on `IF(Cost \u003c 48.015..)`. Post-zv8t it is refused with\n\"would encode 101 features from 100 records\". The fixture is\n`wide_category_beside_a_good_measure()` in crates/improvise-gp/src/fit.rs.\n\n## Acceptance Criteria\n\nThe honest fix is a budget that can decline A COLUMN rather than the whole\ntable: `encoded_width` (and the exact check beside it) would drop the widest\ncategory and report it as a `Dataset::warnings` notice -- gotcha 37(a), it comes\nback as DATA and nothing prints. That keeps the zv8t decision intact (a category\nis still never inferred numeric) and changes only what the budget does when it\ncannot afford one.\n\nDo NOT close this by making the forcing conditional on cardinality: that is\ninference by another name, and makes the ENCODING depend on a budget rather than\non what the document says.\n","notes":"=== TRACKER DEFECT, RECORDED BEFORE THE CLOSE ===\nTHIS TICKET'S STORED DESCRIPTION IS VERBATIM improvise-e8no'S BODY (the wide-\nnumeric-category budget) and contradicts its own title. Confirmed by the\norchestrator with a direct diff of the two DESCRIPTION blocks: IDENTICAL. The\nagent worked the TITLE, which was the right call. Everything below is the actual\nticket.\n\n=== FIXED in PR #139 (merge 1ae2d2e) ===\n\nREPRODUCED at HEAD, end to end: fit_measure returned [(\"Cost\", inf)] -- a user's\nfit report saying one feature carries inf%.\n\nWHERE IT IS BORN: gp_tree::fit::importance_ranking, `100.0 * imp[j] / total` in\nf32, which associates LEFT. TWO DISTINCT OVERFLOWS, and this ticket names only the\nfirst:\n 1. THE MULTIPLY. A lone importance above f32::MAX/100 ~ 3.4e36 makes\n 100.0 * 3e37 = inf before the divide that would have returned it to range.\n 2. THE SUM -- THE SILENT TWIN, and it is real. Two importances at 2e38 are each\n well inside f32, but their f32 TOTAL is inf, so every share came back NaN.\n No panic, no refusal, just NaN% in the report. Exactly cv_regress's shape\n from improvise-pv3g.\n\nREACHABLE PAST pv3g, traced rather than guessed: both callers score importance in\nthe document's OWN units (fit_csv -\u003e real_unit_mse; fit_measure -\u003e mse over\nUNNORMALIZED rows), so an importance is an MSE difference and grows as the\ntarget's SQUARE. That leaves a live window between pv3g's standardize refusal\n(spread ~5e18 at 12 rows) and this overflow (~1.8e18). As with pv3g, every INPUT\nwas finite and the overflow was in the DERIVED quantity.\n\nTHE FIX makes the arithmetic incapable of it rather than checking afterwards: sum\nand ratio in f64, narrowing only the share, which is bounded by 100 and therefore\nalways finite. One guard remains for an importance that ARRIVES non-finite -- note\nNaN \u003c= 0.0 is false, so the pre-existing early return passed NaN straight through.\n\nTHE RIVAL IS THIS TICKET'S OWN TITLE. Reassociating to 100.0 * (imp[j] / total)\nin f32 -- \"multiplies first\", so divide first -- passes the lone-importance test\nAND the shares-sum-to-100 test, and FAILS the twin and the non-finite guard:\n58 passed, 2 failed. It converts NaN% into a serene 0% FOR EVERY FEATURE, which is\nworse for being readable. THE AC AS WRITTEN WOULD HAVE SHIPPED A HALF-FIX.\n\nAND THE REASON IT SAT OPEN IS THE BEST PART. context/code/gp.md section 7 --\npv3g's own write-up -- did not merely mention this bug, it DEFERRED IT ON A COST\nTHAT IS FALSE: \"reordering it moves the bit patterns\nmisclass_rate_and_importance_are_bit_for_bit_what_they_were pins\". That test pins\npermutation_importance's RAW SCORES, which importance_ranking consumes afterwards.\nVERIFIED BY THE ORCHESTRATOR: scoring_known_answers.rs is absent from the diff\nentirely -- byte-identical across the fix. A cost predicted from a test's NAME\nrather than from what it CALLS is how a one-line fix stays filed, and it reads as\ndiligence, which is why it survives review. Corrected in place rather than\ndeleted.\n\nThe tell was visible all along: pv3g's own\nthe_cli_does_not_abort_when_an_importance_share_overflows says IN ITS NAME that a\nshare overflows at 1e18. The clamp stopped the abort; the inf% it was clamping\nnever stopped.\n\nNOT VERIFIED: the f64 round-trip is not bit-pinned for the ORDINARY range.\nimportance_ranking's shares have never been pinned and this did not start; a \u003c=1\nulp shift in a printed {:\u003e5.1}% is possible in principle and nothing would catch\nit. A share-level bit pin is a reasonable follow-up.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T23:33:29Z","created_by":"Edward Langley","updated_at":"2026-09-22T23:11:18Z","closed_at":"2026-09-22T23:11:18Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-4h8a","title":"Six of AppSnapshot's eight restored fields are unguarded; the rival fix passes the suite","description":"Found by the wave's adversarial reviewer at PR #109 head d4d1cd4, by injecting\nthe RIVAL FIX rather than only the bug.\n\nimprovise-yqpd's clause-4 work grew DocumentSnapshot into AppSnapshot, which now\nrestores eight fields. The evidence says \"all six now round-trip through\nAppSnapshot\". Only TWO are pinned by any test.\n\nTHE RIVAL FIX THAT PASSES: replace `self.view_state = snapshot.view_state` with\nrestoration of ONLY view_state.active_view and view_state.status_msg -- the two\nfields the test asserts -- and the ENTIRE workspace suite passes (1585, 0\nfailed).\n\nPushing further, each of these can be removed from App::restore with the whole\nsuite still green:\n cursor_epoch\n abort_effects\n last_reshaped_views\n last_reframed_views\n written_view_frames \u003c- new behaviour with NO test at all\n last_reframed_elsewhere (reverted to the old .clear() shape)\n\nNOT A MERGE BLOCKER, and the current code is the SAFE direction: over-restoring\ncosts nothing, and gotcha 35's asymmetry applies -- over-reporting pays a\nredundant projection, under-reporting leaves a client wrong forever.\n\nTHE HAZARD IS THE NEXT READER. A later \"simplification\" of App::restore back\ntoward the two asserted fields would regress clause 4 for cursors, mode,\nbuffers, panels and search SILENTLY, with a green suite. That is exactly this\nrepo's characteristic defect, and the evidence file currently invites it by\nsaying six fields round-trip without saying four of them are unguarded.\n\nWHAT TO DO: pin the unguarded fields, or state in the evidence that they are\nunguarded and why over-restoring is deliberate. The test to reach for is a\nrejected frame that moved a cursor and a mode, asserting both are back --\nwhich is the user-visible half nothing currently covers.\n","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-mergetrain","owner":"el-github@elangley.org","created_at":"2026-09-21T23:03:30Z","created_by":"Edward Langley","updated_at":"2026-09-23T18:17:08Z","started_at":"2026-09-23T18:17:08Z","lease_expires_at":"2026-09-23T18:22:08Z","heartbeat_at":"2026-09-23T18:17:08Z","labels":["adr-001","protocol","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-mszy","title":"PR 105's evidence claims one test catches the arm swap; sixteen do","description":"Found by the wave's adversarial reviewer of record at PR #105 head 4e08a9b.\n\ncontext/test-evidence/formula-and-io.md and the commit message both claim that\nswapping the `string` and `pipe_quoted` arms in build_primary \"fails\neach_primary_alternative_builds_its_own_expr, and nothing else in the 85-test\ncrate suite does.\"\n\nNOT REPRODUCIBLE. The reviewer performed exactly that swap and measured:\n - 16 failures in improvise-formula: the new guard plus 12 other\n parser::tests::* (parse_where_clause, parse_quoted_string_in_where,\n pipe_quoted_in_where_filter_value, where_keyword_is_case_insensitive, ...)\n and 3 grammar_prop_tests::*\n - PLUS 3 more in improvise-core (formula_where_applied_to_matching_region and\n siblings)\n\nMost fail as debug_assert! PANICS in strip_string_quotes/unquote_pipe at\nparser.rs:352/:359, not as assertion failures. The \"85-test crate suite\" figure\nitself is correct.\n\nHOW THE AUTHOR COULD HONESTLY HAVE SEEN A SMALL FAILURE SET: under that\ninjection `cargo test --workspace` HALTS at improvise-core and never reaches\nimprovise-formula. So a casual measurement sees the core failures only.\n\nTHE CODE IS RIGHT AND THE TEST IS HARMLESS. What is wrong is the EVIDENCE: the\nguard is presented as load-bearing for a fault that 15 pre-existing tests already\ncatch. In this repo a false injection claim in a checked-in evidence file is\nworse than no claim, because the next reader trusts it and stops looking.\n\nTWO ACCEPTABLE RESOLUTIONS, either fine:\n - find a fault that ONLY each_primary_alternative_builds_its_own_expr catches\n (a transposition among arms whose bodies do not debug_assert would be the\n place to look), or\n - state plainly that the guard is belt-and-braces over existing coverage.\n\nWhat is not acceptable is leaving the claim as written.\n\nNote what the reviewer DID confirm about this PR, so this bead is not read as\nundermining it: injecting a non-atomic probe rule into formula.pest produces\nFOUR E0004 non-exhaustive-pattern errors, one per dispatch site. A new grammar\nrule genuinely cannot land silently. That is the PR's real value and it holds.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T23:03:29Z","created_by":"Edward Langley","updated_at":"2026-09-21T23:03:29Z","labels":["docs","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-4h8a","title":"Six of AppSnapshot's eight restored fields are unguarded; the rival fix passes the suite","description":"Found by the wave's adversarial reviewer at PR #109 head d4d1cd4, by injecting\nthe RIVAL FIX rather than only the bug.\n\nimprovise-yqpd's clause-4 work grew DocumentSnapshot into AppSnapshot, which now\nrestores eight fields. The evidence says \"all six now round-trip through\nAppSnapshot\". Only TWO are pinned by any test.\n\nTHE RIVAL FIX THAT PASSES: replace `self.view_state = snapshot.view_state` with\nrestoration of ONLY view_state.active_view and view_state.status_msg -- the two\nfields the test asserts -- and the ENTIRE workspace suite passes (1585, 0\nfailed).\n\nPushing further, each of these can be removed from App::restore with the whole\nsuite still green:\n cursor_epoch\n abort_effects\n last_reshaped_views\n last_reframed_views\n written_view_frames \u003c- new behaviour with NO test at all\n last_reframed_elsewhere (reverted to the old .clear() shape)\n\nNOT A MERGE BLOCKER, and the current code is the SAFE direction: over-restoring\ncosts nothing, and gotcha 35's asymmetry applies -- over-reporting pays a\nredundant projection, under-reporting leaves a client wrong forever.\n\nTHE HAZARD IS THE NEXT READER. A later \"simplification\" of App::restore back\ntoward the two asserted fields would regress clause 4 for cursors, mode,\nbuffers, panels and search SILENTLY, with a green suite. That is exactly this\nrepo's characteristic defect, and the evidence file currently invites it by\nsaying six fields round-trip without saying four of them are unguarded.\n\nWHAT TO DO: pin the unguarded fields, or state in the evidence that they are\nunguarded and why over-restoring is deliberate. The test to reach for is a\nrejected frame that moved a cursor and a mode, asserting both are back --\nwhich is the user-visible half nothing currently covers.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-mergetrain","owner":"el-github@elangley.org","created_at":"2026-09-21T23:03:30Z","created_by":"Edward Langley","updated_at":"2026-09-24T21:44:59Z","started_at":"2026-09-23T18:17:08Z","closed_at":"2026-09-24T21:44:59Z","close_reason":"Landed in main via #254 (reviewed; merged 2026-09-24).","labels":["adr-001","protocol","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-mszy","title":"PR 105's evidence claims one test catches the arm swap; sixteen do","description":"Found by the wave's adversarial reviewer of record at PR #105 head 4e08a9b.\n\ncontext/test-evidence/formula-and-io.md and the commit message both claim that\nswapping the `string` and `pipe_quoted` arms in build_primary \"fails\neach_primary_alternative_builds_its_own_expr, and nothing else in the 85-test\ncrate suite does.\"\n\nNOT REPRODUCIBLE. The reviewer performed exactly that swap and measured:\n - 16 failures in improvise-formula: the new guard plus 12 other\n parser::tests::* (parse_where_clause, parse_quoted_string_in_where,\n pipe_quoted_in_where_filter_value, where_keyword_is_case_insensitive, ...)\n and 3 grammar_prop_tests::*\n - PLUS 3 more in improvise-core (formula_where_applied_to_matching_region and\n siblings)\n\nMost fail as debug_assert! PANICS in strip_string_quotes/unquote_pipe at\nparser.rs:352/:359, not as assertion failures. The \"85-test crate suite\" figure\nitself is correct.\n\nHOW THE AUTHOR COULD HONESTLY HAVE SEEN A SMALL FAILURE SET: under that\ninjection `cargo test --workspace` HALTS at improvise-core and never reaches\nimprovise-formula. So a casual measurement sees the core failures only.\n\nTHE CODE IS RIGHT AND THE TEST IS HARMLESS. What is wrong is the EVIDENCE: the\nguard is presented as load-bearing for a fault that 15 pre-existing tests already\ncatch. In this repo a false injection claim in a checked-in evidence file is\nworse than no claim, because the next reader trusts it and stops looking.\n\nTWO ACCEPTABLE RESOLUTIONS, either fine:\n - find a fault that ONLY each_primary_alternative_builds_its_own_expr catches\n (a transposition among arms whose bodies do not debug_assert would be the\n place to look), or\n - state plainly that the guard is belt-and-braces over existing coverage.\n\nWhat is not acceptable is leaving the claim as written.\n\nNote what the reviewer DID confirm about this PR, so this bead is not read as\nundermining it: injecting a non-atomic probe rule into formula.pest produces\nFOUR E0004 non-exhaustive-pattern errors, one per dispatch site. A new grammar\nrule genuinely cannot land silently. That is the PR's real value and it holds.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T23:03:29Z","created_by":"Edward Langley","updated_at":"2026-09-21T23:03:29Z","labels":["docs","testing"],"dependencies":[{"issue_id":"improvise-mszy","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T01:05:51Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-b4ks","title":"The documented opfs-contract fmt gate exits 1 as a usage error, indistinguishable from a real failure","description":"The recipe circulated in six wave briefs and in an orchestrator message reads:\n\n cargo fmt -p opfs-contract -- --check\n\nFrom the workspace root this exits **1** with:\n\n package `opfs-contract` is not a member of the workspace\n This utility formats all bin and lib files of the current crate using rustfmt.\n\nThat is a USAGE error carrying the SAME EXIT CODE as a formatting failure. A\ncaller checking `$?` cannot tell \"this crate is misformatted\" from \"you spelled\nthe command wrong and nothing was checked\". Verified by the orchestrator.\n\nThe spelling that reaches the second workspace root:\n\n cargo fmt --manifest-path harness/opfs-contract/Cargo.toml -- --check # exit 0\n\nWHY THIS MATTERS MORE THAN A TYPO: an earlier agent reported this gate as\n\"clean\" during the wave's integration review. Either it used a different\nspelling and did not say so, or it read exit 1 as a pass -- and neither is\ndistinguishable from the report as written. A gate whose failure mode looks\nexactly like the thing it checks for will eventually be \"fixed\" by deleting it,\nand in the meantime it launders a non-run into a pass.\n\nThis is the same family as the pipe-into-tail trap that scripts/pr-reviews\ncarries a warning about: `$?` belonging to something other than the thing you\nmeant to test.\n\nWHERE THE RECIPE NEEDS CORRECTING: wherever the harness gate is written down --\nAGENTS.md's gate list, the wave brief template, and any CI step that spells it\nthis way. CI itself uses `cargo fmt -p opfs-contract` from INSIDE\nharness/opfs-contract, where it resolves, which is why CI has been right while\nevery local invocation of the documented form was not.\n\nCONTEXT: harness/opfs-contract carries its own [workspace] table, so\n`cargo fmt --all` does not reach it. Exactly two workspace roots exist --\n`grep -l '^\\[workspace\\]' $(find . -name Cargo.toml -not -path '*/target/*')` --\nso one extra gate is complete, not the first of an unknown number.\n","notes":"SHARPENED 2026-09-21: the boundary is POSITIONAL, and that changes who is at fault.\n\n cargo fmt -p opfs-contract -- --check from the REPO ROOT -\u003e exit 1, 'not a member of the workspace', --check never runs\n cargo fmt -p opfs-contract -- --check from INSIDE the crate -\u003e exit 0, works\n cargo fmt --manifest-path harness/opfs-contract/Cargo.toml -- --check from the root -\u003e exit 0, works\n\nSo CI AS WRITTEN IS CORRECT -- its working-directory: is exactly what makes the -p form resolve. The hazard is a BRIEF or a local pre-push habit telling someone to run it from the root, which is what the orchestrator did in six wave briefs.\n\nThe failure mode is worse than 'wrong spelling' in both directions: from the root the gate reports FAILURE when the crate is clean, and it cannot report SUCCESS when the crate is dirty. It is decoupled from the thing it checks entirely, while wearing the exit code of a real formatting failure.\n\nThis also explains a discrepancy in the wave's records: one agent reported this gate 'clean' and the orchestrator later measured exit 1. Both were telling the truth -- that agent had cd'd into the crate directory and never saw the error. A gate whose result depends on the working directory, with no indication in the output which one it ran in, cannot be audited from a report.\n\nFIX: correct the recipe wherever it is written for humans or agents (AGENTS.md gate list, the wave brief template). Prefer the --manifest-path form, which is position-independent and therefore says what it means. Leave CI alone.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-ci","owner":"el-github@elangley.org","created_at":"2026-09-21T23:03:27Z","created_by":"Edward Langley","updated_at":"2026-09-23T03:29:40Z","started_at":"2026-09-23T02:49:17Z","closed_at":"2026-09-23T03:29:40Z","close_reason":"Merged in PR #159. Closed by the orchestrator after the session handover; the implementing agent's claim was abandoned when its process exited. Details of the fix, the rival injected and what the ticket did not predict are on the PR thread.","labels":["ci","tooling"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-bok4","title":"A failing effect narrates instead of reporting, so a frame containing it is accepted","description":"Found by the wave's integration/review agent auditing PR #109, and confirmed and\nscoped by that PR's author. ADR-001 clause 4 now rolls back the document, the\nmirrors and the session slice -- but the partial-and-silent SHAPE it exists to\nkill survives one layer down, for every command whose target is not a record.\n\nMECHANISM: reduce::broken_chain asks records_named, which names only SetCell and\nClearCell. Everything else is unguarded. Separately, an effect that FAILS writes\na sentence to view_state.status_msg and does NOT set abort_effects, so the frame\ncontaining it is reported ACCEPTED.\n\nMEASURED AS ACCEPTED, both by the reviewer:\n\n [DeleteView(\"Scratch\"), SetNumberFormat { view: \"Scratch\" }]\n [RemoveCategory(\"Region\"), AddItem { category: \"Region\" }]\n\nThe second is the sharper one: it writes its error to the HOST's status line and\nnever tells the client anything at all. A connected browser sees a frame it\ncomposed reported accepted, with one of its commands having done nothing, and no\nchannel carrying the reason.\n\nWHY THIS IS NOT PART OF improvise-yqpd: closing the class needs effects to\nREPORT failure rather than narrate it -- roughly fifty of them -- which is a\ndifferent change from the snapshot-and-restore clause 4 landed. yqpd closed the\nrecord-addressed INSTANCE. That scope is stated in PR #109's body under \"what\nthis PR does NOT close\" rather than left implied.\n\nTHE SHAPE OF A FIX, not yet decided: an Effect currently returns nothing, so\nfailure has nowhere to go except status_msg. Either effects gain a fallible\nresult that apply_effects can turn into an abort, or the per-command check grows\nbeyond records_named to ask \"does this command's target still resolve\" for the\nview-, category- and item-addressed variants too. The first is larger and\ncloses the class; the second is narrower and closes the reachable cases.\n\nNote the interaction with gotcha 21: an abort is not a rollback, and tidy-up\ngoes at the HEAD of a batch where an abort cannot reach it. Any change here has\nto keep that property, because it is what stops a failed save stranding the user\nin a mode whose minibuffer hides the error.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-wire2","owner":"el-github@elangley.org","created_at":"2026-09-21T22:49:20Z","created_by":"Edward Langley","updated_at":"2026-09-23T05:14:41Z","started_at":"2026-09-23T04:12:54Z","closed_at":"2026-09-23T05:14:41Z","close_reason":"Fixed in PR #181, merged as 810077a. Adversarial review passed at 12171fb; the only later changes were merges of gh/main, touching evidence files only (verified at 2619d17). Follow-ups: improvise-514o, and improvise-v4hg (formula-edit parse arm).","labels":["adr-001","protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-hkbc","title":"Guard the position-free ModelCommand vocabulary, which 6nsd's answer silently depends on","description":"improvise-6nsd was closed as \"not a hole, conditionally\": a stale layout tier\ncannot misdirect a mutation BECAUSE no ModelCommand variant carries a row or\ncolumn index. Verified independently by two agents over all 24 variants.\n\nNothing guards it. A future positional variant reopens the hole SILENTLY, and\nlayout_version under-reports here by construction -- gotcha 35's expensive\ndirection, where under-reporting leaves a client wrong forever.\n\nThe guard: an exhaustive match over ModelCommand in improvise-protocol asserting\nevery variant names its target BY NAME (category, item, group, view, axis,\nformat, path) or by a position-free surrogate (RecordCell/RecordHash). Adding a\npositional variant then fails the build and forces the ADR conversation rather\nthan inheriting an answer that no longer holds.\n\nShape it like reduce.rs::records_named, which is already an exhaustive match\nwith an or-pattern for the variants that name no record -- same file, same\ndiscipline, and the two can be read together.\n\nNOT covered by this guard, and stated so nobody thinks it is: position-free ON\nTHE WIRE is not position-free END TO END. improvise-2wu3 is the live\ncounterexample -- web/app.js captures viewport (row, col) at paint time and\nresolves it against the current cache at commit. That is a client bug the\nprotocol's discipline makes possible to have, not one this test can catch.\n","status":"closed","priority":2,"issue_type":"task","assignee":"agent-wire2","owner":"el-github@elangley.org","created_at":"2026-09-21T21:59:19Z","created_by":"Edward Langley","updated_at":"2026-09-23T04:28:19Z","started_at":"2026-09-23T03:33:57Z","closed_at":"2026-09-23T04:28:19Z","close_reason":"PR #172 MERGED (confirmed at 119f84f), adversarially reviewed then re-reviewed after fixes (merge). h3y7: the forward vocabulary guard (ui/reduce/vocabulary_is_complete.rs) requires every Effect to be a same-named Command/ModelCommand variant or carry a NOT_ON_THE_WIRE verdict (Command/Frame/Off/Gap), checked in both directions. hkbc: position_free destructures every ModelCommand with no '..'. c7mu: one shared scanner, improvise_protocol::source_scan::production_code, blanks comments and literals and drops test code for BOTH vocabulary guards. 94x2: name_after skips attributes, so an attributed simple_effect! is still seen; re-review injected three attribute spellings and all were caught. Residual: improvise-59q8 (P3, the scanner's latent line-scan holes).","labels":["protocol","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-k84e","title":"Clause 4 rollback of a frame containing LoadModel: the rebase-then-undo case is untested","description":"ADR-001 clause 4 landed in improvise-yqpd: Host::commands snapshots the document\nAND clones the whole Sessions before a frame, restoring both on failure.\n\nUNTESTED, and the agent that built it named this as the residual it would most\nwant reviewed: a failing frame that CONTAINS a document-replacing command.\n\nThe sequence: LoadModel -\u003e Applied::Rebased -\u003e every mirror reseeded and fresh\nSnapshots owed -\u003e then a later command in the same frame fails -\u003e undo_frame\nrestores the document and the Sessions clone.\n\nIt SHOULD be sound, because the Sessions clone restores ClientSession::generation\nalong with everything else, so the reseed is undone with it. But no fixture\nexercises it and \"should be sound\" is the phrasing that precedes every entry in\nthis repo's gotcha list.\n\nNote the fixture warning from gotcha 39 applies with force here: when testing a\ndocument swap, the incoming document's active view must have the SAME NAME as\nthe outgoing one, or apply_model broadcasts a SwitchView, the client folds it,\nand the stale cursor is read under a key nothing has written -- right by\naccident, with the bug invisible. Use a_document_shaped_differently_under_the_same_view_name.\n","notes":"PARTLY ANSWERED by PR #109's fix round (2026-09-21), scope narrowed.\n\nWHAT IS NOW DONE: the ghost active_view this bead's sibling finding named is FIXED, and a_rolled_back_load_leaves_no_ghost_view_and_no_false_status covers the rolled-back LoadModel. The fixture was renamed so it cannot pass vacuously -- with both documents named Default the ghost is indistinguishable from a correct value, and the test failed left: \"Loaded\", right: \"Default\" before the fix.\n\nChecked before renaming, and worth recording: gotcha 39 requires a SAME-NAME fixture for the stale-cursor case, but that is a DIFFERENT fixture (a_document_shaped_differently_under_the_same_view_name in project.rs). Neither user of host_over_a_loadable_document depends on the names matching, so gotcha 39's guard is untouched.\n\nWHAT REMAINS, and this bead should stay open scoped to it: the Applied::Rebased path proper -- a frame whose LoadModel SUCCEEDS, emits Snapshots, and is THEN rolled back by a later command. The Sessions clone restores ClientSession::generation so it should be sound, but the Snapshots were already computed and discarded, and nothing tests that.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-wire","owner":"el-github@elangley.org","created_at":"2026-09-21T21:59:19Z","created_by":"Edward Langley","updated_at":"2026-09-23T03:29:40Z","started_at":"2026-09-23T03:05:43Z","closed_at":"2026-09-23T03:29:40Z","close_reason":"Merged in PR #163. Closed by the orchestrator after the session handover; the implementing agent's claim was abandoned when its process exited. Details of the fix, the rival injected and what the ticket did not predict are on the PR thread.","labels":["adr-001","protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-lm5e","title":"[test] Debug-string tests verify effect names, not correctness","description":"## Summary\n\nUmbrella: many command tests assert `dbg.contains(\"EffectName\")` on debug-formatted effect lists. That checks type-name presence, not field values, correct application, or shadowing by later effects.\n\n## Steps to Reproduce\n\n1. Change an effect's fields to incorrect values while keeping the type name.\n2. `dbg.contains(\"AddCategory\")`-style tests still pass.\n\n## Acceptance Criteria\n\n- High-value tests assert structured effect fields / post-fold state, not only Debug substrings; remaining Debug checks documented as smoke-only.\n\n## Doctrine\n\n`doctrine(check_the_checker)`\n\n## Location\n\nMultiple files under `crates/improvise-command/src/command/cmd/`\n\n## PR / Attribution\n\nPR #100 F20 umbrella. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","notes":"GROOM 2026-09-21: one of FIVE beads that are the same defect -- improvise-lm5e (Debug-string tests verify effect names, not correctness), improvise-gjat (sequence-associativity test proves a tautology), improvise-ub4o (toggle-group-involution does not apply effects between calls), improvise-sfwy (records_mode_resolve_display asserts non-empty), improvise-a36c (count_aggregation asserts \u003e= 1.0 instead of the value).\n\nOne work unit. A sixth, improvise-jkxr, is already CLOSED and is the worked example of how to do these: inject the fault first, watch the OLD assertion pass, then strengthen it and watch it fail. In jkxr's case replacing a merge with a replace left the old assert!(!item_names.is_empty()) passing; the strengthened assert_eq! failed with left: [\"B\"], right: [\"A\", \"B\"]. Evidence in context/test-evidence/formula-and-io.md.\n\nDo NOT fix these by inspection. design-principles.md section 7 names this exact class -- 'A check that looks like the answer, answering a neighbouring question' -- and records that the FIRST repair of one of them was itself vacuous. The acceptance for each is a demonstrated fault injection, not a stronger-looking assertion.\nPR #148 closes the FIELD-CARRYING-EFFECT SLICE of this umbrella and says so; roughly 60 Debug-substring assertions remain outside the 16, including 9 in cmd/grid.rs. Evidence the step was run rather than argued: one patch made 14 command sites emit the right effect TYPE with a WRONG CONSTANT FIELD. Under the old assertions that was 525 passed / 6 failed; under the new ones 510 / 21. So 14 of the 16 survived the rival, and of the 6 that did fail only one was in the list -- the other five were in ui/app.rs and ui/reduce.rs, meaning THE COMMAND LAYER NEVER CHECKED A FIELD AT ALL. Assertions on FIELDLESS effects (Quit, Save, RebuildLayout, TransposeAxes, AbortChain, TogglePruneEmpty) were deliberately left alone: there the type name IS the whole claim, so naming it is complete rather than smoke.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-cmd","owner":"box@local","created_at":"2026-09-21T20:49:06Z","created_by":"external-grok-bot","updated_at":"2026-09-23T02:17:29Z","started_at":"2026-09-23T01:25:32Z","lease_expires_at":"2026-09-23T01:30:32Z","heartbeat_at":"2026-09-23T01:25:32Z","external_ref":"gh-pr-100-F20-debug","labels":["adversarial-review","pr-100","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-lm5e","title":"[test] Debug-string tests verify effect names, not correctness","description":"## Summary\n\nUmbrella: many command tests assert `dbg.contains(\"EffectName\")` on debug-formatted effect lists. That checks type-name presence, not field values, correct application, or shadowing by later effects.\n\n## Steps to Reproduce\n\n1. Change an effect's fields to incorrect values while keeping the type name.\n2. `dbg.contains(\"AddCategory\")`-style tests still pass.\n\n## Acceptance Criteria\n\n- High-value tests assert structured effect fields / post-fold state, not only Debug substrings; remaining Debug checks documented as smoke-only.\n\n## Doctrine\n\n`doctrine(check_the_checker)`\n\n## Location\n\nMultiple files under `crates/improvise-command/src/command/cmd/`\n\n## PR / Attribution\n\nPR #100 F20 umbrella. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","notes":"GROOM 2026-09-21: one of FIVE beads that are the same defect -- improvise-lm5e (Debug-string tests verify effect names, not correctness), improvise-gjat (sequence-associativity test proves a tautology), improvise-ub4o (toggle-group-involution does not apply effects between calls), improvise-sfwy (records_mode_resolve_display asserts non-empty), improvise-a36c (count_aggregation asserts \u003e= 1.0 instead of the value).\n\nOne work unit. A sixth, improvise-jkxr, is already CLOSED and is the worked example of how to do these: inject the fault first, watch the OLD assertion pass, then strengthen it and watch it fail. In jkxr's case replacing a merge with a replace left the old assert!(!item_names.is_empty()) passing; the strengthened assert_eq! failed with left: [\"B\"], right: [\"A\", \"B\"]. Evidence in context/test-evidence/formula-and-io.md.\n\nDo NOT fix these by inspection. design-principles.md section 7 names this exact class -- 'A check that looks like the answer, answering a neighbouring question' -- and records that the FIRST repair of one of them was itself vacuous. The acceptance for each is a demonstrated fault injection, not a stronger-looking assertion.\nPR #148 closes the FIELD-CARRYING-EFFECT SLICE of this umbrella and says so; roughly 60 Debug-substring assertions remain outside the 16, including 9 in cmd/grid.rs. Evidence the step was run rather than argued: one patch made 14 command sites emit the right effect TYPE with a WRONG CONSTANT FIELD. Under the old assertions that was 525 passed / 6 failed; under the new ones 510 / 21. So 14 of the 16 survived the rival, and of the 6 that did fail only one was in the list -- the other five were in ui/app.rs and ui/reduce.rs, meaning THE COMMAND LAYER NEVER CHECKED A FIELD AT ALL. Assertions on FIELDLESS effects (Quit, Save, RebuildLayout, TransposeAxes, AbortChain, TogglePruneEmpty) were deliberately left alone: there the type name IS the whole claim, so naming it is complete rather than smoke.","status":"closed","priority":2,"issue_type":"bug","owner":"box@local","created_at":"2026-09-21T20:49:06Z","created_by":"external-grok-bot","updated_at":"2026-09-25T00:59:58Z","closed_at":"2026-09-25T00:59:58Z","close_reason":"Landed in #148 (on gh/main)","external_ref":"gh-pr-100-F20-debug","labels":["adversarial-review","pr-100","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-gjat","title":"[test] sequence-associativity test proves a mathematical tautology","description":"## Summary\n\n`law_sequence_associativity` concatenates effect Vecs as `(a+b)+c` vs `a+(b+c)`. List concatenation is trivially associative — the test cannot fail regardless of command behavior (tautology).\n\n## Steps to Reproduce\n\n1. Note the test only compares Vec concatenations of execute results on the same ctx.\n2. It passes for any deterministic executes that return Vecs.\n\n## Acceptance Criteria\n\n- Replace with a property that can fail (ordered fold of effects on state), or remove as non-evidence.\n\n## Doctrine\n\n`doctrine(fold_tests_must_test_order_not_the_axiom)`\n\n## Location\n\n`crates/improvise-command/src/command/cmd/navigation.rs` ~L388-431\n\n## PR / Attribution\n\nPR #100 F16. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","notes":"GROOM 2026-09-21: one of FIVE beads that are the same defect -- improvise-lm5e (Debug-string tests verify effect names, not correctness), improvise-gjat (sequence-associativity test proves a tautology), improvise-ub4o (toggle-group-involution does not apply effects between calls), improvise-sfwy (records_mode_resolve_display asserts non-empty), improvise-a36c (count_aggregation asserts \u003e= 1.0 instead of the value).\n\nOne work unit. A sixth, improvise-jkxr, is already CLOSED and is the worked example of how to do these: inject the fault first, watch the OLD assertion pass, then strengthen it and watch it fail. In jkxr's case replacing a merge with a replace left the old assert!(!item_names.is_empty()) passing; the strengthened assert_eq! failed with left: [\"B\"], right: [\"A\", \"B\"]. Evidence in context/test-evidence/formula-and-io.md.\n\nDo NOT fix these by inspection. design-principles.md section 7 names this exact class -- 'A check that looks like the answer, answering a neighbouring question' -- and records that the FIRST repair of one of them was itself vacuous. The acceptance for each is a demonstrated fault injection, not a stronger-looking assertion.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-cmd","owner":"box@local","created_at":"2026-09-21T20:49:05Z","created_by":"external-grok-bot","updated_at":"2026-09-23T02:17:28Z","started_at":"2026-09-23T01:18:57Z","closed_at":"2026-09-23T02:17:28Z","close_reason":"PR #144. law_sequence_associativity asserted effects_debug((a++b)++c) == effects_debug(a++(b++c)) -- the associativity of Vec::extend, and it never applied an effect. Replaced by a test that folds the batch a Binding::Sequence really emits (one flattened effect vec) and asserts the last write wins across four orderings. Rivals: Move returning Vec::new() when the position does not change, and apply_effects folding the batch in reverse -- the old law passed both. NOT PREDICTED, and it is the transferable part: the first replacement applied each command as its OWN apply_effects batch, and the reversed-fold rival DID NOT FIRE, because each single-Move batch reverses to the same cursor. A fold test that batches per step is testing the wrong fold.","external_ref":"gh-pr-100-F16-assoc","labels":["adversarial-review","pr-100","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-ub4o","title":"[test] toggle-group-involution test doesn't apply effects between calls","description":"## Summary\n\n`law_toggle_group_involution` asserts two `cmd.execute(\u0026ctx)` debug outputs are equal without applying the first effects. `CmdContext` is borrowed/immutable — the test only proves deterministic pure execute, not involution (toggle twice = identity).\n\n## Steps to Reproduce\n\n1. Fault-inject `ToggleGroupAtCursor` to return different effects on odd/even calls without applying between.\n2. Test still passes (same unchanged ctx).\n\n## Acceptance Criteria\n\n- Test applies first effects (or folds state) before second execute; can fail when involution is broken.\n\n## Doctrine\n\n`doctrine(fold_tests_must_test_order_not_the_axiom)`\n\n## Location\n\n`crates/improvise-command/src/command/cmd/grid.rs` ~L26-36\n\n## PR / Attribution\n\nPR #100 F15. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","notes":"GROOM 2026-09-21: one of FIVE beads that are the same defect -- improvise-lm5e (Debug-string tests verify effect names, not correctness), improvise-gjat (sequence-associativity test proves a tautology), improvise-ub4o (toggle-group-involution does not apply effects between calls), improvise-sfwy (records_mode_resolve_display asserts non-empty), improvise-a36c (count_aggregation asserts \u003e= 1.0 instead of the value).\n\nOne work unit. A sixth, improvise-jkxr, is already CLOSED and is the worked example of how to do these: inject the fault first, watch the OLD assertion pass, then strengthen it and watch it fail. In jkxr's case replacing a merge with a replace left the old assert!(!item_names.is_empty()) passing; the strengthened assert_eq! failed with left: [\"B\"], right: [\"A\", \"B\"]. Evidence in context/test-evidence/formula-and-io.md.\n\nDo NOT fix these by inspection. design-principles.md section 7 names this exact class -- 'A check that looks like the answer, answering a neighbouring question' -- and records that the FIRST repair of one of them was itself vacuous. The acceptance for each is a demonstrated fault injection, not a stronger-looking assertion.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-cmd2","owner":"box@local","created_at":"2026-09-21T20:49:05Z","created_by":"external-grok-bot","updated_at":"2026-09-23T03:29:42Z","started_at":"2026-09-23T02:18:56Z","closed_at":"2026-09-23T03:29:42Z","close_reason":"Merged in PR #156. Closed by the orchestrator after the session handover; the implementing agent's claim was abandoned when its process exited. Details of the fix, the rival injected and what the ticket did not predict are on the PR thread.","external_ref":"gh-pr-100-F15-involution","labels":["adversarial-review","pr-100","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-y01k","title":"ws-server silently drops ServerFrame/ClientFrame/closed-doc traffic","description":"## Summary\n\nws-server silently loses protocol traffic in three places: (1) `ServerFrame` serde failure dropped with `continue` in writer task; (2) unparseable ClientFrame text ignored with no log; (3) ClientFrame for a closed/unregistered document yields empty response — client gets nothing (no rejection).\n\n## Steps to Reproduce\n\n1. Force ServerFrame that fails `serde_json::to_string` — frame lost, no log.\n2. Send `{\"Unknown\": {}}` text — no response, no log.\n3. Send commands after document close/evict — empty response, client keeps composing.\n\n## Acceptance Criteria\n\n- Serialize/parse failures are logged (and ideally signaled to the peer); closed-doc frames produce an explicit rejection/error frame.\n\n## Location\n\n`crates/improvise-ws-server/src/transport.rs` ~L179-183, L240-248; `registry.rs` `handle` ~L193-197\n\n## PR / Attribution\n\nPR #100 F17+F18+F19 consolidated. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","status":"closed","priority":2,"issue_type":"bug","owner":"box@local","created_at":"2026-09-21T20:49:05Z","created_by":"external-grok-bot","updated_at":"2026-09-21T21:58:59Z","closed_at":"2026-09-21T21:58:59Z","close_reason":"Fixed in the 2026-09-21 wave, integrated into main at 1574 passed / 0 failed / 7 ignored. Playwright 42 passed / 0 failed / 4 skipped (WebKit, no OPFS) -- up from 37, so the count MOVED. All gates green.\n\nAll three drops logged.","external_ref":"gh-pr-100-F17-19-ws","labels":["adversarial-review","pr-100"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-r4nf","title":"serde_json failures in wasm exports are silent drops, not errors","description":"## Summary\n\nWasm exports `on_key_event`, `typed_cell`, `clicked` use `.ok()` on `serde_json::to_string()`. Serialization failure returns `None`, which JS treats as \"nothing to send\" (not editable / invalid click) — a valid operation is silently dropped.\n\n## Steps to Reproduce\n\n1. Inject a non-serializable value into the frame.\n2. Observe no error reaches the JS caller; operation vanishes.\n\n## Acceptance Criteria\n\n- Serde failures surface as errors to JS (not conflated with empty/None outcomes).\n\n## Location\n\n`crates/improvise-wasm-client/src/lib.rs` — `on_key_event` ~L54, `typed_cell` ~L97, `clicked` ~L178\n\n## PR / Attribution\n\nPR #100 F13. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","status":"closed","priority":2,"issue_type":"bug","owner":"box@local","created_at":"2026-09-21T20:49:04Z","created_by":"external-grok-bot","updated_at":"2026-09-21T21:59:00Z","closed_at":"2026-09-21T21:59:00Z","close_reason":"Fixed in the 2026-09-21 wave, integrated into main at 1574 passed / 0 failed / 7 ignored. Playwright 42 passed / 0 failed / 4 skipped (WebKit, no OPFS) -- up from 37, so the count MOVED. All gates green.\n\nResult\u003cOption\u003cString\u003e, JsError\u003e; Option still means 'nothing to send', a serde failure throws. JS 'if (frame)' unchanged everywhere.\n\nShape-grep confirmed no second site: serde_json::(to_string|from_str)...ok() across crates/ and src/ gives 3 hits, all in wasm-client/lib.rs, all named by this bead. Zero remain.","external_ref":"gh-pr-100-F13-serde","labels":["adversarial-review","pr-100"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-xg1z","title":"editing mode with missing buffer renders empty overlay instead of cell content","description":"## Summary\n\nIf `is_editing()` is true but no `\"edit\"` buffer exists, `render_model` sets `editing` to `Some(\"\")`. The UI shows an empty editor overlay over cell content.\n\n## Steps to Reproduce\n\n1. Fold `ChangeMode(Editing)` without preceding `SetBuffer{name:\"edit\",...}`.\n2. Call `render_model()` — empty overlay instead of cell content.\n\n## Acceptance Criteria\n\n- Missing edit buffer falls back to cell content (or refuses Editing mode loudly); never blank overlay over real content.\n\n## Location\n\n`crates/improvise-wasm-client/src/render.rs` — `render_model` ~L268-271\n\n## PR / Attribution\n\nPR #100 F14. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-web","owner":"box@local","created_at":"2026-09-21T20:49:04Z","created_by":"external-grok-bot","updated_at":"2026-09-23T03:29:41Z","started_at":"2026-09-23T03:02:29Z","closed_at":"2026-09-23T03:29:41Z","close_reason":"Merged in PR #162. Closed by the orchestrator after the session handover; the implementing agent's claim was abandoned when its process exited. Details of the fix, the rival injected and what the ticket did not predict are on the PR thread.","external_ref":"gh-pr-100-F14-edit","labels":["adversarial-review","pr-100"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-sfwy","title":"[test] records_mode_resolve_display asserts non-empty instead of expected value","description":"## Summary\n\nTest `records_mode_resolve_display_returns_values` asserts only `!display.is_empty()` for Region when expected is `\"North\"`. Any non-empty garbage string passes.\n\n## Steps to Reproduce\n\n1. Fault-inject records display for Category to return `\"GARBAGE\"`.\n2. Test still passes.\n\n## Acceptance Criteria\n\n- Assert exact expected display value (`\"North\"` for the fixture).\n\n## Doctrine\n\n`doctrine(check_the_checker)`\n\n## Location\n\n`crates/improvise-core/src/view/layout.rs` — `records_mode_resolve_display_returns_values` ~L810\n\n## PR / Attribution\n\nPR #100 F11. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","notes":"GROOM 2026-09-21: one of FIVE beads that are the same defect -- improvise-lm5e (Debug-string tests verify effect names, not correctness), improvise-gjat (sequence-associativity test proves a tautology), improvise-ub4o (toggle-group-involution does not apply effects between calls), improvise-sfwy (records_mode_resolve_display asserts non-empty), improvise-a36c (count_aggregation asserts \u003e= 1.0 instead of the value).\n\nOne work unit. A sixth, improvise-jkxr, is already CLOSED and is the worked example of how to do these: inject the fault first, watch the OLD assertion pass, then strengthen it and watch it fail. In jkxr's case replacing a merge with a replace left the old assert!(!item_names.is_empty()) passing; the strengthened assert_eq! failed with left: [\"B\"], right: [\"A\", \"B\"]. Evidence in context/test-evidence/formula-and-io.md.\n\nDo NOT fix these by inspection. design-principles.md section 7 names this exact class -- 'A check that looks like the answer, answering a neighbouring question' -- and records that the FIRST repair of one of them was itself vacuous. The acceptance for each is a demonstrated fault injection, not a stronger-looking assertion.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-mergetrain","owner":"box@local","created_at":"2026-09-21T20:49:03Z","created_by":"external-grok-bot","updated_at":"2026-09-23T18:17:09Z","started_at":"2026-09-23T18:17:09Z","lease_expires_at":"2026-09-23T18:22:09Z","heartbeat_at":"2026-09-23T18:17:09Z","external_ref":"gh-pr-100-F11-records","labels":["adversarial-review","pr-100","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-sfwy","title":"[test] records_mode_resolve_display asserts non-empty instead of expected value","description":"## Summary\n\nTest `records_mode_resolve_display_returns_values` asserts only `!display.is_empty()` for Region when expected is `\"North\"`. Any non-empty garbage string passes.\n\n## Steps to Reproduce\n\n1. Fault-inject records display for Category to return `\"GARBAGE\"`.\n2. Test still passes.\n\n## Acceptance Criteria\n\n- Assert exact expected display value (`\"North\"` for the fixture).\n\n## Doctrine\n\n`doctrine(check_the_checker)`\n\n## Location\n\n`crates/improvise-core/src/view/layout.rs` — `records_mode_resolve_display_returns_values` ~L810\n\n## PR / Attribution\n\nPR #100 F11. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","notes":"GROOM 2026-09-21: one of FIVE beads that are the same defect -- improvise-lm5e (Debug-string tests verify effect names, not correctness), improvise-gjat (sequence-associativity test proves a tautology), improvise-ub4o (toggle-group-involution does not apply effects between calls), improvise-sfwy (records_mode_resolve_display asserts non-empty), improvise-a36c (count_aggregation asserts \u003e= 1.0 instead of the value).\n\nOne work unit. A sixth, improvise-jkxr, is already CLOSED and is the worked example of how to do these: inject the fault first, watch the OLD assertion pass, then strengthen it and watch it fail. In jkxr's case replacing a merge with a replace left the old assert!(!item_names.is_empty()) passing; the strengthened assert_eq! failed with left: [\"B\"], right: [\"A\", \"B\"]. Evidence in context/test-evidence/formula-and-io.md.\n\nDo NOT fix these by inspection. design-principles.md section 7 names this exact class -- 'A check that looks like the answer, answering a neighbouring question' -- and records that the FIRST repair of one of them was itself vacuous. The acceptance for each is a demonstrated fault injection, not a stronger-looking assertion.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-mergetrain","owner":"box@local","created_at":"2026-09-21T20:49:03Z","created_by":"external-grok-bot","updated_at":"2026-09-24T21:44:59Z","started_at":"2026-09-23T18:17:09Z","closed_at":"2026-09-24T21:44:59Z","close_reason":"Landed in main via #254 (reviewed; merged 2026-09-24).","external_ref":"gh-pr-100-F11-records","labels":["adversarial-review","pr-100","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-vyrx","title":"eval_bool_cached uses wildcard match on BinOp instead of exhaustive arms","description":"## Summary\n\n`eval_bool_cached` uses `_ =\u003e Err(\"type\".into())` for BinOp instead of listing arithmetic operators explicitly (unlike `eval_bool`). A new `BinOp` variant silently errors at runtime in the cached path while the non-cached path would fail to compile if exhaustive.\n\n## Steps to Reproduce\n\n1. Add `BinOp::Mod` to formula.\n2. Cached evaluator silently type-errors; non-cached fails at compile if arms are exhaustive.\n\n## Acceptance Criteria\n\n- `eval_bool_cached` BinOp match is exhaustive in the same style as `eval_bool`.\n\n## Location\n\n`crates/improvise-core/src/model/types.rs` — `eval_bool_cached` ~L700\n\n## PR / Attribution\n\nPR #100 F12. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","notes":"GROOM 2026-09-21: one of THREE beads that are the same defect -- improvise-492x (formula parser defaults unknown aggregates to Count and unknown comparisons to Ge), improvise-4lgj (the .improv pest walker's _ =\u003e {} arms silently skip unhandled grammar rules), improvise-vyrx (eval_bool_cached wildcards BinOp where eval_bool lists it).\n\nThey are one work unit: same fix shape, same doctrine, and splitting them across agents means three people re-deriving the same argument. design-principles.md section 2 states it outright -- 'Prefer exhaustive match over if let or _ =\u003e wildcards. When a new Axis variant or AppMode is added, non-exhaustive matches produce compile errors that guide you to every place that needs updating.' Each of these three is a place where that guidance was not followed and the failure is therefore SILENT rather than a compile error.\n\nNote the asymmetry that makes vyrx the sharpest: the cached and non-cached paths DISAGREE, so a new BinOp works in one and errors in the other.","status":"closed","priority":2,"issue_type":"bug","owner":"box@local","created_at":"2026-09-21T20:49:03Z","created_by":"external-grok-bot","updated_at":"2026-09-21T21:58:30Z","closed_at":"2026-09-21T21:58:30Z","close_reason":"Fixed in the 2026-09-21 wave, integrated into main at 1574 passed / 0 failed / 7 ignored, all gates green, every guard fault-injected.\n\neval_bool_cached's _ =\u003e Err(\"type\") replaced with the same explicit arm list eval_bool carries. Verified by INJECTING BinOp::Mod: the build now fails at four sites where it previously failed at three -- the fourth being eval_bool_cached, which used to compile silently.\n\nA shape-sweep then found a SECOND instance one level up: eval_bool and eval_bool_cached also wildcarded the outer Expr match, so a new Expr variant usable as a condition would have been a silent type error in BOTH paths. Both now list the six non-comparison variants; injecting Expr::And fails four sites where it previously failed two.\n\nThe rule the sweep produced, which is the generalisable part: a wildcard falling through to the GENERAL path is safe by construction; one falling through to a REFUSAL or a SPECIALISATION is not. That cleared three look-alikes (CellValue::as_f64, CategoryKind::of_name, and the new eval_agg arm) which were left deliberately.","external_ref":"gh-pr-100-F12-binop","labels":["adversarial-review","pr-100"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-a36c","title":"[test] count_aggregation assertion is \u003e= 1.0 instead of exact value","description":"## Summary\n\nTest `count_aggregation` asserts `val \u003e= 1.0` instead of exact `1.0` for `COUNT(Sales)` at `Region=East`. Any inflated count ≥ 1 still passes.\n\n## Steps to Reproduce\n\n1. Fault-inject `AggFunc::Count` to return `values.len() as f64 + 999.0`.\n2. Test still passes.\n\n## Acceptance Criteria\n\n- Assert exact expected count (1.0 for the fixture).\n\n## Doctrine\n\n`doctrine(check_the_checker)`\n\n## Location\n\n`crates/improvise-core/src/model/types.rs` — `count_aggregation` ~L1467\n\n## PR / Attribution\n\nPR #100 F10. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","notes":"GROOM 2026-09-21: one of FIVE beads that are the same defect -- improvise-lm5e (Debug-string tests verify effect names, not correctness), improvise-gjat (sequence-associativity test proves a tautology), improvise-ub4o (toggle-group-involution does not apply effects between calls), improvise-sfwy (records_mode_resolve_display asserts non-empty), improvise-a36c (count_aggregation asserts \u003e= 1.0 instead of the value).\n\nOne work unit. A sixth, improvise-jkxr, is already CLOSED and is the worked example of how to do these: inject the fault first, watch the OLD assertion pass, then strengthen it and watch it fail. In jkxr's case replacing a merge with a replace left the old assert!(!item_names.is_empty()) passing; the strengthened assert_eq! failed with left: [\"B\"], right: [\"A\", \"B\"]. Evidence in context/test-evidence/formula-and-io.md.\n\nDo NOT fix these by inspection. design-principles.md section 7 names this exact class -- 'A check that looks like the answer, answering a neighbouring question' -- and records that the FIRST repair of one of them was itself vacuous. The acceptance for each is a demonstrated fault injection, not a stronger-looking assertion.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-core","owner":"box@local","created_at":"2026-09-21T20:49:02Z","created_by":"external-grok-bot","updated_at":"2026-09-23T02:44:31Z","started_at":"2026-09-23T01:18:33Z","closed_at":"2026-09-23T02:44:31Z","close_reason":"PR #143. count_aggregation asserted \u003e= 1.0 where the exact count is knowable. WORSE THAN FILED: the ticket's own suggested rival (len + 999.0) breaks the EMPTY case, which a different test catches -- so preserve the empty case and ALL 1699 TESTS PASS while COUNT over any non-empty set is wrong by 999. The only thing standing between a wrong COUNT and a green suite was the identity element, the one input it gets right by accident. Two assertions with an ASYMMETRIC fixture (1 matching cell scoped, 3 unscoped), so no single constant satisfies both. Adversarial review confirmed by injection: the constant-1.0 rival fails only the second assertion, the +999 rival only the first, and the neighbour eval_agg_over_no_rows_is_zero_only_where_an_identity_exists passes under BOTH.","external_ref":"gh-pr-100-F10-count","labels":["adversarial-review","pr-100","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-dhw5","title":"CSV analyzer misclassifies mostly-numeric columns with nulls as Label","description":"## Summary\n\n`analyze_records` `all_numeric` excludes `Value::Null`, so a column that is 99% numeric with one null becomes Label rather than Measure. Sparse numeric CSV columns lose numeric typing (numbers become stringified item names).\n\n## Steps to Reproduce\n\n1. Import CSV \"Revenue\" with 100 numeric rows and one null.\n2. Column classifies as Label; numeric values are not Measure cells.\n\n## Acceptance Criteria\n\n- Null/empty cells do not force Label when non-null values are uniformly numeric; sparse measures stay measures.\n\n## Location\n\n`crates/improvise-io/src/import/analyzer.rs` — `analyze_records` ~L124-135\n\n## PR / Attribution\n\nPR #100 F09. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","status":"closed","priority":2,"issue_type":"bug","owner":"box@local","created_at":"2026-09-21T20:49:02Z","created_by":"external-grok-bot","updated_at":"2026-09-21T21:26:10Z","closed_at":"2026-09-21T21:26:10Z","close_reason":"Duplicate of improvise-l1f5, the same defect found independently the same day: analyze_records' all_numeric excludes Value::Null, so a numeric column with one blank is classified Label instead of Measure and its numbers become stringified item names.\n\nKeeping l1f5 because it names the exact location (crates/improvise-io/src/import/analyzer.rs:129-132) and carries a concrete repro (Region,Revenue with East,10 and West,\u003cblank\u003e). This report's mechanism sentence is the clearer statement of WHY and has been copied onto l1f5.","external_ref":"gh-pr-100-F09-label","labels":["adversarial-review","pr-100"],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -238,11 +313,11 @@ {"_type":"issue","id":"improvise-4lgj","title":"Parser wildcards silently ignore unknown grammar rules","description":"## Summary\n\nThe `.improv` pest walker has multiple `_ =\u003e {}` arms (`walk_category_section`, `walk_view_section`, `walk_md`) that silently skip grammar rules the walker does not handle. Extending `improv.pest` with a new rule causes silent data loss on round-trip rather than a loud parse/walker mismatch.\n\n## Steps to Reproduce\n\n1. Add a new rule to `improv.pest` (e.g. `color_line`), emit it in `format_md`.\n2. Parse with `parse_md` — no error; data from the new rule is dropped.\n3. Observe grammar accepts / walker ignores.\n\n## Acceptance Criteria\n\n- Unknown/new grammar rules the walker does not handle fail loudly (or exhaustive matches force updates); round-trip cannot silently drop accepted rules.\n\n## Doctrine\n\n`doctrine(check_the_checker)` — a parse that silently ignores valid grammar rules is indistinguishable from one that rejects them.\n\n## Location\n\n`crates/improvise-io/src/persistence/parse.rs` ~L180, L293, L315\n\n## PR / Attribution\n\nPR #100 F04 (P2 section; parser wildcards). external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","notes":"GROOM 2026-09-21: one of THREE beads that are the same defect -- improvise-492x (formula parser defaults unknown aggregates to Count and unknown comparisons to Ge), improvise-4lgj (the .improv pest walker's _ =\u003e {} arms silently skip unhandled grammar rules), improvise-vyrx (eval_bool_cached wildcards BinOp where eval_bool lists it).\n\nThey are one work unit: same fix shape, same doctrine, and splitting them across agents means three people re-deriving the same argument. design-principles.md section 2 states it outright -- 'Prefer exhaustive match over if let or _ =\u003e wildcards. When a new Axis variant or AppMode is added, non-exhaustive matches produce compile errors that guide you to every place that needs updating.' Each of these three is a place where that guidance was not followed and the failure is therefore SILENT rather than a compile error.\n\nNote the asymmetry that makes vyrx the sharpest: the cached and non-cached paths DISAGREE, so a new BinOp works in one and errors in the other.","status":"closed","priority":2,"issue_type":"bug","owner":"box@local","created_at":"2026-09-21T20:49:00Z","created_by":"external-grok-bot","updated_at":"2026-09-21T21:58:17Z","closed_at":"2026-09-21T21:58:17Z","close_reason":"Fixed in the 2026-09-21 wave, integrated into main at 1574 passed / 0 failed / 7 ignored, all gates green, every guard fault-injected.\n\nThree _ =\u003e {} arms now bail!, plus an exhaustive match over Rule naming the walker that reads each one -- a new grammar rule stops that test COMPILING. It earned its keep immediately by naming Rule::section, which the first draft missed.\n\nTHE FINDING: this is not merely hygiene, it is what made the other five persistence fixes safe. Because walk_file now bails, the pre-existing grammar-walking property test became a live grammar-vs-walker agreement check, so every rule added to improv.pest was exercised with no new test written. Under the old _ =\u003e {} one could have shipped ## Aggregation in the grammar with no walker arm and the whole suite would have stayed green.\n\nTwo of the three silent-wildcard beads (improvise-492x, improvise-vyrx) also closed this wave.","external_ref":"gh-pr-100-F06-parser","labels":["adversarial-review","pr-100"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-df2w","title":"Open empty formula panel is invisible: RenderModel encodes closed as empty rows","description":"## Summary\n\n`RenderModel` carries an explicit `category_panel_open` flag (and `web/app.js` hides `#categories` from it), but `views` / `formulas` visibility is encoded as “empty list means closed” (`paintPanel`: `host.hidden = rows.length === 0`). An **open** formula panel with **zero** formulas therefore paints as closed while mode is still FORMULAS.\n\n## Repro\n\n1. Open a document with no formulas (empty model, or strip formulas from demo).\n2. Press `F` (toggle-panel-and-focus formula → `SetPanelOpen{open:true}` + `ChangeMode(FormulaPanel)`).\n3. Observe mode indicator FORMULAS / hint for formula panel, but `#formulas` stays `hidden` because `formula_rows` returns `[]` when the cache is empty even though `formula_panel_open` is true.\n\n## Expected\n\nOpen panel chrome is visible (empty list UI), matching category panel’s flag-based visibility — or RenderModel exposes `formula_panel_open` / `view_panel_open` and the page uses those flags.\n\n## Actual\n\n`formula_rows` / `view_rows` gate on the open flag **and** content; page treats emptiness as closed. Mode and panel chrome disagree.\n\n## check_the_checker\n\n`a_closed_panel_projects_no_rows_and_an_open_one_does` only opens panels after projecting nonempty `ViewList`/`Formulas`. Injecting `formula_panel_open=true` with an empty formulas cache still yields `model.formulas == []`; the existing assert never sees the gap.\n\n## Doctrine\n\n- design-principles §5 (front end paints; decisions in shared layer) / clients.md RenderModel “empty list means closed” for help — applied to formulas without a flag, unlike categories\n- `doctrine(check_the_checker)`\n\nRelated but distinct from improvise-x0dc (Esc leaving `formula_panel_open` true with **nonempty** formulas so chrome stays).\n\n## Attribution\n\nexternal Grok Bot adversarial code review for Ed Langley\n\n## Location\n\n- `crates/improvise-wasm-client/src/render.rs` — `formula_rows` / `view_rows` / missing flags on `RenderModel`\n- `web/app.js` — `paintPanel`","notes":"## Steps to Reproduce\n1. Load a document with zero formulas (or empty workbook).\n2. Press F to open/focus the formula panel.\n3. Mode shows FORMULAS; #formulas remains hidden.\n\n## Acceptance Criteria\n- Open+empty formula panel paints visible empty chrome (or explicit formula_panel_open flag drives visibility like category_panel_open).\n- A unit/harness check fails if formula_panel_open=true with empty formulas cache but the page/model report the panel closed.\nReopened by the orchestrator: it was closed by mistake. The close was chained after 'gh pr merge 173' without checking the result, and the merge FAILED because #173 went CONFLICTING/DIRTY once #169 (ktlk) merged; both touch the browser RenderModel. The fix is reviewed (MERGE at 2186e3d) but not merged. Close again only after #173 actually merges.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-webpage","owner":"box@local","created_at":"2026-09-21T20:43:16Z","created_by":"box","updated_at":"2026-09-23T04:26:09Z","started_at":"2026-09-23T03:48:40Z","closed_at":"2026-09-23T04:26:09Z","close_reason":"PR #173 MERGED (confirmed at de73ad9). Reviewed at 2186e3d (merge), then re-reviewed after two conflict-resolution merges (merge). CI green per pr-watch --expect. RenderModel::views/formulas are Option\u003cVec\u003c_\u003e\u003e: None is closed, Some([]) is open and empty. The re-review confirmed #169's editor derivation survived and nothing re-derives the seed. (Previously closed by mistake, when a close was chained after a merge that had failed; reopened, and now closed after the merge was confirmed.)","labels":["adversarial-review","wasm-client","web"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-jkxr","title":"parse_duplicate_categories is vacuous: cannot fail for a merge that drops half the items","description":"## Summary\n\n`persistence::parse::parser_edge_cases::parse_duplicate_categories` only asserts `!item_names.is_empty()` after parsing two `## Category: Type` sections (`- A` then `- B`). That cannot fail for the merge defect the test name and placement imply.\n\n## Evidence / fault injection\n\nProduction merge (`ParsedFile::push_category`) concatenates items on duplicate section names (`invariant(duplicate_category_sections_merge_in_pass_one)`). The nearby test `two_sections_naming_one_category_round_trip_to_a_stable_group_order` does assert both halves survive for groups.\n\nInjected fault mental model: change `push_category` to **replace** the first section with the second (keep only `B`), or keep only the first (`A`). Assert `!item_names.is_empty()` still passes. Only a total wipe of items fails.\n\nExpected assertion shape: both `A` and `B` present (order/content), matching the merge invariant — same standard as the group round-trip neighbour.\n\n## Doctrine\n\n- `doctrine(check_the_checker)` — a check nobody has seen fail for the named defect is indistinguishable from one that always passes\n- `invariant(duplicate_category_sections_merge_in_pass_one)`\n- design-principles §7 testing doctrine\n\n## Attribution\n\nexternal Grok Bot adversarial code review for Ed Langley\n\n## Location\n\n`crates/improvise-io/src/persistence/parse.rs` — `parse_duplicate_categories` (~L1096)","notes":"## Steps to Reproduce\n1. Open crates/improvise-io/src/persistence/parse.rs::parse_duplicate_categories.\n2. Mentally change ParsedFile::push_category to replace (not extend) on duplicate name.\n3. Re-run the test — it still passes.\n\n## Acceptance Criteria\n- Test asserts both items A and B are present after parsing the two Type sections.\n- Injecting a replace-not-extend merge makes the test fail.","status":"closed","priority":2,"issue_type":"bug","owner":"box@local","created_at":"2026-09-21T20:43:15Z","created_by":"box","updated_at":"2026-09-21T21:25:59Z","closed_at":"2026-09-21T21:25:59Z","close_reason":"Fixed in 2b086b2 (formatting follow-up e4adf86) on main.\n\nThe finding was verified by injection rather than taken on report: changing\npush_category's 'existing.items.extend(items)' to 'existing.items = items' --\nso a second section REPLACES the first -- left the old assertion PASSING. The\nstrengthened assertion fails with left: [\"B\"], right: [\"A\", \"B\"].\n\nThe test now asserts assert_eq!(item_names, [\"A\", \"B\"]), pinning order as well\nas membership, since insertion order is load-bearing here (gotcha 18). That is\nthe standard its neighbour two_sections_naming_one_category_round_trip_to_a_stable_group_order\nalready held groups to; this was the item-list half of the same invariant held\nto a weaker one.\n\nEvidence recorded in context/test-evidence/formula-and-io.md.","labels":["adversarial-review","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-12ha","title":"Multi-tab browser editing: a SharedWorker cannot host OpfsFs (measured), so pick a shape","description":"CONTEXT: improvise-i0v9 is the bug (a second tab is dead and silent). This is the\ncapability ticket. The user ruled that read-only second tabs are acceptable \"as\nlong as that's clear\", then asked whether the worker should be shared between\ntabs. The answer was measured rather than recalled.\n\nTHE DECISIVE MEASUREMENT, all three engines:\n\n chromium SharedWorkerGlobalScope, isWorkerGlobalScope true, getDirectory ok,\n getFileHandle ok, hasCreateSyncAccessHandle FALSE\n \"TypeError: fh.createSyncAccessHandle is not a function\"\n firefox identical\n webkit getDirectory itself fails (no OPFS at all -- the gap opfs.spec.js\n already skips on)\n\ncreateSyncAccessHandle is spec'd [Exposed=DedicatedWorker] and both engines\nimplement that faithfully. NOT a flag, NOT transient, NOT permissions. The real\nOpfsFs was then run unmodified inside a module SharedWorker and failed at exactly\nthat call.\n\nSO: a SharedWorker CANNOT host OpfsFs as written. Gotcha 28's premise -- the\nhandle's read/write/truncate/flush ARE blocking, which is what let Storage stay\nsynchronous -- is a DEDICATED-WORKER-ONLY premise.\n\nTwo things cut the other way and are worth keeping:\n - SharedWorker itself is in fine shape: present in all three, MODULE shared\n workers work in all three, and two tabs demonstrably share ONE instance (same\n instance id, connections: 2, both served).\n - WebKit's SharedWorker is healthy; its OPFS is what is missing. WebKit is no\n worse off under a shared-worker design than it is today.\n\nTHREE WAYS OUT, ALL PROBED:\n\nA. SharedWorker host with an ASYNC storage seam. createWritable() IS\n [Exposed=Window,Worker] and works in a shared worker (measured, chromium and\n firefox); reads need no handle at all (getFile().text()). But this is\n precisely what gotcha 28 exists to avoid: Storage::read/write are sync,\n App::storage is a Box\u003cdyn Storage\u003e, effect::Save calls it synchronously\n inside Effect::apply inside App::apply_effects -- the one mutation site. An\n async fn in Effect::apply is a DIFFERENT TRAIT, and it reaches the native TUI\n too. Load-bearing, not plumbing.\n NARROWER VARIANT worth costing first: keep Storage sync over an IN-MEMORY\n document, flushed asynchronously from the message loop -- the shape\n commit_deletes() already has. Storage never becomes async; a fifth awaited\n method persist() joins the four. UNCOSTED: what holds a document in memory\n across a LoadModel, and what a crashed tab loses that a sync handle would not.\n\nB. Nested dedicated worker under a SharedWorker. DEAD. Firefox allows it and the\n nested worker gets a real working sync handle with OpfsFs unchanged; Chromium\n and WebKit do not implement nested workers in shared workers at all\n (\"ReferenceError: Worker is not defined\"). The one shape that would have cost\n nothing works in exactly one engine.\n\nC. Keep the dedicated worker, add a RELAY owning no storage -- a SharedWorker or\n BroadcastChannel moving ClientFrame/ServerFrame JSON between follower tabs and\n the one tab whose dedicated worker holds the handle.\n improvise_protocol::frame is already THE one pair for all deployments\n (improvise-xks6), and the argument recorded there is explicitly\n transport-agnostic. A third transport is the case that consolidation was made\n for.\n - probably free: Worker::on_frame's \"one client by construction\". Gotcha 45\n already says the Addressed filter is honoured against a future SharedWorker.\n Session ids, fan-out and per-session mirrors need nothing new.\n - LOAD-BEARING: leader election and failover. The handle IS released when the\n leading tab closes, but bindings.rs::boot has NO RETRY PATH, so a follower\n cannot be promoted without a reload -- the same \"dead until you reload\"\n through a different door.\n - LOAD-BEARING: the relay must serialize frames onto one tail promise.\n web/worker.js had this exact race and web/serial.js exists because of it\n (gotcha 48). A relay forwarding async reintroduces it ACROSS TABS, where\n gotcha 33 answers an overtaken update by DROPPING it.\n - REVISIT: persist-on-dirty. Storage is whole-document (gotcha 41), so N\n editing tabs is N whole-file rewrites through one handle.\n\nRECOMMENDED ORDER (agent's read, not a ruling): C first, A's narrow variant\nsecond, read-only as the fallback it now is. C's real price -- leader failover --\nis THE SAME MISSING RETRY PATH the read-only fallback needs anyway, so that work\nis not wasted either way.\n\nDO NOT treat this as small because Sessions is ready. That readiness is real and\nis the good news; the no is entirely on the storage side.\n\nUNTESTED: Safari proper. Playwright's WebKit has no OPFS in any context so it is\nunaskable here. Safari 17+ ships OPFS; whether createSyncAccessHandle is\ndedicated-only there too is an EXPECTATION, not a measurement.\nUNPROBED: whether BroadcastChannel beats a SharedWorker relay for C. It is in all\nthree engines and needs no new worker, possibly making C cheaper -- but it fans\nout to everyone, so the Addressed filter would be doing real work rather than\nbeing honoured defensively. One probe settles it.\n","notes":"NEXT CHEAPEST PROBE: whether BroadcastChannel beats a SharedWorker relay for option C. It is in all three engines and needs no new worker. The catch is that it fans out to everyone, so the Addressed filter would be doing real work rather than being honoured defensively (gotcha 45).","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-21T20:40:31Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:42:20Z","labels":["architecture","browser","opfs"],"dependencies":[{"issue_id":"improvise-12ha","depends_on_id":"improvise-i0v9","type":"discovered-from","created_at":"2026-09-21T21:31:54Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-12ha","title":"Multi-tab browser editing: a SharedWorker cannot host OpfsFs (measured), so pick a shape","description":"CONTEXT: improvise-i0v9 is the bug (a second tab is dead and silent). This is the\ncapability ticket. The user ruled that read-only second tabs are acceptable \"as\nlong as that's clear\", then asked whether the worker should be shared between\ntabs. The answer was measured rather than recalled.\n\nTHE DECISIVE MEASUREMENT, all three engines:\n\n chromium SharedWorkerGlobalScope, isWorkerGlobalScope true, getDirectory ok,\n getFileHandle ok, hasCreateSyncAccessHandle FALSE\n \"TypeError: fh.createSyncAccessHandle is not a function\"\n firefox identical\n webkit getDirectory itself fails (no OPFS at all -- the gap opfs.spec.js\n already skips on)\n\ncreateSyncAccessHandle is spec'd [Exposed=DedicatedWorker] and both engines\nimplement that faithfully. NOT a flag, NOT transient, NOT permissions. The real\nOpfsFs was then run unmodified inside a module SharedWorker and failed at exactly\nthat call.\n\nSO: a SharedWorker CANNOT host OpfsFs as written. Gotcha 28's premise -- the\nhandle's read/write/truncate/flush ARE blocking, which is what let Storage stay\nsynchronous -- is a DEDICATED-WORKER-ONLY premise.\n\nTwo things cut the other way and are worth keeping:\n - SharedWorker itself is in fine shape: present in all three, MODULE shared\n workers work in all three, and two tabs demonstrably share ONE instance (same\n instance id, connections: 2, both served).\n - WebKit's SharedWorker is healthy; its OPFS is what is missing. WebKit is no\n worse off under a shared-worker design than it is today.\n\nTHREE WAYS OUT, ALL PROBED:\n\nA. SharedWorker host with an ASYNC storage seam. createWritable() IS\n [Exposed=Window,Worker] and works in a shared worker (measured, chromium and\n firefox); reads need no handle at all (getFile().text()). But this is\n precisely what gotcha 28 exists to avoid: Storage::read/write are sync,\n App::storage is a Box\u003cdyn Storage\u003e, effect::Save calls it synchronously\n inside Effect::apply inside App::apply_effects -- the one mutation site. An\n async fn in Effect::apply is a DIFFERENT TRAIT, and it reaches the native TUI\n too. Load-bearing, not plumbing.\n NARROWER VARIANT worth costing first: keep Storage sync over an IN-MEMORY\n document, flushed asynchronously from the message loop -- the shape\n commit_deletes() already has. Storage never becomes async; a fifth awaited\n method persist() joins the four. UNCOSTED: what holds a document in memory\n across a LoadModel, and what a crashed tab loses that a sync handle would not.\n\nB. Nested dedicated worker under a SharedWorker. DEAD. Firefox allows it and the\n nested worker gets a real working sync handle with OpfsFs unchanged; Chromium\n and WebKit do not implement nested workers in shared workers at all\n (\"ReferenceError: Worker is not defined\"). The one shape that would have cost\n nothing works in exactly one engine.\n\nC. Keep the dedicated worker, add a RELAY owning no storage -- a SharedWorker or\n BroadcastChannel moving ClientFrame/ServerFrame JSON between follower tabs and\n the one tab whose dedicated worker holds the handle.\n improvise_protocol::frame is already THE one pair for all deployments\n (improvise-xks6), and the argument recorded there is explicitly\n transport-agnostic. A third transport is the case that consolidation was made\n for.\n - probably free: Worker::on_frame's \"one client by construction\". Gotcha 45\n already says the Addressed filter is honoured against a future SharedWorker.\n Session ids, fan-out and per-session mirrors need nothing new.\n - LOAD-BEARING: leader election and failover. The handle IS released when the\n leading tab closes, but bindings.rs::boot has NO RETRY PATH, so a follower\n cannot be promoted without a reload -- the same \"dead until you reload\"\n through a different door.\n - LOAD-BEARING: the relay must serialize frames onto one tail promise.\n web/worker.js had this exact race and web/serial.js exists because of it\n (gotcha 48). A relay forwarding async reintroduces it ACROSS TABS, where\n gotcha 33 answers an overtaken update by DROPPING it.\n - REVISIT: persist-on-dirty. Storage is whole-document (gotcha 41), so N\n editing tabs is N whole-file rewrites through one handle.\n\nRECOMMENDED ORDER (agent's read, not a ruling): C first, A's narrow variant\nsecond, read-only as the fallback it now is. C's real price -- leader failover --\nis THE SAME MISSING RETRY PATH the read-only fallback needs anyway, so that work\nis not wasted either way.\n\nDO NOT treat this as small because Sessions is ready. That readiness is real and\nis the good news; the no is entirely on the storage side.\n\nUNTESTED: Safari proper. Playwright's WebKit has no OPFS in any context so it is\nunaskable here. Safari 17+ ships OPFS; whether createSyncAccessHandle is\ndedicated-only there too is an EXPECTATION, not a measurement.\nUNPROBED: whether BroadcastChannel beats a SharedWorker relay for C. It is in all\nthree engines and needs no new worker, possibly making C cheaper -- but it fans\nout to everyone, so the Addressed filter would be doing real work rather than\nbeing honoured defensively. One probe settles it.\n","notes":"NEXT CHEAPEST PROBE: whether BroadcastChannel beats a SharedWorker relay for option C. It is in all three engines and needs no new worker. The catch is that it fans out to everyone, so the Addressed filter would be doing real work rather than being honoured defensively (gotcha 45).","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-21T20:40:31Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:42:20Z","labels":["architecture","browser","opfs"],"dependencies":[{"issue_id":"improvise-12ha","depends_on_id":"improvise-i0v9","type":"discovered-from","created_at":"2026-09-21T21:31:54Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-12ha","depends_on_id":"improvise-tm6","type":"parent-child","created_at":"2026-09-25T01:04:35Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-7gip","title":"[bug] OpfsFs::open's dedicated-worker guard checks WorkerGlobalScope, which a SharedWorker satisfies","description":"MEASURED in all three engines at 1f28868.\n\nOpfsFs::open() guards with:\n\n .dyn_into::\u003cweb_sys::WorkerGlobalScope\u003e()\n .map_err(|_| anyhow!(\"OPFS sync access handles require a dedicated Web Worker\"))?\n\n`self instanceof WorkerGlobalScope` is TRUE in a SharedWorkerGlobalScope. So a\nshared worker sails past this check and fails much later as a raw TypeError deep\nin the wasm glue:\n\n [firefox] workerError \"TypeError: arg0.createSyncAccessHandle is not a\n function\" at opfs/pkg/opfs_contract.js:93\n\nThe message names the right requirement; the type cannot check it.\ndyn_into::\u003cweb_sys::DedicatedWorkerGlobalScope\u003e() would.\n\nThis is doctrine(an_invariant_is_made_unrepresentable_not_validated) HALF DONE:\na guard was reached for, and the type chosen is one level too wide, so the\ninvariant is validated-and-wrong rather than unrepresentable.\n\nopfs.rs is wasm32-only with no Rust tests (0t in the inventory), so there is no\ncargo test that can fail first. The instrument that CAN fail first is harness/ --\nthe probe above is exactly the reproduction, and harness/opfs-contract already\nboots the real bundle in a real worker.\n","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:40:31Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:58:57Z","closed_at":"2026-09-21T21:58:57Z","close_reason":"Fixed in the 2026-09-21 wave, integrated into main at 1574 passed / 0 failed / 7 ignored. Playwright 42 passed / 0 failed / 4 skipped (WebKit, no OPFS) -- up from 37, so the count MOVED. All gates green.\n\nGuard is now DedicatedWorkerGlobalScope.\n\nTHE ONE-LINER DOES NOT COMPILE: the type is web-sys feature-gated, so improvise-io/Cargo.toml needed a line too. That is why it looked free and was not.\n\nNOT VERIFIED: that a SharedWorker is actually refused -- the harness has no SharedWorker page. What is asserted is only that a DEDICATED worker still satisfies the narrow type, i.e. the fix did not break the shipped path. Stated explicitly in the evidence rather than implied. improvise-12ha owns the SharedWorker question.","labels":["browser","bug","opfs"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-7xyx","title":"FrameGroup::cat is dead in both front ends and its doc comment promises an echo nobody composes","description":"FrameGroup::cat is read by NEITHER front end -- dead in both. Its doc comment\nsays it is \"echoed back in ToggleGroup\", and resolve.rs has NO ToggleGroup arm\nat all, so the echo it promises cannot happen from a browser.\n\nBy design-principles.md section 6 this is the expensive class: a reason that\nlives only in a comment, where nothing checks it against the code. The comment\nis a promise about a client that does not exist.\n\nTwo outcomes are acceptable: compose ToggleGroup in resolve.rs and make the\ncomment true (z is on the silent list anyway, so this would close part of that),\nor delete the field and the comment together. What is not acceptable is leaving\na doc comment describing a protocol interaction no code performs.\n\nAlso noticed in the same pass: AxisFrame::cats is PARTIAL on both ends -- the\ncount is used, the category NAMES are drawn by neither, so the grid's corner\ncell is blank in both surfaces. Possibly intended; worth a decision either way.\n","status":"closed","priority":2,"issue_type":"task","assignee":"agent-viewhist","owner":"el-github@elangley.org","created_at":"2026-09-21T20:39:58Z","created_by":"Edward Langley","updated_at":"2026-09-23T05:28:40Z","started_at":"2026-09-23T04:38:11Z","closed_at":"2026-09-23T05:28:40Z","close_reason":"Fixed in PR #196, merged after adversarial review at 6b3dd8c. Follow-ups: improvise-w1bu (test gap), improvise-pakc (totals identity).","labels":["docs","protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-hs0u","title":"[bug] gg is implemented in the resolver and unreachable from any browser key","description":"DEMONSTRATED at 1f28868.\n\n PROBE 'g': browser = Upstream (silent)\n PROBE 'G': browser = Local[SetSelected { row: 1, col: 0 }, SetRowOffset(1)]\n\ngrid_move's GridMove::ToStart(true) arm EXISTS and is CORRECT. jump-first-row is\nbound only behind the g prefix (keymap.rs:639), and prefixes are unconditionally\nUpstream in resolve(). So a browser user can reach the last row and cannot get\nback to the first.\n\n0 and Home (jump-first-col) both work, so the asymmetry is ROW-ONLY and reads as\na bug rather than as an absence. The resolver arm is dead code in the browser\ntoday.\n\nSame root cause as the paste ticket: every prefix is Upstream, and\nHost::handle answers ClientFrame::Key with an explicit empty vec, so an upstream\nkey is a black hole rather than a slow path (gotcha 47). Fixing prefixes\nresolvably closes gg, yy and ZZ together.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-web","owner":"el-github@elangley.org","created_at":"2026-09-21T20:39:56Z","created_by":"Edward Langley","updated_at":"2026-09-23T03:29:42Z","started_at":"2026-09-23T02:51:05Z","closed_at":"2026-09-23T03:29:42Z","close_reason":"Merged in PR #161. Closed by the orchestrator after the session handover; the implementing agent's claim was abandoned when its process exited. Details of the fix, the rival injected and what the ticket did not predict are on the PR thread.","labels":["browser","bug","parity"],"dependencies":[{"issue_id":"improvise-hs0u","depends_on_id":"blocked-by:improvise-lut7","type":"blocks","created_at":"2026-09-21T20:40:37Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-hs0u","depends_on_id":"improvise-lut7","type":"related","created_at":"2026-09-21T21:31:57Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-0ygd","title":"[bug] Tab on the +measure column silently discards the commit that Enter accepts","description":"DEMONSTRATED at 1f28868.\n\n PROBE +measure at (0,4), extent GridExtent { rows: 1, cols: 5 }\n PROBE Tab on +measure, the LAST row:\n browser = Upstream (silent)\n tui = doc_changed true, status \"Added new record row\"\n\na_locally_committed_creating_column_agrees_with_the_host passes for\nCreateKind::Measure -- it presses ENTER. Press TAB on the very same cell and:\n\n - resolve.rs::advanced() opens with\n if matches!(advance, Advance::Right) \u0026\u0026 state.grid.records\n \u0026\u0026 row \u003e= row_max \u0026\u0026 col \u003e= col_max { return None; }\n - None propagates out of create_column -\u003e commit -\u003e composed -\u003e Upstream, so\n the WHOLE COMMIT is discarded, not just the advance. The typed measure name\n is lost silently.\n - CommitAndAdvance::execute (cmd/commit.rs) takes the\n at_bottom_right \u0026\u0026 ctx.is_records_mode() branch instead: runs AddRecordRow,\n moves to (row+1, 0) and re-enters the editor. That is the bulk-entry Tab\n contract (see the records-mode Tab memory: Tab always advances to the next\n useful place).\n\n+measure is ALWAYS the last column, so col \u003e= col_max holds for it by\nconstruction. This fires on every single-row records grid and on the last row of\nany records grid.\n\nThis sits inside the already-filed \"records mode is silent in the browser\" gap,\nand closing that gap subsumes it -- but carry the specific observation into that\nticket, because \"records mode is silent\" does not predict \"the same cell answers\nEnter and drops Tab\".\n","notes":"TUI HALF FIXED in PR #135 (merge 42ec30d). LEFT OPEN: the browser half is this\nticket's own subject and is blocked -- see below.\n\nIT REPRODUCED ONLY PARTLY, AND THE SPLIT MATTERS. The headline (\"silently\ndiscards the commit\") is BROWSER-ONLY. In the TUI, driving App::handle_key on a\none-record grid [Region, +cat, Revenue, +measure] with the cursor at (0,3),\n`i` C o s t Tab gives:\n measures=[\"Revenue\",\"Cost\"] \u003c- the commit LANDED\n sel=(1,0) status=\"Added new record row\"\n rows=2 cols=5 \u003c- a record row nobody asked for\nSo the TUI KEEPS the commit and ADVANCES WRONGLY, leaving the measure just created\nunreachable. Same root cause as the browser's discard, seen from the other end.\n\nTHE CAUSE IS A STALE INDEX -- not an abort, not an ordering. AdvanceDir::Right\nreads at_bottom_right off CursorState::col_count, the PRE-COMMIT column count\n(apply_effects rebuilds only after the batch, gotcha 23). A +measure commit\nINSERTS A COLUMN AT THE CURSOR'S OWN INDEX and pushes + one right, exactly as +cat\ndoes, so the cursor is no longer at the right edge. Enter survived ONLY BY\nACCIDENT: EnterAdvance aborts at bottom-right, and the SetSelected restore already\nat the head of the batch held the cursor.\n\nTHE MOST VALUABLE FINDING: context/code/command.md ARGUED THE BUG INTO EXISTENCE,\nand both clauses were false. Verified verbatim on main before the fix:\n \"The measure + shares none of this, and not by luck -- it is the last column,\n so there is no column to its right for an index to shift into, and both\n advances are already special-cased at the bottom-right edge.\"\nThe column that shifts is +measure ITSELF. And \"already special-cased at the\nbottom-right edge\" NAMES THE BUG, not a safeguard. Corrected in the PR with the\ncorrection recorded rather than quietly patched.\n\nTHE DISCRIMINATOR IS THE BUFFER, NOT THE COLUMN, and this decided the fix's shape:\n+measure is ALWAYS the rightmost column, so `cursor.col \u003e= col_max` in records\nmode names THAT ONE CELL AND NO OTHER -- the entire \"Tab at the bottom-right\nstarts the next record\" bulk-entry contract is reachable only by tabbing onto the\n+ with an EMPTY buffer. A guard keyed on the column alone fixes this ticket and\nSILENTLY DELETES BULK ENTRY. The injected rival demonstrates exactly that: dropping\nthe value-awareness passes the new test and fails\ntab_on_an_empty_new_measure_column_still_starts_a_record_row.\n\nWHY THE BROWSER HALF STAYS OPEN AND IS BLOCKED: resolve.rs::create_column early-\nreturns for CreateKind::Category, but CreateKind::Measure falls through to\nadvanced(), whose bottom-right None propagates out through commit -\u003e composed -\u003e\nUpstream, DISCARDING THE WHOLE COMMAND VECTOR -- strictly worse than the host's\nmisbehaviour. But that path is NOT REACHABLE at this head: improvise-bd9o put the\nrecords guard at the entrance of edit_or_drill and its CellId::Create arm declines\nwith not_yet(\"creating a column\"), so the editor never opens on a creating column\nand create_column is dead code. A browser regression test cannot be written before\nimprovise-x0xd's work -- THERE IS NO DOOR TO DRIVE IT THROUGH.\n\nUSER-VISIBLE CHANGE BEYOND THE TICKET'S LETTER, FLAGGED FOR ED: Enter on +measure\npreviously re-entered the editor there; it now behaves exactly like +cat, landing\non the created column in normal mode. That is a deliberate consequence of one\ntruthful predicate at one call site, and command.md already argued the two +\ncolumns are symmetric -- but it is a behaviour change and should be confirmed.\nAlso: +cat with an EMPTY buffer now falls through to the advance instead of\nstaying put (it creates no column, so the \"staying put lands on the inserted\ncolumn\" rationale does not apply). No test pinned the old behaviour either way.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:39:55Z","created_by":"Edward Langley","updated_at":"2026-09-22T22:51:09Z","labels":["browser","bug","parity"],"dependencies":[{"issue_id":"improvise-0ygd","depends_on_id":"improvise-x0xd","type":"blocks","created_at":"2026-09-22T22:51:22Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-0ygd","title":"[bug] Tab on the +measure column silently discards the commit that Enter accepts","description":"DEMONSTRATED at 1f28868.\n\n PROBE +measure at (0,4), extent GridExtent { rows: 1, cols: 5 }\n PROBE Tab on +measure, the LAST row:\n browser = Upstream (silent)\n tui = doc_changed true, status \"Added new record row\"\n\na_locally_committed_creating_column_agrees_with_the_host passes for\nCreateKind::Measure -- it presses ENTER. Press TAB on the very same cell and:\n\n - resolve.rs::advanced() opens with\n if matches!(advance, Advance::Right) \u0026\u0026 state.grid.records\n \u0026\u0026 row \u003e= row_max \u0026\u0026 col \u003e= col_max { return None; }\n - None propagates out of create_column -\u003e commit -\u003e composed -\u003e Upstream, so\n the WHOLE COMMIT is discarded, not just the advance. The typed measure name\n is lost silently.\n - CommitAndAdvance::execute (cmd/commit.rs) takes the\n at_bottom_right \u0026\u0026 ctx.is_records_mode() branch instead: runs AddRecordRow,\n moves to (row+1, 0) and re-enters the editor. That is the bulk-entry Tab\n contract (see the records-mode Tab memory: Tab always advances to the next\n useful place).\n\n+measure is ALWAYS the last column, so col \u003e= col_max holds for it by\nconstruction. This fires on every single-row records grid and on the last row of\nany records grid.\n\nThis sits inside the already-filed \"records mode is silent in the browser\" gap,\nand closing that gap subsumes it -- but carry the specific observation into that\nticket, because \"records mode is silent\" does not predict \"the same cell answers\nEnter and drops Tab\".\n","notes":"TUI HALF FIXED in PR #135 (merge 42ec30d). LEFT OPEN: the browser half is this\nticket's own subject and is blocked -- see below.\n\nIT REPRODUCED ONLY PARTLY, AND THE SPLIT MATTERS. The headline (\"silently\ndiscards the commit\") is BROWSER-ONLY. In the TUI, driving App::handle_key on a\none-record grid [Region, +cat, Revenue, +measure] with the cursor at (0,3),\n`i` C o s t Tab gives:\n measures=[\"Revenue\",\"Cost\"] \u003c- the commit LANDED\n sel=(1,0) status=\"Added new record row\"\n rows=2 cols=5 \u003c- a record row nobody asked for\nSo the TUI KEEPS the commit and ADVANCES WRONGLY, leaving the measure just created\nunreachable. Same root cause as the browser's discard, seen from the other end.\n\nTHE CAUSE IS A STALE INDEX -- not an abort, not an ordering. AdvanceDir::Right\nreads at_bottom_right off CursorState::col_count, the PRE-COMMIT column count\n(apply_effects rebuilds only after the batch, gotcha 23). A +measure commit\nINSERTS A COLUMN AT THE CURSOR'S OWN INDEX and pushes + one right, exactly as +cat\ndoes, so the cursor is no longer at the right edge. Enter survived ONLY BY\nACCIDENT: EnterAdvance aborts at bottom-right, and the SetSelected restore already\nat the head of the batch held the cursor.\n\nTHE MOST VALUABLE FINDING: context/code/command.md ARGUED THE BUG INTO EXISTENCE,\nand both clauses were false. Verified verbatim on main before the fix:\n \"The measure + shares none of this, and not by luck -- it is the last column,\n so there is no column to its right for an index to shift into, and both\n advances are already special-cased at the bottom-right edge.\"\nThe column that shifts is +measure ITSELF. And \"already special-cased at the\nbottom-right edge\" NAMES THE BUG, not a safeguard. Corrected in the PR with the\ncorrection recorded rather than quietly patched.\n\nTHE DISCRIMINATOR IS THE BUFFER, NOT THE COLUMN, and this decided the fix's shape:\n+measure is ALWAYS the rightmost column, so `cursor.col \u003e= col_max` in records\nmode names THAT ONE CELL AND NO OTHER -- the entire \"Tab at the bottom-right\nstarts the next record\" bulk-entry contract is reachable only by tabbing onto the\n+ with an EMPTY buffer. A guard keyed on the column alone fixes this ticket and\nSILENTLY DELETES BULK ENTRY. The injected rival demonstrates exactly that: dropping\nthe value-awareness passes the new test and fails\ntab_on_an_empty_new_measure_column_still_starts_a_record_row.\n\nWHY THE BROWSER HALF STAYS OPEN AND IS BLOCKED: resolve.rs::create_column early-\nreturns for CreateKind::Category, but CreateKind::Measure falls through to\nadvanced(), whose bottom-right None propagates out through commit -\u003e composed -\u003e\nUpstream, DISCARDING THE WHOLE COMMAND VECTOR -- strictly worse than the host's\nmisbehaviour. But that path is NOT REACHABLE at this head: improvise-bd9o put the\nrecords guard at the entrance of edit_or_drill and its CellId::Create arm declines\nwith not_yet(\"creating a column\"), so the editor never opens on a creating column\nand create_column is dead code. A browser regression test cannot be written before\nimprovise-x0xd's work -- THERE IS NO DOOR TO DRIVE IT THROUGH.\n\nUSER-VISIBLE CHANGE BEYOND THE TICKET'S LETTER, FLAGGED FOR ED: Enter on +measure\npreviously re-entered the editor there; it now behaves exactly like +cat, landing\non the created column in normal mode. That is a deliberate consequence of one\ntruthful predicate at one call site, and command.md already argued the two +\ncolumns are symmetric -- but it is a behaviour change and should be confirmed.\nAlso: +cat with an EMPTY buffer now falls through to the advance instead of\nstaying put (it creates no column, so the \"staying put lands on the inserted\ncolumn\" rationale does not apply). No test pinned the old behaviour either way.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:39:55Z","created_by":"Edward Langley","updated_at":"2026-09-22T22:51:09Z","labels":["browser","bug","parity"],"dependencies":[{"issue_id":"improvise-0ygd","depends_on_id":"improvise-6jk","type":"parent-child","created_at":"2026-09-25T01:04:38Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-0ygd","depends_on_id":"improvise-x0xd","type":"blocks","created_at":"2026-09-22T22:51:22Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-wk18","title":"[bug] the advertised-key guard cannot see help.rs literal() lines, so 17 keys are unchecked","description":"DEMONSTRATED at 1f28868.\n\nevery_advertised_command_answers_in_its_own_mode iterates help::advertised(\u0026mode),\nwhich yields (command, args, caption) triples. help.rs ALSO emits 17 literal(..)\nlines naming a KEY STRING and no command, so the guard cannot see them.\nweb/app.js paintHelp renders them verbatim.\n\nOf the 17:\n gg, yy, ZZ -- all three are prefix keys, all three silent in the browser\n :wq :q :q! :import :add-cat :add-item :formula :add-view :set :help and the\n rest -- the whole ex-command reference page; the browser declines at the door,\n so it says something, but it renders a full page of commands it cannot run.\n\nSo the browser paints a help overlay in which, conservatively, an entire page\nplus three scattered lines are inert, and the test that exists to stop exactly\nthat cannot see any of it.\n\nThis is the cheapest of the parity findings and it PROTECTS THE OTHERS: it is\nthe mechanism this repo uses to stop black holes, and it looks away by\nconstruction from the two lines that the gg and paste tickets are about. Without\nit the next gg lands the same way and nothing notices.\n\nFix: either give literal() a command name so the guard can reach it, or add a\nsecond guard over the help pages.\n","notes":"BLOCKED AND RE-SCOPED 2026-09-21 by the keymap agent (PR #112), which took improvise-636d and deliberately did NOT half-do this one. Its reasoning and its proposed design, which is worth more than the ticket had:\n\nWHY IT COULD NOT BE DONE FROM THE KEYMAP GROUND:\n - the guard needs resolve() AND the `furnished` fixture, both private to resolve.rs's mod tests. Duplicating the fixture elsewhere is the disease this repo names repeatedly.\n - the HelpLine reshape both tickets call for breaks the struct patterns at improvise-wasm-client/src/render.rs:346 and src/ui/help.rs:72 -- NEITHER uses `..`, so the reshape is a compile error in a file another agent owns.\n\nTHE PROPOSED SHAPE, designed but not written: keep HelpLine FLAT and PUBLIC, and introduce an internal\n\n HelpRow {\n Keys { mode, command, args, description },\n Prefix { mode, prefix, then, .. },\n Ex { line, .. },\n Prose (HelpLine),\n }\n\nthat pages() RENDERS into HelpLine. One source, no renderer edit, and the guard walks the rows rather than the rendered lines.\n\nThat satisfies uln5's stated blocking cost -- \"carry command/mode/args onto the variant\" -- WITHOUT touching render.rs, which is what made it look expensive.\n\nIT ALSO SPLITS wk18's 17 literal() lines honestly rather than treating them as one lump:\n - gg / yy / ZZ are checkable via Keymaps::prefix_keymap\n - the 14 ex-commands are checkable against the registry from improvise-command's tests\n\nSo the two beads are one change to HelpRow plus two different guards, not one guard over a uniform list.\n\nNOTE THE GROUND THIS NEEDS: resolve.rs's test module, help.rs, and a test that can reach the registry. That is a different partition from the one this wave used, so schedule it with whoever owns resolve.rs rather than with the keymap.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-helpkeys2","owner":"el-github@elangley.org","created_at":"2026-09-21T20:39:19Z","created_by":"Edward Langley","updated_at":"2026-09-23T05:44:50Z","started_at":"2026-09-23T03:55:04Z","closed_at":"2026-09-23T05:44:50Z","close_reason":"Fixed in PR #180, merged after adversarial review (content reviewed at 544744c; the tip-removal delta and the evidence-only merges were reviewed at f248827). Follow-ups: improvise-1c5m, improvise-17ak.","labels":["bug","parity","testing"],"dependencies":[{"issue_id":"improvise-wk18","depends_on_id":"improvise-uln5","type":"related","created_at":"2026-09-22T02:27:15Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-lut7","title":"[bug] p is composed by the browser and can never do anything, so the name-level derivation lies","description":"DEMONSTRATED at 1f28868.\n\n PROBE yank 'y': browser = Upstream (silent), twice -- y is a Binding::Prefix\n PROBE yank: tui = yanked Some(Number(1200.0)), status \"Yanked\"\n PROBE p: browser = Ignored (silent)\n\ny is Binding::Prefix, and resolve() maps EVERY prefix to Upstream\nunconditionally. Command::SetYanked exists on the wire (reduce.rs:32, folded at\n:212) and NOTHING in resolve.rs composes it. So ViewState::yanked is permanently\nNone in a browser, and composed(\"paste\") returns Some(vec![]) -\u003e\nKeyOutcome::Ignored -\u003e silence.\n\nTHE REASON TO FILE THIS RATHER THAN SHRUG: paste appears on NEITHER the declined\nlist NOR the host-needed list. Every mechanical derivation of \"what the browser\ncan do\" -- including the_command_names_that_still_need_the_host_are_these --\nreports it as SUPPORTED. It is dead, and it is dead for a STATE reason that\nname-level derivations cannot see. That is this repo's most-cared-about failure\nmode: a green derivation over a broken thing.\n\nhelp.rs:546-554 advertises yy and p together as a copy/paste pair, so the browser\npaints a help page promising both.\n\nFix is one arm -- \"yank\" =\u003e Some(vec![Command::SetYanked(..)]), and the cache\nalready holds the value via cell_at_cursor().edit_text -- plus making prefixes\nresolvable. OR, if prefixes stay upstream, move p onto declined() so the\nderivation tells the truth. Either is acceptable; leaving the derivation lying\nis not.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-web","owner":"el-github@elangley.org","created_at":"2026-09-21T20:39:18Z","created_by":"Edward Langley","updated_at":"2026-09-23T03:29:43Z","started_at":"2026-09-23T02:51:09Z","closed_at":"2026-09-23T03:29:43Z","close_reason":"Merged in PR #161. Closed by the orchestrator after the session handover; the implementing agent's claim was abandoned when its process exited. Details of the fix, the rival injected and what the ticket did not predict are on the PR thread.","labels":["browser","bug","parity"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-7iww","title":"Cached circular formula publishes the last iteration as a valid number","description":"Report-only hunt at 70025e2. No implementation changes.\n\nLocation: crates/improvise-core/src/model/types.rs:549-571,624-641\n\nStore Revenue=10, add Revenue=Revenue+1. Direct Model::evaluate correctly returns an error. After recompute_formulas, evaluate_aggregated(OverHidden) returns Number(26). The cached evaluator seeds from raw data then iterates 16 times without convergence and leaves the last numeric result in formula_cache. This is the displayed path because default views hide virtual categories. On a workbook with an explicit stored measure target, a circular formula looks like a real calculated value. Separate from closed improvise-ubu, whose depth guard prevents recursive stack overflow; that guard does not run in the cached reference path. Mark nonconverging results as errors and use this direct/cached disagreement to drive a shared evaluator-policy refactor. Duplicate audit: circular, fixed, full descriptions; read improvise-ubu.\n\n## Steps to Reproduce\nStandalone Rust 2024 package with improvise and improvise-io path dependencies:\n```rust\nuse improvise_io::workbook::Workbook;\nfn revenue_book()-\u003eWorkbook {\n use improvise_io::model::cell::{CellKey,CellValue};\n let mut w=Workbook::new(\"T\");w.add_category(\"Region\").unwrap();w.add_item(\"Region\",\"East\").unwrap();w.add_item(\"_Measure\",\"Revenue\").unwrap();\n w.model.set_cell(CellKey::new(vec![(\"Region\".into(),\"East\".into()),(\"_Measure\".into(),\"Revenue\".into())]),CellValue::Number(10.0));w\n}\n#[test] fn circular_formula_not_a_plausible_cached_number() {\n use improvise_io::{formula::parse_formula,model::{Aggregation,cell::{CellKey,CellValue}}};\n let mut w=revenue_book();w.model.add_formula(parse_formula(\"Revenue = Revenue + 1\",\"_Measure\").unwrap());\n let key=CellKey::new(vec![(\"Region\".into(),\"East\".into()),(\"_Measure\".into(),\"Revenue\".into())]);\n assert!(matches!(w.model.evaluate(\u0026key),Some(CellValue::Error(_))));\n w.model.recompute_formulas(\u0026[\"_Index\".into(),\"_Dim\".into()]);\n let actual=w.model.evaluate_aggregated(\u0026key,Aggregation::OverHidden);\n assert!(matches!(actual,Some(CellValue::Error(_))),\"circular formula became {actual:?}\");\n}\n```\nRun cargo test circular_formula_not_a_plausible_cached_number -- --nocapture. Current source failed this test.\n\n## Acceptance Criteria\nThe focused regression passes without weakening the expected behavior; ordinary cases still agree, with both debug and release arithmetic considered.\n","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:37:40Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:58:29Z","closed_at":"2026-09-21T21:58:29Z","close_reason":"Fixed in the 2026-09-21 wave, integrated into main at 1574 passed / 0 failed / 7 ignored, all gates green, every guard fault-injected.\n\nrecompute_formulas stopped at MAX_EVAL_DEPTH and left the 16th iteration in formula_cache. It now records the keys that moved on each pass and, if the last pass still moved any, writes Error(\"circular\") over them. Both paths checked: evaluate was already correct; the cached evaluate_aggregated(OverHidden) was the broken one and is the DISPLAYED path, because default views put the virtual categories on Axis::None -- so a circular formula looked like a real calculated value.\n\nThe cost, and why it is right: the fixed point can only CONFIRM convergence with a pass that changes nothing, so a 16-deep chain is now reported circular -- the same bound evaluate_depth already enforces, so the two evaluators AGREE rather than diverge.","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -278,11 +353,11 @@ {"_type":"issue","id":"improvise-zv8t","title":"fit_measure never tells preprocess which columns are categories, so a numeric- or date-valued category is unspellable","description":"fit_measure never tells preprocess which columns are categories, so a numeric- or date-valued category is unspellable\n\nFound reviewing improvise-wd6n's branch, and it was very nearly foreclosed in\nprose: the first draft of context/code/gp.md §11 called Artefact::NumericCategory\na permanent refusal that \"no vocabulary closes\". That is wrong, and this is the\nticket the prose would have prevented.\n\ncrates/improvise-gp/src/fit.rs passes `\u0026Overrides::default()` to `preprocess`,\nso it never tells preprocess which header columns are document categories --\nalthough the very next line does tell `Features::of`. `preprocess` then infers\nNumeric -\u003e Date -\u003e Categorical from the cell values, so:\n\n * a regular category whose items all parse as numbers (a `Year` category with\n items 2024/2025) becomes ColumnKind::Numeric, and its feature is refused as\n Artefact::NumericCategory\n * a regular category whose items are all ISO dates becomes ColumnKind::Date,\n and its features are refused as Artefact::DatePart\n\nIn both cases `Year IS 2024` is EXACTLY spellable, and the fit is refused\nanyway. Unlike the two families improvise-wd6n declines, this needs no\nformula-language change at all: `Overrides::force[col] = Forced::Categorical`\nalready exists (gp-tree/src/data.rs, consumed in preprocess's `match ov.force`)\nand is the knob. fit.rs knows which columns are categories -- it computes\n`category_columns` on the line above.\n\nThe refusal text for NumericCategory argues about `Expr::Ref` resolving to a\n_Measure item of the same name, which was never the right spelling for a\ncategory column. It also sits awkwardly beside this crate's own rule (gotcha\n37(d), gp.md §11: the check asks the document, never the name's shape) and\nbeside a_coordinate_is_spelled_as_one_even_when_an_item_shares_the_one_hots_name,\nwhich exists precisely to show the two spellings cannot collide.\n\nNeither NumericCategory nor AbsentCoordinate has a test today; they were\ninvented by the wd6n branch and only three of the five families are reached by\nits fixture.\n\n## Acceptance\n\nA fit over a view with a numeric-valued or date-valued regular category emits a\n`Cat IS Item` coordinate for it rather than being refused, demonstrated by a\ntest that fails before the fix. Whatever forcing is applied must not change how\na MEASURE column is inferred -- a numeric measure is still Numeric. If forcing\nturns out to be wrong for some reason not visible from here, say so in\ncontext/code/gp.md and correct the \"permanent refusal\" paragraph either way.\n","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T05:05:31Z","created_by":"Edward Langley","updated_at":"2026-09-21T11:09:28Z","closed_at":"2026-09-21T11:09:28Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-t6db","title":"grid_in ignored prune_empty for a non-active view, hiding a staleness bug","description":"FIXED in PR #78; filed because the FINDING is worth more than the fix and because the shape has now recurred three times.\n\nproject::grid_in built a layout for a view the host is NOT on with GridLayout::new, which does not apply prune_empty. So a client on another view was projected rows its own view config prunes. Measured on a two-view fixture where both views prune and one row is empty:\n\n Second via grid_in (GridLayout::new) 3 rows: North, Service, South\n Second via with_frozen_records (pruned) 2 rows: North, South\n\nOne line. It broke no other test, which is itself evidence nothing depended on the unpruned behaviour.\n\nWHY IT MATTERS MORE THAN ITS SIZE -- IT WAS A BUG HIDING A BUG. The adversarial review of #78 found a P1: after improvise-mcq4 narrowed the cursor fingerprint to the active view, a CellData write that prunes a row out of ANOTHER client's view stopped invalidating that client. Writing the regression test for it FAILED AT THE PRECONDITION -- the bystander's projected grid never lost a row, because grid_in never pruned it. The staleness was real and logically reachable, and completely unobservable through the projection. Fixing grid_in is what made the P1 demonstrable, and the P1 test is what found grid_in.\n\nTHIRD INSTANCE OF ONE TRAP: GridLayout::new does not prune; GridLayout::with_frozen_records does. Previously bitten by App::view_frame (improvise-ni6k, caught by a failing test) and by the reshape benchmark's geometry fixture (improvise-mcq4, caught only by measuring). A constructor whose name suggests it is the general one, and which silently omits a view setting, is worth a look on its own terms -- either new should prune, or the names should say which is which.\n\nSTILL OPEN AROUND IT, not fixed here: nothing asserts that a client on a non-active view is projected a grid matching ITS OWN view config in general. prune_empty is one setting; the rest is a guarantee by inspection rather than by test.\n","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T05:00:08Z","created_by":"claude-agent-protocol","updated_at":"2026-09-21T12:39:35Z","closed_at":"2026-09-21T12:39:35Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-ijql","title":"check-comment-runs.py --check-evidence silently skips the comment-run cap it is named for","description":"UPDATE 2026-09-21, after PR #83 merged: the DOCS half is done — the code-review skill's gate list and every wave brief now run both spellings. The CODE half is NOT done and this ticket is about that: scripts/check-comment-runs.py:1010 still early-returns on args.check_evidence, so the flag SELECTS rather than adds and the flagged run still says nothing about the cap it skipped.\n\nThree agents hit this independently in one campaign, which is the argument for fixing it rather than documenting around it. The reviewer's position was that CI runs all three forms (ci.yml L144/146/148) so this is documentation only; the counter is that the flagged form is the EXPENSIVE one (it needs a compiled workspace), which makes it the one a human runs alone, and a gate whose skip is silent is the defect class this repo keeps finding.\n\nTwo shapes: make --check-evidence additive (run the cap too), or make the flagged run print one line saying the cap did not run. The second is cheaper and matches what PR #83 did for --rust-tests, where the fix was to make the success line name its source.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T04:31:48Z","created_by":"Edward Langley","updated_at":"2026-09-21T05:23:49Z","closed_at":"2026-09-21T05:23:49Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-mcq4","title":"The frame fingerprint is a 4x regression on the held-key path improvise-748f just optimized","description":"MEASURED, not suspected. Found while merging PR #78 (improvise-ni6k) with PR #76 (improvise-748f), which landed first.\n\nimprovise-748f split the geometry fingerprint so that a batch which writes NO cell -- the held-key path, [/] and axis cycle and hide/show -- stays O(config) instead of walking 18,000 cells. improvise-ni6k's App::view_frame builds a GridLayout per view via with_frozen_records, and with_frozen_records applies prune_empty, and prune_empty WALKS CELLS. So for a view with prune_empty on, ni6k puts the O(cells) walk straight back onto the path 748f had just taken it off.\n\nMEASURED on the merged tree, benches/reshape_observation.rs, one CycleAxis batch, prune_empty ON, frame fingerprint disabled vs enabled:\n\n 400 cells 93.6us -\u003e 283.2us 3.0x\n 9,600 cells 1.97ms -\u003e 7.34ms 3.7x\n 18,000 cells 3.04ms -\u003e 12.41ms 4.1x\n 90,000 cells 28.5ms -\u003e 91.7ms 3.2x\n\nThe 16.7ms frame budget is crossed at 18,000 cells, where the pre-fingerprint tree sat comfortably inside it at 3.0ms.\n\nWHY 3-4x AND NOT 2x: view_frames builds every view's layout, before AND after each model effect. The bench fixture has two views, so that is four pruned layout builds against the one rebuild_layout already does.\n\nWHY THE COST WAS NOT SEEN EARLIER: ni6k's own benchmark measures a SetCell batch, where 748f's document_content walk dominates and the fingerprint reads as +2 to +5 percent. And the geometry case, when first added, used the dense_app fixture as-is -- prune_empty OFF -- where with_frozen_records does not walk cells at all and a CycleAxis batch reads 17-176us. THAT FIXTURE MEASURES THE CHEAP HALF AND CALLS IT THE COST. It is the same non-discriminating-fixture failure this repo names everywhere; the numbers above come from turning prune_empty on, and the benchmark now does.\n\nOPTIONS, none taken:\n- Fingerprint only the ACTIVE view for the cursor and fall back to the cheaper config hash (last_reshaped_views) for the client audience. Over-resets bystanders on other views, which is the safe direction per gotcha 35, and removes the per-view multiplier. Probably the right answer.\n- Memoise the pruned layout across the before/after pair.\n- Accept, on the argument that prune_empty plus a large document plus a held key is rare. That argument needs the user, not an agent.\n\nDO NOT close this by re-running the benchmark with prune_empty off.","notes":"FIXED to 1.9x, not closed: one pruned build per model effect remains.\n\nThe coordinator ruled this must be fixed before PR #78 lands, and was right -- a cost measured to four significant figures is a decision to ship, not a thing to file.\n\nWHAT WORKED, AND IT IS NOT WHAT THE TICKET PROPOSED. The ticket's first option (and my own suggestion) was to fingerprint only the ACTIVE view and fall back to the config hash for the client audience. Implemented and measured: it bought NOTHING -- 12.41ms -\u003e 12.56ms at 18,000 cells. The bench's second view is one created fresh with everything on Axis::None, so building it costs nothing. THE PER-VIEW MULTIPLIER WAS NEVER THE COST; two EXPENSIVE builds were. That is only known because it was measured after implementing rather than reasoned about before.\n\nWHAT ACTUALLY WORKED: the opening basis is free. At batch start App::layout already IS the active view's pruned layout, so opening_view_frame() hashes it instead of building a second one. frame_of() is the shared hash and view_frame() builds then calls it.\n\n one CycleAxis batch, prune_empty ON:\n before after fingerprint off\n 400 cells 283.2us 167.0us 93.6us\n 9,600 cells 7.34ms 3.879ms 1.967ms\n 18,000 cells 12.41ms 5.752ms 3.043ms\n 90,000 cells 91.69ms 56.54ms 28.52ms\n\n4.1x -\u003e 1.9x at the canonical size, back inside the 16.7ms frame budget.\n\nTwo guards on the free basis: it is gated on drill_state.is_none(), because a drill freezes the record list and the held layout is then not what view_frame would build; and it is opening-use only, because App::layout is stale mid-batch (gotcha 23).\n\nTHE NARROWING WAS KEPT anyway, because it is the honest shape -- the cursor's question is about one view. The audience half is now the config diff observe_reshape already computes free. It over-reports (safe direction) and is read only behind the epoch gate in adopt_cursor_epoch, so its imprecision cannot reach a batch that reframed nothing. a_reshape_of_one_view_leaves_a_client_on_another_view_alone still passes, so it has not widened into improvise-nynk's negative arm.\n\nBOTH SUBSUMPTION PROOFS RE-RUN AFTER THE NARROWING, since narrowing is exactly what could quietly stop covering something: #76's own two arms still pass with no per-effect reset anywhere; AddFormula/RemoveFormula and SortData are still covered; disabling the narrowed observation fails ten tests.\n\nWHAT REMAINS, AND WHY THIS STAYS OPEN: one pruned layout build per model effect. The LAST one duplicates work rebuild_layout does immediately afterwards, and eliding it means moving the check after the loop -- which is the position that clobbers a deliberate cursor placement in the same batch (the three records-mode tests that caught it the first time). A restructure that knows which effect is last, including on abort, is possible and was not attempted at this hour.\n\nThe benchmark now carries the held-key case with prune_empty ON and a comment saying not to turn it off, because that fixture is the only reason any of this was visible.\nTHE NARROWING BOUGHT ITS SPEED PARTLY WITH CORRECTNESS, and the adversarial review of PR #78 caught it. Correcting my own note above.\n\nI narrowed the frame fingerprint from every view to the active one, measured that it moved 12.41ms to 12.56ms, kept it anyway \"as the honest shape\", and reported that it bought nothing. It bought a P1: a CellData write that prunes a row out of ANOTHER client's view became invisible. view_shapes is config-only -- it hashes the prune_empty FLAG, not which rows survive -- so last_reshaped_views is empty, the one-view frame covers only the host's, and the bystander is never sent ResetCursor while its grid loses a row. Gotcha 24's permanently-stale case.\n\nTHE LESSON I WANT ON THIS TICKET: a measurement showing a change is free is not evidence that it is correct. I answered \"what does this cost\" and reported the answer as though it had also answered \"is this right\". The two questions were asked in the wrong order and only one of them was asked at all.\n\nTHE REPAIR, and the cost trade is accepted deliberately rather than hidden:\n- LayoutGeometry arm keeps the cheap one-view frame. view_mut() writes only the active view, and the structural cases (AddCategory, RemoveCategory, Add/RemoveItem) are already covered by view_shapes.\n- CellData arm restores the all-views frame, because a cell write can prune a row out of any view.\n\n Measured, my own numbers, one batch, prune_empty ON:\n held-key (CycleAxis) 18k cells 5.69ms -- UNCHANGED, 0%\n SetCell 18k cells 10.66ms -\u003e 11.26ms +5.7%\n\nThe +5.7% scales LINEARLY IN VIEW COUNT on the CellData arm. A ten-view document pays it ten times. That is the trade and it is accepted, not hidden.\n\nTHE ABSOLUTE, stated plainly because it should not be discovered later: even after the mcq4 fix, this PR roughly DOUBLES held-key latency on large pruned documents -- 3.04ms to 5.69ms at 18,000 cells -- and 56ms at 90,000 cells is 3.4 frames. The 16.7ms budget is respected at the canonical size and is not respected at the pathological one, which was also true before at 28.5ms.\n\nA SECOND BUG FOUND WHILE WRITING THE REGRESSION TEST, filed as improvise-t6db: the test's precondition failed because grid_in built a non-active view with GridLayout::new, which does not prune. The bystander's projected grid never lost a row, so the P1 was unobservable -- a bug hiding a bug.\n\nSTILL OPEN, unchanged: one pruned build per model effect. The last duplicates rebuild_layout and can only be elided by moving the check after the loop, which clobbers a deliberate cursor placement in the same batch. With the arms now split, moving the LayoutGeometry arm alone after the loop fails 2 tests; the reviewer measured 4 against the unified arm before the split.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T04:20:08Z","created_by":"claude-agent-protocol","updated_at":"2026-09-21T05:00:28Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-mcq4","title":"The frame fingerprint is a 4x regression on the held-key path improvise-748f just optimized","description":"MEASURED, not suspected. Found while merging PR #78 (improvise-ni6k) with PR #76 (improvise-748f), which landed first.\n\nimprovise-748f split the geometry fingerprint so that a batch which writes NO cell -- the held-key path, [/] and axis cycle and hide/show -- stays O(config) instead of walking 18,000 cells. improvise-ni6k's App::view_frame builds a GridLayout per view via with_frozen_records, and with_frozen_records applies prune_empty, and prune_empty WALKS CELLS. So for a view with prune_empty on, ni6k puts the O(cells) walk straight back onto the path 748f had just taken it off.\n\nMEASURED on the merged tree, benches/reshape_observation.rs, one CycleAxis batch, prune_empty ON, frame fingerprint disabled vs enabled:\n\n 400 cells 93.6us -\u003e 283.2us 3.0x\n 9,600 cells 1.97ms -\u003e 7.34ms 3.7x\n 18,000 cells 3.04ms -\u003e 12.41ms 4.1x\n 90,000 cells 28.5ms -\u003e 91.7ms 3.2x\n\nThe 16.7ms frame budget is crossed at 18,000 cells, where the pre-fingerprint tree sat comfortably inside it at 3.0ms.\n\nWHY 3-4x AND NOT 2x: view_frames builds every view's layout, before AND after each model effect. The bench fixture has two views, so that is four pruned layout builds against the one rebuild_layout already does.\n\nWHY THE COST WAS NOT SEEN EARLIER: ni6k's own benchmark measures a SetCell batch, where 748f's document_content walk dominates and the fingerprint reads as +2 to +5 percent. And the geometry case, when first added, used the dense_app fixture as-is -- prune_empty OFF -- where with_frozen_records does not walk cells at all and a CycleAxis batch reads 17-176us. THAT FIXTURE MEASURES THE CHEAP HALF AND CALLS IT THE COST. It is the same non-discriminating-fixture failure this repo names everywhere; the numbers above come from turning prune_empty on, and the benchmark now does.\n\nOPTIONS, none taken:\n- Fingerprint only the ACTIVE view for the cursor and fall back to the cheaper config hash (last_reshaped_views) for the client audience. Over-resets bystanders on other views, which is the safe direction per gotcha 35, and removes the per-view multiplier. Probably the right answer.\n- Memoise the pruned layout across the before/after pair.\n- Accept, on the argument that prune_empty plus a large document plus a held key is rare. That argument needs the user, not an agent.\n\nDO NOT close this by re-running the benchmark with prune_empty off.","notes":"FIXED to 1.9x, not closed: one pruned build per model effect remains.\n\nThe coordinator ruled this must be fixed before PR #78 lands, and was right -- a cost measured to four significant figures is a decision to ship, not a thing to file.\n\nWHAT WORKED, AND IT IS NOT WHAT THE TICKET PROPOSED. The ticket's first option (and my own suggestion) was to fingerprint only the ACTIVE view and fall back to the config hash for the client audience. Implemented and measured: it bought NOTHING -- 12.41ms -\u003e 12.56ms at 18,000 cells. The bench's second view is one created fresh with everything on Axis::None, so building it costs nothing. THE PER-VIEW MULTIPLIER WAS NEVER THE COST; two EXPENSIVE builds were. That is only known because it was measured after implementing rather than reasoned about before.\n\nWHAT ACTUALLY WORKED: the opening basis is free. At batch start App::layout already IS the active view's pruned layout, so opening_view_frame() hashes it instead of building a second one. frame_of() is the shared hash and view_frame() builds then calls it.\n\n one CycleAxis batch, prune_empty ON:\n before after fingerprint off\n 400 cells 283.2us 167.0us 93.6us\n 9,600 cells 7.34ms 3.879ms 1.967ms\n 18,000 cells 12.41ms 5.752ms 3.043ms\n 90,000 cells 91.69ms 56.54ms 28.52ms\n\n4.1x -\u003e 1.9x at the canonical size, back inside the 16.7ms frame budget.\n\nTwo guards on the free basis: it is gated on drill_state.is_none(), because a drill freezes the record list and the held layout is then not what view_frame would build; and it is opening-use only, because App::layout is stale mid-batch (gotcha 23).\n\nTHE NARROWING WAS KEPT anyway, because it is the honest shape -- the cursor's question is about one view. The audience half is now the config diff observe_reshape already computes free. It over-reports (safe direction) and is read only behind the epoch gate in adopt_cursor_epoch, so its imprecision cannot reach a batch that reframed nothing. a_reshape_of_one_view_leaves_a_client_on_another_view_alone still passes, so it has not widened into improvise-nynk's negative arm.\n\nBOTH SUBSUMPTION PROOFS RE-RUN AFTER THE NARROWING, since narrowing is exactly what could quietly stop covering something: #76's own two arms still pass with no per-effect reset anywhere; AddFormula/RemoveFormula and SortData are still covered; disabling the narrowed observation fails ten tests.\n\nWHAT REMAINS, AND WHY THIS STAYS OPEN: one pruned layout build per model effect. The LAST one duplicates work rebuild_layout does immediately afterwards, and eliding it means moving the check after the loop -- which is the position that clobbers a deliberate cursor placement in the same batch (the three records-mode tests that caught it the first time). A restructure that knows which effect is last, including on abort, is possible and was not attempted at this hour.\n\nThe benchmark now carries the held-key case with prune_empty ON and a comment saying not to turn it off, because that fixture is the only reason any of this was visible.\nTHE NARROWING BOUGHT ITS SPEED PARTLY WITH CORRECTNESS, and the adversarial review of PR #78 caught it. Correcting my own note above.\n\nI narrowed the frame fingerprint from every view to the active one, measured that it moved 12.41ms to 12.56ms, kept it anyway \"as the honest shape\", and reported that it bought nothing. It bought a P1: a CellData write that prunes a row out of ANOTHER client's view became invisible. view_shapes is config-only -- it hashes the prune_empty FLAG, not which rows survive -- so last_reshaped_views is empty, the one-view frame covers only the host's, and the bystander is never sent ResetCursor while its grid loses a row. Gotcha 24's permanently-stale case.\n\nTHE LESSON I WANT ON THIS TICKET: a measurement showing a change is free is not evidence that it is correct. I answered \"what does this cost\" and reported the answer as though it had also answered \"is this right\". The two questions were asked in the wrong order and only one of them was asked at all.\n\nTHE REPAIR, and the cost trade is accepted deliberately rather than hidden:\n- LayoutGeometry arm keeps the cheap one-view frame. view_mut() writes only the active view, and the structural cases (AddCategory, RemoveCategory, Add/RemoveItem) are already covered by view_shapes.\n- CellData arm restores the all-views frame, because a cell write can prune a row out of any view.\n\n Measured, my own numbers, one batch, prune_empty ON:\n held-key (CycleAxis) 18k cells 5.69ms -- UNCHANGED, 0%\n SetCell 18k cells 10.66ms -\u003e 11.26ms +5.7%\n\nThe +5.7% scales LINEARLY IN VIEW COUNT on the CellData arm. A ten-view document pays it ten times. That is the trade and it is accepted, not hidden.\n\nTHE ABSOLUTE, stated plainly because it should not be discovered later: even after the mcq4 fix, this PR roughly DOUBLES held-key latency on large pruned documents -- 3.04ms to 5.69ms at 18,000 cells -- and 56ms at 90,000 cells is 3.4 frames. The 16.7ms budget is respected at the canonical size and is not respected at the pathological one, which was also true before at 28.5ms.\n\nA SECOND BUG FOUND WHILE WRITING THE REGRESSION TEST, filed as improvise-t6db: the test's precondition failed because grid_in built a non-active view with GridLayout::new, which does not prune. The bystander's projected grid never lost a row, so the P1 was unobservable -- a bug hiding a bug.\n\nSTILL OPEN, unchanged: one pruned build per model effect. The last duplicates rebuild_layout and can only be elided by moving the check after the loop, which clobbers a deliberate cursor placement in the same batch. With the arms now split, moving the LayoutGeometry arm alone after the loop fails 2 tests; the reviewer measured 4 against the unified arm before the split.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T04:20:08Z","created_by":"claude-agent-protocol","updated_at":"2026-09-21T05:00:28Z","dependencies":[{"issue_id":"improvise-mcq4","depends_on_id":"improvise-pmt0","type":"parent-child","created_at":"2026-09-25T01:05:06Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-5ubs","title":"A cyclic-successor all() can silently omit a variant while its doc says it cannot","description":"Found by the adversarial review of PR #78, which probed it rather than reading the doc comment.\n\nAppMode::all() and Rejection::all() walk a total, cyclic after(). The claim in both doc comments is that a new variant cannot be omitted, because after() has no None arm so the only way to answer for a new variant is to NAME ANOTHER ONE.\n\nThat is half true and the half it gets wrong is the half that matters. ANSWERING after() FOR a new variant is forced by the compiler. POINTING AN EXISTING ARM AT IT IS NOT. A probe variant whose after() arm returns some existing variant, with nothing in the cycle pointing back to it, is simply never walked: all().len() came back 17, not 18, with the suite green.\n\nSo the consolidation replaced five grep-visible lists with one derivation whose totality is asserted in prose and checked by nothing. THE REVIEWER'S META-POINT IS THE REAL TICKET: five disagreeing AppMode lists were visible to grep; all() under-enumerating is visible to nothing. A consolidation owes a test that the surviving derivation is TOTAL, not merely that it agrees with what it replaced.\n\nWHAT IS STILL BETTER THAN BEFORE, so this is not a revert: the compiler now forces a human to look, and two of the five lists it replaced were ALREADY WRONG (both missing SavePrompt). This is a gap in a guarantee, not a regression.\n\nOPTIONS, roughly in order of cost:\n- A test that walks the cycle and asserts every variant is visited, driven by something independent of after() -- which needs an independent enumeration, and that is the problem restated.\n- A derive macro or a dependency (strum). Rejected during PR #78 only because adding a workspace dep mid-wave collides in Cargo.toml; on its own it is the honest answer and cheap.\n- Accept and document the residual precisely, replacing both doc comments, which currently overclaim.\n\nWHATEVER IS CHOSEN, FIX THE DOC COMMENTS FIRST: they presently say the omission is impossible, and a reader checking this property will believe them. An overclaiming comment is worse than none, because it stops the next person looking.","notes":"A FOURTH INSTANCE, inside the PR that found this ticket. The adversarial review of PR #78 added a fourteenth `pub struct` declaring `EffectKind::Model(ModelChange::LayoutGeometry)`, pointed at by nothing, and the whole suite stayed green.\n\nThe site is layout_geometry_effects_actually_change_document_config in ui/effect.rs. Its comment said \"a fourteenth declaring LayoutGeometry must come here and say so\". That is false for exactly the reason this ticket names: adding is compiler-forced only where an EXISTING arm must point at the new thing, and here nothing must. `assert_eq!(cases.len(), 13)` is a count, not a totality check -- it catches a case being deleted from the list, never one being absent from it.\n\nCorrected in #78 to say what it is rather than what it is not; the mechanism is unchanged and the cursor consolidation does not depend on it. Filed here rather than fixed because the honest fix is whatever 5ubs settles on, and a fourth hand-rolled attempt in a fourth place is the disease.\n\nWHAT MAKES THIS ONE WORTH RECORDING: it is the same defect as AppMode::all(), in the PR whose own consolidation work surfaced AppMode::all(). A list that is checked for LENGTH reads exactly like a list that is checked for COMPLETENESS, and both are written as an assert_eq over a number.\n","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T04:01:27Z","created_by":"claude-agent-protocol","updated_at":"2026-09-21T12:39:36Z","closed_at":"2026-09-21T12:39:36Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-349m","title":"format_md/parse_md is not idempotent when a .improv file has two Category sections with the same name","description":"format -\u003e parse -\u003e format differs, the two sides differing only in the order of '\u003e' group lines. DETERMINISTIC -- 8 failures in 8 runs on unmodified main (00f7678) -- so it is NOT gotcha 18's HashSet shape.\n\nCause: the file carries two '## Category: ||' sections with the SAME name. The second merges into the first and its group lines are appended in a position the second parse does not reproduce.\n\nReproducer, from the proptest's own minimal input:\n\n let file = \"v2025-04-09\\n# |\\\\a|\\n## View: a\\n_-: row\\ncollapsed: ||/A--\\n\\\n ## Category: ||\\n- |\\\\a\\\\a\\\\a|,|\\\\a\\\\a|,|\\\\a|,|\\\\a|\\n\u003e _---[|\\\\a|]\\n\u003e _[|\\\\a|]\\n\\\n ## Category: ||\\n- ||[||]\\n\u003e ||[||]\\n\";\n let m1 = parse_md(file).unwrap();\n let printed = format_md(\u0026m1);\n assert_eq!(printed, format_md(\u0026parse_md(\u0026printed).unwrap())); // fails\n\nWHY IT LOOKS FLAKY AND IS NOT: persistence::parse::grammar_prop_tests::generated_file_roundtrips goes red only when the grammar-walking generator happens to emit a duplicate section header, which is rare. It was observed once during PR #70's CI, the rerun passed, and the honest move was to take proptest's minimal input and try it deterministically rather than rerun and move on. That is what turned a 'flake' into a filed bug.\n\nFound by the improvise-pdvm/75ld agent on PR #70; not caused by that PR. bd search for idempotent, roundtrip, duplicate category, format_md and group order returned nothing, so it was not already filed.\n\nWorth deciding as part of the fix: is a duplicate '## Category:' header legal input at all? If it is not, the parser should refuse it and the generator should stop emitting it; if it is, the merge must produce a canonical group order. The round-trip property cannot hold without one of those answers.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T23:42:59Z","created_by":"orchestrator","updated_at":"2026-09-21T04:42:54Z","closed_at":"2026-09-21T04:42:54Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-v5pc","title":"restore the browser assertion that ? opens help","description":"36ade35 moved the shell suite to node and deleted harness/tests/widgets.spec.js, taking the Playwright test '? opens help, and its rows are the shared ones' with it. Nothing replaced it: no node test and no spec asserts that the help overlay is drawn, that its active tab is the shared module's page title, or that l pages it. The evidence entry in context/test-evidence/clients.md survived the deletion and went on promising the coverage for a whole release -- the improvise-5ray cost, now caught by --check-evidence, and the heading is demoted to prose rather than deleted so its two injections (dropping the paintHelp call; removing the mode != Help guard in help_rows) survive for whoever restores it. improvise-ktv1's acceptance -- a browser shows help -- is currently unasserted end to end.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T23:25:28Z","created_by":"claude-agent-scripts","updated_at":"2026-09-21T00:06:02Z","closed_at":"2026-09-21T00:06:02Z","close_reason":"Duplicate of improvise-78v6, already fixed and merged in PR #72. harness/tests/help.spec.js on main carries test(\"? opens help, and its rows carry the keys and the words, not empty frames\") plus a second test that reads an advertised key out of the DOM, presses it, and asserts the selection moved.\n\nWorth noting HOW this duplicate happened, because the filing agent did nothing wrong: it searched bd before filing, as AGENTS.md requires, and found nothing -- because the fix was in a sibling agent's UNMERGED PR at the time. A parallel wave makes in-flight work invisible to bd search. The cheap addition to the wave brief is to check open PRs, not only the tracker, before filing something a sibling unit might own.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-xfsb","title":"Agents should post to GitHub under their own identity, not the owner's","description":"Today every agent comment, review and PR goes out as @fiddlerwoaroof, because gh uses his credentials. The interim is a convention -- open each comment with a line saying a model wrote it (doctrine(an_agent_posting_to_github_says_so), AGENTS.md) -- and a convention is something an agent can forget. A separate identity makes attribution structural instead.\n\nTwo routes:\n\n1. GitHub App installed on the repo. Agents mint an installation token and export GH_TOKEN. Comments render as \u003cname\u003e[bot], the same shape as copilot-pull-request-reviewer[bot]. Scoped permissions, no seat consumed, revocable independently of the owner's account, and the bot suffix is not spoofable by a human account.\n\n2. Machine user with its own PAT as GH_TOKEN. Simpler to set up, consumes a seat on some plans, and is indistinguishable from a person at a glance -- which is most of what we are trying to fix.\n\nPrefer 1. Needs the user: create the App, grant contents+pull_requests+issues write, install it, and put the app id and private key where agents can reach them.\n\nWorth checking while doing it: whether the bot can be requested as a PR reviewer, which would let review agents post as reviews rather than as comments.\n\nOnce it lands, the attribution line becomes belt-and-braces rather than the mechanism, and the doctrine should be amended to say so rather than deleted -- a reader seeing an unattributed comment from the owner's account still needs to know it might be an agent.","notes":"DEFERRED by the user 2026-09-22. Recording the reasoning, because the status was\nset with none and the INTERIM IS LOAD-BEARING WHILE THIS SITS.\n\nWHY IT IS A FINE THING TO DEFER: route 1 needs the user to create a GitHub App,\ngrant it contents+pull_requests+issues write, install it, and put the app id and\nprivate key where agents can reach them. That is credential work only he can do,\nand nothing in the codebase is blocked on it.\n\nWHAT MUST STAY IN FORCE MEANWHILE, and this is the part that would be quietly\nlost: EVERY agent comment, review, PR body and issue still opens with a line\nnaming the model and that it is an agent posting in his session\n(doctrine(an_agent_posting_to_github_says_so), AGENTS.md). While this ticket is\ndeferred that convention is THE MECHANISM, not belt-and-braces -- an\nunattributed comment reads as the owner's own words to everyone who sees it.\nDeferring the structural fix therefore RAISES the cost of forgetting the\nconvention rather than lowering it.\n\nMeasured this session: the convention held across four merged PRs and every\nthread comment. It is not currently failing; it is simply the kind of thing that\nfails silently and only once someone notices.\n\nWHAT REOPENS IT:\n 1. The user creating the App -- the only real blocker.\n 2. Any instance of the convention actually being forgotten on a public\n artefact. One is enough: the failure mode is a human reading a model's\n words as his own, and it is not detectable after the fact by anything\n automated.\n 3. A second person gaining write access to the repo, which makes \"who said\n this\" ambiguous in a way the attribution line alone does not resolve.\n\nNOT A REASON TO REOPEN: more agents, or more PRs. Volume does not change the\nargument either way.\n\nNote the ticket's own closing paragraph still applies and should not be lost: when\nthis lands, the attribution doctrine is AMENDED rather than deleted, because a\nreader seeing an unattributed comment from the owner's account still needs to know\nit might be an agent.","status":"deferred","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T23:04:19Z","created_by":"orchestrator","updated_at":"2026-09-22T06:22:50Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-xfsb","title":"Agents should post to GitHub under their own identity, not the owner's","description":"Today every agent comment, review and PR goes out as @fiddlerwoaroof, because gh uses his credentials. The interim is a convention -- open each comment with a line saying a model wrote it (doctrine(an_agent_posting_to_github_says_so), AGENTS.md) -- and a convention is something an agent can forget. A separate identity makes attribution structural instead.\n\nTwo routes:\n\n1. GitHub App installed on the repo. Agents mint an installation token and export GH_TOKEN. Comments render as \u003cname\u003e[bot], the same shape as copilot-pull-request-reviewer[bot]. Scoped permissions, no seat consumed, revocable independently of the owner's account, and the bot suffix is not spoofable by a human account.\n\n2. Machine user with its own PAT as GH_TOKEN. Simpler to set up, consumes a seat on some plans, and is indistinguishable from a person at a glance -- which is most of what we are trying to fix.\n\nPrefer 1. Needs the user: create the App, grant contents+pull_requests+issues write, install it, and put the app id and private key where agents can reach them.\n\nWorth checking while doing it: whether the bot can be requested as a PR reviewer, which would let review agents post as reviews rather than as comments.\n\nOnce it lands, the attribution line becomes belt-and-braces rather than the mechanism, and the doctrine should be amended to say so rather than deleted -- a reader seeing an unattributed comment from the owner's account still needs to know it might be an agent.","notes":"DEFERRED by the user 2026-09-22. Recording the reasoning, because the status was\nset with none and the INTERIM IS LOAD-BEARING WHILE THIS SITS.\n\nWHY IT IS A FINE THING TO DEFER: route 1 needs the user to create a GitHub App,\ngrant it contents+pull_requests+issues write, install it, and put the app id and\nprivate key where agents can reach them. That is credential work only he can do,\nand nothing in the codebase is blocked on it.\n\nWHAT MUST STAY IN FORCE MEANWHILE, and this is the part that would be quietly\nlost: EVERY agent comment, review, PR body and issue still opens with a line\nnaming the model and that it is an agent posting in his session\n(doctrine(an_agent_posting_to_github_says_so), AGENTS.md). While this ticket is\ndeferred that convention is THE MECHANISM, not belt-and-braces -- an\nunattributed comment reads as the owner's own words to everyone who sees it.\nDeferring the structural fix therefore RAISES the cost of forgetting the\nconvention rather than lowering it.\n\nMeasured this session: the convention held across four merged PRs and every\nthread comment. It is not currently failing; it is simply the kind of thing that\nfails silently and only once someone notices.\n\nWHAT REOPENS IT:\n 1. The user creating the App -- the only real blocker.\n 2. Any instance of the convention actually being forgotten on a public\n artefact. One is enough: the failure mode is a human reading a model's\n words as his own, and it is not detectable after the fact by anything\n automated.\n 3. A second person gaining write access to the repo, which makes \"who said\n this\" ambiguous in a way the attribution line alone does not resolve.\n\nNOT A REASON TO REOPEN: more agents, or more PRs. Volume does not change the\nargument either way.\n\nNote the ticket's own closing paragraph still applies and should not be lost: when\nthis lands, the attribution doctrine is AMENDED rather than deleted, because a\nreader seeing an unattributed comment from the owner's account still needs to know\nit might be an agent.","status":"deferred","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T23:04:19Z","created_by":"orchestrator","updated_at":"2026-09-22T06:22:50Z","dependencies":[{"issue_id":"improvise-xfsb","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T01:05:51Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-84wp","title":"Doctrine: a check that looks like the answer, answering a neighbouring question","description":"Ready-to-apply prose for `context/design-principles.md` §7. Filed rather than committed because PR #60 head `ec03f95` is byte-identical (tree `ec5d531...`) to a tree the reviewer fully tested, and a further commit would destroy that property and require a fresh harness run while `main` was moving several times an hour. The artefact is worth more preserved than rushed.\n\n---\n\n### A check that looks like the answer, answering a neighbouring question\n\nThe characteristic defect in this repo is not a wrong line; it is a check that is *adjacent* to the thing it names. Three instances landed in one campaign (PRs #59/#60), in three different layers, which is why it is worth stating as a shape rather than three gotchas:\n\n- **A vacuous assertion.** `set_panel_open_and_cursor` asserted `category_panel_open` was true after an effect set it true -- once the launch default supplied `true`, the effect could be replaced by a **no-op** and 1391 tests passed. It answered *is the flag true* where it meant *did the effect set it*.\n- **A vacuous repair of it.** The first fix folded `[open:false, open:true]` and asserted the final state -- which a reducer that folds nothing also satisfies. It answered *does the text mention the other direction* where it meant *does the writer move the flag both ways*. **Reading the diff is what failed**, and it failed because the vacuous version contained `open: false`.\n- **A clean auto-merge.** `git merge-tree` reporting no conflict answers *can git combine these texts*, where the question is *does the combination do what both sides meant*. Two PRs edited the same four files with no conflict and neither side CI-tested together.\n\nEach has the same remedy shape: **name the question you actually mean, then find an input that separates it from its neighbour.**\n\n- For an assertion: **assert the direction the default cannot supply.** Closing the panel discriminates; opening it proves nothing.\n- For a repair: inject a **one-directional** fault (`if open { set true }` -- can raise, never lower). A vacuous repair cannot see it, because a test that only asserts the `true` direction is satisfied by a writer that only ever writes `true`.\n- For a merge: **materialise it and run the gates**, and check that a count which should MOVE actually moved. Playwright going 16 -\u003e 17 is what showed the other side survived; \"no conflict\" and an unchanged total would both have been consistent with losing it.\n\nThis generalises the existing `doctrine(fold_tests_must_test_order_not_the_axiom)` (asserting `fold(prefix)+fold(rest) == fold(whole)` is the definition of a left fold, so it answers a neighbouring question too). What the campaign adds is that the pattern recurs in **test repairs** and in **merge hygiene**, not only in production code and not only in fold tests.\n\n---\n\nAcceptance: the section is in §7; `doctrine(fold_tests_must_test_order_not_the_axiom)` cross-references it or is subsumed by it. Worth considering a `kb.pl` doctrine clause, which is orchestrator-only.\n","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T21:38:23Z","created_by":"wave3-webui","updated_at":"2026-09-21T04:49:32Z","closed_at":"2026-09-21T04:49:32Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-qsmp","title":"App::cmd_context's panel-open propagator is untested: C cannot open a closed panel and nothing fails","description":"`App::cmd_context` (crates/improvise-command/src/ui/app.rs) propagates `self.view_state.category_panel_open` into the `CmdContext`. **Hardcode it to `true` and the entire workspace passes** — 1394 tests, nothing fails.\n\nThat matters because `registry.rs` reads it in three places for `toggle-panel-and-focus`s open-vs-close decision and the panel focus/cycle commands. With it stuck true, pressing `C` on a CLOSED category panel decides to *close* it rather than open it — the panel becomes unopenable by its own key — and no test notices.\n\n**Why it is invisible:** `cmd/mod.rs`s `test_helpers` sets `category_panel_open` explicitly on the context it builds, so no command test ever exercises the real propagator. The tests construct the value they then assert on.\n\n**This is PRE-EXISTING and was not caused by the launch-default ruling (PR #60).** That distinction was established by control rather than assumed: with the default inverted, this mutation fails *exactly the same two tests as inverting the default alone* and nothing more. So it was unguarded before the ruling too. The remedy is therefore \"a test at all\", not the precondition-stating fixture that fixed the two genuinely-disarmed tests (`set_panel_open_and_cursor`, `panel_commands_address_the_named_panel_only`).\n\n**The method that found it is worth more than the finding** and is written up in `context/test-evidence/clients.md` under \"How to know a launch default has stopped disarming things\": mutate every site that writes, propagates or reads the value; sites where nothing fails are suspects; then re-run each suspect with the default inverted to separate *vacuous* (fix the fixture) from *unguarded* (file it). Grep cannot do this — it finds sites that NAME the flag, not tests that merely start from a default-built state.\n\nAcceptance: a command-layer test that drives the real `App::cmd_context` rather than a hand-built context, such that `toggle-panel-and-focus` on a closed panel opens it; proved by hardcoding the propagator and watching it fail.\n\nSibling note: `improvise-wasm-client/src/render.rs` has the same propagator shape for `RenderModel`. Hardcoding THAT one true is improvise-wsaw reintroduced one layer up, and Rust sees nothing — only the Chromium harness catches it, at `contract.spec.js`s `toBeHidden`. Consequence worth keeping: a Rust-only run of the `test` CI job cannot see a reintroduced wsaw, which is the sharpest argument on file for the browser job being able to fail the build.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"claude-agent-cmd","owner":"el-github@elangley.org","created_at":"2026-09-20T21:23:53Z","created_by":"wave3-webui","updated_at":"2026-09-21T04:43:50Z","started_at":"2026-09-21T03:27:55Z","closed_at":"2026-09-21T04:43:50Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-5ray","title":"check-evidence ignores #### headings and every browser-test entry uses them","description":"Found by review of PR #65, 2026-09-20 (finding 4). Sibling of improvise-lyyc\nand improvise-r0qa, and the sharpest of the three because this one has already\nlet a false claim stand for a release.\n\nscripts/check-comment-runs.py --check-evidence collects headings with\n`s.startswith(\"### \")` and builds its `defined` set from rust_files(roots) only.\nTwo consequences compound:\n\n 1. A `####` heading is invisible to it — `\"### \"` does not match `\"#### \"`.\n 2. Even if it matched, `defined` holds only Rust `fn` names, so a JavaScript\n test name could never resolve.\n\ncontext/test-evidence/clients.md uses `#### \"quoted name\"` for every browser\ntest precisely BECAUSE of (2) — a `###` heading naming a Playwright test would\nbe reported as an orphan. So the convention that keeps the checker quiet is the\nsame convention that makes it blind.\n\nDEMONSTRATED: appending\n\n #### \"a test that does not exist anywhere\"\n\nto context/test-evidence/clients.md leaves `--check-evidence` exiting 0.\n\nWHAT IT COST: PR #61 deleted `a socket that opened and then dropped reads as\ndisconnected, and stays that way`. Its `####` heading stayed. The gate passed\nfor the whole release while the file promised a guard that was not there —\nexactly the \"dangling explanation\" design-principles §7 says the checker exists\nto prevent.\n\nFIX SHAPE: collect `###`/`####` alike, and extend `defined` with JS test names\n(`test(\"…\"` in harness/tests/*.spec.js and web/tests/*.test.js). Note the\nchecker's own --self-test must grow a case, or this fix is unguarded in exactly\nthe way the thing it fixes was.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"claude-agent-scripts","owner":"el-github@elangley.org","created_at":"2026-09-20T21:22:55Z","created_by":"wave3-jstests","updated_at":"2026-09-20T23:35:11Z","started_at":"2026-09-20T23:22:05Z","closed_at":"2026-09-20T23:35:11Z","close_reason":"fixed in PR #69, each proved by injection","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -301,7 +376,7 @@ {"_type":"issue","id":"improvise-o31u","title":"The landing page's casts render at a 4.8px font on a phone, and it is now the site root","description":"Found in adversarial review of PR #47, measured rather than eyeballed, and\nreproduced independently before filing.\n\nOn a 390px viewport all four asciinema players on `docs/index.html` compute a\nterminal `font-size` of **4.83px**. The arithmetic is not subtle: the player is\ntold `cols: 120`, the container is ~356px wide inside the page's 16px gutters,\nand 356 / 120 gives about 3px per character, so the player scales the font down\nto 4.83px to fit. Desktop is **13.11px** and perfectly fine, so this is purely a\nnarrow-viewport effect and nothing about the current desktop rendering needs to\nchange.\n\nWHY THE PRIORITY IS HIGHER THAN IT LOOKS: until the user's ruling on\nimprovise-kh8 the landing page sat at `/landing/` and this was a page few people\nwould reach on a phone. It is now the SITE ROOT — the first thing a phone\nvisitor sees is four panels of unreadable grey mush above the call to action.\n\nPROPOSED FIX (the reviewer's, and it is the right shape): stop asking the player\nto fit 120 columns into the viewport and let it overflow instead —\n`overflow-x: auto` on `.player`, plus either `fit: false` or an explicit\n`terminalFontSize` in the `AsciinemaPlayer.create()` options, so the font is\nchosen rather than derived from the width.\n\nONE THING TO CHECK WHEN FIXING, because getting it wrong trades this bug for a\nworse one: the overflow must be contained INSIDE `.player`. A fix that lets the\npage itself scroll sideways breaks the whole document on a phone rather than one\nsection of it. The check is that `document.documentElement.scrollWidth` still\nequals `clientWidth` at 390px afterwards.\n\nNOTE A CANDIDATE that would close this for free: improvise-2oxt re-records the\ncasts. If they are re-recorded at a narrower geometry than 120 columns, the\narithmetic changes by itself — so whoever takes 2oxt should decide the geometry\nwith this ticket in front of them rather than after.\n\nA fix was written and measured during PR #47 (4.83px -\u003e 11.95px via a\n`max-width: 900px` media query giving `.player` `overflow-x: auto` and an 880px\nmin-width, page scrollWidth unchanged at 390) and then REVERTED on the user's\nruling that this should be filed rather than fixed in that PR. The measurements\nare evidence that the approach works; the code is not in the tree.\n\n## Steps to Reproduce\n\n1. `./web/build.sh \u0026\u0026 ./web/assemble.sh site dev`\n2. `python3 -m http.server 8099 --directory site`\n3. Open `http://127.0.0.1:8099/` in a browser sized to 390x844 (or Playwright\n with `viewport: { width: 390, height: 844 }`).\n4. Press play on any of the four players and read the computed style of\n `.ap-terminal`: `font-size` is 4.83px. At 1280px wide it is 13.11px.","acceptance_criteria":"At a 390px viewport every player on docs/index.html renders its terminal at a legible font size (\u003e= 10px measured as the computed font-size of .ap-terminal), and document.documentElement.scrollWidth still equals clientWidth so the page itself does not scroll sideways. Desktop rendering at 1280px is unchanged. Verified by measuring both viewports against the assembled site, not against docs/ directly.","status":"closed","priority":2,"issue_type":"bug","assignee":"wave-browser-agent","owner":"el-github@elangley.org","created_at":"2026-09-20T18:22:32Z","created_by":"wave2-p-milestones","updated_at":"2026-09-21T04:43:19Z","started_at":"2026-09-21T03:28:14Z","closed_at":"2026-09-21T04:43:19Z","labels":["area:release"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-jmjz","title":"grid_of evaluates the whole grid three times per frame; compute_col_widths is ~45% of it","description":"Found by improvise-i5tk's benchmark (`cargo bench --bench grid_projection`), which\nwas run to decide a *different* question and answered this one instead.\n\nMEASURED, on this machine, one `project::grid_of` call = one TUI frame's grid cache:\n\n typical 100 x 4 = 400 cells whole-grid 1.902ms col_widths 45%\n large 1200 x 8 = 9600 cells whole-grid 185.644ms col_widths 64%\n 18k cells (gotcha 41) 3000 x 6 = 18000 cells whole-grid 402.435ms col_widths 42%\n pathological 9000 x 10 = 90000 cells whole-grid 5.235s col_widths 44%\n\nA 60fps frame is 16.7ms. At 18,000 cells -- the size gotcha 41 already treats as\nrealistic -- a TUI redraw takes about four tenths of a second, and the TUI redraws\non every keypress.\n\nWHY: the whole grid is evaluated roughly THREE TIMES per frame.\n\n1. `ui/viewport.rs::compute_col_widths` walks every (row, col) calling\n `layout.display_text`, to size the columns.\n2. The SAME function then walks every (row, col) AGAIN summing column totals, to\n size the total row.\n3. `project::grid_of` then evaluates the visible cells via `cell_display`, and\n `column_totals` sums every row again for each visible column.\n\nEvery one of those goes through `Model::evaluate_aggregated`, and every result is\nthrown away at the end of the frame.\n\nWHAT THIS IS NOT: it is not the viewport question. improvise-i5tk asked whether\nnarrowing the TUI's `GridCache` to its viewport would help, and the benchmark says\nit buys only 1.2-1.5x, because passes 1 and 2 are viewport-INDEPENDENT by\nconstruction -- `compute_col_widths` takes no viewport and a column total must sum\nevery row including scrolled-past ones (gotcha 44). So narrowing removes part of\none pass out of three. The fix here is caching or a single fused pass, not windowing.\n\n## Steps to Reproduce\n\n1. `cargo bench --bench grid_projection` on this branch.\n2. Read the `whole-grid` column: 402ms for 18,000 cells, against a 16.7ms frame.\n3. Read the `ratio` column: windowing the cache buys 1.5x, so the cost is not in\n the part a viewport would narrow.\n4. Read the `col_widths` column: one function, taking two whole-grid passes of its\n own, is 42-64% of the frame.","acceptance_criteria":"A frame's grid cache evaluates each cell at most once. The benchmark in benches/grid_projection.rs is the evidence both ways: it must show the whole-grid figure dropping by roughly the number of redundant passes removed, and it must still be run AFTER the change rather than the change argued from the structure. Note what the measurement already rules out: windowing the cache to a viewport is not the fix (1.2-1.5x measured, improvise-i5tk), because compute_col_widths takes no viewport and a column total must sum every row (gotcha 44). Do not lose the totals' whole-grid correctness while removing the whole-grid cost.","notes":"SECOND RUN 2026-09-20, same machine. The col_widths share is stable at 45-46%, not 42-64%: the single 64% in the first run's 'large' row was noise. whole-grid 1.9ms / 190ms / 327ms / 5.0s; ratio 1.2-1.5x throughout. The two figures the decision rests on -- ratio ~1.4x and col_widths ~half the frame -- reproduce; the per-row percentages do not to better than a few points. Quote ranges, not single runs.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:06:57Z","created_by":"wave2-n-cleanups","updated_at":"2026-09-21T11:30:16Z","closed_at":"2026-09-21T11:30:16Z","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-zkkw","title":"The browser shell is only ever run on Chromium, but improvise-bck's criterion says Firefox too","description":"Found walking improvise-bck's acceptance criteria in wave 2. Criterion (1) is\n\"all test plan steps pass in CHROME AND FIREFOX\", and bck's own grooming note\nrecords the standalone deployment as demonstrated by harness/. Both are true of\nChromium and neither is true of Firefox.\n\n`harness/playwright.config.js` declares three projects, and only the chromium\none has no `testMatch`. Firefox and WebKit both carry\n`testMatch: /opfs\\.spec\\.js/`, so `shell.spec.js` — every assertion about\nweb/app.js, including \"the page draws the demo document out of the worker\" and\n\"committing an edit sends one frame and shows the host's value\" — runs on\nChromium alone. The config's own comment says so (\"The shell spec is\nChromium-only\"), and the stated reason is that `routeWebSocket` is slower and\nflakier across three engines.\n\nTHAT REASON APPLIES TO FOUR OF THE SIX SPECS, NOT SIX. The four `?ws=` specs\nmock a peer with `routeWebSocket`. The two under \"the standalone deployment\"\ndo not: they load `/index.html` with no query and drive the in-page worker over\nOPFS — which is precisely the deployment bck's criterion is about, and\nprecisely the code Firefox exercises differently (its OPFS implementation is\nalready run by opfs.spec.js, so the engine is known to support the path).\n\nSo the cheap and honest change is to let the standalone describe block run on\nFirefox while leaving the routeWebSocket block Chromium-only. That is a\n`testMatch`/grep split rather than a third copy of the spec.\n\nWHAT MUST NOT HAPPEN: turning the whole file on for Firefox and then quieting\nwhatever flakes. The flakiness argument is about the mocked-socket specs and is\nrecorded; widening it to the standalone ones would be adopting a reason that\nwas never given for them.\n\n## Steps to Reproduce\n\n1. `grep -n testMatch harness/playwright.config.js` — firefox and webkit are\n both pinned to `opfs.spec.js`.\n2. `cd harness \u0026\u0026 npx playwright test --project=firefox --list` — no test from\n `shell.spec.js` is listed.\n3. improvise-bck criterion (1) nevertheless reads \"in Chrome and Firefox\".","acceptance_criteria":"harness/tests/shell.spec.js's 'the standalone deployment' describe block runs on the firefox project as well as chromium, and passes there in CI; the four routeWebSocket specs stay Chromium-only with the existing reason still written down. Verified by 'npx playwright test --project=firefox --list' naming the two standalone tests, and by a green browser job. improvise-bck criterion (1) is then re-checked against what actually runs.","notes":"PR #72. Tagged @mocked-peer; firefox testMatch widened with grepInvert. Measured 2.0s for the standalone pair, 10/10 under --repeat-each=5.","status":"closed","priority":2,"issue_type":"task","assignee":"wave4-harness","owner":"el-github@elangley.org","created_at":"2026-09-20T18:02:30Z","created_by":"wave2-p-milestones","updated_at":"2026-09-21T00:24:06Z","started_at":"2026-09-20T23:21:57Z","closed_at":"2026-09-21T00:24:06Z","close_reason":"Fixed and merged: rmvx/fdt6/gcvb in PR #71, the harness four in PR #72. Both adversarially reviewed, with each finding's injection re-run against the fix. Closed by the orchestrator with --force because the claiming agent has stood down; the claim guard refusing an unforced close is the mechanism working, not a problem.","labels":["area:epic"],"dependency_count":0,"dependent_count":1,"comment_count":0} -{"_type":"issue","id":"improvise-864a","title":"Concurrent merges to main race on one Pages environment and the superseded deploys fail","description":"DIAGNOSED 2026-09-21 (PR #88). The cause is neither direction this ticket offered, and it is not concurrency in the sense the ticket meant.\n\nMEASURED over all 100 push-to-main CI runs 2026-09-19T22:02Z..2026-09-21T05:02Z, from the run/job/step/log APIs:\n\n 12/12 artifact-not-found deploy failures had ANOTHER run's SUCCESSFUL Pages\n deployment complete inside their own upload-\u003edeploy window\n 2/56 successes had the same\n 0/4 other deploy failures (those are the unrelated 'Create Pages site failed')\n\nDIRECTION IS SETTLED. The oldest run of the 17:47Z batch failed at 17:47:23Z, BEFORE the next deploy started (17:47:31Z) and before any Pages deployment existed at all, since deploy-pages fetches artifact metadata before creating one. It cannot have been caused by its own deploy, by supersession, or by a concurrent one. The artifact was already gone.\n\nTHE DECISIVE OBSERVATION, and it is scoped by artifact NAME rather than by run: run 35560726456 finalized harness-serve, wasm-bundles and github-pages seconds apart, each with an id; 2m21s later its own lookup reported 'Found 2 artifact(s)' with no github-pages. Same run, same token, same call, same retention. That single line rules out service flakiness, eventual consistency, retention, token scope and a v3/v4 backend mismatch simultaneously — any of them would have taken the siblings too.\n\nRULED OUT: queue depth (the three deploys never queued — 9s and 17s apart, each failure taking 1s); retention (all shared retention-days: 1, and the newest-finalized expires LAST, so expiry can yield 0/1/1 or 0/0/1 but never this ticket's 0/1/0); deletion by the action (an upstream feature request asks for that capability, open precisely because nothing does it); action-version drift (successes and failures alternate minute by minute on identically-resolved SHAs).\n\nONE CLAUSE OF THIS TICKET'S OWN CORRECTION IS FALSIFIED. It argued the tip-of-main guard would have skipped the publishing deploy 'and let 52507a6 deploy and fail anyway', assuming 52507a6's loss was independent of its neighbour. It was not: 5f871f7's deploy completed FOUR SECONDS before 52507a6's began. PR #46 aimed at the right quantity for the wrong reason.\n\nIT STILL MUST NOT SHIP. The association is not a mechanism, and the mechanism is not deterministic — 5f871f7 had a foreign deploy land inside its own window and survived. A guard measured at nothing, against a probabilistic effect, is doctrine(measure_the_obvious_fix_before_reporting_it_as_the_fix).\n\n## Acceptance Criteria (REPLACED — the old one asked for the guard this evidence does not support)\n- Either: no two runs hold a github-pages artifact across another run's Pages deployment.\n- Or: narrow the upload-\u003edeploy window and measure the failure rate against it.\n- Before any fix, reproduce deliberately: two pushes seconds apart on a scratch repo, polling /runs/{id}/artifacts every few seconds through both deploys, and watch the github-pages entry disappear at the moment the other deploy completes. Cheap, and it is what turns an association into a mechanism.\n\nFull argument: context/code/tooling.md §3. WHAT IS STILL UNKNOWN: why GitHub removes it. 12/12 vs 2/56 is an association; no upstream issue, changelog or doc describes the behaviour, and both the reaped and surviving artifacts are 404 now (retention-days: 1), so the state is gone.","acceptance_criteria":"A run whose commit is no longer the tip of main skips actions/deploy-pages instead of failing; a burst of merges to main produces exactly one deploy attempt and no red X on the superseded runs.","notes":"CORRECTION 2026-09-20, from adversarial review of PR #46 — THE ORIGINAL DESCRIPTION'S PREMISE IS WRONG, and the fix built on it was closed unmerged.\n\nThe description says only the NEWEST run keeps its artifact. Re-checked against the API, it was the MIDDLE one:\n\n 04e4e3d (oldest) run 35526758561 artifacts=0 deploy=failure\n 5f871f7 (middle) run 35526767814 artifacts=1 deploy=SUCCESS\n 52507a6 (newest) run 35526774007 artifacts=0 deploy=failure \u003c- tip of main\n\nSo a 'skip unless this commit is the tip of main' guard would have skipped 5f871f7 — the one deploy that actually published — and let 52507a6 deploy and fail anyway. Pages never updates, main still red: strictly worse than the status quo. PR #46 closed on that evidence.\n\nThe guard also aimed at the wrong failure class: it reduces the NUMBER of deploys, while the observed error is a missing artifact within a run's OWN scope. Queue depth is not what makes an artifact survive.\n\nWHAT IS STILL UNKNOWN, and what any future fix must establish FIRST: why two of three artifacts were deleted when the upload step was green in all three. Note the surviving artifact belongs to the run whose deploy SUCCEEDED, which suggests the causation may run the other way (the deploys failed because their artifacts were already gone, not the reverse). Do not ship another guard until that is measured — the first one was built on a misread table and review caught it, which is the only reason it did not land.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T17:55:01Z","created_by":"orchestrator","updated_at":"2026-09-21T05:23:47Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-864a","title":"Concurrent merges to main race on one Pages environment and the superseded deploys fail","description":"DIAGNOSED 2026-09-21 (PR #88). The cause is neither direction this ticket offered, and it is not concurrency in the sense the ticket meant.\n\nMEASURED over all 100 push-to-main CI runs 2026-09-19T22:02Z..2026-09-21T05:02Z, from the run/job/step/log APIs:\n\n 12/12 artifact-not-found deploy failures had ANOTHER run's SUCCESSFUL Pages\n deployment complete inside their own upload-\u003edeploy window\n 2/56 successes had the same\n 0/4 other deploy failures (those are the unrelated 'Create Pages site failed')\n\nDIRECTION IS SETTLED. The oldest run of the 17:47Z batch failed at 17:47:23Z, BEFORE the next deploy started (17:47:31Z) and before any Pages deployment existed at all, since deploy-pages fetches artifact metadata before creating one. It cannot have been caused by its own deploy, by supersession, or by a concurrent one. The artifact was already gone.\n\nTHE DECISIVE OBSERVATION, and it is scoped by artifact NAME rather than by run: run 35560726456 finalized harness-serve, wasm-bundles and github-pages seconds apart, each with an id; 2m21s later its own lookup reported 'Found 2 artifact(s)' with no github-pages. Same run, same token, same call, same retention. That single line rules out service flakiness, eventual consistency, retention, token scope and a v3/v4 backend mismatch simultaneously — any of them would have taken the siblings too.\n\nRULED OUT: queue depth (the three deploys never queued — 9s and 17s apart, each failure taking 1s); retention (all shared retention-days: 1, and the newest-finalized expires LAST, so expiry can yield 0/1/1 or 0/0/1 but never this ticket's 0/1/0); deletion by the action (an upstream feature request asks for that capability, open precisely because nothing does it); action-version drift (successes and failures alternate minute by minute on identically-resolved SHAs).\n\nONE CLAUSE OF THIS TICKET'S OWN CORRECTION IS FALSIFIED. It argued the tip-of-main guard would have skipped the publishing deploy 'and let 52507a6 deploy and fail anyway', assuming 52507a6's loss was independent of its neighbour. It was not: 5f871f7's deploy completed FOUR SECONDS before 52507a6's began. PR #46 aimed at the right quantity for the wrong reason.\n\nIT STILL MUST NOT SHIP. The association is not a mechanism, and the mechanism is not deterministic — 5f871f7 had a foreign deploy land inside its own window and survived. A guard measured at nothing, against a probabilistic effect, is doctrine(measure_the_obvious_fix_before_reporting_it_as_the_fix).\n\n## Acceptance Criteria (REPLACED — the old one asked for the guard this evidence does not support)\n- Either: no two runs hold a github-pages artifact across another run's Pages deployment.\n- Or: narrow the upload-\u003edeploy window and measure the failure rate against it.\n- Before any fix, reproduce deliberately: two pushes seconds apart on a scratch repo, polling /runs/{id}/artifacts every few seconds through both deploys, and watch the github-pages entry disappear at the moment the other deploy completes. Cheap, and it is what turns an association into a mechanism.\n\nFull argument: context/code/tooling.md §3. WHAT IS STILL UNKNOWN: why GitHub removes it. 12/12 vs 2/56 is an association; no upstream issue, changelog or doc describes the behaviour, and both the reaped and surviving artifacts are 404 now (retention-days: 1), so the state is gone.","acceptance_criteria":"A run whose commit is no longer the tip of main skips actions/deploy-pages instead of failing; a burst of merges to main produces exactly one deploy attempt and no red X on the superseded runs.","notes":"CORRECTION 2026-09-20, from adversarial review of PR #46 — THE ORIGINAL DESCRIPTION'S PREMISE IS WRONG, and the fix built on it was closed unmerged.\n\nThe description says only the NEWEST run keeps its artifact. Re-checked against the API, it was the MIDDLE one:\n\n 04e4e3d (oldest) run 35526758561 artifacts=0 deploy=failure\n 5f871f7 (middle) run 35526767814 artifacts=1 deploy=SUCCESS\n 52507a6 (newest) run 35526774007 artifacts=0 deploy=failure \u003c- tip of main\n\nSo a 'skip unless this commit is the tip of main' guard would have skipped 5f871f7 — the one deploy that actually published — and let 52507a6 deploy and fail anyway. Pages never updates, main still red: strictly worse than the status quo. PR #46 closed on that evidence.\n\nThe guard also aimed at the wrong failure class: it reduces the NUMBER of deploys, while the observed error is a missing artifact within a run's OWN scope. Queue depth is not what makes an artifact survive.\n\nWHAT IS STILL UNKNOWN, and what any future fix must establish FIRST: why two of three artifacts were deleted when the upload step was green in all three. Note the surviving artifact belongs to the run whose deploy SUCCEEDED, which suggests the causation may run the other way (the deploys failed because their artifacts were already gone, not the reverse). Do not ship another guard until that is measured — the first one was built on a misread table and review caught it, which is the only reason it did not land.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T17:55:01Z","created_by":"orchestrator","updated_at":"2026-09-21T05:23:47Z","dependencies":[{"issue_id":"improvise-864a","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T01:05:44Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-fdt6","title":"A browser cannot add a formula, so FormulaEdit is a room that swallows typing","description":"Found while grooming improvise-bck after wave 1. It is the one acceptance criterion of the standalone web MVP that is not a verification gap but a MISSING FEATURE.\n\nimprovise-bck says: \"load a bundled demo .improv file, EDIT CELLS, ADD A FORMULA, save to OPFS, reload the page, verify persistence\". Everything in that sentence is now demonstrated by harness/ except adding a formula, because THE BROWSER HAS NO FORMULA ENTRY AT ALL.\n\nWHAT EXISTS: the formula PANEL has a projection (FormulaCache) and the browser receives it. What does not exist is any way to compose one — `commit-buffer formula` is in the hand-written list of text-entry commits the client cannot resolve (see improvise-e24v and the audit on improvise-g9jb), so FormulaEdit is a mode a browser can ENTER and not COMMIT. That is the dead-room shape doctrine(a_doorway_to_a_black_hole_is_worse_than_the_key) names, and `strands_the_user` currently refuses SavePrompt and ExportPrompt but NOT FormulaEdit.\n\nSO THERE ARE TWO HONEST OUTCOMES AND THEY SHOULD BE DECIDED TOGETHER:\n 1. Implement `commit-buffer formula` client-side. All its operands are in ViewState (improvise-e24v lists it under \"needs nothing added to the protocol\"), so this is the cheap half — and then the browser can add a formula and improvise-bck's criterion is met as written.\n 2. Or refuse FormulaEdit in `strands_the_user` until (1) lands, so the browser stops offering a room that swallows typing — and improvise-bck's criterion is amended to say formulas are a TUI-only affordance for now.\n\nDOING NEITHER IS THE CURRENT STATE AND IT IS THE WORST OF THE THREE: the mode opens, the user types a formula, and nothing happens.\n\nNOTE FOR WHOEVER TAKES IT: the emitted formula must go through the same add_formula path as a hand-typed one, so _Measure target handling stays identical (gotcha 7) — the same constraint improvise-vx3 worked under. And parse_formula strips pipes off a target (gotcha 34), so the client must not pre-quote.\n\n## Steps to Reproduce\n\n1. Open the document in the browser and press `F` (or `:enter-mode formula-edit`).\n2. The mode opens — `enter-mode` resolves locally in the wasm client.\n3. Type a formula and press Enter. `commit-buffer formula` has no arm in `keys.rs::step`, so `resolve` returns `Upstream`, and `Host::handle` answers `ClientFrame::Key` with an explicit empty vec.\n4. Nothing happens. The typing is gone and no error is shown.\n\n`strands_the_user` refuses `SavePrompt` and `ExportPrompt` for exactly this shape and does not refuse `FormulaEdit`.\n","acceptance_criteria":"Either commit-buffer formula resolves client-side and a browser can add a formula end to end (demonstrated in harness/), or FormulaEdit is refused by strands_the_user with improvise-bck's criterion amended to say formulas are TUI-only for now. The current state — enterable, uncommittable, silent — is not an acceptable outcome. Whichever is chosen, the audit list in improvise-g9jb and the named gap list in keys.rs move with it.","status":"closed","priority":2,"issue_type":"bug","assignee":"claude-wasm-keys-agent","owner":"el-github@elangley.org","created_at":"2026-09-20T07:55:22Z","created_by":"Edward Langley","updated_at":"2026-09-21T00:24:03Z","started_at":"2026-09-20T23:21:15Z","closed_at":"2026-09-21T00:24:03Z","close_reason":"Fixed and merged: rmvx/fdt6/gcvb in PR #71, the harness four in PR #72. Both adversarially reviewed, with each finding's injection re-run against the fix. Closed by the orchestrator with --force because the claiming agent has stood down; the claim guard refusing an unforced close is the mechanism working, not a problem.","labels":["area:browser"],"dependencies":[{"issue_id":"improvise-fdt6","depends_on_id":"improvise-e24v","type":"relates-to","created_at":"2026-09-20T07:55:24Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"improvise-8qlr","title":"Column-group run coalescing is derived twice, and the agreement test does not pin the TUI arm","description":"Found by the post-wave code consistency review, CONFIRMED BY INJECTION.\n\nColumn-group RUN COALESCING is derived in two places:\n * src/ui/grid.rs:189-192, coalescing on `group_name != prev_group`\n * crates/improvise-wasm-client/src/render.rs:339-364 (`col_groups`), coalescing by `label` alone — with a comment saying \"which is what src/ui/grid.rs does ... and the two must agree\"\n\nA comment is what keeps them in step. AxisFrame already owns `groups()`; a `group_runs()` there would make this one rule instead of two.\n\n## Steps to Reproduce\n\n1. In src/ui/grid.rs, force the TUI to emit the group label on EVERY column (`match (true, col_group)`) — a visibly different grid.\n2. `cargo test -p improvise` stays GREEN.\n\n`tests/grid_frame_agreement.rs::the_two_front_ends_draw_the_same_column_header` compares the BROWSER's spans against a hard-coded `vec![(Some(\"Q1\"), 6)]` but only checks the TUI's group line with `group_line.contains(\"▼ Q1\")`. That `contains` is the unpinned arm.\n\n## Note\n\nThe rest of that test file is exemplary — it drives each front end the way it is really driven and records its injected-fault evidence. This is its one weak assertion, and it is weak in the direction that matters: the side the migration could most easily have broken.\n","acceptance_criteria":"The run rule lives once, on AxisFrame (a group_runs() beside groups()), and both front ends call it. the_two_front_ends_draw_the_same_column_header compares the TUI's spans EXACTLY rather than with contains(), so forcing the TUI to emit a group label on every column fails it — verify by injecting that fault.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-render-frame","owner":"el-github@elangley.org","created_at":"2026-09-20T07:49:43Z","created_by":"Edward Langley","updated_at":"2026-09-21T03:46:31Z","started_at":"2026-09-21T03:39:55Z","closed_at":"2026-09-21T03:46:31Z","close_reason":"Fixed on PR #77 (fix/render-frame-wave, head 8f3a976). Each guard proved by injecting the fault it names; GROUPED_FRAME and harness/tests/grouped.spec.js re-pinned deliberately.","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-8uj8","title":"every_rejection_message_says_the_edit_did_not_land covers five of seven variants","description":"Found by the post-wave code consistency review, CONFIRMED BY INJECTION.\n\ncrates/improvise-wasm-client/src/socket.rs:273-288. The test is named `every_rejection_message_says_the_edit_did_not_land` and its doc says the fact \"is asserted over all of them rather than one at a time\". It drives a HAND-WRITTEN LIST of Rejection variants, and that list predates PR #26, which added `NoDocumentPath` and `SaveFailed`.\n\n## Steps to Reproduce\n\n1. Add `NoDocumentPath` and `SaveFailed` to the test's list.\n2. It FAILS: \"... which does not say the edit did not land\".\n\nSo two variants were silently exempted from a rule the test claims is universal, rather than argued out of it.\n\n## The nuance that makes this a re-scope rather than a message fix\n\nThe messages themselves are defensible — a save failure did not fail to APPLY anything, so \"the edit did not land\" is the wrong sentence for it. But then the TEST'S CLAIM is what is wrong, and a test asserting something false about its own subject is worse than no test.\n\ndoctrine(a_hand_enumerated_mirror_needs_an_exhaustive_match) names this exactly.\n","acceptance_criteria":"The test is driven by an EXHAUSTIVE match over Rejection so a new variant cannot slip, and its claim matches what it checks — either every variant really does say the edit did not land, or the test is re-scoped to the subset for which that sentence is correct and says which and why. Adding a variant to Rejection fails the build until someone answers for it.","status":"closed","priority":2,"issue_type":"bug","assignee":"claude-agent-protocol","owner":"el-github@elangley.org","created_at":"2026-09-20T07:49:42Z","created_by":"Edward Langley","updated_at":"2026-09-21T03:47:00Z","started_at":"2026-09-21T03:28:16Z","closed_at":"2026-09-21T03:47:00Z","close_reason":"Closed","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -312,7 +387,7 @@ {"_type":"issue","id":"improvise-sihr","title":"Every minibuffer mode entered from records mode exits to plain Normal while the layout is still records","description":"Found by Copilot on PR #27 against the new Ctrl-W save prompt, and DECLINED there with evidence because fixing the one arm the finding named would have been one arm of a three-arm rule.\n\nTHE BEHAVIOUR: every minibuffer mode entered from RecordsNormal exits to plain Normal while the LAYOUT IS STILL RECORDS. It affects `:` (CommandMode), Ctrl-E (ExportPrompt) and Ctrl-W (SavePrompt, new in PR #27). Only the last is new; the class predates it.\n\nTHE MECHANISM: the exit mode is a CONSTANT, baked into TextEntry::new(.., \"normal\") in the keymap builder and into each commit command's ChangeMode. Nothing carries where the user came FROM, so nothing can put them back.\n\nWHAT IS NOT THE MECHANISM, checked rather than reasoned (doctrine(binding_dump_diff)): the binding TABLE is fine. RecordsNormal goes 155 -\u003e 156 dumped lines across PR #27, the single addition being Char('w')+CONTROL -\u003e enter-mode [\"save-prompt\"], with nothing removed. with_parent inheritance (gotcha 13) is intact. What is lost is the RUNTIME MODE, not a binding.\n\nCHARACTERIZED, NOT FIXED: every_minibuffer_mode_entered_from_records_returns_to_plain_normal drives all three and asserts both halves -- the mode is Normal, and the layout is still records. So the current behaviour is pinned and a fix will have to update it deliberately.\n\nTHE FIX, and it is why this is its own bead: text-entry modes must carry their entry mode. That is a change to TextEntry, to AppMode (the exit is part of a mode's configuration, so it likely belongs beside MinibufferConfig -- note gotcha 11, MinibufferConfig::buffer_key is a String and not \u0026'static str precisely because ViewState must stay Deserialize-able, and the same constraint applies to anything added there), and to three commit commands. Wider than the PR that found it.\n\nWATCH TWO THINGS:\n * The exit is currently spelled in TWO places per mode -- the TextEntry spine's Esc binding and the commit command's ChangeMode. If they are made to carry an entry mode, make them read ONE source or the two will disagree on cancel versus commit.\n * SearchMode is hand-written on purpose and has no commit key (gotcha 11). It is not part of this class; do not sweep it in.\n\nRELATED: improvise-mai9 (a status message written while a minibuffer is open is never drawn) is the other half of why these modes are awkward -- both are consequences of the minibuffer owning the bottom row and nothing tracking what it displaced.\n\n## Steps to Reproduce\n\n1. Enter records mode (`R`).\n2. Press `:`, or `Ctrl-E`, or `Ctrl-W` — any minibuffer mode.\n3. Commit or cancel.\n4. The app is now in plain `Normal` while the LAYOUT is still records: the exit mode is a constant baked into `TextEntry::new(.., \"normal\")` and into each commit command's `ChangeMode`, and nothing carries where the user came from.\n\n`every_minibuffer_mode_entered_from_records_returns_to_plain_normal` drives all three and asserts both halves.\n","acceptance_criteria":"A minibuffer mode entered from RecordsNormal returns to RecordsNormal. Text-entry modes carry their entry mode rather than a constant 'normal'. The exit is currently spelled TWICE per mode — the TextEntry spine's Esc binding and the commit command's ChangeMode — so both read ONE source or they will disagree on cancel versus commit. SearchMode is hand-written on purpose and is not part of this class. every_minibuffer_mode_entered_from_records_returns_to_plain_normal is updated deliberately, not deleted.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T06:12:20Z","created_by":"Edward Langley","updated_at":"2026-09-21T17:09:48Z","closed_at":"2026-09-21T17:09:48Z","labels":["area:tui"],"dependencies":[{"issue_id":"improvise-sihr","depends_on_id":"improvise-mai9","type":"relates-to","created_at":"2026-09-20T06:12:22Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-kc7n","title":"improvise-ws-server never checks Origin, so any page a user visits can drive their session","description":"Found by Copilot on PR #21 round 5, against a reverse-proxy recipe whose IP allow-list had itself been added two rounds earlier to close a DIFFERENT hole. Verified independently by the orchestrator: `grep -rni origin crates/improvise-ws-server/src/` returns NOTHING. The server never inspects the Origin header and accepts every handshake.\n\nTHE HOLE: a WebSocket is EXEMPT FROM THE SAME-ORIGIN POLICY -- there is no CORS preflight on a handshake. So any hostile page that an allowed user visits can open a socket to the endpoint FROM THAT USER'S OWN ALLOWED ADDRESS, and read and edit every document the server holds. That is cross-site WebSocket hijacking.\n\nWHY NETWORK CONTROLS CANNOT SEE IT: an IP allow-list, a private network, loopback binding -- all answer \"may this CLIENT reach the endpoint\", and in this attack the answer is genuinely yes. The request really does come from an allowed client; it is simply being DRIVEN by a page the user did not intend to trust. Only Origin separates the two.\n\nLOOPBACK IS NOT EXEMPT, and this is the half a documentation-only fix cannot reach. In the local pairing case there is no proxy at all, so there is nothing in front to check Origin -- and loopback is not a boundary a browser enforces. While the server runs, ANY page open in that browser can reach ws://127.0.0.1:8787. So the local development path, which is the one the project actually recommends, is the least protected.\n\nDISTINCT FROM improvise-kpnr, and both are needed. kpnr is AUTHENTICATION: who is allowed to talk to this server at all. This is CSWSH: given that someone is allowed, what may put words in their mouth. An answer to either leaves the other open.\n\nTHE FIX IS CHEAP AND SERVER-SIDE: an allow-list of permitted origins checked at the handshake, defaulting to something safe for the local case (the page's own origin, and whatever --bind implies), with a flag to widen it. Refuse the upgrade rather than accepting and ignoring.\n\nTWO THINGS TO GET RIGHT:\n * A MISSING Origin header is not the same as a disallowed one. Non-browser clients (the repo's own tests, curl, a native client) send none, and the browser always does. Decide explicitly what absent means rather than letting the match arm decide by accident.\n * PR #21 has already documented Origin filtering in the PROXY as a required companion to the network control, phrased as \"in addition to, never instead of\". When this lands, the README should say the server does it too, and the proxy line becomes defence in depth rather than the only defence.\n\nTHE GENERAL LESSON, now doctrine(who_may_connect_is_two_questions): ask of any access control whether it constrains the CLIENT or what may DRIVE the client. Three consecutive rounds of that PR reasoned about the page's capabilities and none about the attacker's.\n\n## Steps to Reproduce\n\n1. Run `improvise-ws-server` on any document.\n2. From a browser that is allowed to reach it — the user's own machine, or any address an IP allow-list permits — visit an unrelated page that opens `new WebSocket(\"ws://127.0.0.1:8787\")`.\n3. The handshake succeeds. A WebSocket is exempt from the same-origin policy, so there is no CORS preflight and `grep -rni origin crates/improvise-ws-server/src/` returns nothing — the server never looks.\n4. That page can now read and edit every document the server holds, from the user's own allowed address.\n\nLoopback is not exempt and has no proxy in front of it, so the local development path is the least protected.\n","acceptance_criteria":"improvise-ws-server refuses a handshake whose Origin is not allow-listed, defaulting to something safe for the local case with a flag to widen it. A MISSING Origin is decided explicitly rather than by accident — non-browser clients (the repo's own tests, curl, a native client) send none and browsers always do. The README's proxy-side Origin filtering becomes defence in depth rather than the only defence.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T06:09:42Z","created_by":"Edward Langley","updated_at":"2026-09-21T04:33:43Z","closed_at":"2026-09-21T04:33:43Z","close_reason":"Closed","labels":["area:browser"],"dependencies":[{"issue_id":"improvise-kc7n","depends_on_id":"improvise-kpnr","type":"relates-to","created_at":"2026-09-20T06:09:49Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-mai9","title":"A status message written while a minibuffer is open is never drawn","description":"Found by WU-G while building improvise-lae (PR #27), and CONFIRMED THE HARD WAY in the same PR: Copilot then found a regression that this mechanism causes, which the agent had itself written down an hour earlier and not checked its own change against.\n\nTHE MECHANISM: src/draw.rs::draw_bottom_bar renders the minibuffer INSTEAD OF the status line. So a status_msg written while a minibuffer is open is never drawn at all -- not truncated, not overwritten, simply never rendered.\n\nWHO IS AFFECTED TODAY:\n * CategoryAdd / ItemAdd: a failed `commit-buffer category` is INVISIBLE. Worse, improvise-6t3 deliberately ordered that command's effects so the add effect is emitted LAST, specifically so its failure message would WIN over the command's optimistic status. It wins a race to a surface nobody can see.\n * A failed `:w \u003cpath\u003e` from CommandMode, which is the regression Copilot found on PR #27: the abort stops the batch before ReturnToNormalUnlessMoved and clear-buffer run, so the app stays in CommandMode with the stale command in the minibuffer and the new error hidden behind it.\n\nTHE WORKAROUND ALREADY SHIPPED, and it is a workaround: CommitSave emits [ChangeMode(Normal), SaveAs] so the error lands when the bottom row is the status bar again. That works for one command and does not generalise -- every other text-entry commit has the same hole.\n\nTWO CANDIDATE FIXES, and the choice is the ticket:\n * Draw both: give the minibuffer and the status line separate rows, or render the status into the minibuffer row when a message is pending. Cheap to describe, and it changes frame geometry, which is pinned by exact-frame characterization tests (see improvise-q0vb for what that costs).\n * Make it unrepresentable: a status message set while a minibuffer is open is a bug in the CALLER, so make the caller unable to do it -- e.g. commit paths always leave the mode before reporting. That is what CommitSave now does by hand, promoted to a rule.\n\nPREFER WHICHEVER MAKES THE FAILURE LOUD. The current state is the quiet kind: the code sets a message, the test asserts the message was set, and the user never sees it. A test asserting `app.status_msg == \"...\"` passes in every one of these cases, which is why this survived improvise-6t3's careful effect ordering.\n\n## Steps to Reproduce\n\n1. Enter a text-entry mode that opens a minibuffer — `CategoryAdd` (`N`) is the clearest.\n2. Commit something that fails, so the command sets a status message.\n3. Nothing appears: `src/draw.rs::draw_bottom_bar` renders the minibuffer INSTEAD OF the status line, so the message is never drawn at all — not truncated, not overwritten.\n4. A test asserting `app.status_msg == \"...\"` passes, which is why this survived improvise-6t3's careful effect ordering.\n\nSecond path, found on PR #27: a failed `:w /bad/path` aborts before `ReturnToNormalUnlessMoved`, leaving the app in `CommandMode` with the stale command drawn and the error hidden behind it.\n","acceptance_criteria":"A status message set while a minibuffer is open reaches the user. Either the two share the bottom row, or a commit path is structurally unable to report while its minibuffer is open (which is what CommitSave now does by hand, promoted to a rule). Whichever is chosen, the failure becomes LOUD: a test asserting app.status_msg was set must no longer pass while the user sees nothing.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-render-frame","owner":"el-github@elangley.org","created_at":"2026-09-20T06:04:20Z","created_by":"Edward Langley","updated_at":"2026-09-21T03:46:31Z","started_at":"2026-09-21T03:39:56Z","closed_at":"2026-09-21T03:46:31Z","close_reason":"Fixed on PR #77 (fix/render-frame-wave, head 8f3a976). Each guard proved by injecting the fault it names; GROUPED_FRAME and harness/tests/grouped.spec.js re-pinned deliberately.","labels":["area:tui"],"dependencies":[{"issue_id":"improvise-mai9","depends_on_id":"improvise-sihr","type":"relates-to","created_at":"2026-09-20T06:12:22Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-kpnr","title":"improvise-ws-server has no authentication, and the documented sharing path now exposes it","description":"Found by Copilot on PR #21 (improvise-o8fa, the reverse-proxy documentation), and it is a finding ABOUT the documentation that PR introduced rather than about code.\n\nimprovise-ws-server has NO AUTHENTICATION BY DESIGN. crates/improvise-ws-server/src/transport.rs says it outright: anyone who can open a socket to the port can rewrite every document it serves. The only control today is that it binds loopback.\n\nTHE README NOW TELLS USERS TO PUT A PUBLIC TLS PROXY IN FRONT OF IT (improvise-o8fa, per the user's ruling that the reverse proxy is the supported path to reaching a TUI-hosted document from an https origin). That instruction REMOVES THE ONLY CONTROL. TLS is confidentiality, not access control, and in a copy-pasteable recipe the two read alike.\n\nSo proxy-side auth is now LOAD-BEARING DOCUMENTATION rather than a server feature. PR #21 mitigated it -- the warning leads the section, the Caddyfile carries basic_auth marked not-optional, the --bind note now separates what loopback buys from what it does not, and the tunnel line says cloudflared/ngrok print public URLs and authenticate nothing -- but mitigation in prose is not the same as a property of the system.\n\nTHE DECISION TO MAKE, and it is a real fork rather than an oversight to correct:\n * The server grows a shared secret or token, checked at the websocket handshake. Small, and it makes the documented path safe by default rather than safe if followed.\n * Or \"auth is the proxy's job\" is the PERMANENT answer, stated in the server's own docs as a design position rather than left as an absence. Defensible -- it is what most single-binary dev servers do -- but then the README recipe is the security boundary and should say so in those words.\n\nDO NOT SPLIT THE DIFFERENCE. A token that is optional and off by default is the worst of both: it reads as a feature and defends nothing.\n\nNOTE THE GENERALISABLE PART, worth keeping whichever way this goes: DOCUMENTATION THAT INSTRUCTS AN ACTION CARRIES THAT ACTION'S CONSEQUENCES. The PR #21 section was scrupulous about one axis (build complexity, the cost of a second binary to a one-binary tool) and blind on another (exposure), and the --bind paragraph actively REASSURED the reader -- \"not reachable from anywhere else\", true of the port and false of the deployment. Neither the ticket, nor gotcha 48, nor the agent's own /code-review pass caught it; a reviewer reading the server's source did.\n\n## Rationale\n\nimprovise-ws-server has no authentication by design and `transport.rs` says so outright: anyone who can open a socket to the port can rewrite every document it serves. The only control was loopback binding — and improvise-o8fa's README now instructs users to put a public TLS proxy in front of it, which REMOVES exactly that control. TLS is confidentiality, not access control, and in a copy-pasteable recipe the two read alike.\n\nSo proxy-side auth is now load-bearing documentation rather than a property of the system. PR #21 mitigated it in prose; mitigation in prose is not the same as a mechanism.\n\n## Alternatives Considered\n\n**A shared secret or token checked at the handshake.** Small, and it makes the documented path safe by default rather than safe-if-followed. Note a URL query parameter is the one credential `new WebSocket(url)` can carry — the API sets no headers.\n\n**\"Auth is the proxy's job\" as a stated permanent position**, written into the server's own docs as a design decision rather than left as an absence. Defensible — it is what most single-binary dev servers do — but then the README recipe IS the security boundary and should say so in those words.\n\n**Rejected: a token that is optional and off by default.** The worst of both: it reads as a feature and defends nothing.\n","status":"open","priority":2,"issue_type":"decision","owner":"el-github@elangley.org","created_at":"2026-09-20T05:45:28Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:45:40Z","labels":["area:browser"],"dependencies":[{"issue_id":"improvise-kpnr","depends_on_id":"improvise-kc7n","type":"relates-to","created_at":"2026-09-20T06:09:49Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-kpnr","title":"improvise-ws-server has no authentication, and the documented sharing path now exposes it","description":"Found by Copilot on PR #21 (improvise-o8fa, the reverse-proxy documentation), and it is a finding ABOUT the documentation that PR introduced rather than about code.\n\nimprovise-ws-server has NO AUTHENTICATION BY DESIGN. crates/improvise-ws-server/src/transport.rs says it outright: anyone who can open a socket to the port can rewrite every document it serves. The only control today is that it binds loopback.\n\nTHE README NOW TELLS USERS TO PUT A PUBLIC TLS PROXY IN FRONT OF IT (improvise-o8fa, per the user's ruling that the reverse proxy is the supported path to reaching a TUI-hosted document from an https origin). That instruction REMOVES THE ONLY CONTROL. TLS is confidentiality, not access control, and in a copy-pasteable recipe the two read alike.\n\nSo proxy-side auth is now LOAD-BEARING DOCUMENTATION rather than a server feature. PR #21 mitigated it -- the warning leads the section, the Caddyfile carries basic_auth marked not-optional, the --bind note now separates what loopback buys from what it does not, and the tunnel line says cloudflared/ngrok print public URLs and authenticate nothing -- but mitigation in prose is not the same as a property of the system.\n\nTHE DECISION TO MAKE, and it is a real fork rather than an oversight to correct:\n * The server grows a shared secret or token, checked at the websocket handshake. Small, and it makes the documented path safe by default rather than safe if followed.\n * Or \"auth is the proxy's job\" is the PERMANENT answer, stated in the server's own docs as a design position rather than left as an absence. Defensible -- it is what most single-binary dev servers do -- but then the README recipe is the security boundary and should say so in those words.\n\nDO NOT SPLIT THE DIFFERENCE. A token that is optional and off by default is the worst of both: it reads as a feature and defends nothing.\n\nNOTE THE GENERALISABLE PART, worth keeping whichever way this goes: DOCUMENTATION THAT INSTRUCTS AN ACTION CARRIES THAT ACTION'S CONSEQUENCES. The PR #21 section was scrupulous about one axis (build complexity, the cost of a second binary to a one-binary tool) and blind on another (exposure), and the --bind paragraph actively REASSURED the reader -- \"not reachable from anywhere else\", true of the port and false of the deployment. Neither the ticket, nor gotcha 48, nor the agent's own /code-review pass caught it; a reviewer reading the server's source did.\n\n## Rationale\n\nimprovise-ws-server has no authentication by design and `transport.rs` says so outright: anyone who can open a socket to the port can rewrite every document it serves. The only control was loopback binding — and improvise-o8fa's README now instructs users to put a public TLS proxy in front of it, which REMOVES exactly that control. TLS is confidentiality, not access control, and in a copy-pasteable recipe the two read alike.\n\nSo proxy-side auth is now load-bearing documentation rather than a property of the system. PR #21 mitigated it in prose; mitigation in prose is not the same as a mechanism.\n\n## Alternatives Considered\n\n**A shared secret or token checked at the handshake.** Small, and it makes the documented path safe by default rather than safe-if-followed. Note a URL query parameter is the one credential `new WebSocket(url)` can carry — the API sets no headers.\n\n**\"Auth is the proxy's job\" as a stated permanent position**, written into the server's own docs as a design decision rather than left as an absence. Defensible — it is what most single-binary dev servers do — but then the README recipe IS the security boundary and should say so in those words.\n\n**Rejected: a token that is optional and off by default.** The worst of both: it reads as a feature and defends nothing.\n","status":"open","priority":2,"issue_type":"decision","owner":"el-github@elangley.org","created_at":"2026-09-20T05:45:28Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:45:40Z","labels":["area:browser"],"dependencies":[{"issue_id":"improvise-kpnr","depends_on_id":"improvise-kc7n","type":"relates-to","created_at":"2026-09-20T06:09:49Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-kpnr","depends_on_id":"improvise-tr08","type":"parent-child","created_at":"2026-09-25T01:04:49Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-rucf.3","title":"Projection freshness for a source an external writer can change","description":"Child of improvise-rucf (ADR 003, live-backed models). Filed and DEFERRED 2026-09-19 alongside its parent.\n\nADR 001 governs concurrent EDITS. A live-backed model is read-only, so it has none and the basis check does not apply to it -- the guarantee is not voided, it is scoped to documents improvise owns, which is what it always meant.\n\nWHAT REMAINS IS A DIFFERENT AND SMALLER QUESTION, and this bead is it: an EXTERNAL WRITER changes what clients SEE. So a live-backed projection needs a freshness story -- polled, triggered, or explicitly stale-until-refreshed. That is projection freshness, not conflict resolution, and conflating the two is the trap.\n\nNOTE WHAT CHANGES ABOUT THE PROJECTION ARCHITECTURE: today's diff (ui/project.rs::grid_updates) compares a desired state derived from the App against each client's mirror. With a live source, \"the desired state\" involves latency -- a HashMap lookup becomes a query -- so the VIEWPORT-BOUNDED PROJECTION STOPS BEING AN OPTIMISATION AND BECOMES LOAD-BEARING. improvise-i5tk (narrow the TUI's GridCache to its viewport, benchmark first) is the related bead and its answer may be forced by this one.\n\nSHARED WITH THE SQLITE SOURCE CHILD: the RecordHash -\u003e record index becomes a cache needing invalidation rather than a structure maintained alongside writes. One invalidation story, not two.\n\nEXPLICITLY OUT OF SCOPE: anything resembling a lease, a lock, or a reservation. Those are ADR 001 layer 2 and they are about edits.\n","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T05:31:33Z","created_by":"Edward Langley","updated_at":"2026-09-22T06:24:21Z","dependencies":[{"issue_id":"improvise-rucf.3","depends_on_id":"improvise-rucf","type":"parent-child","created_at":"2026-09-20T05:31:33Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-rucf.2","title":"SQLite source implementation: aggregate pushdown over a user-supplied query, native-only and feature-gated","description":"Child of improvise-rucf (ADR 003, live-backed models). Filed and DEFERRED 2026-09-19 alongside its parent.\n\nThe SQLite implementation of the source trait: aggregate pushdown against a user-supplied query.\n\nWHICH ROWS: a user-supplied SELECT, with a bare table name as sugar over it. A query is strictly more useful (joins, filters, computed columns) and no harder to consume -- a result set is a result set.\n\nTYPE MAPPING, and it is BETTER INPUT THAN CSV rather than merely different: a SQLite column carries a declared type, where import/analyzer.rs::detect_date_format currently guesses a date by pattern-matching text. INTEGER/REAL are measure candidates, TEXT a category candidate, BLOB skip. NULL is ABSENCE, not the string \"null\" (improvise-ych). Dates may be TEXT, INTEGER (epoch) or REAL (julian) -- DETECT, DO NOT ASSUME. SQLite is dynamically typed per value, so both the declared-type and the sampled path are real and the classifier should take typed input where it has it.\n\nWHERE THE BOUNDARY GOES: forbidden(improvise_core, io). The source TRAIT belongs in improvise-core; this implementation belongs in improvise-io, FEATURE-GATED AND NATIVE-ONLY. libsqlite3-sys does not build for wasm32-unknown-unknown under this repo's nix cc-wrapper, and improvise-io is in the wasm bundle, so the wasm CI job must keep passing untouched. VERIFY THE GATE, do not assume it -- the improvise-9ix agent established the wasm failure is the nix wrapper injecting -fzero-call-used-regs rather than proof that bundled SQLite cannot target wasm, so the gating protects the browser bundle rather than working around an impossibility.\n\nNO WRITES, and not as a runtime refusal: per ADR 003 writes are not in the trait a query source implements, and a source projecting no CellId::Record makes a client write unconstructible.\n\nINTERNING AND IDENTITY: DataStore interns names and ADR 002 hashes the coordinate tuple. Both still work -- coordinates come from the query -- but the RecordHash -\u003e record index that resolves a CellId becomes A CACHE WITH AN INVALIDATION STORY rather than a structure maintained alongside writes. That invalidation is shared with the freshness child; do not solve it twice.\n","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T05:31:32Z","created_by":"Edward Langley","updated_at":"2026-09-22T06:24:21Z","dependencies":[{"issue_id":"improvise-rucf.2","depends_on_id":"improvise-rucf","type":"parent-child","created_at":"2026-09-20T05:31:32Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-rucf.1","title":"The .improv source specification: a live-backed document is a saved query plus a pivot configuration","description":"Child of improvise-rucf (ADR 003, live-backed models). Filed and DEFERRED 2026-09-19 alongside its parent; the direction is accepted but not a priority.\n\nA live-backed document splits into OUR configuration and THEIR data, and .improv does not currently distinguish them. Today a file holds categories, views, formulas AND a ## Data section. A live-backed model's categories and cell values belong to the source; the views, formulas and axis assignments belong to improvise.\n\nSo the format grows a SOURCE SPECIFICATION where a live model would otherwise have data: a connection, a query, and the column-to-field mapping. Categories become DERIVED (SELECT DISTINCT) rather than declared.\n\nWHY THIS IS THE FIRST CHILD: persistence/improv.pest is the single source of truth (design-principles 3) and the parser is a tree-walker over it, so the grammar changes before the walker. The grammar-walking test generator reads the grammar AST via pest_meta, so a new production needs generator coverage or the round-trip proptests silently stop describing the format.\n\nTHE FRAMING THAT MAKES THIS FEEL RIGHT RATHER THAN LIKE A COMPLICATION, from the ADR: a live-backed .improv is A SAVED QUERY PLUS A PIVOT CONFIGURATION -- which is exactly what a saved report is in any BI tool. The document stops being \"my data\" and becomes \"my question\", portable between databases in a way a data file never is.\n\nOPEN: whether a document may be PARTLY live (some categories declared, some derived) or whether live and owned are exclusive per document. Exclusive is almost certainly right and much cheaper; say so deliberately rather than leaving it implied.\n","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T05:31:31Z","created_by":"Edward Langley","updated_at":"2026-09-22T06:24:20Z","dependencies":[{"issue_id":"improvise-rucf.1","depends_on_id":"improvise-rucf","type":"parent-child","created_at":"2026-09-20T05:31:31Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -334,7 +409,7 @@ {"_type":"issue","id":"improvise-l4qx","title":"[bug] A text cell is invisible whenever any category is on the None axis","description":"Found during improvise-q08 (2026-09-19), PRE-EXISTING, and it is the NORMAL case rather than an edge one.\n\nModel::evaluate_aggregated takes the aggregating path when none_cats is non-empty, and that path does filter_map(as_f64) -- so a CellValue::Text aggregates to NOTHING and never becomes a CellDisplay. _Index and _Dim sit on Axis::None in a default view, so none_cats is non-empty almost always.\n\nWHY IT MATTERS: .improv has pipe-quoted text cells as a FIRST-CLASS FEATURE -- the format doc gives Region=West, Measure=Revenue = |pending| as an example -- and they do not render. A user can write a text value, save it, load it and not see it.\n\nThe q08 agent hit this while building a fixture, worked around it there, and noted it in the round-trip test comment. Costing a test iteration is how it surfaced; nothing in the suite covers it.\n\nWrite the failing test first: a text cell in a default view (so _Index/_Dim are on None) must render its text. Then decide what aggregation of text should MEAN -- filter_map(as_f64) silently dropping it is clearly wrong, but \"show the single value when there is exactly one, and something explicit when there are several\" is a product decision, not an obvious fix. Note gotcha 7: apply_agg and eval_agg already differ on empty input, so this family has precedent for being decided rather than defaulted.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T23:46:09Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:24:05Z","closed_at":"2026-09-20T00:24:05Z","close_reason":"FIXED. aggregate_cell_values in model/types.rs, shared by evaluate_aggregated and aggregate_raw (which had byte-identical bodies precisely so they could not drift -- improvise-kdp).\n\nTHE RULE IS UNANIMITY, and the ORDER is load-bearing:\n empty -\u003e None (absent, unchanged -- follows apply_agg)\n ALL NUMERIC -\u003e apply_agg -\u003e Number (unchanged; checked FIRST)\n all values equal -\u003e that value\n otherwise -\u003e Error(\"mixed\")\n\nReducing several copies of one value is the identity, so that reduction has an answer even when the values are not numbers -- which covers the case the ticket is actually about. Two different words, or a word among numbers, have no reduction, and gotcha 7 already has precedent for saying so out loud (eval_agg returns Err(\"empty\") for AVG/MIN/MAX over nothing rather than inventing a value). So this is consistent with BOTH existing rules: emptiness follows apply_agg (absent, not zero), undefined-reduction follows eval_agg (explicit error, not silence).\n\nTHE ORDERING IS THE WHOLE DIFFERENCE from the ticket own first suggestion. \"Show the single value when exactly one matches\" taken literally BREAKS Count, whose entire point is to not be the value. The all-numeric branch is tested first, so Count of a lone number is still 1. Proven by injection: swapping the branch order failed exactly one test, a_lone_number_still_counts_as_one_rather_than_passing_through, which is why that guard exists.\n\nREJECTED: \"show the first\" (depends on DataStore hash iteration order, so the displayed value would be arbitrary AND look authoritative while hiding the others -- worse than the blank); \"show a count\" (changes the type of the answer; a count rendered under a Sum measure is indistinguishable from real data); and making the display path differ from the formula path.\n\nBEHAVIOUR CHANGE TO KNOW: a formula ref over MIXED values now errors \"mixed\" instead of \"ref:name\". Still an error, but a truer one, and it no longer silently sums the numbers while dropping the text. Text-valued refs still error exactly as before. Fixed-point convergence untouched.\n\nFailing test first: a_text_cell_renders_in_a_default_view, which also asserts !none_cats.is_empty() to confirm the premise that a DEFAULT view really does put _Index/_Dim on None -- the normal path, not an edge one. 1024 tests pass; three faults injected, all fired.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-vjms","title":"Decide whether a category panel shows the virtual categories","description":"Found by improvise-35e (2026-09-19). A GENUINE BEHAVIOUR DIVERGENCE, which is why that ticket stopped rather than migrating the widget.\n\nThe TUI category panel builds from cat_tree::build_cat_tree, which walks Model::category_names() and therefore draws _Index, _Dim and _Measure. CategoryRow/category_rows carry only regular_category_names(), and CategoryTreeCache own doc says \"Only the categories a user may see -- the client does not know the _-prefixed virtual-category convention\".\n\nSo migrating the category panel to the cache would SILENTLY DROP THREE ROWS from the TUI panel. That is a behaviour change, not a relocation, so doctrine(characterization_survives_the_move) says stop and ask.\n\nTHE DECISION IS improvise-edp: either the browser panel grows the virtual categories, or the TUI panel loses them. Note gotcha 7 -- _Measure items are explicit items UNION formula targets, so a client showing _Measure needs measure_item_names semantics, not a raw item list; that is part of the cost of the first option.\n\nWhichever way it goes, the widget then also needs a tree flattener over CategoryTreeCache -- today build_cat_tree takes \u0026Model.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T23:40:37Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:06:54Z","closed_at":"2026-09-20T00:06:54Z","close_reason":"DECIDED (user, 2026-09-19): the TUI keeps the virtual categories AND the browser gets them too. CategoryTreeCache grows to carry _Index, _Dim and _Measure, so both panels render the same thing and the cache stays the single source for what a category panel shows.\n\nWHAT THIS COSTS, and it is the part to get right: the client now has to be told about the _-prefixed convention, which the caches doc currently says it does not know. In particular _MEASURE ITEMS ARE DERIVED, not a raw list -- gotcha 7: they are the explicit items UNION the formula targets, and add_formula does NOT add items to _Measure. So category_tree_of must ship membership computed through measure_item_names()/effective_item_names(), never category(\"_Measure\").items, or the panel will silently omit every formula target. That is the same trap improvise-ete was closed for proposing.\n\nConsequences to carry into improvise-edp:\n * CategoryTreeCache and its doc comment change -- the \"the client does not know the _-prefixed virtual-category convention\" line is now false and must go.\n * build_cat_tree can then migrate off \u0026Model, which is what unblocks the category panel and therefore edp.\n * A characterization test should pin that the cache carries all seven rows for a default workbook (4 regular + 3 virtual) and that _Measure includes a formula target that is not an explicit item -- one fixture that would fail under the raw-list implementation.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-pauh","title":"Make the beads flake pin actually bind in agent worktrees","description":"Two mitigations, BOTH wanted (user, 2026-09-19). Manual direnv allow is not sufficient on its own -- see the scaling problem below.\n\nTHE PROBLEM. flake.nix pins beads to github:gastownhall/beads/v1.3.0 precisely to control bd version. A git worktree does NOT load that pin: .envrc is unallowed in a fresh worktree, so a process there falls through to ~/.nix-profile/bin/bd. Verified 2026-09-19 with a scrubbed DIRENV env: \"Found RC allowed 0\". Today the profile bd is ALSO 1.3.0, so nothing breaks -- which is exactly why a careless test passes and why this is worth fixing before it bites.\n\nTHE FAILURE MODE IS SILENT. See the bd-1-0-2-dev-embedded-dolt-dependencies memory: bd VERSION SKEW SILENTLY EMPTIES THE DEPENDENCY GRAPH in .beads/issues.jsonl. No error; the loss is invisible until someone asks what blocks what. The flake pin exists to prevent this and an unallowed worktree bypasses it.\n\nWHY MANUAL ALLOW DOES NOT SUFFICE: agent worktrees are created FRESH PER DISPATCH. Allowing the ones that exist right now does not help the next one, so any manual pass is stale the moment another agent starts. Confirmed today -- allow entries were created at 16:35 and a worktree from earlier still reported allowed 0.\n\nMITIGATION 1 -- make the allow automatic. direnv allow (or an equivalent: a whitelist.prefix entry in ~/.config/direnv/direnv.toml covering the worktree root, which would cover every future worktree in one stroke and is probably the better answer) at worktree creation. Note whitelist.prefix is currently [] per direnv status.\n\nMITIGATION 2 -- assert the version rather than trust it. Have .claude/hooks/session-start.sh compare bd version against the flake pin and FAIL LOUDLY on a mismatch. doctrine(force_the_failure) applies: the whole point is that the skew is silent, so the check must be the thing that is not. Read the pin from flake.nix rather than hard-coding it, or the assertion rots the first time the pin moves.\n\nReading with a mismatched bd is safe; it is MUTATING commands that corrupt. If a cheap distinction is available, guard those specifically.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T23:39:38Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:30:40Z","closed_at":"2026-09-20T00:30:40Z","close_reason":"Half 2 landed: section 0 of .claude/hooks/session-start.sh asserts bd matches the flake pin, reading the pinned version OUT OF flake.nix so there is no second copy to rot on the next bump. Fails loudly (exit 2 + banner) on mismatch, on an unparseable pin, AND on an unreadable bd version -- an assertion that cannot answer is not a pass. Placed BEFORE the CLAUDE_CODE_REMOTE gate, because the local worktree is where the problem lives; the cloud path re-checks after installing bd and still completes tracker sync and cargo warm. It blocks nothing -- reads are safe, and the banner names the mutating commands specifically.\n\nDemonstrated both ways rather than asserted: a stub reporting 1.2.4 fires the banner; a stub reporting 1.3.0 is silent; an unparseable version fires; a flake.nix pinning a bare commit fires the cannot-read-the-pin arm. With the real binaries BOTH of today candidates pass (the flake store path and ~/.nix-profile), which is exactly the ticket point -- today pass proves nothing.\n\nHALF 1 IS THE USER TO MAKE, and is reported rather than done: create ~/.config/direnv/direnv.toml (it does not exist today) with [whitelist] prefix = [\"\u003crepo root\u003e\"]. One prefix covers the main checkout and every future worktree since worktrees live under .claude/worktrees/ inside it. Verified without touching the user file by pointing DIRENV_CONFIG at a scratch copy: same worktree went from not-allowed to allowed. There is NO repo-side alternative by design -- direnv refuses to let a repository authorize its own .envrc, and that refusal IS the security model; a .claude/settings.json env block prepending the flake store path was considered and rejected because it hard-codes a machine-specific nix hash that rots on every bump and is less trustworthy than the pin it claims to enforce.\n\nCORRECTION THIS TICKET SURFACED: the evidence in its original description read \"Found RC allowed 0\" as the broken state. It is the opposite -- direnv prints the AllowStatus enum where 0=Allowed, 1=NotAllowed. The conclusion still holds, on better evidence: this agent worktree (created after the users allow batch) reports allowed 1 and its allow file does not exist on disk.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-3al1","title":"ADR-001 c: a rejected batch keeps its values staged, and class-1 force re-composes","description":"ADR 001 layer 1, step c. Depends on step b. This is the half that makes rejection usable.\n\nTWO OBLIGATIONS, both pulled forward from layer 2 by the user force requirement:\n1. The rejected batch VALUES STAY STAGED in the session, so rejection never costs the user their typing. This is the drill staging mechanism doing what it already does; only PUBLISHING the staged keys is layer 2.\n2. FORCE, for conflict class 1 (two writers, same record): re-compose the same INTENT against the current state and submit a fresh batch with a CURRENT basis. One keystroke.\n\nFORCE IS A RE-COMPOSITION, NOT A BYPASS. Never apply a batch whose basis is stale -- a mechanism whose only purpose is to violate the invariant is the invariant deleted with extra steps.\n\nRULE: FORCE NEVER CREATES STRUCTURE. That rule is what keeps class 2 out of this ticket.\n\nTESTS: a rejected edit leaves the staged value intact; force after rejection lands the value and the basis is current at apply time; force does not re-run against the stale basis.","acceptance_criteria":"A rejected batch leaves the user's typing intact, and force re-composes the same INTENT against current state with a fresh basis — one keystroke. FORCE IS A RE-COMPOSITION, NEVER A BYPASS: a mechanism whose only purpose is to violate the invariant is the invariant deleted with extra steps. FORCE NEVER CREATES STRUCTURE, which is what keeps conflict class 2 out of this ticket. Three tests: a rejected edit leaves the staged value intact; force after rejection lands the value with a basis current AT APPLY TIME; force does not re-run against the stale basis. Note the wasm client already keeps the Rejection itself rather than a status string, precisely so force knows which record and which kind (gotcha 43).","notes":"WAVE 2026-09-21: NOT DONE, and the reason is a finding rather than a shortfall. improvise-nxah and improvise-3al1 are IN DIRECT CONTRADICTION and neither bead mentions the other. They are ONE design and should be scheduled as one.\n\nTHE CONTRADICTION, measured: nxah's naive fix is to snapshot ViewState before the optimistic fold and restore it on ServerFrame::Rejected. That WIPES ViewState::buffers -- which is precisely the user's typing that 3al1 requires be kept staged so a force can re-compose it. resolve.rs folds 9 Command::SetBuffer sites alongside 19 Command::Model sites, so a whole-slice rollback is wrong and WHICH FIELDS roll back is a design decision, not a mechanical fix.\n\nTHE SECOND BLOCKER, and it is a genuine protocol gap: there is NO POSITIVE ACKNOWLEDGEMENT. The server answers nothing on success, so a client cannot know when to DISCARD an undo snapshot. With two frames in flight a single pending slot rolls back the wrong one. This is the same shape as the publication gap ADR 001 originally omitted -- it said a batch carries the basis it was composed against and never said how a client learns one, which Projection::Basis had to close.\n\nSo the work is: decide what a rejected frame rolls back on the client given that buffers must survive, and decide how success is acknowledged. Then both beads fall out. Do not start either alone.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-19T23:27:31Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:59:56Z","labels":["area:protocol"],"dependencies":[{"issue_id":"improvise-3al1","depends_on_id":"improvise-fkfg","type":"blocks","created_at":"2026-09-19T23:27:45Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-3al1","depends_on_id":"improvise-nxah","type":"related","created_at":"2026-09-21T21:59:56Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-3al1","depends_on_id":"improvise-xz2z","type":"blocks","created_at":"2026-09-24T00:43:43Z","created_by":"agent-227-fixes","metadata":"{}"}],"dependency_count":2,"dependent_count":3,"comment_count":0} +{"_type":"issue","id":"improvise-3al1","title":"ADR-001 c: a rejected batch keeps its values staged, and class-1 force re-composes","description":"ADR 001 layer 1, step c. Depends on step b. This is the half that makes rejection usable.\n\nTWO OBLIGATIONS, both pulled forward from layer 2 by the user force requirement:\n1. The rejected batch VALUES STAY STAGED in the session, so rejection never costs the user their typing. This is the drill staging mechanism doing what it already does; only PUBLISHING the staged keys is layer 2.\n2. FORCE, for conflict class 1 (two writers, same record): re-compose the same INTENT against the current state and submit a fresh batch with a CURRENT basis. One keystroke.\n\nFORCE IS A RE-COMPOSITION, NOT A BYPASS. Never apply a batch whose basis is stale -- a mechanism whose only purpose is to violate the invariant is the invariant deleted with extra steps.\n\nRULE: FORCE NEVER CREATES STRUCTURE. That rule is what keeps class 2 out of this ticket.\n\nTESTS: a rejected edit leaves the staged value intact; force after rejection lands the value and the basis is current at apply time; force does not re-run against the stale basis.","acceptance_criteria":"A rejected batch leaves the user's typing intact, and force re-composes the same INTENT against current state with a fresh basis — one keystroke. FORCE IS A RE-COMPOSITION, NEVER A BYPASS: a mechanism whose only purpose is to violate the invariant is the invariant deleted with extra steps. FORCE NEVER CREATES STRUCTURE, which is what keeps conflict class 2 out of this ticket. Three tests: a rejected edit leaves the staged value intact; force after rejection lands the value with a basis current AT APPLY TIME; force does not re-run against the stale basis. Note the wasm client already keeps the Rejection itself rather than a status string, precisely so force knows which record and which kind (gotcha 43).","notes":"WAVE 2026-09-21: NOT DONE, and the reason is a finding rather than a shortfall. improvise-nxah and improvise-3al1 are IN DIRECT CONTRADICTION and neither bead mentions the other. They are ONE design and should be scheduled as one.\n\nTHE CONTRADICTION, measured: nxah's naive fix is to snapshot ViewState before the optimistic fold and restore it on ServerFrame::Rejected. That WIPES ViewState::buffers -- which is precisely the user's typing that 3al1 requires be kept staged so a force can re-compose it. resolve.rs folds 9 Command::SetBuffer sites alongside 19 Command::Model sites, so a whole-slice rollback is wrong and WHICH FIELDS roll back is a design decision, not a mechanical fix.\n\nTHE SECOND BLOCKER, and it is a genuine protocol gap: there is NO POSITIVE ACKNOWLEDGEMENT. The server answers nothing on success, so a client cannot know when to DISCARD an undo snapshot. With two frames in flight a single pending slot rolls back the wrong one. This is the same shape as the publication gap ADR 001 originally omitted -- it said a batch carries the basis it was composed against and never said how a client learns one, which Projection::Basis had to close.\n\nSo the work is: decide what a rejected frame rolls back on the client given that buffers must survive, and decide how success is acknowledged. Then both beads fall out. Do not start either alone.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-19T23:27:31Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:59:56Z","labels":["area:protocol"],"dependencies":[{"issue_id":"improvise-3al1","depends_on_id":"improvise-5krd","type":"parent-child","created_at":"2026-09-25T01:04:55Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-3al1","depends_on_id":"improvise-fkfg","type":"blocks","created_at":"2026-09-19T23:27:45Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-3al1","depends_on_id":"improvise-nxah","type":"related","created_at":"2026-09-21T21:59:56Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-3al1","depends_on_id":"improvise-xz2z","type":"blocks","created_at":"2026-09-24T00:43:43Z","created_by":"agent-227-fixes","metadata":"{}"}],"dependency_count":2,"dependent_count":3,"comment_count":0} {"_type":"issue","id":"improvise-fkfg","title":"ADR-001 b: ModelCommand carries its basis; Applied gains a rejected case","description":"ADR 001 layer 1, step b. Depends on step a.\n\nModelCommand carries the basis it was composed against -- (document_generation, layout_version, and the record_versions of the records it touches). Sessions::apply rejects a batch whose basis moved.\n\nApplied grows a THIRD case beside Diff and Rebased. An enum, not a flag: matching is what forces a host to handle it, which is exactly the reasoning gotcha 39 records for rejecting Applied { .., rebase: bool }.\n\nTHE TIERS ARE THE POINT: a CellData batch is stale if LAYOUT moved, because the shape its keys hang off changed. That is the corruption class -- A deletes category Region while B commits a key naming Region=East -- detected without any lock, and it is why a single counter is not enough.\n\nRejection is a NORMAL OUTCOME, not an error. The client already has the new state via projections.\n\nTESTS, both arms per doctrine(one_arm_of_a_symmetric_rule...): a stale CellData batch after a layout change is rejected; a fresh one is not; a batch touching an untouched record is not rejected by another record moving; a WholeWorkbook bump invalidates everything. Beware doctrine(a_rename_can_hide_a_stale_key) -- hold identifiers constant and vary only what makes the basis stale.","status":"closed","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-19T23:27:30Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:58:04Z","closed_at":"2026-09-20T00:58:04Z","close_reason":"Done. ADR 001 layer 1, the correctness floor.\n\nTHE THREE THINGS THE ADR IS EMPHATIC ABOUT:\n * NO BYPASS, AND NONE DESIGNED IN. Basis has NO Default and NO public constructor -- a zero basis is not 'no claim', it is the claim that the document is fresh, which in a new App is TRUE, so a default would be forgeable. BasisCache::claim is the only door a client has and it withholds until the server has published one. There is no force flag anywhere; re_composing_against_the_republished_basis_is_accepted walks the actual force path (refused -\u003e fold the republished basis -\u003e re-compose -\u003e accepted) with nothing that skips the check.\n * STRUCTURAL AND DATA KEPT APART. RecordGone (nothing resolves, nothing to write to, nothing coherent to force -- force never creates structure) vs RecordMoved (resolves at another version, force is one keystroke). The RecordGone test deliberately brings the LAYOUT claim up to date first so the tier above cannot mask it.\n * COUNTERS ON THE App. Nothing added to ViewState; cursor_epoch went beside document_generation for the same reason.\n\nWHERE THE ADR MET THE CODE AND WAS WRONG -- the important part. ADR 001 says 'a batch carries the basis it was composed against' and NEVER SAYS HOW A CLIENT COMES TO KNOW ONE. Nothing in the projection stream carried any of the three tiers, so the check as specified was satisfiable only by a client FORGING a basis: the invariant deleted with extra steps, arriving through the front door. Closed with Projection::Basis + BasisCache -- the server publishes the two document tiers plus the write counters for the records THIS CLIENT'S GRID NAMES (exactly the set it can address a write to, per ADR 002), diffed against what that client was last told and seeded in the bootstrap. Emitted AFTER the grid updates in a batch: the counters describe cells those updates carry, so a basis arriving first would let a client claim a version for a value it had not been shown. Mirror image of gotcha 33. The ADR is amended.\n\nTHREE SMALLER UNDER-SPECIFICATIONS, all now decided:\n * 'Any record the batch touches' -- WHOSE LIST? If the client's, a client that omits a record has a FORCE BY OMISSION: the record tier skipped, no flag to remove, nothing in the message saying so. records_named is the server's own exhaustive read of the ModelCommand (one or-pattern for the 24 that name none, so a new record-writing variant fails the build), and a claim that omits a record the server knows is written gets Rejection::RecordUnstated -- its own case because 'you lost a race' and 'you were never in one' are different things to tell a user.\n * Command::SwitchView carries no basis -- it is LayoutGeometry but sits outside Command::Model, so apply_switch is unchecked. Not a corruption risk (the workbook refuses a view it does not have, and every mirror takes what was ACCEPTED), but a hole in the rule. Filed.\n * reduce_full/apply_command do not check, and that is right: the check is a property of the SESSION DOOR, not of the mutation path, because a host holding the App is the truth rather than a claimant. ui::reduce::caught_up(\u0026App, cmd) serves that host and is not a bypass -- it takes \u0026App, so nothing reachable from a wire can call it.\n\nClient side: the wasm client folds Projection::Basis and BasisCache::claim gates both compose sites, so a client with no basis composes nothing.","dependencies":[{"issue_id":"improvise-fkfg","depends_on_id":"improvise-3x3u","type":"blocks","created_at":"2026-09-19T23:27:47Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-fkfg","depends_on_id":"improvise-5a5y","type":"blocks","created_at":"2026-09-19T23:27:44Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"improvise-5a5y","title":"ADR-001 a: version tiers on App (layout_version, record_version)","description":"ADR 001 layer 1, step a -- the foundation. No wire change, no UX; testable alone.\n\nAdd beside App::document_generation (which already exists and is bumped by install_workbook):\n * layout_version -- bumped by any effect declaring ModelChange::LayoutGeometry\n * record_version -- per RECORD, bumped by a CellData effect touching that record\nA record is the coordinate tuple EXCLUDING _Measure -- record_row_identity(wide, coord_group_excluding_measure), already in kb.pl. NOT the cell: gotcha 1 says editing a category column moves every measure cell in the row, so a cell-level unit is violated by a single legal edit. The unit must be at least as coarse as the coarsest legal write.\n\nThese are SHARED DOCUMENT STATE, so they live on App next to document_generation, never on ViewState (gotcha 30).\n\nBump them from the same place Dirty is computed, so the taxonomy has one reader: App::apply_effects already does dirty.union(Dirty::of(effect.kind())). Do not add a second classification table -- gotcha 35.\n\nTESTS: a LayoutGeometry effect bumps layout_version and not record_version; a CellData effect bumps only the records it touched; an aborted batch (gotcha 21) bumps nothing it did not run. Prove each by injection.","status":"closed","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-19T23:27:28Z","created_by":"Edward Langley","updated_at":"2026-09-19T23:34:34Z","closed_at":"2026-09-19T23:34:34Z","close_reason":"App::layout_version and App::record_versions added beside document_generation, with accessors and a free record_of(\u0026CellKey) that makes record_row_identity a function. 933 tests passing.\n\nTHE DESIGN PROBLEM WORTH KEEPING: an EffectKind says THAT a batch writes cells, never WHICH. So layout_version reads cleanly off the taxonomy in the apply_effects loop, but record_versions cannot. Rejected adding a per-effect \"which records\" method -- a 50-effect burden and exactly the second table gotcha 35 warns against -- in favour of OBSERVING THE DATA: a per-record XOR fingerprint taken when the batch declares a CellData effect, diffed after the loop. The O(cells) pass is bounded by the O(cells) rebuild_layout() that already ends every batch and is paid only on editing batches.\n\nTwo documented semantic consequences: a write landing the value already there bumps nothing (correct -- no basis was invalidated, so rejecting for it would be a rejection for nothing); and a LayoutGeometry effect that drops cells bumps their records when sharing a batch with a CellData one (an over-bump, the safe direction, the same asymmetry Dirty::of is deliberately sloppy in).\n\nA REAL BUG THE TESTS CAUGHT DURING DEVELOPMENT: the first bump_changed_records chained before.keys() with after.keys() and incremented per name, so any REWRITTEN record counted as two writes. It is a HashSet now. Precisely the class of error a weaker fixture would have hidden -- the fixture is deliberately two categories, two measures, two records, because a one-record fixture cannot tell \"the record it touched\" from \"every record\" and a one-measure fixture cannot tell a record from a cell.\n\n7 tests including a_coordinate_rename_is_one_write_to_each_record_not_one_per_measure (the test that earns the record unit) and the_version_tiers_are_not_in_the_view_state. Five faults injected, each fired.","dependency_count":0,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"improvise-3x3u","title":"Resolve the two unaddressed nullary wire variants before concurrency lands","description":"ADR 001 names this as a prerequisite. ApplyAndClearDrill and AcceptWizardModel are routed by Sessions::apply with NO SessionId, while drill_state and wizard live on the single authoritative App. So client B sending Model(ApplyAndClearDrill) commits client A uncommitted staged edits, and Model(AcceptWizardModel) installs the workbook A half-finished wizard built -- replacing the document under everyone.\n\nThat already collides with the user ruling on improvise-tom1 (staged edits are private to whoever staged them); committing them on someone else behalf is the stronger version of the same violation. ADR 001 makes it untenable outright, because layer 2 turns staged keys into reservations held BY a session -- and a command that commits them cannot be unaddressed.\n\nTWO HONEST OPTIONS:\n (a) make them addressed -- carry a SessionId, and the effect acts only on that session staged state, which means the staging map moves off the single App and onto the session;\n (b) remove them from the wire vocabulary and keep them key-path only.\n(b) is cheaper and consistent with tom1. (a) is what layer 2 eventually wants if staged state becomes per-session anyway. Decide deliberately -- note that (b) is not a permanent answer if multi-client drill is ever wanted.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T23:09:55Z","created_by":"Edward Langley","updated_at":"2026-09-19T23:34:33Z","closed_at":"2026-09-19T23:34:33Z","close_reason":"DECIDED: option (b) -- ApplyAndClearDrill and AcceptWizardModel removed from ModelCommand (28 -\u003e 26 variants). Their effects are unchanged and still reached by the key path.\n\nThe bug was DEMONSTRATED before the fix, not assumed: a temporary test against Sessions::apply -- client A stages a drill edit, client B sends Model(ApplyAndClearDrill) -- PASSED on the pre-change code, committing A uncommitted 99 into the document and clearing A drill session.\n\nRejected (a), carrying a SessionId: it is the honest long-term form but requires the staging map to move off the single App onto the session, which IS ADR 001 layer 2. Landing it inside the correctness floor would make the branch unreviewable. The doc comment says so, so (b) is not mistaken for permanent.\n\nTHE MORE DURABLE PART IS THE CORRECTED CRITERION. Gotcha 38 kept these on the grounds that \"the command is portable even where the operand is not\". Portability was the wrong question. Gotcha 36 already states the right one: an unaddressed command is correct exactly when the change is addressed to NO ONE session -- and these are addressed to precisely one, which is never the sender.\n\nThe demonstration cannot survive its own fix (the variant it sends is gone), so the surviving guard is structural: no_model_command_commits_another_clients_session_local_state walks every_model_command() and forbids either effect, plus a companion asserting both effects still exist and are still model changes, so the first cannot pass by naming nothing. Both proven by injection.","dependency_count":0,"dependent_count":1,"comment_count":0} @@ -389,20 +464,20 @@ {"_type":"issue","id":"improvise-60z","title":"Virtual views _Records and _Drill should not be persisted to .improv files","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-15T11:13:29Z","created_by":"Edward Langley","updated_at":"2026-07-31T16:04:38Z","started_at":"2026-07-31T07:26:39Z","closed_at":"2026-07-31T16:04:38Z","close_reason":"format_md skips views where View::is_virtual(); Initial View falls back to the first real view; parse_md drops stale virtual views so older files self-heal. Three regression tests in persistence.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-c5v","title":"Improve records view data entry by making each measure a normal value column (wide format instead of synthetic Value column)","description":"Current records view uses long format with synthetic 'Value' column. Editing values requires being on the 'Value' column; editing measure column renames the coordinate. This is awkward for typical data entry. Switch to wide format: one column per measure with direct value editing. Update build_records_mode, records_display, cell_key, resolve_display, DrillState pending edits, tests, and related effects. Red-green-refactor cycle. Extends/ reopens improvise-rbv.","status":"closed","priority":2,"issue_type":"feature","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-15T10:34:46Z","created_by":"Edward Langley","updated_at":"2026-08-29T19:29:18Z","started_at":"2026-08-29T19:13:28Z","closed_at":"2026-08-29T19:29:18Z","close_reason":"Records mode switched from long format (synthetic 'Value' column + a _Measure column) to wide format: a row is one coordinate tuple, each raw measure is its own editable column, and a trailing '+' column creates new measures.\n\nDerived the case matrix in Prolog rather than guessing it, which surfaced the load-bearing case the description does not mention: in wide format a row GROUPS one cell per measure, so editing a category column must move every one of those cells. The long-format code moved only the cell under the cursor, which would have torn rows in half — half the measures under the old coordinate, half under the new. Both edit paths (plain records and staged drill edits) implement it; both are tested.\n\nDesign decisions:\n- _Dim in the synthetic CellKey now carries the column INDEX, not a label. A category and a measure item can share a name, so a label cannot say which column an edit landed on. Resolved through GridLayout::record_col -\u003e RecordCol {Category, Measure, NewMeasure}. This rippled through synthetic_record_info, CmdContext::synthetic_record_at_cursor, DrillState.pending_edits (now keyed by (row, RecordCol), which also removes ApplyAndClearDrill's dependency on the layout still being the drill layout), and the grid renderer.\n- Measure columns are the raw _Measure items only. Formula targets are computed, so there is nothing to type into them.\n- The '+' column was the user's call over two alternatives, because a blank model has no measures and wide format would otherwise leave nowhere to enter a number — that is the onboarding path pinned by the old edit_record_row_in_blank_model_persists_value test. Adding a measure is a schema change, so it applies immediately even inside a drill rather than being staged.\n\nVerified end-to-end, not just by unit tests: tests/records_wide_smoke.rs imports a real CSV, loads the .improv, and asserts the records grid is two rows with both measures side by side. My first two expectations there were wrong (measure order and row order come from the file/store, not the source CSV) — the code was right both times.\n\nSix existing tests encoded long-format behavior and were rewritten, not deleted: the two-row helper now uses two Region items rather than two measures on one, and the blank-model test drives the new '+' flow.\n\nAlso removed prune_empty's records branch, which was unreachable behind the function's own early return; filed improvise-2yk in case the early return is itself the bug.","dependency_count":0,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"improvise-edp","title":"Establish ViewModel layer: derived data between RenderCache and widgets","description":"Add a classical MVVM ViewModel layer between the RenderCache (wire-format data sent from server) and the widgets (pure renderers). The ViewModel is computed from RenderCache + ViewState + client-local concerns (terminal size, fonts, theme), memoized on state change, and consumed by widgets. Decouples widgets entirely from cache shape — they only know about the ViewModel they consume. Shared concept across native ratatui and browser DOM rendering; both consume viewmodels rather than caches directly.","design":"For each rendering surface, a pair of types: (1) SurfaceCache — the data the server sends over the wire (raw cells, labels, indices). (2) SurfaceViewModel — the render-ready derived data (styled cell display with highlighting, pre-computed visible row range, column positions in terminal coordinates, cursor-region flags). Paired with a pure function fn compute_surface_viewmodel(cache: \u0026SurfaceCache, view: \u0026ViewState, render_env: \u0026RenderEnv) -\u003e SurfaceViewModel. render_env holds client-local concerns: terminal dimensions for native, window dimensions + device pixel ratio for browser, color theme, unicode width handling. Memoization: recompute viewmodel when cache or view state changes; skip when only render_env changes trivially. Widgets read \u0026SurfaceViewModel — entirely decoupled from cache shape. Benefits: (a) widgets are unit-testable with hand-crafted viewmodels, (b) viewmodel logic is testable without any rendering, (c) ratatui and DOM renderers can share the same viewmodel derivation code, (d) memoization prevents redundant recomputation.","acceptance_criteria":"(1) At least one pair (GridCache, GridViewModel) defined with compute function. (2) GridWidget and DOM grid renderer both consume GridViewModel. (3) Unit tests for viewmodel derivation covering edge cases (empty grid, wide cells, highlights). (4) Memoization hook so recomputation is skipped when neither cache nor view state has changed.","notes":"Sits between improvise-35e (cache adapter) and the widget migration issues (improvise-n10, -pca, -jb3, -764). Each widget migration issue should read its viewmodel, not its cache directly. Shared with the browser epic: improvise-cr3 (DOM renderer) should also consume viewmodels. This is the refinement the user asked for — 'the viewmodel is probably a good idea as well'.\n--- DECIDED 2026-09-19 (user) ---\nRenderCache is PER-WIDGET, and state synchronizes by EVENTS, not snapshots.\n\nThis is shape (C) from improvise-rkeq, with per-widget caches:\n * No flat grid-only wire type and no fat aggregate container. Each widget\n owns its own cache type (GridCache, CategoryTreeCache, FormulaCache,\n ViewCache, TileBarCache, WizardCache, HelpCache).\n * Each widget subscribes to and FOLDS its own stream of projection\n Commands. There is no aggregate RenderCache on the wire.\n * The ONE exception is the initial state transfer: a newly connected\n client gets a Snapshot to seed from, then follows the event stream.\n Snapshot stays bootstrap-only (wire_carries(snapshot,bootstrap_only)).\n\nConsequence for this ticket: read \"RenderCache\" as \"that widget's own cache\ntype\", and expect updates to arrive as folded projection commands rather\nthan as a rebuilt snapshot.\n\n--- UNBLOCKED: improvise-vjms decided (user, 2026-09-19) ---\nThe category panel question is answered: the TUI KEEPS the virtual categories\nand the BROWSER GETS THEM TOO. CategoryTreeCache grows to carry _Index, _Dim\nand _Measure so both panels render the same thing from one source.\n\nTHE TRAP TO AVOID when implementing: _Measure items are DERIVED, not stored --\ngotcha 7 says they are the explicit items UNION the formula targets, and\nadd_formula does not add items to _Measure. So category_tree_of must compute\nmembership through measure_item_names()/effective_item_names(), NEVER through\ncategory(\"_Measure\").items, or the panel silently omits every formula target.\nimprovise-ete was closed for proposing exactly that raw-list getter.\n\nAlso: CategoryTreeCache doc comment says \"the client does not know the\n_-prefixed virtual-category convention\" -- that is now false and must be\nremoved rather than left to mislead.\n\nThen build_cat_tree can migrate off \u0026Model (it takes \u0026Model today), which is\nwhat actually unblocks the category panel and this ticket. improvise-35e\nmigrated the other three panels already; this is the fourth.","status":"closed","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:53:04Z","created_by":"spot","updated_at":"2026-09-20T00:25:56Z","closed_at":"2026-09-20T00:25:56Z","close_reason":"PREMISE LARGELY FALSE; the one real piece was built and the rest should not be.\n\nThe ticket asked for a SurfaceViewModel per SurfaceCache plus compute_*(cache, view, render_env). Evidence it does not apply: (a) gotcha 32 already removed the aggregate RenderCache the ticket names, and (b) after improvise-35e the widget seam is ALREADY one cache plus \u0026AppMode -- FormulaContent::new(\u0026FormulaCache, \u0026AppMode), ViewContent::new(\u0026ViewListCache, \u0026str). A per-widget cache built by project.rs is already the render-ready shape: CellDisplay carries formatted strings, GridCache carries col_widths. There is no cache-shape to decouple a widget from. render_env is likewise handled -- the terminal extent enters through ClientSession::set_extent, and inventing a Command for it is forbidden by decision(improvise-cqi, command_set_derived_not_invented).\n\nWHAT WAS REAL AND GOT BUILT: the category panel flattening, which genuinely sits between a cache and a renderer because it mixes CategoryTreeCache with per-client ViewState::expanded_cats. It lives in improvise-protocol::viewmodel (CatTreeEntry + flatten_cat_tree), NOT improvise-command, because improvise-bpq2 cannot reach improvise-command -- the wasm client Cargo.toml excludes it deliberately -- and move-panel-cursor walks this flattening. Both expanded_cats and cat_panel_cursor already live in protocol ViewState, so the derivation inputs and its consumer index are all in that crate. Putting it in improvise-command would have guaranteed bpq2 re-derives it client-side and the two ends drift.\n\nALSO DELIVERED: improvise-vjms. CategoryTreeCache now carries every category; the stale \"the client does not know the _-prefixed convention\" doc is gone; CatTreeEntry carries its Axis so CategoryContent takes a cache and an expanded set and no View -- the FOURTH AND LAST panel off \u0026Model. It also removes a latent panic: the widget called view.axis_of(name), which panics for a category the view has never seen.\n\nA LIVE SHIPPED BUG WAS FIXED, not a hypothetical one: build_cat_tree took \u0026Model and read cat.items.len()/ordered_item_names() -- the RAW list -- so the TUI category panel has been showing _Measure with the WRONG ITEM COUNT and OMITTING EVERY FORMULA TARGET, on main, today. That is the improvise-ete failure mode already shipped. Injecting it back fails exactly ONE test against 1023 green.\n\nNOT BUILT AND NOT WORTH A TICKET AS SPECCED: viewmodels for grid/formula/view/tile-bar. 1031 tests pass.","dependencies":[{"issue_id":"improvise-edp","depends_on_id":"improvise-35e","type":"blocks","created_at":"2026-04-14T00:53:35Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":6,"comment_count":0} -{"_type":"issue","id":"improvise-e0u","title":"Split native binary into in-process client + server, delete \u0026App adapter","description":"The architectural payoff: once every widget consumes RenderCache + ViewState, restructure the native binary so the ratatui UI is a proper client of an in-process server. Main loop has two halves: server holds the full App + projection layer + VFS persistence; client holds ViewState + RenderCache + ratatui widgets. Commands flow through a direct-call channel (zero-serialization in-process transport). Delete the temporary RenderCache::from_app adapter.","design":"Main loop restructure: fn main() spawns (a) a server loop that owns an App + projection layer + VFS storage (via improvise-6mq Storage trait backed by PhysicalFS for native), and (b) a client loop that owns a ViewState + RenderCache and renders ratatui. Communication via a tokio mpsc channel (or plain std::sync::mpsc since native is single-threaded) — no serialization because both sides hold the same Command enum in memory. Client captures keys → resolves via keymap → sends Command upstream → server calls reduce_full → projection commands come back down → client applies them via reduce_view → widgets redraw. Importantly, this is exactly the same message flow as the browser modes; the transport is just a function call. Delete crates/improvise-command/src/ui/app.rs's RenderCache::from_app adapter (issue 2). Native TUI's App now lives on the server side and is never read directly by widgets.","acceptance_criteria":"(1) Native binary has distinct client and server halves with a Command channel between them. (2) Ratatui widgets never read \u0026App. (3) RenderCache::from_app adapter is deleted. (4) All existing tests + integration tests pass. (5) Performance: frame latency unchanged from current native TUI (the projection work is now on the server side, the rendering work on the client side, but it's all the same process). (6) A structural lint: nothing in src/ui/ can import from src/model/ or src/command/ (only from improvise-protocol's cache types).","notes":"Terminal node of the required work in this epic. Depends on issues 1 (reduce_full), 2 (adapter), 3-6 (all widget migrations). After this lands, the native TUI and the browser modes share an identical architecture — only the transport and persistence backend differ.\nDEFERRED 2026-09-19 by user ruling during the whole-backlog campaign.\n\nDeferred as a campaign of its own, not as a rejection. e0u restructures the native binary around an in-process client/server split and deletes the RenderCache::from_app adapter -- it touches draw.rs, every widget, App and the projection layer, so it would conflict with essentially every other work unit in the backlog and would have to go last with everything rebasing onto it.\n\nThe cheap members of its cluster ARE being done now: improvise-2ud (CmdContext borrows the slices), improvise-drg (narrow Effect::apply where one-slice), improvise-s17o (decide reduce_full's fate), improvise-i5tk (benchmark the whole-grid projection first). Those are the parts that do not require the split.\n\nUn-defer when the browser/protocol backlog has settled and e0u can have a wave to itself.","status":"deferred","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:50:54Z","created_by":"spot","updated_at":"2026-09-20T05:31:01Z","dependencies":[{"issue_id":"improvise-e0u","depends_on_id":"improvise-35e","type":"blocks","created_at":"2026-04-14T00:53:39Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-e0u","depends_on_id":"improvise-764","type":"blocks","created_at":"2026-04-14T00:53:42Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-e0u","depends_on_id":"improvise-gxi","type":"blocks","created_at":"2026-04-14T00:53:38Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-e0u","depends_on_id":"improvise-jb3","type":"blocks","created_at":"2026-04-14T00:53:41Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-e0u","depends_on_id":"improvise-n10","type":"blocks","created_at":"2026-04-14T00:53:40Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-e0u","depends_on_id":"improvise-pca","type":"blocks","created_at":"2026-04-14T00:53:40Z","created_by":"spot","metadata":"{}"}],"dependency_count":6,"dependent_count":2,"comment_count":0} +{"_type":"issue","id":"improvise-e0u","title":"Split native binary into in-process client + server, delete \u0026App adapter","description":"The architectural payoff: once every widget consumes RenderCache + ViewState, restructure the native binary so the ratatui UI is a proper client of an in-process server. Main loop has two halves: server holds the full App + projection layer + VFS persistence; client holds ViewState + RenderCache + ratatui widgets. Commands flow through a direct-call channel (zero-serialization in-process transport). Delete the temporary RenderCache::from_app adapter.","design":"Main loop restructure: fn main() spawns (a) a server loop that owns an App + projection layer + VFS storage (via improvise-6mq Storage trait backed by PhysicalFS for native), and (b) a client loop that owns a ViewState + RenderCache and renders ratatui. Communication via a tokio mpsc channel (or plain std::sync::mpsc since native is single-threaded) — no serialization because both sides hold the same Command enum in memory. Client captures keys → resolves via keymap → sends Command upstream → server calls reduce_full → projection commands come back down → client applies them via reduce_view → widgets redraw. Importantly, this is exactly the same message flow as the browser modes; the transport is just a function call. Delete crates/improvise-command/src/ui/app.rs's RenderCache::from_app adapter (issue 2). Native TUI's App now lives on the server side and is never read directly by widgets.","acceptance_criteria":"(1) Native binary has distinct client and server halves with a Command channel between them. (2) Ratatui widgets never read \u0026App. (3) RenderCache::from_app adapter is deleted. (4) All existing tests + integration tests pass. (5) Performance: frame latency unchanged from current native TUI (the projection work is now on the server side, the rendering work on the client side, but it's all the same process). (6) A structural lint: nothing in src/ui/ can import from src/model/ or src/command/ (only from improvise-protocol's cache types).","notes":"Terminal node of the required work in this epic. Depends on issues 1 (reduce_full), 2 (adapter), 3-6 (all widget migrations). After this lands, the native TUI and the browser modes share an identical architecture — only the transport and persistence backend differ.\nDEFERRED 2026-09-19 by user ruling during the whole-backlog campaign.\n\nDeferred as a campaign of its own, not as a rejection. e0u restructures the native binary around an in-process client/server split and deletes the RenderCache::from_app adapter -- it touches draw.rs, every widget, App and the projection layer, so it would conflict with essentially every other work unit in the backlog and would have to go last with everything rebasing onto it.\n\nThe cheap members of its cluster ARE being done now: improvise-2ud (CmdContext borrows the slices), improvise-drg (narrow Effect::apply where one-slice), improvise-s17o (decide reduce_full's fate), improvise-i5tk (benchmark the whole-grid projection first). Those are the parts that do not require the split.\n\nUn-defer when the browser/protocol backlog has settled and e0u can have a wave to itself.","status":"deferred","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:50:54Z","created_by":"spot","updated_at":"2026-09-20T05:31:01Z","dependencies":[{"issue_id":"improvise-e0u","depends_on_id":"improvise-35e","type":"blocks","created_at":"2026-04-14T00:53:39Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-e0u","depends_on_id":"improvise-764","type":"blocks","created_at":"2026-04-14T00:53:42Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-e0u","depends_on_id":"improvise-gxi","type":"blocks","created_at":"2026-04-14T00:53:38Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-e0u","depends_on_id":"improvise-jb3","type":"blocks","created_at":"2026-04-14T00:53:41Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-e0u","depends_on_id":"improvise-ltq","type":"parent-child","created_at":"2026-09-25T01:07:40Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-e0u","depends_on_id":"improvise-n10","type":"blocks","created_at":"2026-04-14T00:53:40Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-e0u","depends_on_id":"improvise-pca","type":"blocks","created_at":"2026-04-14T00:53:40Z","created_by":"spot","metadata":"{}"}],"dependency_count":6,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"improvise-764","title":"Migrate import wizard widget and state to consume RenderCache","description":"Rewrite ui/import_wizard_ui.rs (347 lines) to consume a WizardCache instead of the ImportWizard struct directly. Also decide where the import wizard state itself lives in the new crate layout — probably needs to stay in improvise-command (not improvise-io) so it can be referenced from App without creating a dep cycle.","design":"WizardCache mirrors the ImportWizard state: current step, per-field decisions, preview rows, current validation errors. The widget reads cache + ViewState and draws. The ImportWizard state machine stays wherever it ends up living after the crate-split epic step 3 resolves its placement — either improvise-command (portable state machine) or improvise-io (tangled with CSV parsing). The separation between wizard state (session/view-ish) and import pipeline (pure data processing) is worth getting right because it affects the worker-server's ability to run imports.","acceptance_criteria":"(1) import_wizard_ui.rs no longer reads the wizard state directly — takes WizardCache. (2) ImportWizard is split into state + pipeline; state lives in an appropriate crate that doesn't cycle. (3) Native TUI wizard rendering unchanged. (4) Unit tests with fixture WizardCaches for each step.","notes":"Depends on improvise-edp (ViewModel layer). Wizard widget consumes WizardViewModel derived from WizardCache + ViewState. The wizard state machine itself (step tracking, field decisions) lives in the ImportWizard struct on the server side; the cache is a snapshot of its renderable state; the viewmodel adds any styling/layout derivation the widget needs.\nPRIORITY RAISED P3-\u003eP2 (groomed 2026-08-30): this gates P2 work (improvise-ltq improvise-e0u ), so leaving it P3 made 'bd ready' unworkable in priority order — the P2 cannot be reached without it. Priority should be non-decreasing along dependency edges. Revert if the intent was that the downstream P2 is genuinely not wanted yet.\n--- DECIDED 2026-09-19 (user) ---\nRenderCache is PER-WIDGET, and state synchronizes by EVENTS, not snapshots.\n\nThis is shape (C) from improvise-rkeq, with per-widget caches:\n * No flat grid-only wire type and no fat aggregate container. Each widget\n owns its own cache type (GridCache, CategoryTreeCache, FormulaCache,\n ViewCache, TileBarCache, WizardCache, HelpCache).\n * Each widget subscribes to and FOLDS its own stream of projection\n Commands. There is no aggregate RenderCache on the wire.\n * The ONE exception is the initial state transfer: a newly connected\n client gets a Snapshot to seed from, then follows the event stream.\n Snapshot stays bootstrap-only (wire_carries(snapshot,bootstrap_only)).\n\nConsequence for this ticket: read \"RenderCache\" as \"that widget's own cache\ntype\", and expect updates to arrive as folded projection commands rather\nthan as a rebuilt snapshot.","status":"closed","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:50:47Z","created_by":"spot","updated_at":"2026-09-20T00:58:17Z","closed_at":"2026-09-20T00:58:17Z","close_reason":"Done, and the ticket's open design question is answered.\n\nThe widget is rewritten against a new WizardCache (+ WizardStepKind, WizardFieldKind, WizardProposal, WizardDateField) in improvise-protocol::render, built by project::wizard_cache / wizard_of. ImportWizard, ImportPipeline, FieldKind, DateComponent and Workbook all stop at the projection.\n\nTHE ANSWER TO 'WHERE DOES THE WIZARD STATE LIVE': THERE IS DELIBERATELY NO Projection::Wizard. A Projection is addressed to a widget on EVERY client, which is correct exactly when the change is addressed to NO ONE session. A half-open import dialogue belongs to precisely the session that opened it. This follows settled doctrine rather than re-deciding it -- ImportWizard is on App and not ViewState (gotcha 30), and AcceptWizardModel was cut from ModelCommand for exactly this reason (gotcha 38 / improvise-tom1). Broadcasting the wizard would re-create that bug on the RENDERING side: every client drawing one client's dialogue.\n\nSo: a cache WITHOUT a projection is the shape session-local residue takes when it still has to be drawn by two different front ends. This resolves the open question gotcha 32 parks on this ticket.\n\nThe type still lives in improvise-protocol rather than improvise-command for the flatten_cat_tree reason: the wasm client reaches only the protocol, and when it grows a wizard it must draw THESE rows, not a second derivation.\n\nMulti-client import wants the ADDRESSED form, which is ADR 001 layer 2.\n\nONE GAP: WizardStep::Preview is unreachable from both fixtures -- it needs a document that parses to zero records without needing path selection. Filed.","dependencies":[{"issue_id":"improvise-764","depends_on_id":"improvise-edp","type":"blocks","created_at":"2026-04-14T00:53:38Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} {"_type":"issue","id":"improvise-jb3","title":"Migrate help and which_key widgets to consume RenderCache + ViewState","description":"Rewrite the small static-content widgets: ui/help.rs (5-page help overlay, 617 lines but mostly static text) and ui/which_key.rs (prefix-key hint popup). Lowest-risk widgets in the migration because their content is almost entirely derived from static data plus a small amount of ViewState (help_page index, active transient_keymap).","design":"HelpCache: largely static; just the page-index state and the content pages (constant). Could even be stateless if the renderer reads help_page directly from ViewState. WhichKeyCache: derived from the active transient_keymap — a flat list of (key, binding description) entries. Each widget becomes a pure render function taking its cache + ViewState for minimal dynamic bits.","acceptance_criteria":"(1) help.rs and which_key.rs no longer import App. (2) Unit-testable with fixture caches (or no cache for help). (3) Native TUI rendering unchanged.","notes":"Depends on improvise-edp (ViewModel layer). Help widget consumes HelpViewModel (derived from the static help content + current help_page); which_key consumes WhichKeyViewModel (derived from the active transient_keymap). Low-complexity widgets, but go through the viewmodel layer for consistency with the rest of the migration.\nPRIORITY RAISED P3-\u003eP2 (groomed 2026-08-30): this gates P2 work (improvise-e0u improvise-ltq ), so leaving it P3 made 'bd ready' unworkable in priority order — the P2 cannot be reached without it. Priority should be non-decreasing along dependency edges. Revert if the intent was that the downstream P2 is genuinely not wanted yet.\nSTALE ASSUMPTION (groomed 2026-08-30): this design refers three times to 'the active transient_keymap' as a ViewState field. That field is gone (improvise-jc8). ViewState now holds transient_prefix: Option\u003c(KeyCode, KeyModifiers)\u003e — the key that OPENED the prefix — and the sub-keymap is resolved on demand via KeymapSet::prefix_keymap(mode, key, mods), which walks parent keymaps.\n\nConsequence for the WhichKeyCache described here: it cannot be derived from ViewState alone any more. It needs ViewState (for the armed prefix) plus the keymap set (to resolve it and call binding_hints()). App::armed_prefix_keymap() does exactly that today and is the natural source. This matters because the whole point of jb3 is that widgets stop reading \u0026App — so decide whether the cache is populated server-side from the resolved keymap, or whether the hint list itself is what crosses to the client. The latter is probably right: hints are (label, command-name) pairs, which are portable, whereas a Keymap is not.\n--- DECIDED 2026-09-19 (user) ---\nRenderCache is PER-WIDGET, and state synchronizes by EVENTS, not snapshots.\n\nThis is shape (C) from improvise-rkeq, with per-widget caches:\n * No flat grid-only wire type and no fat aggregate container. Each widget\n owns its own cache type (GridCache, CategoryTreeCache, FormulaCache,\n ViewCache, TileBarCache, WizardCache, HelpCache).\n * Each widget subscribes to and FOLDS its own stream of projection\n Commands. There is no aggregate RenderCache on the wire.\n * The ONE exception is the initial state transfer: a newly connected\n client gets a Snapshot to seed from, then follows the event stream.\n Snapshot stays bootstrap-only (wire_carries(snapshot,bootstrap_only)).\n\nConsequence for this ticket: read \"RenderCache\" as \"that widget's own cache\ntype\", and expect updates to arrive as folded projection commands rather\nthan as a rebuilt snapshot.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-14T07:50:23Z","created_by":"spot","updated_at":"2026-09-20T00:58:16Z","closed_at":"2026-09-20T00:58:16Z","close_reason":"The widget half was already done, and the ticket's own premise was stale -- its grooming note said so and was right.\n\nHelpWidget::new(help_page: usize) and WhichKeyWidget::new(\u0026[(String, \u0026'static str)]) were NEVER \u0026App-coupled; the git history shows no migration because none was needed. The which-key derivation happens in draw.rs, not in the widget: App::armed_prefix_keymap() + Keymap::binding_hints(). The widget is already a pure renderer of the hint list.\n\nWHAT REMAINS IS A PROTOCOL GAP, NOT A WIDGET PROBLEM, and it is filed as its own ticket: ViewState::transient_prefix is portable, but THE KEYMAP SET IS NOT ON THE CLIENT, so a browser which-key cannot be derived from ViewState alone. Either the resolved hint list crosses (a projection or a ViewState field) or the client gets the keymaps. That is a real decision and it was never this ticket's.","dependencies":[{"issue_id":"improvise-jb3","depends_on_id":"improvise-edp","type":"blocks","created_at":"2026-04-14T00:53:37Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} {"_type":"issue","id":"improvise-pca","title":"Migrate panel widgets (category/formula/view/tile_bar) to consume RenderCache","description":"Rewrite the smaller ratatui panel widgets so each one takes its relevant sub-cache slice instead of \u0026App. Targets: ui/category_panel.rs + cat_tree.rs, ui/formula_panel.rs, ui/view_panel.rs, ui/tile_bar.rs, ui/panel.rs (generic frame). Each becomes a pure renderer of its own cache structure.","design":"Four sub-cache types: CategoryTreeCache (flattened cat/item/group tree with expand state, current cursor index), FormulaListCache (raw strings + target info), ViewListCache (names + active flag), TileBarCache (per-category axis assignment). Each widget's render takes \u0026SubCache and \u0026ViewState (for mode / cursor). Widget signatures: fn draw_category_panel(frame, area, cache: \u0026CategoryTreeCache, view: \u0026ViewState); same shape for the others. The flattening logic that's currently in ui/cat_tree.rs (build_cat_tree) moves into cache construction rather than happening at render time. Handcrafted fixture caches for unit tests. Panels become entirely render-only; no App dependency. Shared with the browser/standalone clients later if they ever add panel rendering (post-MVP for those).","acceptance_criteria":"(1) Each of the four panel widgets no longer imports App. (2) Each is unit-testable with a fixture sub-cache. (3) Native TUI renders identically to before. (4) Sub-cache types live in improvise-protocol so they can be serialized for remote clients later.","notes":"Depends on improvise-edp (ViewModel layer). Each panel widget consumes its own sub-viewmodel (CategoryTreeViewModel, FormulaListViewModel, ViewListViewModel, TileBarViewModel), computed from the corresponding sub-cache + ViewState. Panels become pure renderers of viewmodels; all flattening, ordering, and highlight logic lives in the compute_*_viewmodel functions.\n--- DECIDED 2026-09-19 (user) ---\nRenderCache is PER-WIDGET, and state synchronizes by EVENTS, not snapshots.\n\nThis is shape (C) from improvise-rkeq, with per-widget caches:\n * No flat grid-only wire type and no fat aggregate container. Each widget\n owns its own cache type (GridCache, CategoryTreeCache, FormulaCache,\n ViewCache, TileBarCache, WizardCache, HelpCache).\n * Each widget subscribes to and FOLDS its own stream of projection\n Commands. There is no aggregate RenderCache on the wire.\n * The ONE exception is the initial state transfer: a newly connected\n client gets a Snapshot to seed from, then follows the event stream.\n Snapshot stays bootstrap-only (wire_carries(snapshot,bootstrap_only)).\n\nConsequence for this ticket: read \"RenderCache\" as \"that widget's own cache\ntype\", and expect updates to arrive as folded projection commands rather\nthan as a rebuilt snapshot.","status":"closed","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:49:06Z","created_by":"spot","updated_at":"2026-09-20T00:58:16Z","closed_at":"2026-09-20T00:58:16Z","close_reason":"Already done -- delivered by improvise-35e (92fd3a8) and improvise-vjms (986af9e), both already on main. No work was needed and none was invented.\n\nEVIDENCE: CategoryContent::new(\u0026CategoryTreeCache, \u0026HashSet\u003cString\u003e), FormulaContent::new(\u0026FormulaCache, \u0026AppMode), ViewContent::new(\u0026ViewListCache, \u0026str), TileBar::new(\u0026TileBarCache, \u0026AppMode, usize). None takes \u0026App, \u0026Model, \u0026View or \u0026Workbook outside its test module.\n\nONE RESIDUAL DERIVATION, filed separately rather than fixed: ViewContent::axis_summary hard-codes !cat.starts_with('_') to filter virtual categories out of the axis summary, while ViewSummary::axes carries EVERY category. That is the same convention improvise-vjms deliberately pushed onto the client for the category panel, still applied locally by the view panel. Not a bug today -- there is no browser view panel -- but it is a second, divergent answer waiting.","dependencies":[{"issue_id":"improvise-pca","depends_on_id":"improvise-edp","type":"blocks","created_at":"2026-04-14T00:53:36Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} {"_type":"issue","id":"improvise-n10","title":"Migrate GridWidget to consume RenderCache + ViewState (not \u0026App)","description":"Rewrite ui/grid.rs so that GridWidget::render takes \u0026GridCache + \u0026ViewState instead of reaching into \u0026App. The grid is the largest and most important widget — 1036 lines, mixes layout calc + ratatui drawing + direct App reads. The migration separates 'what to draw' (data in GridCache) from 'how to draw it' (ratatui primitives). Also aligns with improvise-cr3 (browser DOM renderer), which renders the same GridCache shape to DOM — share the GridCache type between them.","design":"pub struct GridCache { pub col_widths: Vec\u003cu16\u003e, pub row_labels: Vec\u003cString\u003e, pub col_labels: Vec\u003cString\u003e, pub cells: Vec\u003cVec\u003cCellDisplay\u003e\u003e, pub cursor: (usize, usize), pub highlights: Vec\u003cHighlight\u003e, pub mode_indicator: ModeIndicator, ... }. GridWidget becomes: fn draw_grid(frame: \u0026mut Frame, area: Rect, cache: \u0026GridCache, view: \u0026ViewState). All current dynamic calculations (col width from content, layout metrics, records mode detection) move to GridCache construction in the projection layer, not at render time. Widget is pure drawing. The temporary RenderCache::from_app adapter (issue 2) populates GridCache from App+View+GridLayout; later the projection layer computes it server-side for remote clients. During this migration, the widget is tested against a handcrafted GridCache fixture — no App needed. Share GridCache type with improvise-cr3 by placing it in improvise-protocol.","acceptance_criteria":"(1) GridWidget no longer imports App or reads anything outside GridCache + ViewState. (2) Widget is unit-testable with a fixture GridCache. (3) Native TUI renders identically to before for every .improv file in tests/. (4) GridCache type is shared with improvise-cr3 (both rasterize the same data structure). (5) Performance: per-frame render time unchanged (the projection layer work happens outside the widget, not inside).","notes":"Depends on issue improvise-edp (ViewModel layer) — the grid widget consumes GridViewModel (client-computed from GridCache + ViewState + render env), not GridCache directly. Share design work with improvise-cr3 (browser DOM renderer) — both target the same GridViewModel type, differing only in the rendering backend.\n--- DECIDED 2026-09-19 (user) ---\nRenderCache is PER-WIDGET, and state synchronizes by EVENTS, not snapshots.\n\nThis is shape (C) from improvise-rkeq, with per-widget caches:\n * No flat grid-only wire type and no fat aggregate container. Each widget\n owns its own cache type (GridCache, CategoryTreeCache, FormulaCache,\n ViewCache, TileBarCache, WizardCache, HelpCache).\n * Each widget subscribes to and FOLDS its own stream of projection\n Commands. There is no aggregate RenderCache on the wire.\n * The ONE exception is the initial state transfer: a newly connected\n client gets a Snapshot to seed from, then follows the event stream.\n Snapshot stays bootstrap-only (wire_carries(snapshot,bootstrap_only)).\n\nConsequence for this ticket: read \"RenderCache\" as \"that widget's own cache\ntype\", and expect updates to arrive as folded projection commands rather\nthan as a rebuilt snapshot.","status":"closed","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:49:03Z","created_by":"spot","updated_at":"2026-09-20T00:58:15Z","closed_at":"2026-09-20T00:58:15Z","close_reason":"Done, partially -- and the part left undone is the finding.\n\nGridWidget no longer holds a \u0026Model. Three derivations moved:\n * COLUMN TOTALS -\u003e GridCache::totals + project::column_totals. The widget was summing every row of each visible column through evaluate_aggregated_f64. This MUST be projected, not client-derived: the sum runs over every row INCLUDING THE ONES A VIEWPORT SCROLLED PAST, so a client holding only its window cannot compute it. An EMPTY strip means 'no totals' (records mode / empty grid) -- deliberately not a row of blanks, and fit_to_labels pads it only once non-empty. Invalidated blanks the totals: they are contents, and a stale sum under fresh cells is the one number a user takes at face value.\n * PAGE BAR -\u003e GridCache::page_coords, framed by nothing (it names categories, not positions) so it survives a reshape.\n * THE ITALIC-AGGREGATE RULE -\u003e read off CellId::Aggregate (ADR 002), which already carried the fact. The widget had been walking none_cats x Model::category(..).kind.is_regular() -- a second, hand-rolled copy of what project::cell_id decides.\n\nNOT DONE, DELIBERATELY: THE GRID'S FRAME. Six facts still come off GridLayout + View -- header level count (row_cats/col_cats), multi-level label TUPLES and inline GROUP HEADERS (row_items), column group spans (col_group_for), the collapse indicator (is_group_collapsed), and record_col (session-local by design, since it keys pending drill edits -- that one is CORRECT to keep off).\n\nTHE HEADLINE, AND IT IS A LIVE DIVERGENCE TODAY: GridCache::row_labels is GridLayout::row_label, which JOINS A MULTI-LEVEL TUPLE WITH '/'. That is exactly the rendering src/ui/grid.rs::two_row_categories_produce_cross_product_labels asserts must NOT appear. So the protocol carries a form of the labels the TUI considers wrong, and the wasm client renders precisely that form: the browser grid shows Food/Alice where the TUI shows two header columns, with no multi-level headers, no repeat suppression, no groups and no collapse indicators. Successor ticket filed -- it is a co-ordinated change (row_labels/col_labels change TYPE and improvise-wasm-client consumes them), and an additive row_levels beside row_labels is the wrong fix, because two answers to 'what are the labels' is the disease.\n\nUNVERIFIED: project::grid_of now evaluates every cell per frame where GridWidget evaluated only the visible ones. The totals loop was already O(rows x visible cols) and compute_col_widths was already whole-grid, so this WIDENS an existing cost rather than introducing one -- but on a wide grid it is a real widening and there is no benchmark. The fix would be to give the TUI a real viewport, which needs the width/window chicken-and-egg untangled first.","dependencies":[{"issue_id":"improvise-n10","depends_on_id":"improvise-edp","type":"blocks","created_at":"2026-04-14T00:53:36Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} {"_type":"issue","id":"improvise-35e","title":"Temporary adapter: RenderCache::from_app bridge for incremental widget migration","description":"Build a function that constructs a complete RenderCache from an \u0026App on every frame. This is a temporary bridge that lets ratatui widgets migrate from '\u0026App direct reads' to 'RenderCache consumer' one at a time, without breaking the build. Each migrated widget reads from the adapter's output; unmigrated widgets keep reading \u0026App. When every widget has been migrated and the binary split is complete, this adapter is deleted.","design":"pub fn RenderCache::from_app(app: \u0026App, subs: SubscriptionSet) -\u003e RenderCache. Walks the full App state and populates every subscribed-to sub-cache: grid cells/labels/widths, category tree, formula list, view list, tile bar axes, wizard state, etc. SubscriptionSet is the full set initially (native TUI subscribes to everything); later issues refine it per client. Called on every frame from run_tui before drawing. Unperformant by construction (rebuilds everything per frame) but correct, and it's throwaway code. Cache shape: pub struct RenderCache { grid: Option\u003cGridCache\u003e, category_tree: Option\u003cCategoryTreeCache\u003e, formulas: Option\u003cFormulaListCache\u003e, views: Option\u003cViewListCache\u003e, tile_bar: Option\u003cTileBarCache\u003e, wizard: Option\u003cWizardCache\u003e, help: Option\u003cHelpCache\u003e, ... }. Each field is Some if subscribed and populated from App data.","acceptance_criteria":"(1) RenderCache struct exists with optional sub-caches for every rendering surface. (2) from_app populates all subscribed sub-caches from current App state. (3) run_tui calls from_app once per frame before drawing. (4) Tests demonstrate the cache contents match what the corresponding widgets would read from App directly.","notes":"Throwaway code explicitly marked as such. Deleted after issue 7 (binary split) lands. Depends on issue 1 (reduce_full) only because it needs to know what the cache shape should be, which is determined by what widgets read.\nGROOMED 2026-09-19: the RenderCache that actually landed in improvise-cqi is grid-only (row_labels/col_labels/col_widths/cells) — not the 'grid + category_tree + formulas + views + tile_bar + wizard + help' shape this ticket's design describes. Blocked on improvise-rkeq until the shape is decided; writing from_app against the wrong one is the expensive mistake.\n--- DECIDED 2026-09-19 (user) ---\nRenderCache is PER-WIDGET, and state synchronizes by EVENTS, not snapshots.\n\nThis is shape (C) from improvise-rkeq, with per-widget caches:\n * No flat grid-only wire type and no fat aggregate container. Each widget\n owns its own cache type (GridCache, CategoryTreeCache, FormulaCache,\n ViewCache, TileBarCache, WizardCache, HelpCache).\n * Each widget subscribes to and FOLDS its own stream of projection\n Commands. There is no aggregate RenderCache on the wire.\n * The ONE exception is the initial state transfer: a newly connected\n client gets a Snapshot to seed from, then follows the event stream.\n Snapshot stays bootstrap-only (wire_carries(snapshot,bootstrap_only)).\n\nConsequence for this ticket: read \"RenderCache\" as \"that widget's own cache\ntype\", and expect updates to arrive as folded projection commands rather\nthan as a rebuilt snapshot.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-14T07:48:38Z","created_by":"spot","updated_at":"2026-09-19T23:40:22Z","closed_at":"2026-09-19T23:40:22Z","close_reason":"PREMISE CHECK FIRST, and the ticket as written is DEAD: RenderCache::from_app(app, subs) -\u003e RenderCache cannot be written because that aggregate does not exist and will not (improvise-rkeq, gotcha 32). It was not written. The builders it asked for already existed and were already pub -- grid_of, category_rows, formula_texts, view_summaries, tile_entries -- and Dirty already answers the SubscriptionSet, so no parallel type was invented.\n\nTWO THINGS WERE GENUINELY MISSING, both real:\n1. THE ROOT CRATE COULD NOT NAME A CACHE TYPE. improvise had no improvise-protocol dependency and improvise-command re-exported core/formula/io but NOT protocol, so no ratatui widget in src/ could write fn new(cache: \u0026FormulaCache). That was the hard blocker and nothing complained because nobody had tried. Fixed by re-exporting the protocol through improvise-command (and the root crate), so a host names the caches through the crate that builds them and the root takes no new dependency.\n2. THE BUILDERS RETURNED PAYLOADS, NOT CACHES. Added category_tree_of/formulas_of/view_list_of/tile_bar_of, each one line over the existing payload fn so there is no second derivation. document_projections and the tests server_truth now both derive FROM the caches, so what a widget holds and what a client folds cannot disagree.\n\nThree widgets migrated off \u0026Model/\u0026View: formula panel, view panel, tile bar. No aggregate type and no adapter type -- a caller wanting several caches calls several builders, and no widget names the seam, so deleting the temporary half is deleting call sites.\n\n6 tests, each an EQUIVALENCE between the cache and what the widget used to read off the model (plus pinned render output where drawing has layout logic). Three faults injected into the builders, all fired. 970 workspace tests pass.","dependencies":[{"issue_id":"improvise-35e","depends_on_id":"improvise-gxi","type":"blocks","created_at":"2026-04-14T00:53:34Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-35e","depends_on_id":"improvise-rkeq","type":"blocks","created_at":"2026-09-19T12:49:24Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":3,"comment_count":0} {"_type":"issue","id":"improvise-gxi","title":"Define unified reduce_full(\u0026mut App, \u0026Command) -\u003e Vec\u003cCommand\u003e","description":"Extract the single shared reducer function that all three deployment modes use to apply a Command to the authoritative state. Replaces today's App::handle_key → Cmd::execute → Effect::apply chain with a named function that takes a Command (data, not trait object), runs the command through the existing registry + effect pipeline, and returns any outbound projection commands. Load-bearing for the entire unified architecture — native TUI, ws-server, and worker-server all import this same function.","design":"Signature: pub fn reduce_full(app: \u0026mut App, cmd: \u0026Command) -\u003e Vec\u003cCommand\u003e. Internally: (1) resolve the Command to a Cmd trait object via the registry (or match directly on the Command enum), (2) build a CmdContext from the current App state, (3) run execute() to produce effects, (4) apply each effect to App, (5) walk the effect list and compute outbound projection Commands for each subscribing client (empty if no subscribers). The function lives in improvise-command (which requires App to move there too — see epic notes). Command enum is defined in improvise-protocol (improvise-cqi). The native TUI's existing App::handle_key becomes a thin wrapper: resolve the key to a Command via the keymap, call reduce_full, drop the returned projections (single-session native has no remote subscribers). The ws-server and worker-server call reduce_full directly on received Commands and dispatch the returned projections back over their respective transports.","acceptance_criteria":"(1) pub fn reduce_full exists in improvise-command. (2) App::handle_key is rewritten to resolve key → Command via keymap, then call reduce_full, then drop projections. (3) All existing native TUI behavior is preserved — integration and unit tests pass unchanged. (4) Keymap lookup returns Command values (data) rather than Box\u003cdyn Cmd\u003e trait objects. (5) CmdRegistry's dispatch API becomes 'dispatch_command(cmd: \u0026Command, ctx: \u0026CmdContext) -\u003e Vec\u003cEffect\u003e' or equivalent. (6) A unit test demonstrates reduce_full applied to a Command produces the same final App state as the old path.","notes":"Depends on improvise-cqi (Command enum defined in improvise-protocol) and on App having moved to improvise-command (a crate-epic step 5 detail that needs updating — see improvise-3mm notes). Cross-epic: improvise-cqq (browser projection layer) formally depends on this issue since it needs reduce_full to exist.\nGROOMED 2026-09-19 — PREREQUISITES DISSOLVED. The notes said this depends on \"App having moved to improvise-command (a crate-epic step 5 detail that needs updating — see improvise-3mm notes)\". improvise-3mm is closed and App already lives at crates/improvise-command/src/ui/app.rs, so that prerequisite is satisfied, not pending. improvise-cqi is closed too: Command, ViewState and reduce_view are in improvise-protocol, and improvise-command depends on it. Nothing blocks this now — it is in bd ready.\n\n--- PARTIALLY LANDED 2026-09-19; ACs 4-5 BLOCKED, deliberately not forced ---\nDONE: reduce_full(\u0026mut App, \u0026Command) -\u003e Vec\u003cCommand\u003e (AC 1), apply_command\n(the inner half a server uses to drive Sessions::project_all), reduce_key,\nand App::handle_key is now a wrapper over App::dispatch_key(key) -\u003e Dirty\n(AC 2 in spirit). All existing behaviour preserved (ACs 3, 6).\n\nNOT DONE, and not doable as written: ACs 4 and 5 ask that keymap lookup\nreturn Command values and that CmdRegistry gain dispatch_command(\u0026Command).\nThe Command enum's client-\u003eserver half is exactly the ViewOnly portable-slice\nmutations -- cqi derived it from those effects and nothing else -- so it has\nno SetCell, AddCategory, SetAxis or Save. A keymap cannot return a Command for\nmost bindings, and reduce_full over a Command cannot drive a model mutation.\nForcing it would mean inventing variants ahead of the mutations, which\ndecision(improvise-cqi, command_set_derived_not_invented) and\ndoctrine(derive_the_enum_from_the_effects) both forbid.\n\nBLOCKED ON: the new ticket to derive the model half of Command from the 29\nEffectKind::Model effects. Until then reduce_full and reduce_key are two\ndoors and ui/reduce.rs's module doc explains why.\n\n--- AC 4 IS UNREACHABLE BY DESIGN, not for want of vocabulary (2026-09-19) ---\nimprovise-kmgs landed ModelCommand, so the \"the vocabulary is missing\" reason\nis gone. AC 4 still cannot be met, and the sharper reason is worth recording:\n\na keymap binding names an INTENT, resolved against CmdContext -- \"move-cursor\ndown\". A Command is the RESOLVED MUTATION -- SetSelected { row, col } -- which\nonly exists once the cursor and layout have been read. So a keymap LOOKUP can\nnever return a Command; only dispatch, after CmdContext, can. That is a\nproperty of the Cmd/Command split itself, not a gap.\n\nRECOMMEND: rewrite AC 4 to \"dispatch (not lookup) yields a Command where the\nbinding resolves to a model mutation\", or drop it. Do not force the original\nwording.\n\nAC 5 is satisfied in substance: model_effects(\u0026ModelCommand) -\u003e Vec\u003cBox\u003cdyn\nEffect\u003e\u003e in ui/reduce.rs is the \"or equivalent\". It was deliberately NOT hung\noff CmdRegistry as dispatch_command, because the mapping needs neither the\nregistry nor a CmdContext and a method ignoring \u0026self would misdescribe it.","status":"closed","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:48:36Z","created_by":"spot","updated_at":"2026-09-19T23:29:48Z","started_at":"2026-09-19T21:15:06Z","closed_at":"2026-09-19T23:29:48Z","close_reason":"CLOSED, re-scoped: ACs 1-3 and 6 delivered; ACs 4-5 resolved as written-against-a-false-premise.\n\nDELIVERED (wave 2, merged): reduce_full(\u0026mut App, \u0026Command) -\u003e Vec\u003cCommand\u003e, apply_command (the inner half a server uses to drive Sessions::project_all), reduce_key, and App::handle_key as a wrapper over App::dispatch_key(key) -\u003e Dirty. All existing behaviour preserved.\n\nAC 5 satisfied in substance: model_effects(\u0026ModelCommand) -\u003e Vec\u003cBox\u003cdyn Effect\u003e\u003e in ui/reduce.rs is the \"or equivalent\". Deliberately NOT hung off CmdRegistry as dispatch_command, because the mapping needs neither the registry nor a CmdContext and a method ignoring \u0026self would misdescribe it.\n\nAC 4 IS UNREACHABLE BY DESIGN, and the reason is sharper than \"the vocabulary is missing\" (improvise-kmgs supplied the vocabulary and it is still unreachable): a keymap binding names an INTENT resolved against CmdContext -- \"move-cursor down\" -- while a Command is the RESOLVED MUTATION -- SetSelected { row, col } -- which only exists once the cursor and layout have been read. So a keymap LOOKUP can never return a Command; only dispatch, after CmdContext, can. That is a property of the Cmd/Command split itself. Forcing it would mean either inventing intent-shaped Command variants (which decision(improvise-cqi, command_set_derived_not_invented) forbids) or dissolving the split.\n\nClosing rather than leaving open: it blocks improvise-cqq and the 35e -\u003e edp -\u003e cr3 chain to improvise-1ey, which is now the priority, and there is no remaining work here that a future ticket would not state better. If dispatch-yields-a-Command is wanted, file it as its own ticket against the real question.","dependencies":[{"issue_id":"improvise-gxi","depends_on_id":"improvise-cqi","type":"blocks","created_at":"2026-08-29T12:37:17Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":5,"comment_count":0} -{"_type":"issue","id":"improvise-ltq","title":"Epic: Native TUI as in-process client of the unified server architecture","description":"Restructure the native binary so the ratatui UI is a client of an in-process server, exactly the way the DOM renderer is a client of the service worker in the standalone deployment and of the ws-server in the network deployment. One binary process, two halves: a 'server' holding the full App + projection layer, and a 'client' holding ViewState + render cache + ratatui widgets. Commands flow through a direct in-process channel (same shape as websocket/postMessage protocols, zero serialization overhead). The server emits projection commands back to the client's cache. Result: the ratatui path is architecturally identical to the browser paths — same reduce_full, same Command vocabulary, same cache-based rendering. Widgets stop reading \u0026App directly.","design":"Four deployment modes all share one architecture: (1) native TUI = in-process client + server with direct-call transport, (2) network thin-client = browser main thread + ws-server with websocket transport, (3) standalone web = browser main thread + worker with postMessage transport, (4) hybrid = in-process native client + server PLUS remote ws-server subscriber = native TUI with remote browser observer. Under this epic, mode (1) gets restructured to match (2) and (3). The shared pieces: reduce_full, projection layer with per-client subscriptions, Command vocabulary, render cache shape. The difference per mode is only the transport + which clients subscribe to which projection kinds. Native TUI subscribes to every projection kind because it renders everything (grid, panels, help, wizard); browser thin-client MVP subscribes to grid only. Staging: introduce a temporary RenderCache::from_app adapter, migrate widgets one at a time with the adapter bridging, split the binary and delete the adapter at the end. Every widget migration is individually landable.","notes":"Implications for other epics: (A) improvise-cqq (browser epic projection layer) gains a prerequisite on issue 1 of this epic (unified reduce_full). (B) improvise-3mm (crate-epic step 5) needs its design updated to put App in improvise-command, not improvise-tui, so reduce_full can live alongside App without pulling ratatui into ws-server or worker-server. (C) improvise-cr3 (browser DOM renderer) and issue 3 of this epic (ratatui grid cache consumer) share the RenderCache grid shape and can share the GridViewModel type — coordinate the two to avoid duplication. Stretch issues cover hybrid mode (native TUI + remote browser subscriber) and native undo/replay via command logging.\nGROOMED 2026-09-19: implication (B) in the description is done — improvise-3mm closed with App in improvise-command, which is where this epic wanted it. Implication (A) is recorded as a real edge (cqq depends on gxi), so it no longer needs to live in prose.\nDEFERRED 2026-09-19: the epic closes when improvise-e0u closes, and e0u is deferred by user ruling this session. All of ltq's other children are already closed. Nothing to do here until e0u gets its own campaign.","status":"deferred","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:48:34Z","created_by":"spot","updated_at":"2026-09-20T05:31:01Z","dependencies":[{"issue_id":"improvise-ltq","depends_on_id":"improvise-35e","type":"blocks","created_at":"2026-04-14T00:53:49Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-ltq","depends_on_id":"improvise-764","type":"blocks","created_at":"2026-04-14T00:53:52Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-ltq","depends_on_id":"improvise-e0u","type":"blocks","created_at":"2026-04-14T00:53:52Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-ltq","depends_on_id":"improvise-edp","type":"blocks","created_at":"2026-04-14T00:53:49Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-ltq","depends_on_id":"improvise-gxi","type":"blocks","created_at":"2026-04-14T00:53:48Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-ltq","depends_on_id":"improvise-jb3","type":"blocks","created_at":"2026-04-14T00:53:51Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-ltq","depends_on_id":"improvise-n10","type":"blocks","created_at":"2026-04-14T00:53:50Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-ltq","depends_on_id":"improvise-pca","type":"blocks","created_at":"2026-04-14T00:53:50Z","created_by":"spot","metadata":"{}"}],"dependency_count":8,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-ltq","title":"Epic: Native TUI as in-process client of the unified server architecture","description":"Restructure the native binary so the ratatui UI is a client of an in-process server, exactly the way the DOM renderer is a client of the service worker in the standalone deployment and of the ws-server in the network deployment. One binary process, two halves: a 'server' holding the full App + projection layer, and a 'client' holding ViewState + render cache + ratatui widgets. Commands flow through a direct in-process channel (same shape as websocket/postMessage protocols, zero serialization overhead). The server emits projection commands back to the client's cache. Result: the ratatui path is architecturally identical to the browser paths — same reduce_full, same Command vocabulary, same cache-based rendering. Widgets stop reading \u0026App directly.","design":"Four deployment modes all share one architecture: (1) native TUI = in-process client + server with direct-call transport, (2) network thin-client = browser main thread + ws-server with websocket transport, (3) standalone web = browser main thread + worker with postMessage transport, (4) hybrid = in-process native client + server PLUS remote ws-server subscriber = native TUI with remote browser observer. Under this epic, mode (1) gets restructured to match (2) and (3). The shared pieces: reduce_full, projection layer with per-client subscriptions, Command vocabulary, render cache shape. The difference per mode is only the transport + which clients subscribe to which projection kinds. Native TUI subscribes to every projection kind because it renders everything (grid, panels, help, wizard); browser thin-client MVP subscribes to grid only. Staging: introduce a temporary RenderCache::from_app adapter, migrate widgets one at a time with the adapter bridging, split the binary and delete the adapter at the end. Every widget migration is individually landable.","notes":"Implications for other epics: (A) improvise-cqq (browser epic projection layer) gains a prerequisite on issue 1 of this epic (unified reduce_full). (B) improvise-3mm (crate-epic step 5) needs its design updated to put App in improvise-command, not improvise-tui, so reduce_full can live alongside App without pulling ratatui into ws-server or worker-server. (C) improvise-cr3 (browser DOM renderer) and issue 3 of this epic (ratatui grid cache consumer) share the RenderCache grid shape and can share the GridViewModel type — coordinate the two to avoid duplication. Stretch issues cover hybrid mode (native TUI + remote browser subscriber) and native undo/replay via command logging.\nGROOMED 2026-09-19: implication (B) in the description is done — improvise-3mm closed with App in improvise-command, which is where this epic wanted it. Implication (A) is recorded as a real edge (cqq depends on gxi), so it no longer needs to live in prose.\nDEFERRED 2026-09-19: the epic closes when improvise-e0u closes, and e0u is deferred by user ruling this session. All of ltq's other children are already closed. Nothing to do here until e0u gets its own campaign.","status":"deferred","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:48:34Z","created_by":"spot","updated_at":"2026-09-20T05:31:01Z","dependencies":[{"issue_id":"improvise-ltq","depends_on_id":"improvise-35e","type":"blocks","created_at":"2026-04-14T00:53:49Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-ltq","depends_on_id":"improvise-764","type":"blocks","created_at":"2026-04-14T00:53:52Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-ltq","depends_on_id":"improvise-edp","type":"blocks","created_at":"2026-04-14T00:53:49Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-ltq","depends_on_id":"improvise-gxi","type":"blocks","created_at":"2026-04-14T00:53:48Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-ltq","depends_on_id":"improvise-jb3","type":"blocks","created_at":"2026-04-14T00:53:51Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-ltq","depends_on_id":"improvise-n10","type":"blocks","created_at":"2026-04-14T00:53:50Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-ltq","depends_on_id":"improvise-pca","type":"blocks","created_at":"2026-04-14T00:53:50Z","created_by":"spot","metadata":"{}"}],"dependency_count":7,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-i34","title":"OPFS-backed Storage implementation for wasm target","description":"Implement the Storage trait (or vfs backend) against the browser's Origin Private File System. Provides read/write/list operations from wasm that work across Chrome, Firefox, and Safari. Falls back to an in-memory or IndexedDB-backed implementation if OPFS is unavailable in the target environment.","design":"Use web-sys to access navigator.storage.getDirectory() and the File System Access API. OPFS is async-only, so either (a) make the Storage trait async-compatible (preferred if we switch to an async trait in the abstraction issue) or (b) wrap OPFS calls in a queued executor so the sync trait API can block on completion via a yield loop. Option (a) is cleaner; the abstraction issue (improvise-6mq) should probably commit to async up front if OPFS is the target. Directory layout: one root directory under OPFS for improvise, with subdirectories per purpose (autosave, user files, session state). File picker integration: when the user says 'open a file' in the browser, use the native file picker and either copy the file into OPFS or open it directly via the File System Access API's handle-based API. Fallback: if OPFS is unavailable (older Safari, some privacy modes), provide an IndexedDB-backed implementation using the idb crate — exposes the same trait.","acceptance_criteria":"(1) Storage implementation for wasm target exists, keyed off a compile-time target check. (2) Round-trips: write bytes, read bytes, list files, delete file. (3) Manual test: load a .improv file via file picker, edit in the DOM renderer, save to OPFS, reload page, file is still there. (4) Unit tests using a memory-backed stub where OPFS is unavailable (CI).","notes":"Depends on improvise-6mq (Storage abstraction must exist first). May reveal a need to change the abstraction from sync to async, in which case update 6mq before starting this.\nUnblocked by improvise-6mq (Storage trait landed 2026-08-29: crates/improvise-io/src/storage.rs, two methods read/write on opaque document paths, PhysicalFs + MemoryFs). NOTE: still not startable in the current dev environment — no wasm32-unknown-unknown std in the Nix toolchain and no rustup. See improvise-ywd notes.\nToolchain unblocked 2026-08-30: wasm32-unknown-unknown added to the flake (improvise-ywd), and all four bundle crates check clean. See improvise-fuj first — the two effects that still call std::fs directly need a decision on how a wasm build injects its Storage backend, which is the same decision this ticket needs.\nSYNC-VS-ASYNC RESOLVED (2026-08-30) — the abstraction does NOT need to become async, so improvise-6mq stands as built.\n\nThe DESIGN here says 'OPFS is async-only' and the NOTES say this 'may reveal a need to change the abstraction from sync to async, in which case update 6mq before starting'. That premise is incomplete. OPFS exposes two APIs:\n\n - the promise-based one (getDirectory, getFileHandle, createWritable) — async\n - FileSystemFileHandle.createSyncAccessHandle() -\u003e FileSystemSyncAccessHandle,\n whose read/write/truncate/flush/close are SYNCHRONOUS, and which is available\n ONLY inside a dedicated Web Worker. It exists precisely so wasm can do\n blocking file I/O; it is the mechanism SQLite's WASM build uses for its OPFS\n VFS.\n\nimprovise-9x6 puts 'the full App + command pipeline + projection layer + VFS persistence inside a browser worker'. So the persistence layer never runs on the main thread, and the sync path is available to it.\n\nShape that falls out:\n - ASYNC, once, at worker init(): navigator.storage.getDirectory(), getFileHandle(), createSyncAccessHandle().\n - SYNC, thereafter: the Storage impl holds the access handles; read/write are ordinary blocking calls, satisfying the existing two-method trait unchanged.\n\nThis avoids an invasive async migration through persistence::save/load and every caller, which is the outcome 6mq's Option-B choice was already hedging toward.\n\nCAVEAT: asserted from API knowledge, not tested — there is no browser here. Confirm createSyncAccessHandle availability against the target browsers (it is the weakest on Safari) before relying on it. The IndexedDB fallback this ticket already calls for is the hedge, and IndexedDB IS async-only — so the fallback, not OPFS, is what would force the async question. Decide whether the fallback is in scope before starting.\n\nPREREQUISITE: improvise-fuj — how a wasm build injects its Storage instance. App holds no Storage today; persistence::save/load hardwire PhysicalFs, and two effects bypass the trait entirely. That plumbing is host-verifiable and should land first.","status":"closed","priority":2,"issue_type":"feature","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-14T07:34:08Z","created_by":"spot","updated_at":"2026-09-01T06:11:09Z","started_at":"2026-09-01T06:06:19Z","closed_at":"2026-09-01T06:11:09Z","close_reason":"Storage gained list/delete (PhysicalFs + MemoryFs, shared storage_contract test). OpfsFs added for wasm32 using sync access handles, keeping the trait synchronous — createSyncAccessHandle's read/write/truncate/flush/close are blocking, worker-only, which suits improvise-9x6. Async work is four explicit methods: open/attach/refresh/commit_deletes. delete is deferred (index updated immediately, removeEntry on commit). No IndexedDB fallback per user decision — verified Safari 16.4+ has sync handle methods, so it isn't needed. AC(1)(2)(4) met; AC(3) is a browser test that cannot run here, tracked as improvise-xf1 and blocking improvise-9x6.","dependencies":[{"issue_id":"improvise-i34","depends_on_id":"improvise-6mq","type":"blocks","created_at":"2026-04-14T00:40:10Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-i34","depends_on_id":"improvise-fuj","type":"blocks","created_at":"2026-08-30T09:57:13Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":2,"comment_count":0} {"_type":"issue","id":"improvise-ywd","title":"Wasm compatibility audit and fixes across core/formula/command/io","description":"Verify every crate that belongs in the wasm bundle builds for wasm32-unknown-unknown and runs correctly. Fix anything that doesn't: feature-gate wall-clock time, swap Instant for web_time, enable chrono wasmbind, isolate dirs crate usage, verify pest/serde/indexmap/flate2/enum_dispatch all compile. Ship a cargo-check invocation per crate as a CI gate.","design":"Systematic audit: for each crate (improvise-core, improvise-formula, improvise-command, improvise-io-persistence), run cargo check --target wasm32-unknown-unknown and fix what breaks. Known hazards: (1) dirs crate — not wasm-compatible, isolate behind a trait function. (2) chrono::Local::now() — needs wasmbind feature, used in import analyzer for date parsing. (3) std::time::Instant — swap for web_time crate which falls back to js performance.now() in wasm. (4) std::fs in persistence — handled by the VFS abstraction issue. (5) std::env and std::process — probably none in core, verify. (6) flate2 — verify rust_backend feature works in wasm (should). Strategy: don't feature-gate everything individually, instead do the minimal isolation needed so each crate's public API is portable, and let the downstream bundle crate (improvise-web-standalone) opt in to what it actually needs.","acceptance_criteria":"(1) cargo check --target wasm32-unknown-unknown -p improvise-core passes. (2) Same for improvise-formula, improvise-command, and improvise-io (or improvise-persistence if split). (3) No new feature flags required to get these to build — the core path is wasm-compatible by default. (4) CI job added that runs the wasm checks on every push.","notes":"Depends on the full crate split (epics 1-5) being done — the audit needs each crate to exist independently. Does not depend on the VFS abstraction issue (std::fs concerns are handled there). These two can proceed in parallel.\nTOOLCHAIN-BLOCKED in this dev environment (checked 2026-08-29). The deliverable is 'cargo check --target wasm32-unknown-unknown' per crate plus a CI gate, but the Nix toolchain here ships no wasm32 std and there is no rustup to add it:\n\n $ cargo check -p improvise-core --target wasm32-unknown-unknown\n error[E0463]: can't find crate for 'std'\n note: the wasm32-unknown-unknown target may not be installed\n $ which rustup -\u003e not found\n\nSource changes could be written speculatively (Instant -\u003e web_time, chrono wasmbind, isolating dirs) but none could be VERIFIED, which is the whole point of an audit. Adding wasm32-unknown-unknown to the flake's rust toolchain is the actual first step of this ticket.\n\nSame constraint hits improvise-cqi acceptance criterion 5 ('builds for both host target and wasm32-unknown-unknown').","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-14T07:33:49Z","created_by":"spot","updated_at":"2026-08-30T16:51:24Z","started_at":"2026-08-30T16:47:28Z","closed_at":"2026-08-30T16:51:24Z","close_reason":"AC 1-3 turned out to need NO source changes; the blocker was the toolchain, exactly as the notes I left on 2026-08-29 predicted.\n\nAdded targets = [\"wasm32-unknown-unknown\"] to the flake's rustToolchain (flake.nix:26) and verified wasm32 std lands in the sysroot. Then all four bundle crates check clean, first try, with no feature flags:\n\n improvise-core ok\n improvise-formula ok\n improvise-io ok\n improvise-command ok\n\nThat satisfies AC 1, AC 2 and AC 3 (AC 3 explicitly: 'no new feature flags required — the core path is wasm-compatible by default'). The root improvise crate does NOT build for wasm because crossterm does not; that is correct — it is the terminal binary, not part of the bundle — so the CI job checks the four bundle crates by name rather than the workspace.\n\nAC 4: .github/workflows/ci.yml adds a wasm job plus a host job (fmt, clippy -D warnings, test). There was no test/lint CI before this — only cargo-dist's release.yml and the Claude bots. Verified clippy -D warnings and fmt pass locally so the job is not red on arrival, and validated the workflow YAML parses.\n\nTHE AUDIT'S REAL VALUE WAS THE HAZARD LIST, because a cargo check gate cannot catch anything that merely panics at runtime. Going through the DESIGN section's named hazards one by one:\n\n (1) dirs NON-ISSUE — declared at root Cargo.toml:36, used nowhere (already noted on improvise-v9w)\n (2) chrono Local::now NON-ISSUE — never called\n (3) std::time::Instant REAL — App::last_autosave; Instant::now() panics on wasm32-unknown-unknown yet compiles fine. FIXED: improvise-command now uses web-time, which re-exports std::time off-wasm and performance.now() on it. Verified both targets build and all 14 native suites pass.\n (4) std::fs REAL, NOT FIXED — StartImportWizard (effect.rs:931) and LoadModel (effect.rs:1052) still call std::fs::read_to_string directly, bypassing the Storage trait from improvise-6mq. Compiles on wasm, fails in a browser. Filed as improvise-fuj, because the fix needs a decision on how a wasm build injects its Storage backend — which also determines how improvise-i34 plugs in, so it should not be guessed at here.\n (5) std::env/process NON-ISSUE — std::env appears only in a test\n (6) flate2 NON-ISSUE — compiles for wasm as-is\n\nCaveat worth stating plainly: 'compiles for wasm32' is all that has been verified. Nothing here has been RUN on a wasm host — there is no browser or wasm runtime in this environment — so the web-time fix is verified by dual-target compilation and unchanged native behaviour, not by execution. Actual runtime verification arrives with improvise-i34 / improvise-tm6.","dependencies":[{"issue_id":"improvise-ywd","depends_on_id":"improvise-3mm","type":"blocks","created_at":"2026-04-14T00:40:09Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} {"_type":"issue","id":"improvise-6mq","title":"Introduce Storage/VFS abstraction in persistence layer","description":"Refactor persistence/mod.rs to go through a Storage trait instead of std::fs directly. Native build wires PhysicalFS; later issues wire OPFS for wasm. Behavior-preserving refactor — .improv files still save and load identically on native. Foundation for the standalone web deployment and for alternative formats (sqlite, parquet) that can target any backend.","design":"Option A: adopt the vfs crate directly (vfs::VfsPath, vfs::PhysicalFS). Pros: batteries included, filesystem-path-based API, open-source implementations. Cons: sync-only; wasm backends may need async. Option B: define our own narrow Storage trait with read_bytes/write_bytes/list/stat methods, sync for native and async-wrapped for wasm. Pros: tailored to our needs. Cons: more code to maintain. Recommend starting with Option A (vfs crate) and falling back to Option B if OPFS's async-only nature forces the issue. Refactor persistence::{save_md, load_md, save_json, load_json} to take \u0026dyn Storage (or VfsPath) as the target parameter. Autosave path resolution (currently uses the dirs crate) goes behind the abstraction too — native resolves to a dirs path, wasm resolves to a fixed OPFS path.","acceptance_criteria":"(1) Persistence functions take a storage parameter rather than reading/writing files directly. (2) Native build wires PhysicalFS (or equivalent) and all existing persistence tests pass unchanged. (3) dirs crate usage is isolated behind a function that can be stubbed for wasm. (4) Round-trip tests still pass. (5) A memory-backed test fixture exists for unit testing persistence without touching disk.","notes":"Depends on crate-split epic step 3 (improvise-8zh — improvise-io extraction) so the refactor happens in the right crate. Valuable independently of wasm: enables testing persistence without tempfile, simplifies fuzzing.","status":"closed","priority":2,"issue_type":"feature","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-14T07:33:47Z","created_by":"spot","updated_at":"2026-08-29T19:40:58Z","started_at":"2026-08-29T19:37:37Z","closed_at":"2026-08-29T19:40:58Z","close_reason":"persistence::save/load now go through a Storage trait; save/load remain thin PhysicalFs wrappers so every caller and all 83 persistence tests are untouched (AC 2).\n\nCHOSE OPTION B OVER THE RECOMMENDED OPTION A. The design section recommends starting with the vfs crate. I measured the actual surface first: persistence touches the filesystem in exactly two places — write the whole document in save, read the whole document in load. autosave_path is pure path manipulation with no I/O. That does not justify a dependency plus a VfsPath abstraction. The ticket's own con for Option A is decisive: it is sync-only while OPFS, the actual wasm target, is async — adopting A would mean doing B later anyway. The trait is two methods:\n\n fn read(\u0026self, path: \u0026Path) -\u003e Result\u003cVec\u003cu8\u003e\u003e\n fn write(\u0026self, path: \u0026Path, bytes: \u0026[u8]) -\u003e Result\u003c()\u003e\n\nPaths are opaque document names, not a directory tree, so an OPFS or map-backed backend implements it without pretending to have directories, metadata or seeking.\n\nAC 3 IS VACUOUS: 'dirs crate usage is isolated behind a function that can be stubbed for wasm' — there is no dirs usage. dirs = \"6\" is declared at root Cargo.toml:36 and referenced nowhere under src/ or crates/*/src/. It is simply an unused dependency; noted on improvise-v9w, which already covers removing unused root deps.\n\nGzip moved from streaming File+GzEncoder to encode-then-write. The .gz suffix still selects it, which makes gzip a property of the document name rather than of the backend. physical_and_memory_backends_write_identical_bytes pins that swapping backends does not change the bytes.\n\nAC 5 (memory-backed fixture): MemoryFs, plus three persistence tests that round-trip a workbook with categories, items and data through it — no tempfile, no disk. That is the shape a browser backend plugs into.\n\nVerified beyond unit tests: import to .improv, headless add-cat against it, and the .gz path all still work through the CLI.","dependencies":[{"issue_id":"improvise-6mq","depends_on_id":"improvise-8zh","type":"blocks","created_at":"2026-04-14T00:40:08Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":5,"comment_count":0} {"_type":"issue","id":"improvise-rbv","title":"Include _Measure in _Dim columns for records mode","description":"In records mode, _Dim columns are generated from regular (non-virtual) categories plus a synthetic 'Value' column. _Measure is excluded because it starts with '_'. This means records show a generic 'Value' column instead of showing which measure (Revenue, Cost, etc.) each record belongs to. _Measure items should appear as _Dim columns in records mode so the measure name is visible per-record.","status":"closed","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-14T07:30:40Z","created_by":"spot","updated_at":"2026-04-15T11:16:12Z","closed_at":"2026-04-15T11:16:12Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-dz8","title":"Add Axis::Filter for drill-fixed coordinates","description":"Drill mode sets fixed coordinates (e.g. Product=Widgets, Region=North) as Axis::Page, but these should be immutable constraints, not user-navigable page dimensions. Add Axis::Filter variant that: (1) excludes categories from tile bar in tile select mode, (2) excludes from page cycling via [ and ], (3) visually distinguished from Page in the UI. Drill uses Filter instead of Page for its fixed coordinates.","status":"closed","priority":2,"issue_type":"feature","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-14T07:30:04Z","created_by":"spot","updated_at":"2026-09-01T05:45:01Z","started_at":"2026-09-01T05:38:46Z","closed_at":"2026-09-01T05:45:01Z","close_reason":"Axis::Filter added. DrillIntoCell fixes coordinates on Filter instead of Page. All three ticket requirements met: excluded from tile select (View::tileable_cats, used by tile_bar + MoveTileCursor + TileAxisOp), excluded from [/] page cycling (page_cat_data stays on categories_on(Axis::Page)), and visually distinct (Fix ⚲, yellow, via Axis::color -\u003e AxisColor::Filter). Data filtering preserved via View::filtering_cats (Page ∪ Filter), used by GridLayout and add-record-row. Axis::next maps Filter to itself so nothing can rotate into it. .improv grammar gains a 'filter' keyword with round-trip test — defensive, since Filter only occurs in the never-persisted _Drill view. Note: adding the variant caused exactly ONE compile error outside axis.rs, because improvise-rml had already consolidated the display matches.","dependencies":[{"issue_id":"improvise-dz8","depends_on_id":"improvise-rml","type":"blocks","created_at":"2026-04-16T16:02:39Z","created_by":"Ed L","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-1ey","title":"Browser frontend MVP: end-to-end working demo","description":"Wire everything together into a working browser demo: start the ws-server, open the HTML shell in a browser, it loads the wasm client, connects to the websocket, receives the initial snapshot, renders the grid, accepts keystrokes, and round-trips commands to the server. This is the milestone where the architecture proves itself end-to-end. Scope: open an existing .improv file server-side, view it in the browser, type a number into a cell, see the server-side value update and the browser's projection arrive with the new value.","design":"HTML shell: minimal index.html with a \u003cdiv\u003e for the grid and a \u003cscript\u003e tag bootstrapping the wasm module. JS bootstrap: loads wasm, opens websocket connection, wires on_message from the socket into wasm.on_message, wires wasm.on_key_event output into the socket send. Reconnection: on socket close, show a status indicator; on reconnect, request a fresh snapshot. No auth, no TLS, localhost only. Test by pointing at examples/demo.improv or web/demo.improv.\n\n(Design field corrected 2026-09-19: it named bank-info.improv, which PR #22 deleted as unloadable -- hand-damaged during the v2025-04-09 migration and unreadable since April. roadmap.org line 194 is generated from this field, so the fix belongs here and the file is regenerated, never hand-edited. Note 'no auth, no TLS' is still an accurate description of improvise-ws-server and is now tracked as improvise-kpnr and improvise-kc7n rather than being merely a scope note.)","acceptance_criteria":"(1) ws-server serves a file from CLI arg, accepts a websocket connection. (2) Browser loads page, wasm initializes, grid renders. (3) Arrow keys move the cursor locally (view effect) with no round-trip. (4) Typing a number + Enter in a cell round-trips to the server and the new value appears. (5) Scrolling fetches cells for the new viewport. (6) Reload recovers cleanly from current server state. (7) Video or screenshot of the demo attached to the issue.","notes":"Final milestone of the browser epic. Depends on projection layer (improvise-cqq), ws-server (improvise-q08), wasm client (wasm-client id), DOM renderer (dom-renderer id). No new logic — just glue and testing.\nGROOMED 2026-09-20 after wave 1. The acceptance criteria are met and the evidence is better than the one the ticket asked for.\n\nCriterion (7) was 'video or screenshot of the demo attached to the issue'. What exists instead: crates/improvise-wasm-client/tests/browser_round_trip.rs drives the client's own serialized frames through a REAL Host over a REAL App, and harness/tests/shell.spec.js renders web/app.js under Chromium and asserts the grid draws, a header reads Revenue, an editable cell exists, and click-to-edit commits and shows the host's value. A screenshot proves a moment; these prove the path, in CI.\n\n(1)-(4) and (6) are covered by those two. (5), 'scrolling fetches cells for the new viewport', is covered in principle by Client::set_viewport and the GridExtent work in PR #23, but NOT by an executed test -- web/demo.improv is too small to scroll (improvise-plcx).\n\nThe design field's reference to bank-info.improv was corrected 2026-09-20; that file was deleted as unloadable (improvise-odbc) and roadmap.org:194 is generated from this field.\nVERIFICATION PASS, wave 2 WU-P, 2026-09-20 at 52507a6, criterion by criterion.\nConfirms the earlier annotation on (5) and SHARPENS (2) and (6), which it\nrecorded as met by evidence that does not in fact reach them.\n\n(1) ws-server SERVES A FILE FROM A CLI ARG AND ACCEPTS A WEBSOCKET — MET.\n crates/improvise-ws-server/tests/socket.rs is the one test on a real\n socket: handshake, JSON framing, SessionId routing and which document a URL\n asked for. crates/improvise-ws-server/src/main.rs is the clap entry.\n\n(2) BROWSER LOADS THE PAGE, WASM INITIALISES, GRID RENDERS — MET, but note\n WHICH DEPLOYMENT. harness/tests/shell.spec.js's \"the page draws the demo\n document out of the worker\" runs web/app.js under Chromium and asserts a\n table, a \"Revenue\" header and an editable cell — over the IN-PAGE WORKER,\n not over a ws-server. The four `?ws=` specs use mocked peers\n (`routeWebSocket`), so no test in this repository points a real browser at\n a real improvise-ws-server. Since improvise-xks6 both deployments speak one\n frame pair and `Host::handle` is one implementation, so the untested\n difference is the transport alone — but that is an argument, not a run, and\n the earlier annotation did not say so.\n\n(3) ARROW KEYS MOVE THE CURSOR LOCALLY WITH NO ROUND-TRIP — MET.\n crates/improvise-wasm-client/tests/lockstep.rs::\n a_locally_resolved_cursor_move_lands_where_the_host_would_put_it, plus\n a_locally_resolved_jump_to_the_last_row_agrees_with_the_host and\n a_client_with_no_window_answers_no_cursor_key for the edges. `resolve`\n returns KeyOutcome::Local, which is by construction the no-host path.\n\n(4) TYPING A NUMBER + ENTER ROUND-TRIPS AND THE NEW VALUE APPEARS — MET twice,\n at two levels. crates/improvise-wasm-client/tests/browser_round_trip.rs::\n a_browser_client_seeds_types_a_number_and_sees_it_come_back drives the\n client's own serialized frames through a real Host over a real App;\n shell.spec.js's \"committing an edit sends one frame and shows the host's\n value\" does it in Chromium and asserts the HOST's formatting (\"20,000\"),\n not the string typed.\n\n(5) SCROLLING FETCHES CELLS FOR THE NEW VIEWPORT — NOT MET BY AN EXECUTED\n TEST, and this is what keeps the ticket open. browser_round_trip.rs::\n a_resize_reprojects_the_grid_at_the_new_extent covers a RESIZE, which is\n the extent changing at a fixed offset; scrolling is the offset moving,\n which is where gotcha 47's \"the window is the client's own fact\" and the\n GridExtent arithmetic actually bite. web/demo.improv is too small to\n scroll, so the fixture is the blocker: improvise-plcx, an open dependency\n of this ticket, is what closes it.\n\n(6) RELOAD RECOVERS CLEANLY FROM CURRENT SERVER STATE — PARTIALLY MET, and\n less than the earlier annotation claimed. What is tested is the SEED: a\n fresh client's handshake and Snapshot, in browser_round_trip.rs. What is\n not tested anywhere is a browser actually reloading and coming back onto a\n document that has moved since — the reload half of the OPFS story is\n covered at the STORAGE layer by harness/tests/opfs.spec.js, which drives\n the storage contract inside a real Worker and never goes through app.js.\n\n(7) VIDEO OR SCREENSHOT ATTACHED TO THE ISSUE — NOT MEETABLE AS WRITTEN (bd\n has no attachments) and superseded, on the same argument as improvise-bck's\n criterion 4.\n\nSO: this ticket's remaining content is (5) via improvise-plcx. (2) and (6) are\nmet well enough to ship and their gaps are named above rather than rounded off.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:24:44Z","created_by":"spot","updated_at":"2026-09-20T18:02:18Z","labels":["area:epic"],"dependencies":[{"issue_id":"improvise-1ey","depends_on_id":"improvise-cqq","type":"blocks","created_at":"2026-08-30T10:08:33Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-1ey","depends_on_id":"improvise-cr3","type":"blocks","created_at":"2026-04-14T00:25:43Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-1ey","depends_on_id":"improvise-plcx","type":"blocks","created_at":"2026-09-20T16:20:27Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-1ey","depends_on_id":"improvise-q08","type":"blocks","created_at":"2026-04-14T00:25:43Z","created_by":"spot","metadata":"{}"}],"dependency_count":4,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"improvise-1ey","title":"Browser frontend MVP: end-to-end working demo","description":"Wire everything together into a working browser demo: start the ws-server, open the HTML shell in a browser, it loads the wasm client, connects to the websocket, receives the initial snapshot, renders the grid, accepts keystrokes, and round-trips commands to the server. This is the milestone where the architecture proves itself end-to-end. Scope: open an existing .improv file server-side, view it in the browser, type a number into a cell, see the server-side value update and the browser's projection arrive with the new value.","design":"HTML shell: minimal index.html with a \u003cdiv\u003e for the grid and a \u003cscript\u003e tag bootstrapping the wasm module. JS bootstrap: loads wasm, opens websocket connection, wires on_message from the socket into wasm.on_message, wires wasm.on_key_event output into the socket send. Reconnection: on socket close, show a status indicator; on reconnect, request a fresh snapshot. No auth, no TLS, localhost only. Test by pointing at examples/demo.improv or web/demo.improv.\n\n(Design field corrected 2026-09-19: it named bank-info.improv, which PR #22 deleted as unloadable -- hand-damaged during the v2025-04-09 migration and unreadable since April. roadmap.org line 194 is generated from this field, so the fix belongs here and the file is regenerated, never hand-edited. Note 'no auth, no TLS' is still an accurate description of improvise-ws-server and is now tracked as improvise-kpnr and improvise-kc7n rather than being merely a scope note.)","acceptance_criteria":"(1) ws-server serves a file from CLI arg, accepts a websocket connection. (2) Browser loads page, wasm initializes, grid renders. (3) Arrow keys move the cursor locally (view effect) with no round-trip. (4) Typing a number + Enter in a cell round-trips to the server and the new value appears. (5) Scrolling fetches cells for the new viewport. (6) Reload recovers cleanly from current server state. (7) Video or screenshot of the demo attached to the issue.","notes":"Final milestone of the browser epic. Depends on projection layer (improvise-cqq), ws-server (improvise-q08), wasm client (wasm-client id), DOM renderer (dom-renderer id). No new logic — just glue and testing.\nGROOMED 2026-09-20 after wave 1. The acceptance criteria are met and the evidence is better than the one the ticket asked for.\n\nCriterion (7) was 'video or screenshot of the demo attached to the issue'. What exists instead: crates/improvise-wasm-client/tests/browser_round_trip.rs drives the client's own serialized frames through a REAL Host over a REAL App, and harness/tests/shell.spec.js renders web/app.js under Chromium and asserts the grid draws, a header reads Revenue, an editable cell exists, and click-to-edit commits and shows the host's value. A screenshot proves a moment; these prove the path, in CI.\n\n(1)-(4) and (6) are covered by those two. (5), 'scrolling fetches cells for the new viewport', is covered in principle by Client::set_viewport and the GridExtent work in PR #23, but NOT by an executed test -- web/demo.improv is too small to scroll (improvise-plcx).\n\nThe design field's reference to bank-info.improv was corrected 2026-09-20; that file was deleted as unloadable (improvise-odbc) and roadmap.org:194 is generated from this field.\nVERIFICATION PASS, wave 2 WU-P, 2026-09-20 at 52507a6, criterion by criterion.\nConfirms the earlier annotation on (5) and SHARPENS (2) and (6), which it\nrecorded as met by evidence that does not in fact reach them.\n\n(1) ws-server SERVES A FILE FROM A CLI ARG AND ACCEPTS A WEBSOCKET — MET.\n crates/improvise-ws-server/tests/socket.rs is the one test on a real\n socket: handshake, JSON framing, SessionId routing and which document a URL\n asked for. crates/improvise-ws-server/src/main.rs is the clap entry.\n\n(2) BROWSER LOADS THE PAGE, WASM INITIALISES, GRID RENDERS — MET, but note\n WHICH DEPLOYMENT. harness/tests/shell.spec.js's \"the page draws the demo\n document out of the worker\" runs web/app.js under Chromium and asserts a\n table, a \"Revenue\" header and an editable cell — over the IN-PAGE WORKER,\n not over a ws-server. The four `?ws=` specs use mocked peers\n (`routeWebSocket`), so no test in this repository points a real browser at\n a real improvise-ws-server. Since improvise-xks6 both deployments speak one\n frame pair and `Host::handle` is one implementation, so the untested\n difference is the transport alone — but that is an argument, not a run, and\n the earlier annotation did not say so.\n\n(3) ARROW KEYS MOVE THE CURSOR LOCALLY WITH NO ROUND-TRIP — MET.\n crates/improvise-wasm-client/tests/lockstep.rs::\n a_locally_resolved_cursor_move_lands_where_the_host_would_put_it, plus\n a_locally_resolved_jump_to_the_last_row_agrees_with_the_host and\n a_client_with_no_window_answers_no_cursor_key for the edges. `resolve`\n returns KeyOutcome::Local, which is by construction the no-host path.\n\n(4) TYPING A NUMBER + ENTER ROUND-TRIPS AND THE NEW VALUE APPEARS — MET twice,\n at two levels. crates/improvise-wasm-client/tests/browser_round_trip.rs::\n a_browser_client_seeds_types_a_number_and_sees_it_come_back drives the\n client's own serialized frames through a real Host over a real App;\n shell.spec.js's \"committing an edit sends one frame and shows the host's\n value\" does it in Chromium and asserts the HOST's formatting (\"20,000\"),\n not the string typed.\n\n(5) SCROLLING FETCHES CELLS FOR THE NEW VIEWPORT — NOT MET BY AN EXECUTED\n TEST, and this is what keeps the ticket open. browser_round_trip.rs::\n a_resize_reprojects_the_grid_at_the_new_extent covers a RESIZE, which is\n the extent changing at a fixed offset; scrolling is the offset moving,\n which is where gotcha 47's \"the window is the client's own fact\" and the\n GridExtent arithmetic actually bite. web/demo.improv is too small to\n scroll, so the fixture is the blocker: improvise-plcx, an open dependency\n of this ticket, is what closes it.\n\n(6) RELOAD RECOVERS CLEANLY FROM CURRENT SERVER STATE — PARTIALLY MET, and\n less than the earlier annotation claimed. What is tested is the SEED: a\n fresh client's handshake and Snapshot, in browser_round_trip.rs. What is\n not tested anywhere is a browser actually reloading and coming back onto a\n document that has moved since — the reload half of the OPFS story is\n covered at the STORAGE layer by harness/tests/opfs.spec.js, which drives\n the storage contract inside a real Worker and never goes through app.js.\n\n(7) VIDEO OR SCREENSHOT ATTACHED TO THE ISSUE — NOT MEETABLE AS WRITTEN (bd\n has no attachments) and superseded, on the same argument as improvise-bck's\n criterion 4.\n\nSO: this ticket's remaining content is (5) via improvise-plcx. (2) and (6) are\nmet well enough to ship and their gaps are named above rather than rounded off.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:24:44Z","created_by":"spot","updated_at":"2026-09-20T18:02:18Z","labels":["area:epic"],"dependencies":[{"issue_id":"improvise-1ey","depends_on_id":"improvise-6jk","type":"parent-child","created_at":"2026-09-25T01:07:38Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-1ey","depends_on_id":"improvise-cqq","type":"blocks","created_at":"2026-08-30T10:08:33Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-1ey","depends_on_id":"improvise-cr3","type":"blocks","created_at":"2026-04-14T00:25:43Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-1ey","depends_on_id":"improvise-plcx","type":"blocks","created_at":"2026-09-20T16:20:27Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-1ey","depends_on_id":"improvise-q08","type":"blocks","created_at":"2026-04-14T00:25:43Z","created_by":"spot","metadata":"{}"}],"dependency_count":4,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-cr3","title":"DOM renderer for browser grid (reads ViewState + render cache)","description":"JS/TS (or Rust via web-sys) layer that subscribes to the wasm client's state and renders the grid to the DOM. MVP: rebuild a \u003ctable\u003e element on each state change, no virtual-DOM diffing. Reads ViewState for mode indicator, cursor highlight, minibuffer text; reads render cache for cell contents, labels, column widths. Captures browser keyboard events and routes them into the wasm client's on_key_event.","design":"Option A: pure JS/TS module that reads wasm-exposed state via JsValue and updates DOM imperatively. Simpler for MVP. Option B: Rust + web-sys in the wasm crate, rendering from inside wasm. More code sharing but bigger bundle. Start with Option A. Renderer: single \u003ctable\u003e for the grid body, \u003cthead\u003e for column labels, \u003ctbody\u003e with rows. On state change, re-render the affected sections. Cursor highlight is a CSS class on the selected \u003ctd\u003e. Mode indicator is a \u003cdiv\u003e above the table. Minibuffer is a \u003cdiv\u003e shown conditionally when mode is Editing/FormulaEdit/etc.","acceptance_criteria":"(1) Grid renders from a ViewState + RenderCache snapshot. (2) Cursor highlight updates on cursor move without full re-render (nice to have, not required for MVP). (3) Mode indicator reflects current AppMode. (4) Keyboard events on document are captured and routed to wasm on_key_event. (5) Works in Chrome and Firefox. Help overlay, panels, import wizard, tile bar — all deferred post-MVP.","notes":"Consumes viewmodels (not the render cache directly) per improvise-edp. Specifically GridViewModel for grid rendering. Shares GridViewModel type and compute_grid_viewmodel function with ratatui's grid widget (improvise-n10) — one derivation, two rendering backends. DOM-specific concerns (device pixel ratio, CSS class names) live in the RenderEnv the viewmodel is computed against, not in the viewmodel itself.\nPRIORITY RAISED P3-\u003eP2 (groomed 2026-08-30): this gates P2 work (improvise-1ey improvise-6jk improvise-djm ), so leaving it P3 made 'bd ready' unworkable in priority order — the P2 cannot be reached without it. Priority should be non-decreasing along dependency edges. Revert if the intent was that the downstream P2 is genuinely not wanted yet.","status":"closed","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:24:41Z","created_by":"spot","updated_at":"2026-09-20T00:37:49Z","closed_at":"2026-09-20T00:37:49Z","close_reason":"Landed as ad5d338 (merge of c72832f). 1090 tests passing, clippy/fmt clean, wasm clean on all six browser-bundle crates.\n\nWHAT IT IS: render.rs builds a RenderModel in which every question with a SILENT wrong answer is already decided in Rust under test, because the DOM half (web/app.js) is untested JavaScript -- editable is a fact about the CellId variant (Record only, per ADR 002; None is 'no projected cell', NOT 'aggregate'); the selection is placed by SUBTRACTING the scroll offset, because GridCache is viewport-indexed while ViewState::selected is absolute; the category panel is viewmodel::flatten_cat_tree, never a second walk. The page is four string calls: viewport, on_frame, render_model, typed_cell.\n\nVERIFIED HEADLESS END TO END with the real artifact: the built wasm module driven from Node over a real websocket to a real improvise-ws-server -- seed, render, type 98765, the value moves server-side, the projection comes back formatted as 98,765. That is improvise-1ey acceptance (4) with only the DOM missing.\n\nNOT VERIFIED, stated plainly: no browser and no headless DOM was available. web/app.js is syntax-checked and every call it makes is confirmed present in the generated .d.ts with the right arity, but NO PIXEL OF IT HAS BEEN RENDERED -- click-to-edit, the ResizeObserver extent arithmetic and the CSS are unexercised. That is what remains of 1ey along with keyboard nav (improvise-bpq2: ClientMsg has no Key variant, so a forwarded keypress is silently skipped today).\n\nFOLLOW-UPS FILED: improvise-zv4m/xks6 (protocol should own the framing -- socket.rs mirrors ws-server's enums because a native crate cannot be a wasm dependency), improvise-odbc (bank-info.improv does not parse, which matters because 1ey names it as the demo file).","dependencies":[{"issue_id":"improvise-cr3","depends_on_id":"improvise-edp","type":"blocks","created_at":"2026-04-14T00:53:44Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-cr3","depends_on_id":"improvise-gsw","type":"blocks","created_at":"2026-04-14T00:25:42Z","created_by":"spot","metadata":"{}"}],"dependency_count":2,"dependent_count":3,"comment_count":0} {"_type":"issue","id":"improvise-gsw","title":"improvise-wasm-client crate (keymap + reduce_view in wasm)","description":"Create the wasm crate that runs in the browser. Depends on improvise-protocol (Command, ViewState, reduce_view) and on the extracted improvise-command crate for the keymap. Exposes wasm-bindgen entry points: init, on_key_event, on_message (incoming command from server), current_view_state (for the DOM renderer to read). Resolves browser keyboard events locally via the keymap, dispatches the resulting command through reduce_view, serializes the command for sending upstream.","design":"crates/improvise-wasm-client/. wasm-bindgen + web-sys. Entry points: #[wasm_bindgen] fn init() -\u003e Handle (allocates ViewState + RenderCache); fn on_key_event(handle, browser_key_event) -\u003e Option\u003cString\u003e (resolves via keymap, dispatches via reduce_view, returns serialized Command to send upstream or None if no-op); fn on_message(handle, serialized_command) (deserializes incoming Command, applies via reduce_view, marks render dirty); fn render_state(handle) -\u003e JsValue (exposes ViewState + RenderCache for the DOM renderer). Key conversion: browser KeyboardEvent → improvise neutral Key enum via a From impl. The keymap is the same Keymap type used server-side, but with no ratatui dependency (from improvise-command after crate epic step 5).","acceptance_criteria":"(1) Crate compiles to wasm32-unknown-unknown. (2) Size budget: under 500 KB compressed. (3) on_key_event correctly resolves common keys (arrows, letters, Enter, Esc) via the keymap and dispatches through reduce_view. (4) on_message correctly deserializes and applies projection commands. (5) A small JS test harness can drive the crate end-to-end without a real websocket.","notes":"Transport-agnostic at the Rust layer: on_message accepts serialized commands regardless of source, and outgoing commands are returned to the JS bootstrap which routes them to a WebSocket (thin-client epic 6jk) or a worker MessageChannel (standalone epic tm6). The same wasm artifact is reused literally in both deployments — only the JS bootstrap differs. Depends on improvise-cqi (protocol crate) and crate-epic step 5 (improvise-3mm) so Keymap is importable without ratatui. Does not include the DOM renderer itself — that is improvise-cr3, which consumes the state this crate exposes.","status":"closed","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:24:37Z","created_by":"spot","updated_at":"2026-09-19T23:40:23Z","closed_at":"2026-09-19T23:40:23Z","close_reason":"crates/improvise-wasm-client landed complete, not a skeleton. 28 tests; cargo check -p improvise-wasm-client --target wasm32-unknown-unknown clean.\n\nTHE TICKET AC 3 IS A FALSE PREMISE and the design turns on it: a browser client resolves almost NO keys locally. A binding names an INTENT resolved against a CmdContext (Model, GridLayout, cursor, yanked cell) while a Command is the RESOLVED MUTATION -- the same reason improvise-gxi AC 4 was closed. So keys travel upstream UNRESOLVED as ClientMessage::Key { code, modifiers }.\n\nTHE ONE LOCAL PATH is minibuffer text entry, allowed for a STRUCTURAL reason rather than convenience: the target buffer is AppMode::minibuffer().buffer_key, the same field TextEntry::new reads (gotcha 11), so the two rules are DERIVED FROM ONE FACT rather than duplicated. Four things were considered and rejected with reasons in the module docs: cursor movement (GridCache is viewport-indexed, so a client cannot tell last-visible-row from last-row and cannot clamp), panel cursors (the flattening depends on expanded_cats), any modified character (KeyPattern::AnyChar matches under ANY modifier on the host, so mirroring the fallback would be a SUPERSET -- local resolution may only ever be a strict subset), and CommandMode Backspace (the single TextEntry::rebind, which leaves the mode on an empty buffer).\n\nimprovise-command is a DEV-dependency only, so the browser graph is formula -\u003e core -\u003e protocol -\u003e client: no App, Workbook, persistence or wizard. tests/lockstep.rs drives the real host and the client over the same keypress across all seven text-entry modes.\n\nAC 2 (under 500KB compressed) UNVERIFIED -- no wasm-pack/wasm-opt available; the dependency graph is the strongest evidence here and someone should measure a real release build. AC 5 (JS harness) not written, and belongs with improvise-cr3.","dependencies":[{"issue_id":"improvise-gsw","depends_on_id":"improvise-3mm","type":"blocks","created_at":"2026-04-14T00:25:41Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-gsw","depends_on_id":"improvise-cqi","type":"blocks","created_at":"2026-04-14T00:25:41Z","created_by":"spot","metadata":"{}"}],"dependency_count":2,"dependent_count":3,"comment_count":0} {"_type":"issue","id":"improvise-q08","title":"improvise-ws-server binary (tokio + tungstenite session wrapper)","description":"New binary crate that wraps today's App behind a websocket. Accepts connections, creates a ClientSession per connection, receives Command messages from clients, dispatches them into the App, drains the outbound projection queue, sends projection commands back down. One App instance per session for the MVP (no shared authoritative state across sessions yet).","design":"crates/improvise-ws-server/. Tokio runtime, tokio-tungstenite for websocket. On connect: create App + ClientSession, send initial Snapshot (full ViewState + render cache filled from current viewport). Main loop: recv Command from client → feed into reduce_full (today's App::handle_key path, but command-keyed instead of key-keyed) → drain projection queue → send Commands down. On disconnect: drop session. Single session per connection for MVP. No auth, no collab, localhost only. Wire format: serde_json to start (easy to debug), bincode/postcard later if size matters.","acceptance_criteria":"(1) Binary crate builds and accepts websocket connections on a configurable port. (2) Initial snapshot is sent on connect. (3) Command round-trip works: client Command in → model updated → projection Command out. (4) Disconnection cleanly drops session state. (5) Integration test: spawn server, connect a test client, send a SetCell, verify a CellsUpdated comes back.","notes":"Depends on improvise-protocol (wire types) and improvise-mae/vb4/projection-emission for the server-side logic. The server itself is a thin transport wrapper — all the real logic lives in the App and projection layer.","status":"closed","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:23:14Z","created_by":"spot","updated_at":"2026-09-19T23:46:08Z","closed_at":"2026-09-19T23:46:08Z","close_reason":"crates/improvise-ws-server landed whole; all five ACs met and tested. 14 tests (13 in host.rs + 1 real-socket integration). Host::handle is where every decision is made and every unit test points; transport.rs is framing, JSON and one handshake rule, with a doc comment saying that if the socket test ever becomes the place a behaviour is pinned, that behaviour belongs in host.rs.\n\nONE APP PER DOCUMENT, as ruled. Not per connection (the ticket premise, now false -- Sessions exists to hold many ClientSessions against one App, and per-connection Apps would make broadcast, apply_switch and apply_model dead code and give two tabs on one document two documents) and not per server. No singleton: Host is a value the transport is handed. The multi-document registry slots into transport.rs where host_loop is -- a HashMap\u003cDocumentId, Host\u003e plus a document id on Connect -- and nothing above it changes, because Sessions never owned the App.\n\nTHE ONLY ADDITION TO THE SHARED VOCABULARY IS ClientMsg::Viewport { rows, cols }, and it is derived rather than invented: the extent is the one session input no Command carries by construction. It is also the HANDSHAKE -- the bootstrap Snapshot seed carries the VISIBLE cells, so a snapshot built at extent zero is an empty grid needing an immediate second round trip. That rule lives in transport::connection alone, which keeps Host::connect total.\n\nNO KEYPRESS DOOR: the retraction arrived before one was built, so nothing had to be removed. ClientMsg carries Viewport and Command only.\n\nEvery non-obvious behaviour proven by injection, including both arms of the broadcast (relay to nobody FAILED; relay to everyone FAILED). The document-swap fixture keeps the incoming active view named Default per gotcha 39 fixture warning, so it cannot pass by accident.","dependencies":[{"issue_id":"improvise-q08","depends_on_id":"improvise-cqi","type":"blocks","created_at":"2026-04-14T00:25:39Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-q08","depends_on_id":"improvise-cqq","type":"blocks","created_at":"2026-04-14T00:25:40Z","created_by":"spot","metadata":"{}"}],"dependency_count":2,"dependent_count":3,"comment_count":0} @@ -410,7 +485,7 @@ {"_type":"issue","id":"improvise-cqi","title":"Extract improvise-protocol crate (Command, ViewState, reduce_view)","description":"Create a new crate that holds the types shared between the server and the wasm client: Command enum (the wire format), ViewState struct, render cache types (CellDisplay, visible region), and the reduce_view function. Depends on improvise-core so it can reference CellKey, CellValue, AppMode. Serde on everything that crosses the wire. This is the load-bearing crate for the browser epic.","design":"pub enum Command { /* user-initiated */ MoveCursor(Delta), EnterMode(AppMode), AppendMinibufferChar(char), ScrollTo { row, col }, SetCell { key: CellKey, value: CellValue }, AddCategory(String), ... /* server→client projections */ CellsUpdated { changes: Vec\u003c(CellKey, CellDisplay)\u003e }, ColumnLabelsChanged { labels: Vec\u003cString\u003e }, ColumnWidthsChanged { widths: Vec\u003cu16\u003e }, RowLabelsChanged { labels: Vec\u003cString\u003e }, ViewportInvalidated, ... }. pub fn reduce_view(vs: \u0026mut ViewState, cache: \u0026mut RenderCache, cmd: \u0026Command) — pattern-matches the command and applies view-slice effects + render-cache updates. Client imports this directly. Server imports Command + ViewState so it can serialize wire messages and maintain a mirror of each client's view state for projection computation.","acceptance_criteria":"(1) New crate crates/improvise-protocol/ exists. (2) Depends only on improvise-core, improvise-formula (if needed for anything), and serde. No ratatui, no crossterm. (3) Command, ViewState, RenderCache, CellDisplay all implement Serialize+Deserialize. (4) reduce_view has unit tests for view effect application and cache updates. (5) Crate builds for both host target and wasm32-unknown-unknown.","notes":"Depends on improvise-vb4 (ViewState must exist as a distinct type first) and on crate-epic step 2 (improvise-36h — improvise-core must be extracted so protocol can depend on it without pulling ratatui).\nNote (2026-08-29): acceptance criterion 5 (builds for wasm32-unknown-unknown) is not verifiable in the current dev environment — no wasm32 std in the Nix toolchain and no rustup. See improvise-ywd notes. The rest of this ticket is host-verifiable.\nCORRECTION (2026-08-29): the DESIGN section's justification for ViewState serialization is wrong, and misleading in a way that would invert the architecture.\n\nIt says: 'Server imports Command + ViewState so it can serialize wire messages and maintain a mirror of each client's view state.' The server does NOT serialize ViewState to maintain the mirror. Per improvise-cqq, it updates each session's view state 'via the shared reduce_view' — i.e. by applying the same commands the client applies. ViewState is a PROJECTION of the command stream, not a wire message. improvise-ltq (commands both directions) and improvise-m91 ('a command log is a deterministic replay') say the same.\n\nViewState: Serialize is still needed, but for exactly one reason: improvise-q08's bootstrap — 'On connect: create App + ClientSession, send initial Snapshot (full ViewState + render cache filled from current viewport)'. A client that never saw the earlier stream needs a value to fold onto. Snapshot-then-stream, not state-on-every-change.\n\nConsequences for whoever picks this up:\n- AC 3 stands, but do not build a server that ships ViewState on change. Steady-state traffic is Commands only.\n- RenderCache is local, rebuilt by applying projection commands; it needs Serialize only to ride in that same bootstrap Snapshot.\n- Because the only serialization point is bootstrap, host-only fields must NOT be in ViewState: a fresh client should not inherit a half-open import wizard, a pending prefix key, or staged drill edits.\n\nBLOCKER FOUND (2026-08-29): ViewState as it stands cannot live in this crate at all. crates/improvise-command/src/ui/app.rs:381 holds transient_keymap: Option\u003cArc\u003cKeymap\u003e\u003e. Keymap is in improvise-command, which will depend on improvise-protocol (improvise-gxi puts reduce_full there, taking \u0026Command) — a hard dependency cycle, independent of serde. wizard: Option\u003cImportWizard\u003e is a softer version (improvise-io is acyclic but outside AC 2's allowlist); drill_state and AppMode simply lack derives.\n\nProposed fix for the hard one, per the maintainer: flip the Arc. The transient keymap is always the sub-keymap of a Binding::Prefix at a specific (mode, key, mods), set during dispatch and consumed on the next keypress. ViewState can hold that address — Option\u003c(KeyCode, KeyModifiers)\u003e — and let KeymapSet, which already owns every keymap, resolve it. Both consumers can: App::handle_key has keymap_set and the mode; draw.rs only needs binding_hints() for the which-key popup. Nothing between set and consume changes the mode, since a Prefix binding emits SetTransientKeymap and nothing else.","status":"closed","priority":2,"issue_type":"feature","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-14T07:23:10Z","created_by":"spot","updated_at":"2026-09-01T09:03:56Z","started_at":"2026-09-01T08:55:39Z","closed_at":"2026-09-01T09:03:56Z","close_reason":"crates/improvise-protocol created. AC(1) crate exists; AC(2) depends only on improvise-core + serde, verified by cargo tree (no ratatui/crossterm/tokio) and by the CI wasm job; AC(3) Command/ViewState/RenderCache/CellDisplay all Serialize+Deserialize with a per-variant JSON round-trip test; AC(4) reduce_view has 12 tests covering view application, cache updates, out-of-range projections, and determinism over a repeated stream; AC(5) builds for host and wasm32, added to the CI wasm job.\n\nCommand's client-\u003eserver half was derived, not invented: it is the set of ViewOnly effects targeting the portable slice, which improvise-h0b had already separated from host-local state.\n\nOne addition beyond a pure move: ViewState::active_view, because reduce_view runs on a client with no Workbook and cursors is keyed by view name. Two writers only (App::switch_to_view, App::install_workbook), guarded by the_two_active_view_names_never_diverge. Whether the two copies may diverge is improvise-g0f, which now blocks improvise-cqq.\n\nFollowed the 2026-08-29 correction on the ticket: ViewState is a projection of the command stream, serialized only for the bootstrap Snapshot. Nothing here ships ViewState on change.","dependencies":[{"issue_id":"improvise-cqi","depends_on_id":"improvise-36h","type":"blocks","created_at":"2026-04-14T00:25:38Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-cqi","depends_on_id":"improvise-a2x","type":"blocks","created_at":"2026-08-30T15:56:13Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-cqi","depends_on_id":"improvise-h0b","type":"blocks","created_at":"2026-08-29T20:48:44Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-cqi","depends_on_id":"improvise-jc8","type":"blocks","created_at":"2026-08-29T20:44:42Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-cqi","depends_on_id":"improvise-vb4","type":"blocks","created_at":"2026-04-14T00:25:37Z","created_by":"spot","metadata":"{}"}],"dependency_count":5,"dependent_count":6,"comment_count":0} {"_type":"issue","id":"improvise-mae","title":"Tag existing effects as model / view / projection-emitting","description":"Add a classification to every Effect so the server can decide which effects trigger projection-command emission to connected clients. Pure view effects (cursor move, mode change) don't project. Model effects (SetCell, AddCategory, AddFormula) do project — they emit CellsUpdated or similar commands to clients whose viewport includes the change. Prerequisite for the server-side projection emission layer.","design":"Add a method to Effect like fn kind(\u0026self) -\u003e EffectKind where EffectKind is { ModelTouching, ViewOnly }. If Step 4 of crate epic (Effect→enum) has landed, this is a const match on variant. If not, each struct impls a kind() method. ModelTouching effects are further classified by what they change, so projection computation knows what kind of command to emit: { CellData, CategoryShape, ColumnLabels, RowLabels, FormulaResult, LayoutGeometry }.","acceptance_criteria":"(1) Every existing Effect has a kind classification. (2) A const/fn on Effect returns the kind without running apply. (3) Unit tests verify classification for each effect. (4) Audit notes list every effect and its kind.","notes":"Does not depend on Effect-as-enum refactor (crate epic step 4) but is much cleaner if that's landed. Depends on the AppState split so effects that cross slices are clearly cross-cutting.\nAUDIT (AC 4) — all 58 effects and their kinds, as of the closing commit:\n\nCellData (12): AddCategory, AddFormula, AddItem, AddItemInGroup, ApplyAndClearDrill, CleanEmptyRecords, ClearCell, RemoveCategory, RemoveFormula, RemoveItem, SetCell, SortData\n\nLayoutGeometry (14): CreateView, CycleAxis, DeleteView, HideItem, SetAxis, SetNumberFormat, SetPageSelection, ShowItem, SwitchView, ToggleGroup, TogglePruneEmpty, TransposeAxes, ViewBack, ViewForward\n\nWholeWorkbook (3): AcceptWizardModel, ImportJsonHeadless, LoadModel\n\nViewOnly (29): AbortChain, ChangeMode, CloseWizard, EnterEditAtCursor, ExportCsv, HelpPageNext, HelpPagePrev, HelpPageSet, MarkDirty, RebuildLayout, Save, SaveAs, SetBuffer, SetColOffset, SetDrillPendingEdit, SetPanelCursor, SetPanelOpen, SetRowOffset, SetSearchMode, SetSearchQuery, SetSelected, SetStatus, SetTileCatIdx, SetTransientKeymap, SetYanked, StartDrill, StartImportWizard, ToggleCatExpand, WizardApply\n\nNotes on the non-obvious ones:\n- SetSelected/SetRowOffset/SetColOffset are ViewOnly despite writing the shared, persisted View: cursor and scroll are per-client. Residual inconsistency filed as improvise-8uz.\n- Save/SaveAs/MarkDirty touch model_state (dirty, file_path) but change nothing renderable -\u003e ViewOnly.\n- ExportCsv reads the model and writes a file; no state change -\u003e ViewOnly.\n- RebuildLayout rebuilds a derived cache -\u003e ViewOnly.\n- SortData reorders the data store, which changes records-mode row identity -\u003e CellData.\n- ApplyAndClearDrill writes cells through free-function helpers -\u003e CellData.\n- SetTransientKeymap lives in command/keymap.rs, not effect.rs.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-14T07:22:02Z","created_by":"spot","updated_at":"2026-08-29T20:03:28Z","started_at":"2026-08-29T19:57:52Z","closed_at":"2026-08-29T20:03:28Z","close_reason":"All 58 effects classified. EffectKind::{ViewOnly, Model(ModelChange)} with ModelChange::{CellData, LayoutGeometry, WholeWorkbook}. Full audit table in the notes (AC 4).\n\nkind() HAS NO DEFAULT IMPL. The design section offered a per-struct method; making it required instead means a new effect cannot forget to classify itself. That matters because the failure mode is silent — a mis-declared ViewOnly edit would simply never reach connected clients. It paid off immediately: the compiler found a 58th Effect impl, SetTransientKeymap, living in command/keymap.rs rather than effect.rs, which I would have missed classifying by eye.\n\nDERIVED THE CLASSIFICATION, THEN VERIFIED IT. I extracted what each apply() body touches and reasoned about it in Prolog rather than eyeballing 58 effects. Two things that came out of it:\n\n1. The naive signal is WRONG for 21 effects. The View is stored inside model_state.workbook, so 'touches model_state' does not mean 'is a model change'. SetAxis, SetPageSelection, ToggleGroup, HideItem, ShowItem, TransposeAxes, CycleAxis, SetNumberFormat, SwitchView, ViewBack, ViewForward, CreateView, DeleteView and TogglePruneEmpty are LayoutGeometry, not CellData; Save/SaveAs/MarkDirty touch model_state for bookkeeping only and are ViewOnly. A classification done by grepping for model_state would have had the projection layer broadcasting cell updates for cursor moves.\n\n2. My own extraction was wrong twice, and I caught it by disbelieving the output rather than by luck: multi-line method chains hid workbook.model.set_cell(), and free-function helpers hid ApplyAndClearDrill's writes, so SetCell, SortData, RemoveItem and others first came out ViewOnly — obviously false. Fixed the extraction (flatten chains) and re-derived, then read the remaining suspicious ones directly.\n\nTESTS VERIFY DECLARATIONS EMPIRICALLY, not by restating them (AC 3). Rather than a table asserting kind() == what kind() returns, the tests apply each effect to a probe App and fingerprint the shared state it claims not to touch: view_only_effects_leave_shared_state_alone (16 effects, model data and view config both unchanged), cell_data_effects_actually_change_model_data, layout_geometry_effects_actually_change_view_config. Verified they bite by mislabelling SetCell as ViewOnly — the exact silent-drop bug — which fails cell_data_effects_actually_change_model_data.\n\nJUDGEMENT CALL, recorded rather than buried: SetSelected/SetRowOffset/SetColOffset write the shared, persisted View but are per-client concerns — one client scrolling must not move another's viewport — so they are ViewOnly. They are the only ViewOnly effects that mutate shared workbook state, which the test has to special-case. Filed improvise-8uz to resolve that properly before improvise-cqq builds the projection layer on this.","dependencies":[{"issue_id":"improvise-mae","depends_on_id":"improvise-vb4","type":"blocks","created_at":"2026-04-14T00:25:36Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} {"_type":"issue","id":"improvise-vb4","title":"Split AppState into ModelState + ViewState (standalone refactor)","description":"Refactor the current App struct to make the model/view slice boundary explicit in types. Today App mixes Model with session state (mode, cursor, scroll, buffers, etc.); split them so that ModelState holds document state and ViewState holds per-session UI state. Server continues to own both, but as distinct fields. Valuable independently of the browser epic: cleaner persistence (only model slice serializes), cleaner undo (undo walks model effects only), better test boundaries, and it's the foundational slice separation every downstream issue depends on.","design":"pub struct ModelState { model: Model, file_path: Option\u003cPathBuf\u003e, dirty: bool }. pub struct ViewState { mode: AppMode, selected, row_offset, col_offset, search_query, search_mode, yanked, buffers, expanded_cats, help_page, drill_state_session_part, tile_cursor, panel_cursors }. App becomes { model_state: ModelState, view_state: ViewState, layout: GridLayout, ... }. Audit every App field and assign it. Gray zones: Model.views stays in Model (document state). drill_state staging map is session (View); commit flushes to Model. yanked is View. file_path/dirty are Model.","acceptance_criteria":"(1) App contains ModelState and ViewState as typed subfields. (2) No App field lives outside exactly one slice (or is explicitly called out as derived, like layout cache). (3) All existing tests pass. (4) Effect apply methods updated to take \u0026mut the correct slice where obvious, or \u0026mut App where cross-cutting. (5) Audit report in issue notes: every field classified.","notes":"Additional implementation note merged from superseded improvise-dwe: a small number of effects today read App-level/layout-derived state at apply time (e.g. EnterEditAtCursor reads display_value from layout). For these, pre-compute the derived value in the command's Cmd::execute (where CmdContext has layout access) and bake it into the effect struct's own fields. Apply bodies then become pure model/view state writes. Example: EnterEditAtCursor { target_mode, initial_value: String } with initial_value computed in execute and consumed by apply.\n\n---\n\n## Field-by-field audit (improvise-97o)\n\nRefactor landed across 4 commits: f11d79f (3vr scaffold), 917b928 (x2c\nModelState fields), 9ad8abd (ew0 ViewState fields), 7249fac (99k residue\ndocs). All 29 original App fields classified below.\n\n| Field | Slice | Why |\n|---|---|---|\n| workbook | ModelState | Document semantics (Workbook = Model + named views + active view). |\n| file_path | ModelState | Path on disk for save/load — IO bookkeeping. |\n| dirty | ModelState | Divergence from on-disk state — IO bookkeeping. |\n| mode | ViewState | Current AppMode (Normal / Editing / etc.). |\n| status_msg | ViewState | Transient status-line text. |\n| wizard | ViewState | Optional import wizard, present only in import flow. |\n| search_query | ViewState | Current search string. |\n| search_mode | ViewState | True while typing into the search prompt. |\n| formula_panel_open | ViewState | Formula side panel toggle. |\n| category_panel_open | ViewState | Category side panel toggle. |\n| view_panel_open | ViewState | View side panel toggle. |\n| cat_panel_cursor | ViewState | Selected row in category panel. |\n| view_panel_cursor | ViewState | Selected row in view panel. |\n| formula_cursor | ViewState | Selected formula in formula panel. |\n| yanked | ViewState | Last `y`-yanked CellValue for paste. |\n| tile_cat_idx | ViewState | Highlighted tile in tile-select mode. |\n| view_back_stack | ViewState | View navigation history (back). |\n| view_forward_stack | ViewState | View navigation history (forward). |\n| drill_state | ViewState | Frozen records snapshot + staging edits for drill view. |\n| help_page | ViewState | Current page in help overlay. |\n| expanded_cats | ViewState | Categories expanded in the panel tree. |\n| buffers | ViewState | Named text-entry buffers (edit, formula, command, ...). |\n| transient_keymap | ViewState | Emacs-style prefix key chord state. |\n| layout | App-derived | Pure function of model_state.workbook + view_state.drill_state + active view; rebuilt via rebuild_layout(). |\n| last_autosave | App-runtime | Wall-clock instant for autosave debounce. |\n| term_width | App-runtime | Terminal width, updated on resize. |\n| term_height | App-runtime | Terminal height, updated on resize. |\n| abort_effects | App-transient | Flag scoped to a single apply_effects call; reset on entry. |\n| keymap_set | App-config | Keymap configuration loaded once at startup. |\n\nCounts: ModelState 3, ViewState 20, App-derived 1, App-runtime 3, App-transient 1, App-config 1 = 29 total.\n\nimprovise-drg (step 5: narrow Effect::apply signatures) was deferred —\nthe issue's framing has a hidden trait-design decision (you can't change\none impl's signature without changing the trait). Three options floated\n(split into ModelEffect/ViewEffect/AppEffect, body-level rebind, or\ndefault-method dispatch); none picked yet. vb4 does not depend on drg.","status":"closed","priority":2,"issue_type":"feature","assignee":"jeeves","owner":"el-github@elangley.org","created_at":"2026-04-14T07:21:59Z","created_by":"spot","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-05-03T04:16:32Z","close_reason":"Audit report written into vb4 notes per criterion (5). All 29 original App fields classified into ModelState (3) / ViewState (20) / App-derived (1) / App-runtime (3) / App-transient (1) / App-config (1). vb4 acceptance criteria all met: (1) typed model_state + view_state subfields ✓ (2) no overlap ✓ (3) tests pass ✓ (4) Effect::apply still \u0026mut App per criterion (4)'s allowance for cross-cutting; impl-level narrowing deferred to improvise-drg (step 5) which is P3/optional. Refactor commits: f11d79f, 917b928, 9ad8abd, 7249fac.","dependencies":[{"issue_id":"improvise-vb4","depends_on_id":"improvise-97o","type":"blocks","created_at":"2026-04-26T11:17:18Z","created_by":"jeeves","metadata":"{}"}],"dependency_count":1,"dependent_count":3,"comment_count":0} -{"_type":"issue","id":"improvise-6jk","title":"Epic: Browser frontend via synchronized Redux-style stores","description":"Build a browser frontend for improvise by treating the client as a thin Redux-style peer of the existing server. Commands are the wire format; each side runs a reducer over its own state slice. Server is structurally unchanged (still runs today's full App with ratatui session, effect pipeline, formula eval). Client is a new thin peer: ViewState + render cache + reduce_view + keymap + DOM renderer. Commands flow both directions — upstream (user intent resolved client-side) and downstream (projection commands emitted by the server after model-touching effects). See child issues for sequenced steps.","design":"Client holds ViewState (mode, cursor, scroll, minibuffer, search, yanked, expanded cats, drill buffer, panel cursors) plus a render cache (visible cells, labels, col widths). Server holds full App as today. Wire type: Command enum, serde over websocket. Upstream: user-initiated commands resolved by client-side keymap. Downstream: projection commands (CellsUpdated, ColumnLabelsChanged, etc.) emitted by server after effect application. Client reduce_view pattern-matches commands and applies view-slice effects; client never runs formula eval or touches Model. Server emits projections by walking the effect list after each command and computing per-viewport deltas per connected client. Effect split (model vs view) is a server-internal tag driving projection emission — never crosses the wire. Key→command resolution is client-side (keymap in wasm bundle) for local responsiveness.","acceptance_criteria":"Closes when improvise-1ey does. 1ey's criteria (1)-(4) and (6) are met and evidenced better than the ticket asked — browser_round_trip.rs drives real frames through a real Host, and harness/tests/shell.spec.js renders web/app.js under Chromium. (5), 'scrolling fetches cells for the new viewport', is covered in principle by set_viewport and GridExtent but NOT by an executed test, because web/demo.improv is too small to scroll — improvise-plcx is what would close that. Decide whether (5) needs the test before this epic closes.","notes":"Prereqs from crate-split epic (improvise-xgl): step 2 (improvise-core, improvise-36h) for shared types; step 5 (improvise-command split, improvise-3mm) so wasm client can import keymap + Command + reduce_view without ratatui. Step 4 (Effect enum, improvise-45v) useful but not strictly required. Target bundle: 200-500 KB compressed — formula layer stays server-side.\nCHECKED, wave 2 WU-P: STAYS OPEN, held by improvise-1ey, which is held by\nimprovise-plcx.\n\nThis epic's criteria asked to \"decide whether (5) needs the test before this\nepic closes\". The decision is YES, and the reason is that (5) is not the same\nquestion as the resize test that stands in for it. improvise-1ey's annotation\nnow says so: browser_round_trip.rs covers the EXTENT changing at a fixed\noffset, while scrolling is the OFFSET moving, which is exactly where gotcha\n47's \"the window is the client's own fact\" and the GridExtent arithmetic bite\n— the row frame's own length is the window clipped to the grid and so differs\nprecisely at the bottom edge. Standing on the resize test would be counting a\ncriterion as met by a test that cannot see it, which is the defect shape this\nrepo names most often.\n\nimprovise-plcx supplies the fixture that makes the test possible.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:21:21Z","created_by":"spot","updated_at":"2026-09-20T18:05:07Z","labels":["area:epic"],"dependencies":[{"issue_id":"improvise-6jk","depends_on_id":"improvise-1ey","type":"blocks","created_at":"2026-04-14T00:26:00Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-6jk","depends_on_id":"improvise-cqi","type":"blocks","created_at":"2026-04-14T00:25:57Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-6jk","depends_on_id":"improvise-cqq","type":"blocks","created_at":"2026-04-14T00:25:58Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-6jk","depends_on_id":"improvise-cr3","type":"blocks","created_at":"2026-04-14T00:26:00Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-6jk","depends_on_id":"improvise-gsw","type":"blocks","created_at":"2026-04-14T00:25:59Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-6jk","depends_on_id":"improvise-mae","type":"blocks","created_at":"2026-04-14T00:25:57Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-6jk","depends_on_id":"improvise-q08","type":"blocks","created_at":"2026-04-14T00:25:58Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-6jk","depends_on_id":"improvise-vb4","type":"blocks","created_at":"2026-04-14T00:25:56Z","created_by":"spot","metadata":"{}"}],"dependency_count":8,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-6jk","title":"Epic: Browser frontend via synchronized Redux-style stores","description":"Build a browser frontend for improvise by treating the client as a thin Redux-style peer of the existing server. Commands are the wire format; each side runs a reducer over its own state slice. Server is structurally unchanged (still runs today's full App with ratatui session, effect pipeline, formula eval). Client is a new thin peer: ViewState + render cache + reduce_view + keymap + DOM renderer. Commands flow both directions — upstream (user intent resolved client-side) and downstream (projection commands emitted by the server after model-touching effects). See child issues for sequenced steps.","design":"Client holds ViewState (mode, cursor, scroll, minibuffer, search, yanked, expanded cats, drill buffer, panel cursors) plus a render cache (visible cells, labels, col widths). Server holds full App as today. Wire type: Command enum, serde over websocket. Upstream: user-initiated commands resolved by client-side keymap. Downstream: projection commands (CellsUpdated, ColumnLabelsChanged, etc.) emitted by server after effect application. Client reduce_view pattern-matches commands and applies view-slice effects; client never runs formula eval or touches Model. Server emits projections by walking the effect list after each command and computing per-viewport deltas per connected client. Effect split (model vs view) is a server-internal tag driving projection emission — never crosses the wire. Key→command resolution is client-side (keymap in wasm bundle) for local responsiveness.","acceptance_criteria":"Closes when improvise-1ey does. 1ey's criteria (1)-(4) and (6) are met and evidenced better than the ticket asked — browser_round_trip.rs drives real frames through a real Host, and harness/tests/shell.spec.js renders web/app.js under Chromium. (5), 'scrolling fetches cells for the new viewport', is covered in principle by set_viewport and GridExtent but NOT by an executed test, because web/demo.improv is too small to scroll — improvise-plcx is what would close that. Decide whether (5) needs the test before this epic closes.","notes":"Prereqs from crate-split epic (improvise-xgl): step 2 (improvise-core, improvise-36h) for shared types; step 5 (improvise-command split, improvise-3mm) so wasm client can import keymap + Command + reduce_view without ratatui. Step 4 (Effect enum, improvise-45v) useful but not strictly required. Target bundle: 200-500 KB compressed — formula layer stays server-side.\nCHECKED, wave 2 WU-P: STAYS OPEN, held by improvise-1ey, which is held by\nimprovise-plcx.\n\nThis epic's criteria asked to \"decide whether (5) needs the test before this\nepic closes\". The decision is YES, and the reason is that (5) is not the same\nquestion as the resize test that stands in for it. improvise-1ey's annotation\nnow says so: browser_round_trip.rs covers the EXTENT changing at a fixed\noffset, while scrolling is the OFFSET moving, which is exactly where gotcha\n47's \"the window is the client's own fact\" and the GridExtent arithmetic bite\n— the row frame's own length is the window clipped to the grid and so differs\nprecisely at the bottom edge. Standing on the resize test would be counting a\ncriterion as met by a test that cannot see it, which is the defect shape this\nrepo names most often.\n\nimprovise-plcx supplies the fixture that makes the test possible.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:21:21Z","created_by":"spot","updated_at":"2026-09-20T18:05:07Z","labels":["area:epic"],"dependencies":[{"issue_id":"improvise-6jk","depends_on_id":"improvise-cqi","type":"blocks","created_at":"2026-04-14T00:25:57Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-6jk","depends_on_id":"improvise-cqq","type":"blocks","created_at":"2026-04-14T00:25:58Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-6jk","depends_on_id":"improvise-cr3","type":"blocks","created_at":"2026-04-14T00:26:00Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-6jk","depends_on_id":"improvise-gsw","type":"blocks","created_at":"2026-04-14T00:25:59Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-6jk","depends_on_id":"improvise-mae","type":"blocks","created_at":"2026-04-14T00:25:57Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-6jk","depends_on_id":"improvise-q08","type":"blocks","created_at":"2026-04-14T00:25:58Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-6jk","depends_on_id":"improvise-vb4","type":"blocks","created_at":"2026-04-14T00:25:56Z","created_by":"spot","metadata":"{}"}],"dependency_count":7,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-3mm","title":"Step 5: Extract improvise-command crate below improvise-tui","description":"Move src/command/ into an improvise-command crate that depends on improvise-core (+ formula) but NOT on ui/. The tui crate (with App, draw.rs, widgets) depends on improvise-command. This is the boundary most worth enforcing — it's where the current Cmd/Effect/App architecture is conceptually clean but mechanically unenforced. Should be mostly straightforward after step 4 (Effect-as-enum) because commands no longer transitively depend on App.","design":"Target shape: improvise-command contains command/ (Cmd, CmdContext, CmdRegistry, keymap.rs, all Cmd impls), ui/effect.rs (Effect trait + all impl blocks), AppState (the semantic state struct — workbook, mode, status_msg, dirty, buffers, expanded_cats, yanked, drill_state, search_query, search_mode, transient_keymap, view_back/forward_stack, panel cursors + open flags, help_page, wizard, file_path), AppMode, ModeKey, Keymap, MinibufferConfig, DrillState. No ratatui or crossterm deps. improvise-tui contains App { state: AppState, term_width, term_height, layout: GridLayout, last_autosave, keymap_set }, draw.rs, ui/ widgets, main.rs (binary). Depends on improvise-command + improvise-io + improvise-core + improvise-formula. Effect::apply takes \u0026mut AppState (per improvise-dwe), so all impl Effect for Foo blocks live in improvise-command without needing App. The apply_effects dispatch loop moves to tui: for e in effects { e.apply(\u0026mut self.state); } self.rebuild_layout();. cmd_context() bridges both layers — it reads layout/term_dims from App and everything else from AppState.","acceptance_criteria":"(1) crates/improvise-command/ contains command/ + ui/effect.rs + AppState + AppMode + ModeKey + Keymap + MinibufferConfig + DrillState. (2) No use crate::ui::draw, use ratatui, use crossterm imports remain in improvise-command. (3) improvise-tui contains App (wrapping AppState) + ui/ widgets + draw.rs + main.rs. (4) cargo build -p improvise-command succeeds without compiling ratatui or crossterm. (5) cargo test -p improvise-command runs command/effect tests in isolation. (6) All workspace tests pass. (7) cargo clippy --workspace --tests clean.","notes":"With improvise-dwe in place, this step is largely mechanical file moves + lib.rs re-exports, mirroring Phase B (improvise-36h) and Phase 3 (improvise-8zh). Key items: (1) AppMode and MinibufferConfig must be pure data (no rendering-layer types); audit before moving. (2) ImportWizard lives in AppState as session state — moves to improvise-command. Its render path is in improvise-tui's ui/import_wizard_ui.rs which stays up in tui. (3) KeymapSet + default_keymaps() both fine to move — they're pure data. (4) main.rs stays in improvise-tui as the binary entry point. (5) crates/improvise-tui/ replaces the current main improvise crate as the binary target; the root workspace's [[bin]] should probably point there. (6) The draw.rs event loop + TuiContext stay in improvise-tui. (7) Watch for accidental pub(crate) on items that become cross-crate — fix visibilities case by case.","status":"closed","priority":2,"issue_type":"feature","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-14T06:53:59Z","created_by":"spot","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-06-13T20:57:07Z","close_reason":"Done in d60ee8d (prep: command layer de-coupled from ratatui/crossterm via own command::key types + semantic MinibufColor) and a85e6ca (extraction). crates/improvise-command now holds command/ (cmd, keymap, parse, key), ui/{app,effect,cat_tree}.rs, and the pure viewport helpers; depends on core+formula+io and provably NOT ratatui/crossterm (cargo tree -e normal = 0 hits; grep = 0 hits). Tests: 665 workspace green (improvise-command 227 in isolation), clippy --workspace --all-targets clean. DEVIATION from the recorded design: App moved WHOLE into improvise-command rather than splitting an AppState struct out, and the root 'improvise' crate stays the binary (re-exporting improvise_command under crate::command / crate::ui::{app,effect,cat_tree} so widgets/draw.rs/main.rs are untouched) instead of a renamed improvise-tui crate. This matches the improvise-ltq design correction and keeps the diff a pure file-move + re-export.","dependencies":[{"issue_id":"improvise-3mm","depends_on_id":"improvise-dwe","type":"blocks","created_at":"2026-04-15T23:29:27Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":3,"comment_count":0} {"_type":"issue","id":"improvise-45v","title":"Step 4: Convert Effect trait to an enum (in-place, no crate change)","description":"Refactor Effect from a trait (Box\u003cdyn Effect\u003e with apply(\u0026self, \u0026mut App)) into a data enum, with a single apply(app: \u0026mut App, effect: \u0026Effect) function in the tui layer. This is the key semantic change that unblocks splitting command from ui: once effects are pure data, the command layer no longer transitively depends on App. Do this step in-place before any crate split so the diff stays isolated from packaging churn.","design":"Replace trait Effect with pub enum Effect { SetCursor { row, col }, SetMode(AppMode), AppendChar(char), ... } — one variant per current Effect struct (~50 variants). The existing Effect structs' fields become variant payloads. Move all apply() bodies into one apply_effect(app: \u0026mut App, e: \u0026Effect) function that matches exhaustively. Commands still return Vec\u003cEffect\u003e (no Box). Rationale: (1) design-principles.md §1 already claims 'effects can be logged, replayed, or composed' — an enum actually delivers that, a trait object does not. (2) matches the enum-heavy style used elsewhere (Binding, Axis, CategoryKind, BinOp). (3) exhaustive matching catches missed cases at compile time when adding new effects. Note: changes_mode() becomes a const match on the enum variant.","acceptance_criteria":"(1) trait Effect is gone. (2) Effect is a pub enum with one variant per previous struct. (3) apply_effect(app, effect) lives in ui/effect.rs (or similar) and is exhaustive. (4) Vec\u003cBox\u003cdyn Effect\u003e\u003e is replaced with Vec\u003cEffect\u003e in Cmd::execute signature and throughout command/. (5) All 568 existing tests pass. (6) No behavior change.","notes":"Biggest single semantic change in the epic. Do it in a branch, keep the commit history clean (one commit per submodule of effects migrated is fine). Watch for effects with nontrivial apply bodies — e.g., drill reconciliation and import wizard effects — those may need helper functions rather than inline match arms to keep apply_effect readable.","status":"closed","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T06:36:11Z","created_by":"spot","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-16T06:26:50Z","close_reason":"Superseded. Effect stays as a trait — the original blocker (command transitively depending on App via Effect::apply(\u0026mut App)) is resolved by splitting App into AppState (semantic state, in improvise-command) + App (rendering state wrapper, in improvise-tui). Effect::apply takes \u0026mut AppState; the few effects that currently read App-level state (layout, display_value) pre-compute those values in their Cmd::execute and pass them via the effect struct's own fields (self). No enum conversion, no dual trait, no noise. See improvise-3mm for the revised design.","dependencies":[{"issue_id":"improvise-45v","depends_on_id":"improvise-8zh","type":"blocks","created_at":"2026-04-13T23:54:40Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"improvise-8zh","title":"Step 3: Extract improvise-io crate (persistence + import)","description":"Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub.","acceptance_criteria":"(1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges.","notes":"Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path.","status":"closed","priority":2,"issue_type":"task","assignee":"spot","owner":"el-github@elangley.org","created_at":"2026-04-14T06:35:23Z","created_by":"spot","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-16T06:09:19Z","close_reason":"Done on worktree-improvise-ewi-formula-crate. Created crates/improvise-io/ containing persistence/ + import/; depends only on improvise-core and improvise-formula (plus external: anyhow, chrono, csv, flate2, indexmap, pest, serde, serde_json). No crate::ui::* or crate::command::* imports in moved code. All 616 tests pass (219 main + 190 core + 65 formula + 142 io); clippy clean; 'cargo build -p improvise-io' succeeds standalone. Kept as one crate per acceptance criterion #5.","dependencies":[{"issue_id":"improvise-8zh","depends_on_id":"improvise-36h","type":"blocks","created_at":"2026-04-13T23:54:39Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":3,"comment_count":0} @@ -431,129 +506,182 @@ {"_type":"issue","id":"improvise-pby","title":"2.2 Write the README","description":"Replace existing README.md with new one under 250 lines. Sections in order: Title, one-sentence pitch, inline demo GIF, why this exists (Lotus Improv reference), quick start, key bindings, installation (Nix/crates.io/prebuilt - no Homebrew), codebase overview, expectations disclaimer, license. No badges, TOC, or contributing guide.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:32Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-09T07:02:52Z","close_reason":"README written with all 10 required sections, committed. License updated to Apache-2.0 per user request.","dependency_count":0,"dependent_count":2,"comment_count":0} {"_type":"issue","id":"improvise-ihv","title":"2.1 Add Nix tooling for asciinema, VHS, and cargo-dist","description":"Modify flake.nix to add pkgs.asciinema and pkgs.vhs to dev shell nativeBuildInputs. Add cargo-dist if packaged in nixpkgs. Add runtime deps for VHS (ttyd, ffmpeg) if needed. Verify tools work via nix develop --command. Add nix run app or shell script for demo recording workflow.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:30Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-09T07:30:19Z","close_reason":"asciinema, vhs, cargo-dist added to flake.nix dev shell and verified. cargo-dist wrapper enables 'cargo dist' subcommand. scripts/record-demo.sh added.","dependency_count":0,"dependent_count":3,"comment_count":0} {"_type":"issue","id":"improvise-yk4","title":"Phase 2: README and demo artifacts","description":"The main launch work. README is 80% of the launch, demo artifacts are 20%. Covers Nix tooling for asciinema/VHS, writing the README, creating demo GIF and asciinema casts, and verifying all artifacts.","status":"closed","priority":2,"issue_type":"feature","assignee":"spot","owner":"el-github@elangley.org","created_at":"2026-04-09T04:05:49Z","created_by":"Edward Langley","updated_at":"2026-04-14T07:47:43Z","closed_at":"2026-04-14T07:47:43Z","close_reason":"All 5 subtasks closed; artifacts verified present and tracked in git (README.md, docs/demo.gif, docs/demo.tape, docs/casts/*.cast).","dependencies":[{"issue_id":"improvise-yk4","depends_on_id":"improvise-abz","type":"blocks","created_at":"2026-04-08T23:37:37Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-yk4","depends_on_id":"improvise-d4w","type":"blocks","created_at":"2026-04-08T23:37:36Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-yk4","depends_on_id":"improvise-ihv","type":"blocks","created_at":"2026-04-08T23:37:34Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-yk4","depends_on_id":"improvise-odx","type":"blocks","created_at":"2026-04-08T23:37:35Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-yk4","depends_on_id":"improvise-pby","type":"blocks","created_at":"2026-04-08T23:37:36Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":5,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-txh4","title":"Type-ahead across a refused commit: text typed into the reopened editor replays into the refused cell's editor, and the retry writes it there","description":"Found reviewing PR #274 at head 8d9de94e. Not a regression: at gh/main the retry writes the cursor cell, which is the same cell here.\n\n**Steps (real Host, the `host()` fixture):**\n1. A presses `l i 4 2`, so the editor is open on North/Cost with `42` typed.\n2. B writes North/Revenue. B's relays are held.\n3. A presses Enter. The commit is sent, the advance reopens the editor on South/Cost (1,1), and the buffer is empty.\n4. **Before the answer arrives,** A types `7` into the South/Cost editor. That sends the unawaited frame `[SetBuffer edit \"7\", SetOpenedEditor touched]`, which queues behind the awaited commit.\n5. The commit is refused with `RecordMoved(north)`.\n6. `refused()` drops the commit frame and replays the settled slice plus the rest of `in_flight`. The editor is back on North/Cost (0,1), but the buffer is **\"7\"**: the SetBuffer composed for the South/Cost editor lands in the restored North/Cost editor.\n7. `opened` is `Retry(north, Cost)`. The recapture matches, so it becomes `Target`.\n8. The retry Enter is `Accepted`.\n\n**Result:** North/Cost = 7. The user's 42 is gone, and 7 (typed for South/Cost) is stored in North/Cost.\n\nThe `Retry`/`Gone` rule (9ww8, 8phw) checks that the **cell** is the refused frame's cell. It does not check that the **text** belongs to that editor. The replay of type-ahead is the #221-era `refused()` design (settled + remaining in_flight).\n\n**Options, none tried:**\n- (a) On a refused commit, drop the in-flight editor frames composed after it, since they belong to an editor the refusal unmade.\n- (b) Restore the refused frame's own typed text as the buffer.\n- (c) Decline the retry when the buffer was touched by a later editor.\n\nNeeds a keypress inside one host round trip plus a crossing write, so it is P3. Related to improvise-n4i9 (type-ahead into an awaiting editor) and improvise-8phw.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T01:39:48Z","created_by":"agent-review-221-251","updated_at":"2026-09-24T01:39:48Z","labels":["adversarial-review","pr-274"],"dependencies":[{"issue_id":"improvise-txh4","depends_on_id":"improvise-8phw","type":"related","created_at":"2026-09-24T01:39:55Z","created_by":"agent-review-221-251","metadata":"{}"},{"issue_id":"improvise-txh4","depends_on_id":"improvise-n4i9","type":"related","created_at":"2026-09-24T01:39:55Z","created_by":"agent-review-221-251","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-cz3o","title":"EnterEditAtCursor's status-not-refuse rule is unguarded, and a scripted commit whose advance lands on a declined cell prints a refusal to stderr (#277)","description":"Found in adversarial review of PR #277 (improvise-enz6), head aab62a36.\n\n1. **The status-not-refuse rule is unguarded.** `context/code/command.md` (#277) says `EnterEditAtCursor` writes a status line and not `App::refuse`, because the commit before it succeeded: \"A `commit-cell-edit` script line whose advance lands on a fold must not report its write as refused.\" Rival: `Some(refusal) =\u003e app.refuse(refusal)` in `EnterEditAtCursor::apply`. The whole workspace stays green (`cargo test --workspace --no-fail-fast`). Measured through the CLI, the rival breaks what the rule protects. With `_Measure` on rows [Revenue, Profit=formula] and Month on columns, `improvise cmd 'commit-cell-edit editing 7 Month/Jan _Measure/Revenue' -f t.improv` stores 7 and exits **1** under the rival. It exits 0 under the PR.\n\n2. **A successful scripted commit now prints a refusal-shaped line.** On the same fixture under the PR, the command exits 0 and stores 7, but stderr reads \"Profit is computed by a formula — edit the formula, not the cell\". At 202e7eac (gh/main) the same command prints nothing. The user asked to write Revenue, and the message is about a cell they did not touch. It comes from the re-entry the headless run does not need.\n\n3. **A false comment.** `resolve::AGGREGATE_DECLINE` is documented as \"What the host says of an aggregate, the fold of a collapsed group included\". The host says `FOLD_REFUSAL` (\"Collapsed group — press z to expand it first\") of a fold, and `context/code/clients.md` (#277) says so itself.\n\nAC: a test fails under the rival in (1). It can assert `!last_batch_refused()` after a commit whose advance lands on a declined cell, or the CLI exit code. Decide whether (2) is acceptable and say so in `context/code/cli.md`, or suppress the re-entry's status for a headless run. Correct the comment in (3).\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T01:29:27Z","created_by":"agent-review-277","updated_at":"2026-09-24T01:29:27Z","labels":["adversarial-review","area:cli","pr-277"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-ik6l","title":"Browser Enter on a touched editor over a declined cell is a silent Upstream: no write, no message, editor stays open (#282's kept editor)","description":"Found in adversarial review of PR #282 (improvise-5m9z), head 942f57e5. It answers the orchestrator's question \"a touched editor survives; what happens at commit?\"\n\n#282 keeps a TOUCHED editor open on a cell the host later projects as declined (a fold, an aggregate or a formula's result). The case is an advance past the window followed by typing before the reply arrives. Enter on that editor goes through `resolve::commit`. Its `id` is Aggregate or Derived, so `written` -\u003e `record_at_cursor` -\u003e `None`, and `commit` returns `None`. The key outcome is `KeyOutcome::Upstream`. `Host::handle` answers a Key frame with nothing (gotcha 47), so Enter does nothing: no write, no message, and the editor stays open with the user's text.\n\nRepro (reviewer's scratch test on 942f57e5, not pushed): `fold_host`, window (2, 6), cursor at (1,1). `i`, `Enter`, `7` are folded and sent before any reply is delivered, and then the replies are delivered. Then `Enter`: `resolve` returns Upstream. The mode is still Editing, the status is \"\", the buffer is \"7\", and the host's cell count is unchanged.\n\nThe TUI never reaches this state, because it declines the open. Its nearest equivalent is an editor already open on a fold (`an_editor_already_open_on_a_fold_commits_nothing_there`), where Enter says \"Collapsed group — press z to expand it first\". The browser should decline out loud the same way, with `declined_at`'s text. This is the same family as improvise-l3hm and improvise-kkcy (silent Upstream where the TUI declines).\n\nThe #282 docs say \"no write can follow on that cell\". That is true, but it does not say that Enter goes dead.\n\nAC: Enter on a touched editor over a declined cell composes a SetStatus decline, not Upstream. A test drives this state, and a rival that returns `None` fails it.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T01:29:13Z","created_by":"agent-review-277","updated_at":"2026-09-24T01:29:13Z","labels":["adversarial-review","browser","pr-282"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-kimw","title":"An empty-record write naming a non-host view re-derives that view's layout (and, with formulas, clones + recomputes the model) ~5x per command","description":"Found by the adversarial review of PR #281 (improvise-wpxr) at head ba3d2875.\n\nA wire SetCell/ClearCell of an EMPTY record, naming a view the host is not on, derives that view's layout once per resolution call, not once per command. The calls are: CheckedBatch's record tier (will_be_created_by_the_frame, stale_record), broken_chain, and model_effects (unwritable -\u003e resolve_cell and aggregate_refusal, then resolve_cell again). That is five or six derivations per write, before grid_in derives the same layout once more to project it.\n\nWithout formulas each derivation is only GridLayout::with_frozen_records, which is cheap (about 2.4ms measured). But App::with_layout_of goes through project::rederived_for. When the two views' none_cats differ and the document has any formula, that CLONES THE MODEL AND RUNS recompute_formulas on every call.\n\nMEASURED (release build, Apple Silicon laptop under load, so noisy; a scratch test, discarded). Document: Region(150) x Product(120) x _Measure{Revenue, Cost}, about 18k stored Revenue cells, plus one formula (Profit = Revenue - Cost). The host sits on \"Other\", which puts Product on None. The client names \"Default\".\n- ClearCell of an empty record: best of 5 = 1.82s.\n- SetCell of a stored record, same host: 0.65s.\n- One resolve_record of the empty record alone: 227ms.\n- So about 1.1s per write is repeated derivation.\n- For contrast, with no formula the empty-record write costs the same as the host-on-Default case (about 0.24-0.34s, dominated by the pre-existing projection cost, which is improvise-swre).\n\nWho pays: the browser worker deployment most of all. There, the App's view_state is the host's own, so every client on a non-default view is \"not on the host's view\". Holding clear-cell or paste over a column of empty cells pays about 1s+ per key on a mid-size document with a formula.\n\nFix shape: derive once per command, or once per frame. For example, resolve the named view's layout once in model_effects and in the CheckedBatch record tier and pass it down. Or memoise the derived (model, layout) keyed on (view name, layout_version, document_generation), together with improvise-swre. Correctness is not in question: #281's resolution is right. This is cost only.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T01:23:47Z","created_by":"agent-review-281","updated_at":"2026-09-24T01:23:47Z","labels":["adversarial-review","performance","pr-281"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-s2gb","title":"every_silent_binding_is_on_the_list walks only Normal and search: a silent binding in a panel mode passes the whole suite","description":"Found reviewing PR #280 (improvise-oyaa) at 88e53aed. The new guard walks only ModeKey::Normal and SearchMode, but the browser also stands in the formula, category and view panels, TileSelect, Help, Editing and RecordsNormal. Rival: I added .bind(KeyCode::Char('Z'), \"commit-export\") to the FormulaPanel keymap. The client has no arm for commit-export, so Z goes Upstream, a black hole. cargo test --workspace stayed at 0 failed. The #264 help-row guard cannot see it because no help row names it, and the #280 guard cannot see it because it never walks FormulaPanel. A throwaway probe ran the PR's own every_binding and press over every AppMode's keymap, with the search query both empty and seeded. At head it finds no silent, unlisted binding in any mode the browser can enter. The only hits are modes whose door the browser refuses (COMMAND, EXPORT, SAVE AS, IMPORT) and RECORDS INSERT, whose entry edit-or-drill declines. So widening the guard to every mode except those costs nothing today and closes the gap. The exclusions should be stated as the modes declined() refuses at the door, not as a hand list.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T01:21:55Z","created_by":"agent-review-264","updated_at":"2026-09-24T01:21:55Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-i5z6","title":"improvise-formula lib test binary measured over the 2s tripwire under load (2.2-5.5s)","description":"Reported by two agents on 2026-09-23 (M4 Max, heavy parallel load): improvise-formula's lib test binary took 2.21s, 3.84s and 5.47s; earlier 1.77s. May be load, but design-principles §7 says compare to the run's median binary. Measure unloaded and as a ratio to the median; if it is an outlier, find the cost (likely proptest/grammar work, cf. improvise-8shu's OnceLock fix for improvise-io).","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-24T01:18:38Z","created_by":"orchestrator","updated_at":"2026-09-24T01:18:38Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-3j70","title":"The GIT_CONFIG_GLOBAL/NOSYSTEM nulls in run_git have no witness: vwjj's -c core.excludesFile=/dev/null masks the excludes-based one","description":"Found while doing improvise-eahe, and true at #276's head before eahe as well.\n\n`run_git` sets `GIT_CONFIG_GLOBAL=/dev/null` and `GIT_CONFIG_NOSYSTEM=1` (improvise-53zz). The witness for both halves is a global or system config whose `core.excludesFile` refuses `*.gz`. Since improvise-vwjj, `run_git` also passes `-c core.excludesFile=/dev/null`, and a `-c` argument outranks every config file. So the witness can no longer see either half.\n\nMeasured by injecting each rival into `tests/shipped_documents_load.rs` and running the binary. Both rivals are **green**, both on the per-setting tests at ddcdf93a and on eahe's combined test:\n- dropping `GIT_CONFIG_GLOBAL=/dev/null`;\n- dropping `GIT_CONFIG_NOSYSTEM=1`.\n\nThe two lines are still worth keeping, because they null every *other* config key a user might set. But no test pins them. The fix is a witness that uses a config key which no `-c` argument overrides and which breaks `git init` or `git add`. For example, `core.sharedRepository = nonsense` might fail `git init`, but that is untested; check it on git 2.55 first. Put it in the same hostile child as eahe's other settings, and show both rivals red.\n","status":"in_progress","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-24T01:18:29Z","created_by":"agent-docs-wave","updated_at":"2026-09-24T01:24:58Z","started_at":"2026-09-24T01:24:58Z","lease_expires_at":"2026-09-24T01:29:58Z","heartbeat_at":"2026-09-24T01:24:58Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-5m9z","title":"Browser advance past the announced window opens the editor on an unprojected cell","description":"Found on PR #277 (improvise-enz6). resolve::reopened_at asks the landing cell's projected CellId to decline folds/formulas; an advance past the client's announced viewport lands on a cell with no projected id yet, so the browser opens the editor there regardless. No write can follow (the commit needs a projected record), but the editor state diverges from the TUI, which declines. Fix: treat an unprojected landing as await-the-host (like opened_on's awaiting seed) rather than open.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T01:03:25Z","created_by":"orchestrator","updated_at":"2026-09-24T01:03:25Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-oyaa","title":"NEEDS_THE_HOST completeness is checked only where a help row names the key","description":"Found on PR #275 (improvise-fo0w). resolve::NEEDS_THE_HOST's two guards (#264) prove no false entries and that no help row is an unmarked dead key, but not that every dead binding is listed: removing force-quit from the list fails no test, because no help row names Ctrl-Q. Fix: derive completeness from the Normal and search-mode keymaps (every binding either resolves locally, declines out loud, or is listed), not only from help rows.","status":"in_progress","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-24T01:02:24Z","created_by":"orchestrator","updated_at":"2026-09-24T01:02:41Z","started_at":"2026-09-24T01:02:41Z","lease_expires_at":"2026-09-24T01:07:41Z","heartbeat_at":"2026-09-24T01:02:41Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-0629","title":"a_help_row_the_browser_cannot_run_is_drawn_marked does not see an ex row drawn plain","description":"Found reviewing PR #264 (improvise-uln5) at cf5c6fc7. The render test asserts the search row's kind is dim, but for the ex-command (Literal) rows it asserts only that the description contains 'not in the browser'. Rival: in RenderHelpRow's Literal arm, override kind back to 'dim' only when the macro's dim flag is set (ignoring runs_the_command_line). The non-dim ex rows (:w, :q, :add-cat ...) then render as help-keys with a marked description, and all 21 render tests pass. The production code is correct today (verified in Chromium: every ex row is help-dim); this is a missing assertion. Fix: assert row.kind == dim for every ex row too.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T00:40:35Z","created_by":"agent-review-264","updated_at":"2026-09-24T00:40:35Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-joed","title":"A commit declined as 'still waiting' loses the typed text: Enter's welded clear-buffer still runs","description":"Found by agent-8xq0 (PR stacked on #221). In the browser, a keyboard commit whose target is not captured yet (the host has not answered this client's own last write) returns the status 'still waiting for the host to answer your last edit' and writes nothing. Since improvise-8xq0 this applies to value cells as well as coordinate renames. But Enter in Editing/RecordsEditing is the Sequence [commit-cell-edit, clear-buffer edit], and the sequence resolver keeps the clear-buffer step, so the editor stays open with an EMPTY buffer: the text the user typed is lost. Pinned as observed in resolve::tests::a_value_commit_before_the_host_answers_the_last_one_waits_out_loud (which asserts only the status and no write). improvise-fy9o (PR #227) moves the clear to the head of the commit command; whether the waiting decline then keeps the text depends on where that clear lands relative to the decline, so check after both merge. Reachable only by pressing Enter twice inside one host round trip.","status":"in_progress","priority":3,"issue_type":"bug","assignee":"agent-8xq0","owner":"el-github@elangley.org","created_at":"2026-09-24T00:36:48Z","created_by":"agent-8xq0","updated_at":"2026-09-24T00:51:06Z","started_at":"2026-09-24T00:51:06Z","lease_expires_at":"2026-09-24T00:56:06Z","heartbeat_at":"2026-09-24T00:51:06Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-3til","title":"App::restore's rebuild-after-view_state order is unguarded: rebuilding before view_state is restored passes the suite","description":"Found by the adversarial review of PR #261 (improvise-8wy9) at head 86b3d72c.\n\nApp::restore rebuilds the layout LAST, after view_state is restored. rebuild_layout reads view_name(), which comes from view_state.active_view, so the order matters. Nothing guards it.\n\nRIVAL THAT PASSES: move `self.rebuild_layout();` up to just after `self.model_state.workbook = snapshot.workbook;`, so it runs before `self.view_state = snapshot.view_state`. cargo test -p improvise-command --no-fail-fast stays green (648 passed), and so does #261's new test. Its frame never moves the host's view, so both orders give the same layout.\n\nWHY IT MATTERS: a client's DeleteView of the view the host is on moves the host's view_state.active_view (effect::DeleteView, `app.view_state.active_view = app.view_name()`). If that frame is then refused and rolled back, the rival builds the layout for the default view, then puts the host back on the deleted-and-restored view. The host's layout then describes a different view from the one it is on, and the next frame resolves its surrogates against that wrong grid.\n\nWITNESS (a scratch test, run and then discarded): host() plus a view \"Other\" (Region on Column, _Measure on Row, so 1 row x 2 cols), with the host switched to Other and the layout rebuilt. A client sends the frame [DeleteView(Other), RemoveItem(South), SetCell(South)]. It is refused ChainBroken. The test asserts view_name()==\"Other\" and layout.row_count()==1 and col_count()==2. It passes at 86b3d72c. Under the rival it fails at row_count, 2 against 1.\n\nCHEAP FIX: add that test next to a_frame_after_a_rolled_back_reshape_resolves_against_the_restored_grid in ui/host.rs. Better still, follow it with a write that resolves only through Other's layout.\n","status":"in_progress","priority":3,"issue_type":"bug","assignee":"agent-hzjo2","owner":"el-github@elangley.org","created_at":"2026-09-24T00:21:33Z","created_by":"agent-review-261","updated_at":"2026-09-24T00:22:36Z","started_at":"2026-09-24T00:22:36Z","lease_expires_at":"2026-09-24T00:27:36Z","heartbeat_at":"2026-09-24T00:22:36Z","labels":["adr-001","adversarial-review","pr-261","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-qnry","title":"Tab-commit's rightmost-column clamp in the browser resolver is unasserted: dropping .min(col_max) passes the whole workspace","description":"Found in the adversarial review of PR #256 (improvise-rq3f) at head eb146bcc. This predates #256, which is test-only.\n\nIn crates/improvise-protocol/src/resolve.rs, `advanced` has the arm `Advance::Right =\u003e Some((row, (col + 1).min(col_max)))`. Dropping the clamp gives `Some((row, col + 1))` (rival `right_unclamped`). That passes the whole workspace: cargo test --workspace --no-fail-fast, 1942 passed, 0 failed.\n\n#256's new test `tab_in_an_editor_commits_and_lands_one_column_right` sits on a centred cursor on purpose, so no edge clamps the move. That makes it blind to the clamp. The host's `CommitAndAdvance` (`AdvanceDir::Right`, commit.rs:1020) clamps with `(col + 1).min(col_max)`. Without the clamp the browser, pressing Tab at the rightmost column of a non-records grid, would send `SetSelected { col: cols }` past the grid and open the editor on no cell.\n\nFix: add a lockstep case, not a unit test. Put the cursor at the rightmost column of a non-records grid at a nonzero row, press i then Tab, and compare the selection and mode with the host. Relation: improvise-3tt4's acceptance test (lockstep Tab at the rightmost column, comparing buffers) may cover this incidentally. Check it against `right_unclamped` before closing either.\n","status":"in_progress","priority":3,"issue_type":"task","assignee":"agent-qnry-ybum","owner":"el-github@elangley.org","created_at":"2026-09-24T00:19:59Z","created_by":"agent-review-256","updated_at":"2026-09-24T00:21:18Z","started_at":"2026-09-24T00:21:18Z","lease_expires_at":"2026-09-24T00:26:18Z","heartbeat_at":"2026-09-24T00:21:18Z","labels":["adversarial-review","pr-256"],"dependencies":[{"issue_id":"improvise-qnry","depends_on_id":"improvise-3tt4","type":"related","created_at":"2026-09-24T00:20:09Z","created_by":"agent-review-256","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-98pd","title":"Three first-party sources still write non-item coordinates: set-cell, undeclared Data item, stale page selection","description":"Follow-up to improvise-gk51 (option (b) from that bead). gk51 made the projection and the refusal ask one predicate, `Model::first_non_item`, so a cell whose coordinate names no item now projects as `CellId::NonItem` and is offered to no one. That repairs the disagreement. It does not stop such coordinates from entering the document. Three first-party sources still create them:\n\n- **The `set-cell` script command.** `effect_cmds.rs` `SetCellCmd` writes the key as given and adds no item. For example, `set-cell 5 Region/Mars _Measure/Revenue`.\n- **A `.improv` `## Data` line naming an undeclared item.** `persistence/parse.rs` sets the cell and adds no item.\n- **A page selection naming no item.** `View::set_page_selection` does not validate. Every pivot cell's key then carries the stale item, and since gk51 the whole grid projects as `NonItem`. Found while testing gk51; the ticket did not predict it.\n\nThe fourth source, drill commits after an item is removed, is improvise-5mqm.\n\nDecide per source whether to refuse, to add the item, or to accept and let the projection decline, and write the decision down.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T00:17:27Z","created_by":"agent-viewhist","updated_at":"2026-09-24T00:17:27Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-a61f","title":"improvise-wasm-client lib tests compile only under --workspace: test-support feature not requested in its dev-deps","description":"Found in the adversarial review of PR #259 at c349d14d. The same code is on gh/main 202e7eac, so the gap predates both PRs.\n\n`cargo test -p improvise-wasm-client --lib` does not compile:\n\nerror[E0277]: the trait bound `FormulaEntry: From\u003c\u0026str\u003e` is not satisfied\n --\u003e crates/improvise-wasm-client/src/render.rs:793\n\n`impl From\u003c\u0026str\u003e for FormulaEntry` is `#[cfg(any(test, feature = \"test-support\"))]` in improvise-protocol/src/render.rs:757. The only crate that enables `test-support` is improvise-command, in its own [dev-dependencies]. improvise-wasm-client's dev-dependencies do not request it. `cargo test --workspace` compiles only because feature unification pulls the feature in from improvise-command's test build, which is the same shape repo-map's Wasm section warns about for the wasm job.\n\nCI runs only --workspace, so it cannot see this. It bites anyone who runs one crate's tests, and it would bite a per-crate CI split.\n\nFix candidate: add `improvise-protocol = { path = \"../improvise-protocol\", features = [\"test-support\"] }` to improvise-wasm-client's [dev-dependencies]. Check: `cargo test -p improvise-wasm-client --lib` compiles standalone. It is also worth a sweep: run `cargo test -p \u003ccrate\u003e --no-run` for every workspace member.\n","status":"in_progress","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-24T00:14:26Z","created_by":"agent-review-259-260","updated_at":"2026-09-24T00:20:20Z","started_at":"2026-09-24T00:20:20Z","lease_expires_at":"2026-09-24T00:25:20Z","heartbeat_at":"2026-09-24T00:20:20Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-a0i3","title":"shipped_documents_load: GIT_LITERAL_PATHSPECS or GIT_GLOB_PATHSPECS in the caller's env fail 9 of 10 tests","description":"Found in the adversarial review of PR #260 at 322de370 (git 2.55.0). tests/shipped_documents_load.rs::git() removes only the names that git rev-parse --local-env-vars lists, and the pathspec-magic variables are not in that list. shipped_documents() runs ls-files -- '*.improv' '*.improv.gz'.\n\nMeasured:\n- GIT_LITERAL_PATHSPECS=1 cargo test --test shipped_documents_load: 1 passed, 9 failed. every_shipped_document_loads fails its \"at least one shipped document\" precondition (line 33), because the globs are read literally.\n- GIT_GLOB_PATHSPECS=1: the same 1 passed, 9 failed, because '*' does not cross '/' under glob magic.\n\nThe failures are loud, not vacuous passes. This is the same spurious-red class as improvise-53zz, improvise-vwjj and improvise-pr3i, reached through pathspec parsing rather than through excludes.\n\nNot measured: GIT_NOGLOB_PATHSPECS, GIT_ICASE_PATHSPECS. GIT_ATTR_SOURCE=HEAD also fails loudly, with 8 of 10 failing on \"bad --attr-source\", as the pr3i ticket already notes.\n\nFix candidate: env_remove GIT_LITERAL_PATHSPECS, GIT_GLOB_PATHSPECS, GIT_NOGLOB_PATHSPECS, GIT_ICASE_PATHSPECS (and GIT_ATTR_SOURCE) in git(). Test: rerun_with_env with each one set, for a scratch-repo shape test AND for every_shipped_document_loads.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T00:14:25Z","created_by":"agent-review-259-260","updated_at":"2026-09-24T00:14:25Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-opbo","title":"GridLayout::evaluate_in/column_total accept a FoldMemo built by another layout and silently use its aggregation and hidden set","description":"Found reviewing PR #258 (improvise-pntu/0jw5, head 6b3577a0). ChatGPT Codex raised the same P2 on #258. After pntu, FoldMemo owns agg+covers, and GridLayout::evaluate_in(model, key, memo) / column_total(model, col, memo) accept ANY \u0026FoldMemo and ignore self's none_cats and hidden set. So layout_b.evaluate_in(.., \u0026layout_a.fold_memo()) evaluates under layout A's aggregation and hidden predicate.\n\nDemonstrated (scratch test, reverted): margin_book([\"R = Revenue / Cost\"]) with Q1 collapsed. View A shows all months; view B is A with Feb hidden. Key {Month=Q1,_Measure=R}. b.evaluate(key) = 2.0 (10/5); b.evaluate_in(key, \u0026a.fold_memo()) = 5.0 (40/8).\n\nIt is wrong on the memo's FIRST use, so here #258 is wider than #247, where a fresh foreign memo was harmless. Not reachable from production today: the only caller is viewport::grid_body, which builds layout.fold_memo() for the same layout. context/code/core.md section 14 names it as open (\"a memo built for one layout and passed to another's evaluate_in\"), but no ticket existed.\n\nFix shape: make the pairing unrepresentable. For example, layout.fold_memo() returns a per-build evaluator borrowing \u0026self that exposes evaluate(key)/column_total(col), and the memo-taking GridLayout methods go away. Test: the two-layout repro above, asserting b's reading through the shared evaluator equals b.evaluate.\n","status":"in_progress","priority":3,"issue_type":"bug","assignee":"agent-mergetrain","owner":"el-github@elangley.org","created_at":"2026-09-24T00:14:23Z","created_by":"agent-review-258","updated_at":"2026-09-24T00:30:38Z","started_at":"2026-09-24T00:30:38Z","lease_expires_at":"2026-09-24T00:35:38Z","heartbeat_at":"2026-09-24T00:30:38Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-n4i9","title":"Type-ahead into an awaiting cell editor replaces the value the TUI extends: fast Tab Tab 5 stores 5 where the TUI stores 5095 (#227)","description":"Found while verifying improvise-mdfc on #227 (head 3fcd407b), in Chromium over the worker, with tall.improv and R01/Cost = 50 in the rightmost column.\n\nKeys `i 9 Tab Tab 5 Enter`, pressed back to back by Playwright, store 5095 or 5, depending on whether `5` arrives before the worker's answer. In 5 runs: 5095 twice, 5 three times. The TUI always stores 5095. With a 200 ms pause after the second Tab, the editor shows `509` and the browser stores 5095 in 5 of 5 runs. So the seed itself is right after mdfc; this is the type-ahead half.\n\nCause: the reopened editor is `OpenedEditor::awaiting()`, with an empty buffer. A key typed before the answer marks it touched, and `settle_awaited_seed` then never seeds it. The key REPLACES the value the TUI would have extended. #227 accepted this as a cost (\"what the user sees is what gets written\", protocol.md §3). It is never another record's number, but it does diverge from the TUI whenever the answer is slower than the next key: a held key, or any `?ws=` socket.\n\nOptions, none tried:\n(a) Record the characters typed while awaiting, and on settle write seed + typed. Backspace while awaiting needs a rule.\n(b) Paint the cached value dimmed as a placeholder while awaiting, as #227's review suggested, so the user at least sees that the value will be replaced.\n(c) Hold keys typed while an awaiting editor is unsettled until the answer arrives.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T00:06:09Z","created_by":"agent-227-fixes","updated_at":"2026-09-24T00:06:09Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-29w2","title":"pr-watch-test: the CLOSED case no longer exercises the wait; closed PR #46 reports green MERGEABLE/BLOCKED and a rival passes 52/52","description":"Found by the delta review of PR #253 at 8cdea3a4 (b13355c7..8cdea3a4), which answers improvise-24lz.\n\nAt 8cdea3a4 the CLOSED fixture was changed from UNKNOWN/UNKNOWN to CONFLICTING/DIRTY, the shape closed PR #74 returns live. That shape is real, but it has a side effect. `mergeable == CONFLICTING` breaks the loop on the first poll whether the state check exists or not. So the CLOSED \"read once, not waited on\" case now holds by the loop's structure: no rival can fail it without also failing the CLOSED exit-code case.\n\nA different live closed shape does need the fix. Closed PR #46 has every check green and reports MERGEABLE/BLOCKED. A green BLOCKED rollup is waited on, and no test has that shape.\n\nRival (R8) that passes pr-watch-test.sh 52/52: exit 5 at the top of the loop for MERGED only, and exit 5 for CLOSED only after the loop breaks. Live against #46 with --timeout 15:\n- base 202e7eac: exit 3, \"timed out ... MERGEABLE/BLOCKED\"\n- R8: exit 3\n- 8cdea3a4: exit 5\n\nSo the CLOSED half of improvise-1qaa can regress with the suite green. Before 8cdea3a4, the rival \"state checked only at timeout\" also failed the CLOSED poll-count case. It no longer does: that case now passes, and only the exit-code case catches the rival.\n\nThe doc claim is also wrong. tooling.md at 8cdea3a4 says: \"A closed one need not: #74 reads CONFLICTING/DIRTY, which already ended the wait\". #46 shows a closed PR can hit the green-BLOCKED wait.\n\n## Steps to Reproduce\nApply R8 to scripts/pr-watch. Run scripts/pr-watch-test.sh and get 52 passed. Then run `scripts/pr-watch 46 --timeout 15`: it exits 3.\n\n## Acceptance Criteria\n- Add a CLOSED fixture in the #46 shape: MERGEABLE/BLOCKED with a green rollup, asserting exit 5 and one poll. Keep the #74 shape as well.\n- Confirm that R8 and \"state checked only at timeout\" each fail the new case.\n- Correct the tooling.md sentence.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T00:06:06Z","created_by":"agent-review-253","updated_at":"2026-09-24T00:06:06Z","labels":["adversarial-review-pr-253"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-sx7h","title":"#257's su4u restore survives commit-and-advance: Editing/RecordsEditing re-enter an equal mode, so a stale message follows the editor onto the next cell","description":"Found in the adversarial review of PR #257 (improvise-su4u) at head 2b1c5bf7.\n\n#257's `draw.rs::press` puts a stale status message back when the mode before a key has a minibuffer and the mode after it is EQUAL. The PR body and `context/code/tui.md` say that leaving the minibuffer, \"by a successful commit or by Esc\", drops the message. For two of the eight text-entry modes that is false. `Editing` and `RecordsEditing` commit with `commit-cell-edit` and `commit-and-advance-right` (`CommitAndAdvance`). That command drops to Normal, commits, advances, and then `EnterEditAtCursor` re-enters the same `AppMode::editing()`/`records_editing()` value. So the mode after an Enter or Tab that committed and moved to ANOTHER cell equals the mode before it. The commit writes no message, so the stale one is restored beside a different cell's editor. It keeps being restored across every commit until Esc or some key writes a message.\n\nReproduced with a scratch test driving `press`, not committed. Workbook: Region{North}, measures Cost/Revenue, one cell. Keys: `R`, `o`, `5`, Tab, `7`. After `o` the status is \"Added new record row\", in RecordsEditing at (1,0). Tab commits \"5\" and moves to (1,1). \"Added new record row\" is still `status_msg` and is still drawn beside `edit: 7`. With a second `o` on the existing row, the refusal \"That record row is already here — ... fill this one in first\" survives the Tab that filled the coordinate in. At base 202e7eac (#233) both vanish on the first key.\n\nWhy the test misses it: `a_minibuffer_refusal_stands_while_the_user_fixes_it` uses only FormulaEdit. That is the one-category fixture shape. FormulaEdit's successful commit leaves the mode AND writes \"Formula added\", so the test cannot tell \"same mode\" from \"same editor session\".\n\nMode equality stands in for \"the same minibuffer session\", and a commit that re-enters the same mode breaks it. Candidate discriminators, for the author to judge: the cursor/`opened_editor` also unchanged, or a restore only when the key did not reach a commit. The second is the keymap copy the PR rightly avoided. The test for the fix must press Tab in (Records)Editing with a stale message and assert that it is gone.\n","status":"in_progress","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-24T00:01:18Z","created_by":"agent-review-257","updated_at":"2026-09-24T00:10:48Z","started_at":"2026-09-24T00:10:48Z","lease_expires_at":"2026-09-24T00:15:48Z","heartbeat_at":"2026-09-24T00:10:48Z","labels":["adversarial-review","area:tui","pr-257"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-gk51","title":"Wire writes to a records-mode row with a non-item coordinate are projected as Record and then refused (#252 regression)","description":"Found in adversarial review of PR #252 (improvise-pr8t/nacd), head 47b166ee. Introduced by that PR.\n\n#252 makes App::non_item_refusal refuse a wire SetCell/ClearCell unless every coordinate is in Model::effective_item_names. The projection was not changed. In records mode, project::cell_id projects every measure cell of every stored record as CellId::Record. That includes a record whose category coordinate names no item. So the host now offers a cell as editable and then refuses the write. ADR 002 says the kind of identity is the permission, and command.md says the refusal asks \"the projection's own question, never a second derivation of it\". This is a second derivation, and it disagrees.\n\nSuch records exist, and first-party paths make them:\n- the script/`:` command `set-cell 5 Region/Mars _Measure/Revenue` (effect_cmds.rs SetCellCmd) adds no item;\n- a `.improv` `## Data` line naming an undeclared item (parse.rs sets the cell, adds no item);\n- drill commits after an item is removed (improvise-5mqm).\n\nProbe (reviewer's, not pushed): records mode (_Index Row, _Dim Column), Region={East}, _Measure={Revenue}, stored {Region=East,Revenue}=1 and {Region=Mars,Revenue}=5.\n- At 47b166ee, cell_id gives Record for (1,2), and unwritable returns \"Mars is no item of Region\". A caught-up wire SetCell is answered Rejected(MutationRefused).\n- Under the b114 rule (base 202e7eac), the same write is Accepted.\nThe TUI key path still edits the same cell, so the two front ends now disagree.\n\nFix shape: make the projection and the refusal ask one predicate. Either records-mode cell_id projects such a row as not-a-Record (e.g. Coordinate/Aggregate), or both call a shared \"names only items\" helper. Alternatively, close the root: set-cell and parse add the item, and 5mqm stops writing orphans. Pin it with an enumeration: every CellId::Record the host projects, in every view and in records mode, has unwritable == None.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T23:59:37Z","created_by":"agent-review-252","updated_at":"2026-09-23T23:59:37Z","labels":["adversarial-review","pr-252"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-4v9f","title":"A browser rename onto an existing destination outside the grid window is refused RecordUnstated on every retry and reopen (milr)","description":"Found reviewing PR #251 at head 29d2adef (improvise-milr's fix). The server now checks a rename's destination (`records_at_stake`), and the client claims `seen`, which is the Basis it held at open. The server publishes that Basis only for the records in the client's current grid window (`published_basis(app, \u0026self.grid)`), and `BasisCache::apply` replaces the whole cache each time. So a destination outside the window is never claimed, and the rename is refused `RecordUnstated`.\n\n`context/code/clients.md` accepts that refusal as the right direction to err. What it does not say: **no retry, and no reopen of the editor, can ever succeed** unless the source row and the destination row are in the window at the same moment. The cursor must be on the source when the editor opens, so for records that sort far apart (for example with 100 regions, renaming Aardvark onto Zulu) a browser can never merge the two. The TUI key path can. After this PR the only way out is resizing the window.\n\nProbe (real Host, `three_records_host`, a viewport of 1 row, rename North onto the existing West): `basis records known: 1`, then `Rejected(RecordUnstated(West))` three times in a row, with the text retyped between attempts. North is never renamed.\n\nThis needs a decision, not necessarily code:\n- (a) Accept it and document it in `clients.md`, next to kndq's policy. It may even be the right answer if kndq decides to refuse such merges anyway.\n- (b) Let the host publish the destination's counter on demand.\n- (c) Make `RecordUnstated` on a rename's destination forceable.\n\nThe ticket is linked to kndq because kndq's policy decides whether merging onto an existing record should be allowed at all.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T23:59:05Z","created_by":"agent-review-221-251","updated_at":"2026-09-23T23:59:05Z","labels":["adversarial-review","pr-251"],"dependencies":[{"issue_id":"improvise-4v9f","depends_on_id":"improvise-kndq","type":"related","created_at":"2026-09-23T23:59:12Z","created_by":"agent-review-221-251","metadata":"{}"},{"issue_id":"improvise-4v9f","depends_on_id":"improvise-milr","type":"related","created_at":"2026-09-23T23:59:14Z","created_by":"agent-review-221-251","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-pr3i","title":"Scratch repos inherit info/exclude from GIT_TEMPLATE_DIR","description":"tests/shipped_documents_load.rs's scratch repositories still take a user-level `info/exclude` from `GIT_TEMPLATE_DIR`. `git init` copies the template directory into the new `.git`, and `git rev-parse --local-env-vars` does not list `GIT_TEMPLATE_DIR`, so `git()` does not remove it. `GIT_CONFIG_GLOBAL=/dev/null` nulls `init.templateDir` in config, but the env var bypasses config.\n\nReproduced at PR #255 head 56ac72a2 (git 2.55.0): with `GIT_TEMPLATE_DIR=\u003cdir\u003e`, where `\u003cdir\u003e/info/exclude` holds `*.gz`, `a_gzipped_document_is_walked` FAILS at `git add` (\"Use -f if you really want to add them\"). This is the same symptom as improvise-53zz and improvise-vwjj, reached by a third path.\n\nFix candidates: `env_remove(\"GIT_TEMPLATE_DIR\")` in `run_git`, or `init --template=` with an empty value in `scratch_repo`. Test: rerun_with_env with GIT_TEMPLATE_DIR pointing at a dir whose info/exclude holds `*.gz`.\n\nAdjacent, not reproduced as user-level: the system gitattributes (`git var GIT_ATTR_SYSTEM`) is not disabled by GIT_CONFIG_NOSYSTEM; it needs GIT_ATTR_NOSYSTEM=1. GIT_ATTR_SOURCE fails loudly (\"bad --attr-source\"), not silently.\n\nFound in the adversarial review of #255.\n","status":"in_progress","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T23:56:07Z","created_by":"agent-review-255","updated_at":"2026-09-23T23:58:13Z","started_at":"2026-09-23T23:58:13Z","lease_expires_at":"2026-09-24T00:03:13Z","heartbeat_at":"2026-09-23T23:58:13Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-6u6s","title":"e265's enumeration has no non-contiguous group, so remark's run identity by header position (h0iv) is unguarded: a name-keyed rival passes all three tests","description":"Found reviewing PR #250 (improvise-e265) at d6e366dc.\n\nremark (prune_axis_items) keys each open run by its header's POSITION, so two runs under two equal headers stay two (context/code/core.md, the e265 section, claims this for h0iv). Nothing tests it. The TWO/THREE fixtures in layout.rs group_membership have every group's items contiguous under each parent, and a same-name run under a different parent is always separated by an outer-level change, which truncates the run stack anyway.\n\nRival, injected and run: key the input's open run by hash(level, cat_name, group_name) in place of the header's position. All three group_membership tests pass. A scratch test with the fixture Month = [Jan in Q1, Mar ungrouped, Feb in Q1] under North in Upper, which checks per-level run identity between consecutive surviving rows (not just innermost), fails at 'mask 0b101, collapsed []: run identity at level 1, row 1': pruning Mar merges North's two Q1 runs into one.\n\nThe ticket's own enumeration (300,580 cases) named non-contiguous groups; the PR's dropped them. Two gaps to close: (1) add a non-contiguous fixture, and ideally a hole level (a middle category with no groups); (2) assert the whole open-run stack per level, plus same-run-as-previous-row per level, not only innermost_group. The real code passes both on all five fixtures (TWO, THREE, NONCONTIG, HOLE, and an outer axis with two adjacent groups), 5,856 cases.\n\nCost note: the enumeration is already ~0.9-1.7s of improvise-core's lib binary (the rest is 0.16-0.18s, M4 Max under load avg 60-160). Adding fixtures should come with a cheaper construction: build each fixture's layout once per collapse subset and drive prune_axis_items with a keep predicate per mask, not two Workbook builds per mask.","status":"in_progress","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T23:54:33Z","created_by":"agent-review-250","updated_at":"2026-09-23T23:57:15Z","started_at":"2026-09-23T23:57:15Z","lease_expires_at":"2026-09-24T00:02:15Z","heartbeat_at":"2026-09-23T23:57:15Z","dependencies":[{"issue_id":"improvise-6u6s","depends_on_id":"improvise-e265","type":"discovered-from","created_at":"2026-09-23T23:54:38Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-24lz","title":"pr-watch: a PR whose head moved and then merged exits 5, not 2; exit-5 ordering against the head checks is unpinned","description":"Found by the adversarial review of PR #253 at b13355c7 (improvise-1qaa).\n\npr-watch checks `state` AFTER the --expect check but BEFORE the head-pin check. The two head checks guard the same hazard: a verdict about a tree that no longer exists (PR #67 merged at a head that predated its fix). Exit 5 now wins over one of them and not the other:\n\n- merged at another head, with --expect aaaa: exit 2 (correct; also confirmed live on #216 with --expect deadbeef)\n- no --expect, first poll head a OPEN and running, next poll head b MERGED: exit 5, with the message \"PR 1 is MERGED, nothing to wait for\". The watcher never saw b, and the message does not name the head.\n\nSKILL.md and tooling.md describe exit 5 as \"someone else already merged it\", a normal answer the caller should hear as that. So a caller following the docs accepts a merge at a head nobody watched. That is exactly what the pin exists to refuse.\n\nFix: move the state check below the pin block. On the first poll the pin block only assigns, so \"ends on the first poll\" still holds. Consider printing the head in the exit-5 message too.\n\nNeither ordering is pinned by a test. Both rivals pass pr-watch-test.sh 50/50: state check moved above --expect, and state check moved below the pin. Add two cases: moved-then-merged, and merged-at-another-head with --expect.\n\nDoc claims to correct in the same change:\n(1) tooling.md says GitHub reports a MERGED **or CLOSED** PR as UNKNOWN/UNKNOWN permanently. Live, closed PR #74 reports CONFLICTING/DIRTY, so the CLOSED fixtures are an invented shape. The behaviour is unaffected, because state is read first.\n(2) SKILL.md says exit 5 \"ends on the first poll\". It ends on the first poll that SEES the PR closed, which may be mid-wait.\n","notes":"REGRESSION introduced by PR #253, not a pre-existing gap. Ran the same moved-then-merged fixture against the base script at 202e7eac: exit 2, 'head moved aaaaaaaaaaaa -\u003e bbbbbbbbbbbb during the wait'. At b13355c7: exit 5.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T18:33:04Z","created_by":"agent-review-253","updated_at":"2026-09-23T18:33:21Z","labels":["adversarial-review-pr-253"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-8wy9","title":"App::restore's layout rebuild is unguarded: removing it leaves the workspace green","description":"Found by the adversarial review of PR #254 (improvise-4h8a) at head 67438b8f.\n\n`App::restore` ends with `self.rebuild_layout()`. `app.layout` is derived state, not an `AppSnapshot` field, so the new `snapshot_restore` tests never compare it.\n\nRIVAL THAT PASSES: delete the `self.rebuild_layout();` line from `App::restore`. `cargo test --workspace --no-fail-fast` stays fully green (39 binaries ok, exit 0).\n\nWHY IT MATTERS: `Host::undo_frame` calls `restore` and does not rebuild afterwards. `App::resolve_aggregate` and `record_in_layout` read `self.layout` through `layout_cell_keys`. So after a refused frame, the next frame's `Checked::against` would resolve surrogates against the grid of the frame that was rolled back, until some later `apply_effects` rebuilds it. The code is correct today. This is the same class as 4h8a: nothing stops a simplification from removing the rebuild.\n\nCHEAP FIX: in `snapshot_restore::restore_puts_back_every_field_the_snapshot_took` (crates/improvise-command/src/ui/app.rs), have the fixture change the grid's shape (for example, add an item under the added category, or set an axis), rebuild, and then after `restore` assert that the layout is equal to a freshly rebuilt one. `format!(\"{:?}\", app.layout)` works, or compare `row_count`/`col_count` and the labels. Then run the rival above and watch it fail.\n","status":"in_progress","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T18:31:21Z","created_by":"Edward Langley","updated_at":"2026-09-23T23:54:31Z","started_at":"2026-09-23T23:54:31Z","lease_expires_at":"2026-09-23T23:59:31Z","heartbeat_at":"2026-09-23T23:54:31Z","labels":["adr-001","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-cota","title":"bd-audit-ids skips an absent --assume-unchanged file as deleted: ls-files -d lists it, git status does not","description":"Found in adversarial delta review of PR #244 at head 33d563a9 (fix acecc63d, improvise-tdfd).\n\nThe tdfd fix skips an absent tracked file only when `git ls-files -d` lists it. But `ls-files -d` also lists an absent file marked `--assume-unchanged`. `git status` shows nothing for that file, so `commit -a` never records the deletion.\n\nThe audit therefore skips a file whose citations stay in HEAD, and exits 0. This is the same shape as tdfd: absent, not a deletion git will record, never read.\n\nReproduce in a throwaway repo:\n1. Commit `context/dead.md` citing an unknown id.\n2. Run `git update-index --assume-unchanged context/dead.md`, then `rm context/dead.md`.\n3. `git status --short` is empty. `git ls-files -d` prints `context/dead.md`.\n4. The audit prints \"all 1 cited ids resolve\" and exits 0.\n5. Control: without step 2 (a plain unstaged delete), the audit also exits 0. That is by design.\n\nRare, and CI checks out fresh, so P3. Fix: ask a deletion oracle that honours the index flags, such as `git diff --name-only --diff-filter=D`, which assume-unchanged hides. Alternatively, exclude `h`-flagged entries of `ls-files -v`. Add a case beside the skip-worktree case.","status":"in_progress","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T18:29:15Z","created_by":"agent-review-tooling-delta","updated_at":"2026-09-24T00:02:35Z","started_at":"2026-09-24T00:02:35Z","lease_expires_at":"2026-09-24T00:07:35Z","heartbeat_at":"2026-09-24T00:02:35Z","labels":["adversarial-review","pr-244"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-rq3f","title":"Browser Tab-commit's advance-right and the tile cursor's upper clamp are unasserted: breaking either passes the whole workspace","description":"Found in adversarial delta review of PR #246 at 790a33ba. This predates #246. The resolver code is untouched by that PR.\n\nTwo faults injected into `crates/improvise-protocol/src/resolve.rs` each pass the whole workspace (`cargo test --workspace --no-fail-fast`: 1945 passed, 0 failed):\n\n- `advanced`: `Advance::Right =\u003e Some((row, (col + 1).min(col_max)))` changed to `Some((row, col))`. So `commit-and-advance-right` (Tab in an editor) in the browser no longer advances, and nothing notices.\n- `tile_cursor`: the upper clamp `(count - 1) as i32` changed to `count as i32`. So moving right from the last tile selects an index past the end, and nothing notices.\n\nFor comparison, five other resolver faults (page_rows, scroll offset, panel cursor, gg, G) are each caught by a named lockstep test. So these two are gaps in coverage, not a sign that lockstep is blind in general.\n\nFix: add a lockstep case for each, with a cursor at a nonzero index. Tab-commit should land where the host's CommitAndAdvance lands. l or Right on the last tile should agree with the host's MoveTileCursor.\n","status":"in_progress","priority":3,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T18:20:29Z","created_by":"agent-review-246b","updated_at":"2026-09-23T18:23:45Z","started_at":"2026-09-23T18:23:45Z","lease_expires_at":"2026-09-23T18:28:45Z","heartbeat_at":"2026-09-23T18:23:45Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-xoxh","title":"lockstep.rs deliver() folds host-sent frames through fold_outgoing, and one merged-in test still folds its own commands through fold","description":"Found in adversarial delta review of PR #246 at 790a33ba.\n\n1. `crates/improvise-wasm-client/tests/lockstep.rs::deliver` folds the HOST's `ServerFrame::Commands` through `client.fold_outgoing(\u0026cmds)`. e0dec966 changed it from `fold` along with the 19 loops that fold the client's own resolved commands. But `deliver` models what the host sends, which `context/code/clients.md` says is `fold`'s door. The PR comment says \"the two remaining `fold` calls model what the host sends\", but `deliver` is a third host-side call, and it is on the wrong door.\n\n Latent today: `deliver`'s only caller (`switching_between_a_records_view_and_a_pivot_view_settles_both_ways`) sends view-only frames, so nothing is awaited and both doors settle immediately. With an awaited frame in flight, `fold_outgoing` would queue the host's relays at the BACK as the client's own frame, not directly behind the head as `settle_relay` does. That is the pre-jpba ordering, so a test built on `deliver` could not see a jpba regression.\n\n2. `renaming_a_formula_from_the_panel_lands_the_same_document_as_the_host`, which arrived from gh/main in the merge 790a33ba, still folds the client's own resolved commands through `client.fold(cmd)`. So after the merge, \"every loop over the client's own commands uses fold_outgoing\" is not true at the head.\n\nFix: `deliver` goes back to `fold` for `ServerFrame::Commands`, and the renaming test uses `fold_outgoing`.\n","status":"in_progress","priority":3,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T18:20:19Z","created_by":"agent-review-246b","updated_at":"2026-09-23T18:29:43Z","started_at":"2026-09-23T18:29:43Z","lease_expires_at":"2026-09-23T18:34:43Z","heartbeat_at":"2026-09-23T18:29:43Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-vwjj","title":"The scratch-repo tests still read the user's default excludes file (XDG git/ignore), so a *.gz there fails a_gzipped_document_is_walked despite GIT_CONFIG_GLOBAL=/dev/null","description":"Found reviewing PR #248 (improvise-53zz) at head f5cfc7ed.\n\n`run_git` sets `GIT_CONFIG_GLOBAL=/dev/null` and `GIT_CONFIG_NOSYSTEM=1`, which stops git reading the user's global and system config files. It does not stop git reading the user's **default** excludes file. When `core.excludesFile` is unset, git falls back to `$XDG_CONFIG_HOME/git/ignore`, or `~/.config/git/ignore` when XDG_CONFIG_HOME is unset. That fallback does not depend on any config file, so both variables leave it in force. It is also the most common place for a global ignore, because it needs no config line.\n\nMeasured on git 2.55.0 at f5cfc7ed. A probe test re-ran `a_gzipped_document_is_walked` through the PR's own `rerun_with_env` with `XDG_CONFIG_HOME=\u003ctmp\u003e` and `\u003ctmp\u003e/git/ignore` containing `*.gz`. The child failed at `git add`: \"The following paths are ignored by one of your .gitignore files: docs/model.improv.gz\". Setting `HOME=\u003ctmp\u003e` with `\u003ctmp\u003e/.config/git/ignore` fails the same way. That is 53zz's symptom 2 on the path the fix does not cover. The PR's test sets `core.excludesFile` explicitly inside a config file, so it cannot see this.\n\nFix options, cheapest first: pass `-c core.excludesFile=/dev/null` in `run_git` (a `-c` argument, which `--local-env-vars` does not strip), or point `XDG_CONFIG_HOME` and `HOME` at an empty temp dir. Also consider `core.attributesFile`, which has the same XDG default. Rival: run the gzip shape test in a child with `XDG_CONFIG_HOME` pointing at a dir whose `git/ignore` holds `*.gz`, and assert it passes.\n","status":"in_progress","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T18:17:06Z","created_by":"agent-review-248","updated_at":"2026-09-23T18:24:02Z","started_at":"2026-09-23T18:24:02Z","lease_expires_at":"2026-09-23T18:29:02Z","heartbeat_at":"2026-09-23T18:24:02Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-metf","title":"prune_empty evaluates every cell with a fresh fold memo","description":"Found reviewing #247 (improvise-h6z1): GridLayout::prune_empty (view/layout.rs ~620) evaluates each cell with its own fresh memo — the same shape h6z1 fixed in grid_body, on the same keypress path. Measure first on the folded-chain bench; share one memo per build if it pays.","status":"in_progress","priority":3,"issue_type":"task","assignee":"agent-mergetrain","owner":"el-github@elangley.org","created_at":"2026-09-23T18:14:55Z","created_by":"orchestrator","updated_at":"2026-09-24T00:05:23Z","started_at":"2026-09-24T00:05:23Z","lease_expires_at":"2026-09-24T00:10:23Z","heartbeat_at":"2026-09-24T00:05:23Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-0jw5","title":"Nothing guards that viewport::grid_body shares one FoldMemo: a per-cell fresh memo there passes the whole suite","description":"Found reviewing PR #247 (improvise-h6z1, head 4f75fffc). The PR's whole effect is grid_body making one FoldMemo and passing it to cell_value and column_total. Rival: in viewport.rs cell_value, replace the memo with FoldMemo::default() per cell. cargo test --workspace --no-fail-fast: zero failures. a_fold_row_evaluates_each_formula_once_across_the_row calls GridLayout::evaluate_in directly with a memo of its own, so it pins the layout-level API and not the build that is supposed to use it; the totals' memo is likewise unguarded. Only benches/grid_projection.rs (folded chain ~18k: 1.19s -\u003e 0.52s measured) can see a regression, and it is not a gate. Add a test in improvise-command that counts FOLD_FORMULA_EVALUATIONS (or an equivalent observable) across one grid_body call on a folded chain and asserts 9 per fold row, with the per-cell rival shown failing. Note the counter is #[cfg(test)] pub(crate) in improvise-core, so it is not visible from improvise-command as-is.","status":"in_progress","priority":3,"issue_type":"task","assignee":"agent-mergetrain","owner":"el-github@elangley.org","created_at":"2026-09-23T18:13:31Z","created_by":"agent-review-247","updated_at":"2026-09-23T18:24:44Z","started_at":"2026-09-23T18:24:44Z","lease_expires_at":"2026-09-23T18:29:44Z","heartbeat_at":"2026-09-23T18:24:44Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-pntu","title":"FoldMemo's key omits the aggregation and the covers predicate: one memo shared across two covers returns the first answer","description":"Found reviewing PR #247 (improvise-h6z1, head 4f75fffc); also ChatGPT Codex's P2 on that PR. FoldMemo is pub and keyed by (CellKey, span.over). evaluate_covering_in / evaluate_spanning_in are pub and take any agg and covers. Demonstrated: margin_book(['R = Revenue / Cost']), key {Month=Q1, _Measure=R}, Aggregation::Exact. evaluate_covering_in with covers=all then with covers=hide-Feb on ONE memo returns 5.0 for the second call; a fresh memo returns 2.0. Not reachable from viewport::grid_body today (agg and covers are constant per build), which is why the suite is green. The PR includes span in the key precisely because the _in functions are public; agg and covers are the same argument left unanswered. Fix shape: bind agg+covers into the memo (e.g. a per-build evaluator object that owns memo, agg and covers, so the _in functions cannot be handed a mismatched pair), rather than widening the key with a closure identity. Test: the one above, asserting the second call equals a fresh evaluation.","status":"in_progress","priority":3,"issue_type":"bug","assignee":"agent-mergetrain","owner":"el-github@elangley.org","created_at":"2026-09-23T18:13:20Z","created_by":"agent-review-247","updated_at":"2026-09-23T18:24:44Z","started_at":"2026-09-23T18:24:44Z","lease_expires_at":"2026-09-23T18:29:44Z","heartbeat_at":"2026-09-23T18:24:44Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-nmm5","title":"Lockstep never exercises a refusal: a ClientState refusal-ordering regression is visible only to client.rs unit tests","description":"Found while fixing improvise-3kfc (PR #246). tests/lockstep.rs now folds the client's own commands through fold_outgoing, which is the right door, but it never refuses a frame. So a regression in how ClientState orders a refusal against queued relays or view-only frames is caught only by the client.rs unit tests and the two Host-level jpba tests. Add at least one lockstep case that sends two frames, has the host refuse the first, and compares client and mirror after both answers.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-23T18:10:26Z","created_by":"orchestrator","updated_at":"2026-09-23T18:10:26Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-tdfd","title":"bd-audit-ids silently skips a tracked file absent by skip-worktree or sparse checkout, not only a deleted one","description":"PR #244 head e2649820 (ChatGPT Codex P2, confirmed). The deleted-file filter keeps a path only if `[ -e \"$f\" ] || [ -L \"$f\" ]`, so ANY tracked path absent from the working tree is dropped, not only a deleted one.\n\nReproduced in a detached worktree at e2649820: context/adr/001-concurrent-edits.md holds the only citation of improvise-k2i4. With a stub bd (BD_AUDIT_BD) that knows every other cited id, the audit exits 1. After marking that file skip-worktree and moving it out of the tree, it exits 0 (\"all 498 cited ids resolve\").\n\n`ls-files -d` does NOT list the skip-worktree path, so skipping only what `ls-files -d` reports, and treating any other missing tracked path as exit 4, discriminates. Needs a skip-worktree case beside the deleted-file case.\n","status":"in_progress","priority":3,"issue_type":"bug","assignee":"agent-mergetrain","owner":"el-github@elangley.org","created_at":"2026-09-23T17:59:48Z","created_by":"agent-review-tooling4","updated_at":"2026-09-23T18:16:08Z","started_at":"2026-09-23T18:16:08Z","lease_expires_at":"2026-09-23T18:21:08Z","heartbeat_at":"2026-09-23T18:16:08Z","labels":["adversarial-review","pr-244"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-fd0j","title":"pr-reviews-test watchdog needs bash 5.1 but checkers.sh pins only major 5","description":"PR #237 head 785202b8 (ChatGPT Codex P2, confirmed). bounded_reviews runs `wait -n -p done \"$pid\" \"$dog\"`; the -p option is bash 5.1. find_modern_bash in scripts/checkers.sh accepts any BASH_VERSINFO[0] \u003e= 5, so a bash 5.0 host is accepted and the suite dies (`done` unbound under set -u).\n\nDemonstrated shape under macOS /bin/bash 3.2 run directly: 2 passed, 23 failed, where gh/main's suite is 25/25 under 3.2. CI (5.2) and nix (5.3) are fine, so this is a contract mismatch, not a live CI failure.\n\nFix: raise find_modern_bash's floor to 5.1 (with a checkers-test case for 5.0), or detect the watchdog winner without -p.\n","status":"in_progress","priority":3,"issue_type":"bug","assignee":"agent-mergetrain","owner":"el-github@elangley.org","created_at":"2026-09-23T17:59:47Z","created_by":"agent-review-tooling4","updated_at":"2026-09-23T18:16:06Z","started_at":"2026-09-23T18:16:06Z","lease_expires_at":"2026-09-23T18:21:06Z","heartbeat_at":"2026-09-23T18:16:06Z","labels":["adversarial-review","pr-237"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-bbwa","title":"check-ci-graph counts a --ci step in a job that needs: a job which can be skipped; the dependent job is skipped and a skipped check reads as passing","description":"At PR #228 head d6706774: add a job 'gate' with job-level 'if: false' (no checkers.sh step, so nothing disqualifies it), give kb 'needs: [gate]', and add gate to pages-deploy's needs. check-ci-graph exits 0 with all 9 suites counted, but on a PR GitHub skips kb (its dependency was skipped) and a skipped required check counts as success. Same class as improvise-tvte (a condition written around the call), one hop away: the disqualifiers are read only on the counted job. build/js/kb have no needs: today. Cheapest close: treat a job-level needs: as a disqualifier for the counted step, or require every needed job to be unconditional.","status":"in_progress","priority":3,"issue_type":"bug","assignee":"agent-mergetrain","owner":"el-github@elangley.org","created_at":"2026-09-23T17:59:22Z","created_by":"agent-review-tooling4","updated_at":"2026-09-23T18:02:24Z","started_at":"2026-09-23T18:02:24Z","lease_expires_at":"2026-09-23T18:07:24Z","heartbeat_at":"2026-09-23T18:02:24Z","labels":["adversarial-review","pr-228"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-nacd","title":"Wire SetCell on a stored record with a _Measure that is no item (unknown name, or a parent-only group) is accepted and stores a non-item coordinate","description":"Found in adversarial review of PR #235 (improvise-b114/xur5), head 5c19fdd4. Pre-existing: the PR does not introduce it, and it does not close it.\n\nPR #235's rule is \"no coordinate names a group\". The invariant it protects\n(improvise-gw18) is wider: \"every coordinate names an item\". Two writes fall in\nthe gap. Both are accepted at 5c19fdd4 and store a non-item coordinate:\n\n- A caught-up wire SetCell on the stored record `{Month=Feb}` with measure\n `Bogus`, which is no item and no group of `_Measure`. It stores\n `Month=Feb, _Measure=Bogus = 7`.\n- The same write with measure `Totals`, where `Totals` is a PARENT group\n (`Group::new(\"Money\").with_parent(\"Totals\")`) that holds no item directly.\n `items_in_group(\"Totals\")` is empty, so `group_refusal` does not see it.\n It stores `_Measure=Totals = 7`.\n\nNeither cell is drawn in any pivot view, which is the gw18 shape: invisible data\nthat persists through format_md and counts in records mode. The record half is\nsafe, because an unknown record does not resolve. The measure half is unchecked\nwhenever the record is stored.\n\nThreat model is the same as b114/xur5: a hand-rolled or stale client. No honest\nclient composes these writes.\n\nProbe: `review_235::probe_parent_only_group_and_unknown_measure` (reviewer's,\nnot pushed). Fix shape: refuse a `_Measure` coordinate that is not in\n`measure_item_names()` (items plus formula targets; formula targets are refused\nanyway by the Derived rule). Or generalise `group_refusal` to \"names no item of\nits category\".\n","status":"in_progress","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T16:16:42Z","created_by":"agent-review-235-239","updated_at":"2026-09-23T18:11:47Z","started_at":"2026-09-23T18:11:47Z","lease_expires_at":"2026-09-23T18:16:47Z","heartbeat_at":"2026-09-23T18:11:47Z","labels":["adversarial-review","pr-235"],"comments":[{"id":"ec47fc8d-7311-511f-a2ee-dd5c6345723d","issue_id":"improvise-nacd","author":"agent-review-221-251","text":"Reachable by an ordinary race since PR #274 (seen at 3dad3b09), not only by a hand-built frame. A captures a keyboard editor on North/Cost. B sends RemoveItem(_Measure, Cost), accepted and delivered. A presses Enter. The captured OpenedCell still names measure Cost, the record basis passes because North's version did not move, and the host Accepts SetCell North/Cost = 42 with Cost no longer an item. The old cursor guard would not have caught it either: the reset leaves the cursor on a Record.","created_at":"2026-09-24T03:24:26Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} -{"_type":"issue","id":"improvise-vwc7","title":"An item may share its name with a group of its category, so a key naming it is ambiguous: a wire write to the item is refused in rows and accepted in _Measure","description":"Found in adversarial review of PR #235 (improvise-b114/xur5), head 5c19fdd4. Pre-existing model gap. #235 makes it matter, and PR #221 (improvise-fdk1) will add a second way in.\n\n`Category` lets an item and a group share a name. `add_item` and\n`add_item_in_group` check only `items`, and `add_group` checks only `groups`.\nOnce they share a name, a key naming it is ambiguous: `Month=Q2` is both the\nitem Q2's row and the collapsed group Q2's fold row.\n\nThe wire behaves inconsistently, as measured at 5c19fdd4 with\n[AddItem, SetCell] frames that carry the SetCell's basis:\n\n- `_Measure`: AddItem `Money` (Money is already a group of Revenue and Cost),\n then SetCell `{Feb, Money}`. The frame is **accepted** and stored.\n- Row category: in `host_on_a_fold` (Q2 collapsed), AddItem `Month=Q2`, then\n SetCell `{Q2, Revenue}`. The frame is **refused** (`MutationRefused`) and\n undone. The new record is unstored, so `aggregate_refusal` finds the key by\n position, finds the FOLD row, and sees an Aggregate. A write to a real item\n was refused because the fold shares its key.\n\n#235's `group_refusal` exempts a name that is also an item, which is the right\ncall given the model. But the root is the namespace, not the refusal.\nimprovise-fdk1's `RenameRecordCoordinate` (PR #221) creates the target item\n\"if need be\", so a rename onto a group's name will create exactly this\nnamesake. So will the TUI's records-mode category edit.\n\nDecide one of: (a) the model refuses an item named like a group of its category,\nand a group named like an item (smart constructors); or (b) keys disambiguate a\nfold from an item. (a) is the smaller change. Probe:\n`review_235::probe_add_item_then_write_in_one_frame` (reviewer's, not pushed).\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T16:16:42Z","created_by":"agent-review-235-239","updated_at":"2026-09-23T16:16:42Z","labels":["adversarial-review","pr-235"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-pr8t","title":"#235's same-name-item exemption in group_refusal is unpinned: removing it refuses a real item and the workspace stays green","description":"Found in adversarial review of PR #235 (improvise-b114/xur5), head 5c19fdd4.\n\n`App::group_refusal` exempts a name that is also an item of the same category\n(`!category.items.contains_key(name)`). Nothing pins that exemption. With it\nremoved (`let group = true \u0026\u0026 !items_in_group(name).is_empty()`), the full\nworkspace suite stays green (`cargo test --workspace --no-fail-fast`, 0 failed).\n\nUnder that rival, a real item is refused. Fixture: `_Measure` has Revenue and\nCost in group Money, and ALSO an ungrouped item named Money\n(`Category` permits that: `add_item` checks only `items`). The stored cell\n`{Month=Feb, _Measure=Money}` is a legitimate item cell. At 5c19fdd4 a caught-up\nwire SetCell on it is accepted (correct). With the rival it is refused.\n\nFix: one test that writes to an item whose name is also a group of its category\nand asserts Accepted and the stored value. The reviewer's probe is\n`review_235::probe_item_named_like_its_categorys_group_is_writable` (not pushed).\n","status":"in_progress","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T16:16:40Z","created_by":"agent-review-235-239","updated_at":"2026-09-23T18:11:44Z","started_at":"2026-09-23T18:11:44Z","lease_expires_at":"2026-09-23T18:16:44Z","heartbeat_at":"2026-09-23T18:11:44Z","labels":["adversarial-review","pr-235"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-su4u","title":"A minibuffer refusal (formula parse error) vanishes on the first correcting keystroke under #233's status expiry","description":"Found reviewing PR #233 (improvise-cq3d) at head d9ea5946.\n\n#233 clears `status_msg` at the start of every TUI keypress (`draw.rs::press`). In a\nminibuffer mode `minibuffer_line` draws the message on the right of the prompt, and\nsince improvise-v4hg a formula that does not parse keeps the user in `FormulaEdit`\nwith the error. The error now disappears on the first correcting keystroke:\n\n- type `Total = (` in the formula editor, press Enter: the row reads\n `formula: Total = (▌ ... Formula error: --\u003e 1:10`\n- press Backspace once: the row reads `formula: Total = ▌`, and the error is gone.\n\nThe user is shown the error once, but it is gone before they finish fixing the thing\nit describes. The same applies to any refusal that leaves the user in a minibuffer.\nBefore #233 the error stayed while they edited, and then stayed forever (the cq3d bug).\n\nPossible rule: in a text-entry mode, expire the message only when the key leaves the\nmode, or only on the commit key. Decide whether that is worth the extra rule; it is a\ndesign choice, not a crash. Reproduce it through `press`, not `handle_key`, because\nthe expiry is in the TUI loop only.\n","status":"in_progress","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T16:14:43Z","created_by":"agent-review-233-242","updated_at":"2026-09-23T18:23:44Z","started_at":"2026-09-23T18:23:44Z","lease_expires_at":"2026-09-23T18:28:44Z","heartbeat_at":"2026-09-23T18:23:44Z","labels":["adversarial-review","area:tui","pr-233"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-m6op","title":"Cross-instance Debug fingerprint: Model.measure_agg's sort is pinned by no test","description":"Found reviewing PR #241 (head 3a4b0eac). Model's Debug sorts measure_agg (HashMap\u003cString, AggFunc\u003e) into a BTreeMap so two equal documents print alike. No committed test sees it: replacing .field(\"measure_agg\", \u0026aggs) with the raw HashMap left cargo test --workspace --no-fail-fast green in 3 of 3 runs. The only cross-instance comparison, a_model_command_lands_where_its_keypress_lands, has an empty measure_agg, so a HashMap of zero entries has one order (the same vacuity djcn fixed for View/DataStore). formula_cache's sort IS pinned (recomputing_formulas_leaves_the_model_fingerprint_alone fails when it is unsorted). A reviewer scratch test with two measure_agg entries failed 3/3 under the injection. Fix: give the pivoted() fixture two or more measure_agg entries (and ideally a formula so formula_cache is non-empty across instances too), then re-inject.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T16:05:41Z","created_by":"agent-review-241","updated_at":"2026-09-23T16:05:41Z","dependencies":[{"issue_id":"improvise-m6op","depends_on_id":"improvise-djcn","type":"discovered-from","created_at":"2026-09-23T16:05:41Z","created_by":"agent-review-241","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-cwfj","title":"The remaining set_status declines: panel.rs (3) and keymap dispatch's arg-error arm should refuse","description":"Remainder of improvise-i3o5 after PR #236. cmd/panel.rs:708 'Move cursor to a category header to change axis', :726 'No category selected…' and :818 'No category to delete' decline with set_status, so they exit 0 when run headless. command/keymap.rs::dispatch writes set_status(e) for a bound key whose constructor returns Err. ORCHESTRATOR RULING: that Err is a refusal (cli.md §10), so dispatch should refuse(e). Before converting dispatch, check nothing depends on a bound key's Err leaving last_batch_refused false. Assigned to agent-refuse-fu.","status":"in_progress","priority":3,"issue_type":"bug","assignee":"agent-refuse-fu","owner":"el-github@elangley.org","created_at":"2026-09-23T15:47:49Z","created_by":"orchestrator","updated_at":"2026-09-23T15:47:59Z","started_at":"2026-09-23T15:47:59Z","lease_expires_at":"2026-09-23T15:52:59Z","heartbeat_at":"2026-09-23T15:47:59Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-tvte","title":"check-ci-graph counts a checkers.sh invocation that never runs on a PR: step/job if: conditions and shell control flow are ignored","description":"Found by adversarial re-review of PR #228 at head c1417359.\n\ncheck-ci-graph.py counts an invocation that never runs on a PR. The count is silent, so a suite can be reported as run in CI when it is not. Two sources:\n\n1. **YAML conditions are ignored.** A step's `if:` and a job's `if:` are never read. A suite whose only invocation is in a `- if: failure()` step, or in a job gated like pages-deploy (`if: github.event_name == 'push' \u0026\u0026 github.ref == 'refs/heads/main'`), counts as run on every PR.\n2. **Shell control flow is ignored.** Each of these counts the suite, although it never runs:\n - `true || ./scripts/checkers.sh X`\n - `false \u0026\u0026 ./scripts/checkers.sh X`\n - `exit 0` on the line before the invocation, in a block\n - `if false; then` … `fi` around the invocation\n - the invocation inside a shell function that is never called\n\nEach was demonstrated with a probe over suite_verdict/suites_run, requiring pr-watch where the shape is its only invocation.\n\nThe shell half cannot be fully closed by static analysis. The YAML-condition half can be: refuse, or refuse to count, any invocation in a step or job that carries an `if:`. Whatever is not closed should be stated as a residual in context/code/tooling.md. At present that section says only what is caught.\n\nLower priority than the multi-line/heredoc fail-open. Every shape here needs someone to write a condition around the invocation. That filed ticket needs only a prose mention of the script.\n","status":"in_progress","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T15:41:52Z","created_by":"agent-review-228b","updated_at":"2026-09-23T15:48:09Z","started_at":"2026-09-23T15:48:09Z","lease_expires_at":"2026-09-23T15:53:09Z","heartbeat_at":"2026-09-23T15:48:09Z","labels":["adversarial-review","pr-228"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-9xy9","title":"Cargo.nix has no freshness gate: dev-dependency test-support edges drifted on main unnoticed, and the nix-build job builds only #improvise","description":"Found by the adversarial review of PR #318 (head 689480bf).\n\nOn gh/main (c91cc055), Cargo.nix had drifted from the Cargo.toml files, and nothing noticed. Five dependency edges ask for improvise-core's or improvise-protocol's `test-support` feature:\n- the root crate's improvise-protocol;\n- improvise-command's improvise-core and improvise-protocol;\n- improvise-wasm-client's improvise-protocol.\n\nMain's Cargo.nix mentions `test-support` three times. PR #318 regenerated it with crate2nix for unicase, and four more `test-support` edges appeared that had nothing to do with that PR.\n\nWhy CI misses it: the `nix-build` job runs only `nix build .#improvise`. That builds the non-test crate graph, so a missing dev-dependency feature edge never fails. A missing *normal* dependency does fail, which is why #318 had to regenerate for unicase. No job checks that Cargo.nix is what crate2nix would generate from the current Cargo.lock and manifests.\n\nFix: a CI step, or a checkers suite, that regenerates Cargo.nix and diffs it (`crate2nix generate` then `git diff --exit-code Cargo.nix`). Alternatively, build the crate2nix test derivations. Either way, inject a stale edge and watch the check fail.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T06:19:23Z","created_by":"Edward Langley","updated_at":"2026-09-25T06:19:23Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-8jk6.17","title":"Headless import suite misses three boundary faults: every bad formula named, CSV float measures, :import-json on CSV","description":"Found in the adversarial review of PR #321 at efaa5050. The headless import suite (tests/headless_cli.rs) does not see three faults at the CLI boundary, although the user treats that boundary as a product and context/code/cli.md section 13 states its contract. Each fault below was injected alone, and the whole workspace suite was run:\n\n1. **Every bad formula is named.** cli.md section 13 says: \"Every bad formula is named on stderr\". The headless test passes one bad and one good formula, so naming only the first bad one (the author's rival R5) is caught only by the io unit test `a_formula_that_does_not_parse_stops_the_build_and_is_named`. The fix is cheap: pass two bad `--formula`s in `an_import_formula_that_does_not_parse_refuses_names_it_and_writes_nothing`.\n2. **A CSV float measure.** With `csv_parser::number_in`'s f64 branch disabled (integers only), `1000.50` and similar measures are silently dropped. Only io unit tests failed (4 csv_parser tests and one wizard test). No headless or root integration test imports a CSV with a decimal measure and checks the value.\n3. **`:import-json` / ModelCommand::ImportJsonHeadless on a CSV.** With its CSV branch changed back to `ImportPipeline::new(Value::Array(recs.to_vec()))`, the JSON constructor, only `import_headless_refuses_records_that_collide` failed, and it failed only incidentally, because Revenue stopped being a measure. No test for this path asserts that a numeric CSV column is proposed as a Measure, or that a category's text is kept verbatim.\n\nThe wizard-only rivals R6 and R8 have no headless surface, so a unit test is the right level for them. They are not part of this ticket.\n","status":"in_progress","priority":3,"issue_type":"task","assignee":"agent-headless-import","owner":"el-github@elangley.org","created_at":"2026-09-25T06:15:00Z","created_by":"Edward Langley","updated_at":"2026-09-25T06:20:11Z","started_at":"2026-09-25T06:20:11Z","lease_expires_at":"2026-09-25T06:25:11Z","heartbeat_at":"2026-09-25T06:20:11Z","labels":["headless","import","tests"],"dependencies":[{"issue_id":"improvise-8jk6.17","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T06:15:00Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-tpm8","title":"import --extract on a field with no detected date format is silently ignored and exits 0","description":"`improvise import --no-wizard --extract F:Year` on a field F with no detected date format does nothing and exits 0 without a word. Examples are a years-only column (`2023,2024,2025`, forced with `--time`), or a column where one value fails to parse.\n\nFound by the adversarial review of PR #322 at 6b910889 (years.csv, `--time Year --extract Year:Year --extract Year:Month`). The output was one flat category of the raw years, and there was no warning. That result is fine; the silence is not, because the user asked for a component and got none. The behaviour is not new with #322. Sibling of improvise-31oe.2 and improvise-8jk6.5 (other silent headless no-ops).\n\nExpected: a warning naming the field and saying no date format was detected, so the extraction was not applied. It could also be an error, matching how 8jk6.5 is resolved.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T06:09:25Z","created_by":"Edward Langley","updated_at":"2026-09-25T06:09:25Z","labels":["headless","import"],"dependencies":[{"issue_id":"improvise-tpm8","depends_on_id":"improvise-8jk6.3","type":"discovered-from","created_at":"2026-09-25T06:09:25Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-d0n6","title":"Hierarchical date import: an unparsed raw value shares the item namespace with generated months/years and silently merges (latent)","description":"From ChatGPT Codex's P2 on PR #322 (review at 195f3b44, still applicable at 6b910889). The hierarchical date import (improvise-8jk6.3) keeps a value the detected format cannot parse as a raw, ungrouped item in the SAME category as the generated items. So a raw value spelled like a generated item or group (`2024-01`, `2024`) merges with it. `Category::add_item_in_group` returns the existing item whichever came first, so either the raw record lands in the grouped month, or the month's later records land in an ungrouped raw item. The warning \"kept its raw value … ungrouped\" is then inaccurate.\n\nLATENT today: `detect_date_format` requires EVERY distinct value to parse, and `date_format` is written nowhere else outside tests. So no shipped input reaches the unparsed branch. The review checked this with a CSV mixing valid dates and `2024-01`: no format was detected, and the column became a plain category. It becomes live the moment a format can be chosen or detected from a sample.\n\nFix before that: refuse, fall back to Split for the column, or give raw values a namespace that cannot collide. Add a mixed valid/invalid regression whose raw spelling equals a generated item and a generated group. Related: improvise-vwc7 (an item may share its name with a group of its category).\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T06:09:23Z","created_by":"Edward Langley","updated_at":"2026-09-25T06:09:23Z","labels":["import"],"dependencies":[{"issue_id":"improvise-d0n6","depends_on_id":"improvise-8jk6.3","type":"discovered-from","created_at":"2026-09-25T06:09:23Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-nuo7","title":"An empty formula SUM evaluates to -0.0 and renders as -0.00","description":"A formula `SUM(...)` over an empty match evaluates to -0.0, and the grid and CSV export render it as `-0.00`. Rust's `f64` `Sum` starts from -0.0, so an empty iterator sums to negative zero.\n\nReproduction, found by the adversarial review of PR #322 at 6b910889: `Y24 = SUM(Revenue WHERE Date = |2024|)` in a document where no record has `Date=2024`. `export-csv` writes `-0.00` in every row. The #322 author saw it too (PR body, \"a second small ticket\").\n\nExpected: `0.00`. Gotcha 7 says an empty SUM is `0`. Fix at the aggregation site, e.g. start the fold from +0.0, and pin it with a test on the rendered text, not on `== 0.0`: -0.0 == 0.0 is true, so an equality assertion is vacuous here.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T06:09:21Z","created_by":"Edward Langley","updated_at":"2026-09-25T06:09:21Z","labels":["core","formula"],"dependencies":[{"issue_id":"improvise-nuo7","depends_on_id":"improvise-8jk6.3","type":"discovered-from","created_at":"2026-09-25T06:09:21Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-3mhc","title":"core.md §19 says z z leaves the view as it was, which is false for a group with Expanded(n\u003e=2)","description":"Found reviewing PR #317 (improvise-felo.6). context/code/core.md §19 ('z writes one fold, or none') says 'So z z leaves the view as it was'. The PR body says the same. The sentence after it concedes the case that makes that false. Probe on a Year\u003eQuarter tree with expanded(1): Date, where Y25 carries Expanded(2): z on Y25 writes Collapsed, and the second z removes the fold, since Y25 inherits open. Q1b under Y25 was open and is now closed. The 2 is lost, and the view is visibly different after z z. The same pair of presses also drops an explicit Collapsed that agrees with what the group inherits (decision 3 on the PR). The screen is unchanged then, but a later expanded(N): Cat now opens that group. Fix: say what z z does preserve, which is the toggled group's own open or closed state, and name both exceptions. Or, if the user wants z z to be an identity, keep the displaced fold. That is the PR's user question 2.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T05:33:46Z","created_by":"Edward Langley","updated_at":"2026-09-25T05:33:46Z","labels":["core","docs"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-h6o6","title":"Measure how many generated .improv files a Refusal now skips","description":"Found reviewing PR #317 (improvise-felo.6). generated_file_parses now accepts a typed persistence::parse::Refusal (a HierarchyCycle or a FoldConflict). generated_file_roundtrips skips it through prop_assume!(result1.is_ok()). The property still fails on any parse error that is not a Refusal, so it still discriminates. What it lost is coverage. A refused file stops at build_category or build_view, so the rest of that file (formulas, data, later views) is never checked, and its round trip is never run. That covers self-parent lines like \u003e ||[||], and a collapsed: plus an expanded: line on one target, both empty names being common in the walk. The rate was not measured in review. Want: count Ok, Refusal and Err over a deterministic run, and state the rate in context/test-evidence. If it is more than a few percent, make the generator avoid the two shapes (for example, distinct names on a \u003e line), so a Refusal stays rare rather than routine.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T05:31:59Z","created_by":"Edward Langley","updated_at":"2026-09-25T05:31:59Z","labels":["io","tests"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-qypb","title":"View::expand_depth is a pub usize map, so expanded(0): Cat is still writable","description":"Found reviewing PR #317 (improvise-felo.6) at 8d3230e6. That commit answered Codex's P2 by making GroupFold::Expanded carry a NonZeroUsize, so a group fold of 0 can no longer be built. The category form is the same hole and is still open. View::expand_depth is a pub HashMap\u003cString, usize\u003e, and format_md writes every entry as expanded(N): Cat. Probe: wb.active_view_mut().expand_depth.insert(\"Date\".into(), 0) makes format_md write expanded(0): Date, and parse_md then refuses it. The grammar's depth_number starts at 1. set_expand_depth(cat, 0) is guarded and removes the entry, but the field is public. No production path writes 0 today. Fix: make the map HashMap\u003cString, NonZeroUsize\u003e, as Expanded now is, or make the field private. Extend every_fold_the_api_can_build_reads_back_from_the_file_it_writes to cover it.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T05:31:54Z","created_by":"Edward Langley","updated_at":"2026-09-25T05:31:54Z","labels":["core","io"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-31oe.11","title":"Autosave and the headless final write call persistence::save directly, bypassing App::storage (gotcha 2)","description":"Found reviewing PR #315 (ADR 006, head 50234b49). Citations are at gh/main b639266b.\n\nTwo writes go around `App::storage` (gotcha 2, improvise-fuj). Each calls the path-taking `persistence::save`, which always writes the real filesystem, whatever backend the host picked:\n\n1. **Autosave.** `App::autosave_if_needed` (`crates/improvise-command/src/ui/app.rs:1215-1224`, called from `src/draw.rs:93`) writes `persistence::autosave_path(path)` through `persistence::save(\u0026self.model_state.workbook, \u0026ap)`. It also runs on a wall-clock timer, with no effect and no command firing.\n2. **The headless final write.** `run_headless_commands` (`src/main.rs:442-446`) ends with `persistence::save(\u0026app.model_state.workbook, path)` rather than the `Save` effect through `app.storage`. `run_headless_import` (`src/main.rs:312`) does the same.\n\nConsequence: an `App` built with `App::with_storage(MemoryFs)` still writes to disk on autosave. So \"effects never touch std::fs\" holds only because these two are not effects. improvise-fuj's close reason says every document write goes through `App::storage`, and these two do not. Today both callers happen to use `PhysicalFs`, so nothing breaks yet. It is latent. It becomes live the moment a replay or test runs the TUI loop, or the headless loop, over `MemoryFs` (ADR 006 clause 6 proposes exactly that).\n\nFix direction: route both writes through `app.storage` (`persistence::save_to` or the `Save` effect). Demonstrate first with a test: an `App::with_storage(MemoryFs)` with `dirty` set and an elapsed autosave timer must write only into the `MemoryFs`.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T05:21:47Z","created_by":"Edward Langley","updated_at":"2026-09-25T05:21:47Z","dependencies":[{"issue_id":"improvise-31oe.11","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T05:21:47Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-felo.9","title":"A typed 1.005 displays as 1.00 at two places: format_f64 rounds the binary value, not the typed decimal","description":"Reproduced at gh/main c91cc055 (format.rs, cell.rs and resolve.rs unchanged since b639266b) by a scratch crate outside the workspace: typed(\"1.005\") -\u003e CellValue::Number, then NumberFormat::parse(\",.2\").value(...) draws 1.00; format_f64(1.005, false, 2) likewise. Half-away-from-zero on the decimal the user typed gives 1.01. Same miss for 1.015 -\u003e 1.01 (want 1.02), 1.025 -\u003e 1.02 (want 1.03), 0.285 -\u003e 0.28 (want 0.29). 2.675, 0.125, 1.115, 8.345, 1.045 happen to agree.\n\nCause: format.rs round_half_away scales the f64 by 10^d. 1.005 is stored as 1.00499999999999989..., so 1.005*100 = 100.49999999999999, +0.5 truncates to 100.\n\nIndependent of ADR 005 (improvise-l2b8, PR #320), which fixes it as a side effect for exact values. A fix that needs no numeric-tower change: round the value's SHORTEST round-trip spelling (format!(\"{n}\"), the same spelling edit_text already uses, improvise-umi4) half-away-from-zero as a decimal string, so display rounds what was typed. Gotcha 8 holds either way: display-only, never fed back.\n\nTest first (AGENTS.md): format_f64(1.005, false, 2) == \"1.01\", plus 1.015/1.025/0.285, plus the existing pikk large-magnitude case.\n\nFound by the adversarial review of PR #320.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T05:19:07Z","created_by":"Edward Langley","updated_at":"2026-09-25T05:19:07Z","dependencies":[{"issue_id":"improvise-felo.9","depends_on_id":"improvise-felo","type":"parent-child","created_at":"2026-09-25T05:19:07Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-nn4s.7","title":"Faces: theme download/upload, browser half","description":"ADR 004 Deferred: a Blob download of the current theme as JSON and a file input to upload one. Needs no configuration decision. The TUI half waits on improvise deciding how it is configured.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T04:48:39Z","created_by":"agent-faces","updated_at":"2026-09-25T04:48:39Z","dependencies":[{"issue_id":"improvise-nn4s.7","depends_on_id":"improvise-nn4s","type":"parent-child","created_at":"2026-09-25T04:48:39Z","created_by":"agent-faces","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-nn4s.6","title":"Faces step 6: migrate the remaining TUI Color literals, including the twelve fg(Black).bg(accent) selection sites","description":"ADR 004 clause 8 step 6, opportunistic. The selection sites want a selection base face carrying reverse (clause 3/5), which is NOT a no-op on a TestBackend buffer (fg Black bg X vs fg X REVERSED) and is not named as a clause-8 exception -- needs the user's ruling before it lands. Do not touch src/ui/grid.rs while improvise-felo.6 is in flight.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T04:48:36Z","created_by":"agent-faces","updated_at":"2026-09-25T04:48:36Z","dependencies":[{"issue_id":"improvise-nn4s.6","depends_on_id":"improvise-nn4s","type":"parent-child","created_at":"2026-09-25T04:48:36Z","created_by":"agent-faces","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-nn4s.5","title":"Faces step 5: browser maps faces to CSS custom properties through a 16-slot table","description":"ADR 004 clause 8 step 5 and clause 4's browser half. Ship a 16-slot table per scheme (light, optional dark; slot 2 = #2f6f4f / #8ec07c per the ADR), resolve Indexed 16-255 by the xterm formula in shared code, emit --face-\u003cname\u003e-fg/bg etc from the resolved theme, retire style.css's nine stray hex values, and pin by test that every face has a browser mapping. Also closes the AxisColor half of improvise-pcje.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T04:48:33Z","created_by":"agent-faces","updated_at":"2026-09-25T04:48:33Z","dependencies":[{"issue_id":"improvise-nn4s.5","depends_on_id":"improvise-nn4s","type":"parent-child","created_at":"2026-09-25T04:48:33Z","created_by":"agent-faces","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-nn4s.4","title":"Faces step 4: retire MinibufColor from the wire; both front ends derive the minibuffer face from the mode","description":"ADR 004 clause 8 step 4. MinibufferConfig.color names colours, not roles. After step 2 the TUI no longer reads it; the browser still does (render.rs, app.js className). Remove the field and have the browser name minibuffer.\u003ctext-entry\u003e faces instead. Wire change: MinibufferConfig is in the Snapshot.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T04:48:31Z","created_by":"agent-faces","updated_at":"2026-09-25T04:48:31Z","dependencies":[{"issue_id":"improvise-nn4s.4","depends_on_id":"improvise-nn4s","type":"parent-child","created_at":"2026-09-25T04:48:31Z","created_by":"agent-faces","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-nn4s.3","title":"Faces step 3: lift PanelContent::active_color into panel.{formula,category,view} faces","description":"ADR 004 clause 8 step 3. The three panels' accents are a private TUI table (Yellow/Cyan/Blue); lift them into faces derived from Panel::ALL so the browser can reach them.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T04:48:28Z","created_by":"agent-faces","updated_at":"2026-09-25T04:48:28Z","dependencies":[{"issue_id":"improvise-nn4s.3","depends_on_id":"improvise-nn4s","type":"parent-child","created_at":"2026-09-25T04:48:28Z","created_by":"agent-faces","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-8jk6.13","title":"Folder import: a file with no measure columns gets a per-file view paged onto another file hidden measure","description":"Found reviewing PR #314 at 4e68372c. source_view sets the _Measure page selection only when the file has a measure; a file with none (e.g. entity,tag) leaves it unset, so the view pages onto the model first measure, which that same view hides. Reproduced: merge a.csv \"entity,x\" and tags.csv \"entity,tag\"; grid_in(tags) shows page _Measure=x and a Record cell for measure x, editable, under File=tags. Same family as the header-only CSV P2 on PR #314 and improvise-yd2t. Decide what a measureless file view shows (perhaps records mode, or _Measure off the page) and test it.","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-lpzu","owner":"el-github@elangley.org","created_at":"2026-09-25T04:35:25Z","created_by":"Edward Langley","updated_at":"2026-09-25T05:02:24Z","started_at":"2026-09-25T04:36:55Z","closed_at":"2026-09-25T05:02:24Z","close_reason":"Landed in PR #314; reviewed PASS at c43ee724","labels":["import"],"dependencies":[{"issue_id":"improvise-8jk6.13","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T04:35:25Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-8jk6.12","title":"Folder import: four per-file-view rules have no test that fails when they break","description":"Found reviewing PR #314 at 4e68372c. Each fault below was injected alone and the io lib suite (286), headless_cli and folder_import_views all stayed green: (1) source_view: derived date parts of a file own column not counted as own (replace the date_extractions clause with false) -- no folder fixture has a date column with extraction; (2) source_views: drop the intra-batch dedupe `views.iter().any(|v| v.name == name)` -- files Default.csv and \"Default 2.csv\" would both be named \"Default 2\" and the insert would silently lose one view; (3) merge_csvs_from: clear columns before each file instead of unioning same-stem files (a/x.csv + b/x.csv); (4) scalar_name: drop the trim, so a whitespace-only value becomes an item \" \" (pre-existing ych rule, now load-bearing for the sparse-record rule). Each rule is stated in context/code/formula-and-io.md; add a test that fails under each injected fault.","status":"closed","priority":3,"issue_type":"task","assignee":"agent-lpzu","owner":"el-github@elangley.org","created_at":"2026-09-25T04:35:24Z","created_by":"Edward Langley","updated_at":"2026-09-25T05:02:22Z","started_at":"2026-09-25T04:36:52Z","closed_at":"2026-09-25T05:02:22Z","close_reason":"Landed in PR #314; reviewed PASS at c43ee724","labels":["import"],"dependencies":[{"issue_id":"improvise-8jk6.12","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T04:35:24Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-8jk6.11","title":"JSON import: a blank string in a numeric column makes it a Label, though the build treats \"\" as absent","description":"Found reviewing PR #314 (improvise-lpzu / 8jk6.2). analyze_records (crates/improvise-io/src/import/analyzer.rs) filters only Value::Null out of `present`, so a JSON column [10.0, \"\"] is mixed and proposed as Label, while Plan::place / scalar_name treat \"\" (and whitespace-only) as no coordinate. CSV is unaffected because parse_csv_field maps \"\" to Null. Reproduced: ImportPipeline::new(json!([{\"Region\":\"East\",\"Amount\":10.0},{\"Region\":\"West\",\"Amount\":\"\"}])) proposes Amount: Label. Decide: the analyzer should use the same blank rule as scalar_name (one home for \"is this value absent\"), and test it with a JSON numeric column holding \"\" and \" \". PR #314 context/code/formula-and-io.md claims the analyzer already classifies over present values only; true for null, not for \"\".","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T04:34:55Z","created_by":"Edward Langley","updated_at":"2026-09-25T04:34:55Z","labels":["import"],"dependencies":[{"issue_id":"improvise-8jk6.11","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T04:34:55Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-gtn7.10","title":"Show how many records the current view covers (and how many it cannot place)","description":"USER 2026-09-25, on whether a record missing a category that sits on Row/Column should appear under a '(none)' position: 'I think leaving it out for now is fine, maybe a UI element somewhere that shows the number of records underneath the current view?' Since sparse import (improvise-8jk6.2, PR #314) a record lacking a coordinate for a category on Row/Column appears in no grid position, silently. Add an indicator (status line / view bar in the TUI, the equivalent in the browser) showing the number of records under the current view, and ideally how many of those the grid cannot place because they lack a coordinate on a visible axis. The count is computed in the shared layer and projected (design-principles section 5), not derived per front end. Mind cost: it must not be an O(cells) scan per keypress (ADR 008/009: occupancy counts may provide it).","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-25T04:30:53Z","created_by":"Edward Langley","updated_at":"2026-09-25T04:30:53Z","labels":["browser","protocol","tui","ux"],"dependencies":[{"issue_id":"improvise-gtn7.10","depends_on_id":"improvise-gtn7","type":"parent-child","created_at":"2026-09-25T04:30:53Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-31oe.7","title":"No headless output but a file: export to stdout","description":"The only headless output is a file. `export` writes a path, and there is no way to write a view to stdout, so every pipeline has to go through a temporary file and then `cat` it. Found by the repo-stats pipeline, PR #313 (improvise-1xfb.5.1).\n\nRepro:\n\n improvise cmd 'export -' -f m.improv\n\nWhat happens: a file named `-` is written, or the argument is taken as a path.\n\nWhat should happen: `export -`, or an equivalent flag on `improvise cmd`/`script`, writes the CSV to stdout, and status messages stay on stderr so the stream is clean. Needs deciding: whether this is a path convention in the command or a CLI-level option, given that effects never touch std::fs (gotcha 2) and the output goes through Storage. Related: improvise-2mqz (ExportCsv has no wire form).\n","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-25T04:30:07Z","created_by":"agent-gapfile","updated_at":"2026-09-25T04:30:07Z","labels":["area:cli","headless"],"dependencies":[{"issue_id":"improvise-31oe.7","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T04:30:07Z","created_by":"agent-gapfile","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-8jk6.10","title":"import --formula can only target _Measure: accept the [Category] target suffix","description":"`import --formula` can only target `_Measure`. The `Name = expr [Category]` suffix that `.improv` and the formula minibuffer accept is not available from the import flag, so a formula that computes an item of another category, such as a `Total` item of Region, cannot be added during a headless import. Found by the repo-stats pipeline, PR #313 (improvise-1xfb.5.1).\n\nRepro:\n\n improvise import s.csv --no-wizard --category Region --measure Sales --formula 'Total = North + South [Region]' -o s.improv\n\nWhat happens: the formula is rejected or lands in `_Measure`, rather than being a formula targeting Region. If it is dropped, see also the silent-drop ticket filed in the same batch.\n\nWhat should happen: `--formula` accepts the same target-category suffix as the other two entry points. This should go through the one formula parser rather than a new split. See improvise-zgb for the bug in the earlier split.\n","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-25T04:30:06Z","created_by":"agent-gapfile","updated_at":"2026-09-25T04:30:06Z","labels":["headless","import"],"dependencies":[{"issue_id":"improvise-8jk6.10","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T04:30:06Z","created_by":"agent-gapfile","metadata":"{}"},{"issue_id":"improvise-8jk6.10","depends_on_id":"improvise-8jk6.5","type":"related","created_at":"2026-09-25T04:30:28Z","created_by":"agent-gapfile","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-8jk6.9","title":"Date --extract has no ISO week component","description":"Date `--extract` offers Year, Month and Quarter (import/analyzer.rs::DateComponent) but not the ISO week. Weekly reports, such as churn per week, cannot be built on import. Found by the repo-stats pipeline, PR #313 (improvise-1xfb.5.1).\n\nRepro:\n\n improvise import d.csv --no-wizard --time Date --extract Date:Week -o d.improv\n\nWhat happens: there is no Week component, so there is no weekly category.\n\nWhat should happen: a Week component whose item is the ISO 8601 year-week, for example `2026-W39`. It must use the ISO week-year, not the calendar year, so that late-December and early-January dates land in the right week. It should sort correctly as text, and the wizard should offer it. Related: improvise-8jk6.3 (a date column as one hierarchical category).\n","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-25T04:30:04Z","created_by":"agent-gapfile","updated_at":"2026-09-25T04:30:04Z","labels":["headless","import"],"dependencies":[{"issue_id":"improvise-8jk6.9","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T04:30:04Z","created_by":"agent-gapfile","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-31oe.6","title":"Views and export have no sorting and no top-N","description":"Views and export have no sorting and no top-N. Rows appear in item insertion order (gotcha 18), and nothing can order them by a measure or keep only the largest N. Headless reporting, such as \"top 10 files by churn\", needs both. Found by the repo-stats pipeline, PR #313 (improvise-1xfb.5.1), which had to sort and cut outside improvise.\n\nWhat should happen: a view setting, available from the command language and persisted in `.improv`, that sorts the row axis by a column's value in either direction, with an optional limit. Export honours it. Needs deciding: where the setting lives (on the View, as number_format and prune_empty do); how it interacts with groups and multi-level rows; and whether a limit is part of the view or only an export option.\n","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-25T04:30:03Z","created_by":"agent-gapfile","updated_at":"2026-09-25T04:30:03Z","labels":["headless","view"],"dependencies":[{"issue_id":"improvise-31oe.6","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T04:30:03Z","created_by":"agent-gapfile","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-8jk6.8","title":"export writes formatted display text (\"17,512.00\") instead of values: offer raw values","description":"`export` writes each cell's formatted display text, for example `\"17,512.00\"`, instead of its value. The view's number format, with its thousands separator and fixed decimals, ends up in the CSV. Another tool then has to un-format the number, and the precision beyond the display rounding is lost (gotcha 8: display rounding is view-only). Found by the repo-stats pipeline, PR #313 (improvise-1xfb.5.1).\n\nRepro, on a view with format `,.2f` and a cell holding 17512:\n\n improvise cmd 'export v.csv' -f m.improv\n cat v.csv\n\nWhat happens: the cell is written as `\"17,512.00\"`.\n\nWhat should happen: offer raw values, meaning full-precision numbers with no grouping and text as-is. This could be an option on `export` or the default for CSV. Which one is the default is a decision for the fix. Keep formatted output available for people who want what the screen shows.\n","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-25T04:30:02Z","created_by":"agent-gapfile","updated_at":"2026-09-25T04:30:02Z","labels":["export","headless"],"dependencies":[{"issue_id":"improvise-8jk6.8","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T04:30:02Z","created_by":"agent-gapfile","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-8jk6.7","title":"export of a records view writes the editor's +cat and + columns into the CSV","description":"`export` of a records view writes the editor's creating columns, `+cat` and `+`, into the CSV. These columns are TUI affordances for adding a category or a measure (gotcha 1; improvise-2zq and improvise-c5v), not data. Found by the repo-stats pipeline, PR #313 (improvise-1xfb.5.1).\n\nRepro, on any model with data:\n\n improvise cmd 'toggle-records-mode' 'export r.csv' -f m.improv\n head -1 r.csv\n\nWhat happens: the header row ends with the `+cat` column name and a `+` column, and every row has empty cells for them.\n\nWhat should happen: export writes only the category and measure columns. The creating columns are session UI and belong to no document or file. See persistence/export.rs::render_csv and GridLayout::record_col.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T04:30:01Z","created_by":"agent-gapfile","updated_at":"2026-09-25T04:30:01Z","labels":["export","headless"],"dependencies":[{"issue_id":"improvise-8jk6.7","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T04:30:01Z","created_by":"agent-gapfile","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-31oe.5","title":"Records-mode columns follow the view: hidden measures drop out, and a view can hide category columns","description":"A view cannot hide a whole category's column. `hide-item` hides one item at a time, and the only way to drop a category from the grid is to move it to an axis, which changes what is aggregated. Headless users who configure a view for export cannot make a column disappear while keeping the data it aggregates. Found by the repo-stats pipeline, PR #313 (improvise-1xfb.5.1).\n\nExample: a records view over Region, Product and Month, where the export should omit the Month column but still include every record.\n\nWhat should happen: the view gets a per-category \"hidden\" setting, available from the command language and persisted in `.improv`, which removes that category's column from the grid and export. How it relates to the None axis in pivot mode, and whether it applies only to records mode, needs deciding first.\n","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-25T04:29:59Z","created_by":"agent-gapfile","updated_at":"2026-09-25T04:57:23Z","labels":["headless","view"],"dependencies":[{"issue_id":"improvise-31oe.5","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T04:29:59Z","created_by":"agent-gapfile","metadata":"{}"}],"comments":[{"id":"e8472f46-aa5a-5af8-a94f-558959990e04","issue_id":"improvise-31oe.5","author":"Edward Langley","text":"USER 2026-09-25 (asked 'Is there any way to [choose] just the columns shown in drill?'; answered 'I think 1+2 make sense, reseting is fine'): widened scope -- records-mode columns follow the VIEW. GridLayout::records_columns (view/layout.rs:591) takes only the model, so every regular category and every _Measure item (even one the view hides) is a column. Make it take the view: measures hidden in the view drop out, and a view can hide category columns (persisted like hidden items). Drill-specific defaults are a separate ticket.","created_at":"2026-09-25T04:57:18Z"}],"dependency_count":0,"dependent_count":1,"comment_count":1} +{"_type":"issue","id":"improvise-31oe.4","title":"create-view puts every unmentioned category on Page with its first item selected, silently filtering the view","description":"`create-view` puts every category the command does not mention on the Page axis, with that category's first item selected. The new view is therefore silently filtered to one slice of every other category. Headless users see totals that are missing most of the data. Found by the repo-stats pipeline, PR #313 (improvise-1xfb.5.1).\n\nRepro, on a model with categories Region, Product and Month:\n\n improvise cmd 'create-view V' 'set-axis Region row' 'export v.csv' -f m.improv\n\nWhat happens: Product and Month sit on Page with their first items selected, so v.csv shows one product in one month.\n\nWhat should happen: a category the user never placed should not filter the view. Put it on the None axis, which aggregates over it, rather than on Page with an arbitrary selection. A Page slice should be something the user asked for. This changes what `create-view` means in the TUI too, so it needs a decision before the fix. Compare improvise-cj4u, a closed ticket about Label categories in new views.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T04:29:58Z","created_by":"agent-gapfile","updated_at":"2026-09-25T04:29:58Z","labels":["headless","view"],"dependencies":[{"issue_id":"improvise-31oe.4","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T04:29:58Z","created_by":"agent-gapfile","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-gtn7.9","title":"Expand ~ in paths typed into the TUI (:w, :import, save/export prompts)","description":"USER 2026-09-25, verbatim: 'resolving ~ in pathname: ~/.... this probable [sic] only makes sense in the TUI at the moment.' A path typed into the TUI -- ':w ~/x.improv', ':import ~/data.csv', the SavePrompt and ExportPrompt minibuffers -- is used literally, so '~' is a directory named '~'. The shell expands ~ for CLI arguments already, so this is TUI-only; the browser has no home directory (OPFS), so expansion must NOT live in the shared command layer or the Storage trait as a general rule. Expand '~' and '~/' against the user's home at the native edge (PhysicalFs, or where the TUI turns a typed string into a path); decide whether '~user/' is in scope (recommend no). Tests first: each of :w, :import, save and export resolves '~/x' under a fake HOME; a path merely containing '~' elsewhere is untouched; the browser/worker path is unaffected.","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-25T03:59:27Z","created_by":"Edward Langley","updated_at":"2026-09-25T03:59:27Z","labels":["tui","ux"],"dependencies":[{"issue_id":"improvise-gtn7.9","depends_on_id":"improvise-gtn7","type":"parent-child","created_at":"2026-09-25T03:59:27Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-1xfb.8","title":"Doc generator links any code-span path that exists on disk, so ignored build output becomes a dead GitHub link","description":"From the PR #312 review (issuecomment-5826385748): pathHref in scripts/build-doc-site.mjs turns a code span into a github.com/.../tree/main/\u003cpath\u003e link whenever the path exists on disk, tracked or not. In a local build site/ exists (now gitignored), so the doc-site.md spans `site/` and `site/docs` link to a 404; the Pages build lacks site/ and renders plain text, so the published site is correct. Fix: pathHref asks git ls-files (tracked paths) instead of the disk; test with an ignored-but-present path.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T03:52:32Z","created_by":"Edward Langley","updated_at":"2026-09-25T03:52:32Z","labels":["docs","tooling"],"dependencies":[{"issue_id":"improvise-1xfb.8","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T03:52:32Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-yd2t","title":"Page cycling ([ and ]) steps onto hidden items and draws an empty grid","description":"page_cat_data (crates/improvise-command/src/command/cmd/navigation.rs) and the layout's PageAxis items come from Model::effective_item_names, which ignores View::hidden_items. So a view that hides a Page item still pages onto it, and the grid is empty there. Found by improvise-lpzu: each per-file view from a folder import puts _Measure on Page and hides other files' measures, so ] in a 'coupling' view reaches 'age-months'. The Default view had the same behaviour before, but nothing in it hid a Page item. Decide whether paging skips hidden items (both front ends: the browser resolver pages too, protocol resolve.rs) and test it with a hidden item BETWEEN two visible ones.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T03:50:15Z","created_by":"agent-lpzu","updated_at":"2026-09-25T03:50:15Z","labels":["import"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-1xfb.7","title":"scripts/agent-git: refuse a blank actor, and test the amend claim its suite names","description":"Found reviewing PR #310 at 256057b7 (improvise-1xfb.3). Non-blocking.\n(1) The refusal is [ -z ], so BEADS_ACTOR=' ' commits as 'Claude ( )' with exit 0, and an actor containing \u003c or \u003e is silently stripped by git ('a\u003cb\u003e' becomes 'Claude (ab)'). The refusal message also says \"unset\" for the empty case.\n(2) scripts/agent-git-test.sh line 45 comments \"a plain amend keeps the agent as author\" but asserts nothing about amend: a wrapper that appends --reset-author to any --amend passes 11/11. Assert it or drop the claim.\n(3) The script's \"why\" comment points at agent-rules.md, which only points on to context/test-evidence/tooling.md. Point at the file that carries the argument.\n","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T03:36:45Z","created_by":"review-310","updated_at":"2026-09-25T03:36:45Z","labels":["agents","tooling"],"dependencies":[{"issue_id":"improvise-1xfb.7","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T03:36:45Z","created_by":"review-310","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-1xfb.6","title":"kb-test.sh case 18 hardcodes the baseline's location/3 count, so no one can add a location fact","description":"Found by the bookkeeping agent on PR #311 (2026-09-25): scripts/kb-test.sh case 18 asserts '34 of the baseline's are missing', which assumes exactly 35 location/3 facts in kb.pl. Adding location(grid_width, ...) failed the kb suite, so the fact was dropped. Make the case derive the count from the fixture/baseline rather than a literal; show it failing on a +1 location fact first.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T03:22:11Z","created_by":"Edward Langley","updated_at":"2026-09-25T03:22:11Z","labels":["kb","tooling"],"dependencies":[{"issue_id":"improvise-1xfb.6","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T03:22:11Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-b45z","title":"No test pins that a Label category does not consume a MAX_CATEGORIES slot: counting labels toward the cap passes the whole workspace","description":"Found by the adversarial review of PR #307 (improvise-felo.1) at 7b9e868b. Injected fault: in Model::add_category (crates/improvise-core/src/model/types.rs), count Label categories toward MAX_CATEGORIES -- `.filter(|c| c.kind.is_regular() || c.kind == CategoryKind::Label)`. The whole workspace stays green (2139 tests). The rule 'a Label sits outside MAX_CATEGORIES' (core.md, design-principles.md, improvise-dy3j) has no guard: a_document_at_the_category_limit_plus_a_label_reloads adds its label AFTER filling to the cap, so the label is written and re-read after every regular category and never shares the count. Pre-existing at gh/main; not introduced by felo.1. Fix shape: a core unit test that adds a label FIRST and then fills to MAX_CATEGORIES regular categories, asserting all are accepted; plus the round-trip twin with the label section written before the regular ones.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T02:39:16Z","created_by":"Edward Langley","updated_at":"2026-09-25T02:39:16Z","labels":["adversarial-review","core","pr-307"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-felo.2","title":"DataStore load is O(k log k) per cell and memory O(cells x k): store build tripled from 12 to 24 categories","description":"Measured on an M4 Max by improvise-felo.1 (examples/category_width_bench.rs): store build 26-30 ms at 12 categories vs 80-83 ms at 24 (2,500 records); 54-57 vs 163-172 ms at 5,000. Not a blocker; worth profiling CellKey construction/sorting and the secondary index insert. Distinct from improvise-sk32 (records-mode quadratic in records). Found by the improvise-felo.1 coupling inventory (MAX_CATEGORIES 12 -\u003e 24, PR #307; context/code/core.md section 9). Reachable at 12 too; more likely at 24.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T02:30:29Z","created_by":"Edward Langley","updated_at":"2026-09-25T02:30:29Z","labels":["core","perf"],"dependencies":[{"issue_id":"improvise-felo.2","depends_on_id":"improvise-felo","type":"parent-child","created_at":"2026-09-25T02:30:29Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-79h5","title":"agent-rules rule 8: newer-main SHA wording and the amend-vs-squash asymmetry","description":"Found reviewing PR #305 at ac2efdc8. Two wording gaps in agent-rules rule 8 (\"Reconcile diverging history only by merge\"), neither blocking:\n\n1. \"To take a newer main, run `git merge \u003cSHA\u003e` with the SHA you pinned (rule 7).\" The SHA pinned at task start is by definition not newer main. Rule 7 says re-resolve on purpose before merging a newer main, so rule 8 should say: re-pin (`git rev-parse gh/main`), then merge that SHA.\n2. The rule forbids squashing unpushed WIPs (`reset --soft`, a `rebase -i` fixup), but it forbids amending only \"a commit you have pushed\". Amending an unpushed commit also replaces history, so the two limits disagree. Ask the user which one is meant: either forbid amend outright, or say why an unpushed amend is allowed when an unpushed squash is not.\n","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T01:59:02Z","created_by":"Edward Langley","updated_at":"2026-09-25T02:15:34Z","closed_at":"2026-09-25T02:15:34Z","close_reason":"Landed in PR #305 (merge 6427312d), reviewed PASS at 9b9b1efa","labels":["agents","tooling"],"comments":[{"id":"81f8ed00-3aa3-52ad-9e6d-65765e1d1dae","issue_id":"improvise-79h5","author":"Edward Langley","text":"USER, 2026-09-25, verbatim: 'I don't mind amending on a branch that hasn't been pushed, but don't do anything that requires a force push.' Settles the second half: the line is the push, not the amend.","created_at":"2026-09-25T02:06:22Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} +{"_type":"issue","id":"improvise-at5k","title":"kb.pl doctrines still treat history rewrites as routine after the merge-only rule","description":"Found reviewing PR #305 at ac2efdc8. The user's 2026-09-25 rule (no squash or rebase; reconcile diverging history only by merge; \"the merge structure of git history is a causal record\") is documented by #305 in AGENTS.md and agent-rules rule 8, but two kb.pl doctrines still read as licensing rewrites:\n\n1. doctrine(a_review_names_the_sha_it_reviewed): \"the author does not rewrite history while a review is in flight\" implies rewriting is fine otherwise. Should say the author never rewrites history (agent-rules rule 8) and announces any push.\n2. doctrine(a_hard_reset_discards_knowledge_written_to_the_working_branch): the remedy \"after any reset or force-move of a branch holding knowledge, re-count the predicates\" presents reset/force-move as a normal resync step. Under the rule, local main resyncs by `git merge --ff-only gh/main`, and a reset that drops commits is forbidden.\n\nkb.pl is orchestrator-only and written by `scripts/kb replace`; this ticket is for the orchestrator.\n","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T01:59:01Z","created_by":"Edward Langley","updated_at":"2026-09-25T03:44:45Z","closed_at":"2026-09-25T03:44:45Z","close_reason":"Both doctrines rewritten via scripts/kb replace in PR #311 (merged a4feee44); reviewed PASS","labels":["agents","tooling"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-4klp","title":"Duplicate-category name check against date-derived categories is untested","description":"Found reviewing PR #304 (improvise-cgqt) at 92233cff. ImportPipeline::taken_category_names includes the date-extraction names (e.g. when_Year), which is what stops a user naming the occurrence category after one. If the name slipped through, wb.add_category would be idempotent on the existing category and each cell key would carry that category twice. Removing the date_extractions chain from taken_category_names fails no test in the workspace. Add a test that types a date-derived name on NameDuplicateCategory and is refused.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T01:55:39Z","created_by":"Edward Langley","updated_at":"2026-09-25T02:22:13Z","closed_at":"2026-09-25T02:22:13Z","close_reason":"Landed in PR #304 (merged); reviewed PASS at 22cbfa67","labels":["adversarial-review","import","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-9bit","title":"Import collision check counts records with disjoint measures as colliding","description":"Found reviewing PR #304 (improvise-cgqt) at 92233cff. collisions_among keys on the coordinate tuple only, so {region: East, revenue: 10} and {region: East, cost: 5} are reported as a collision although no cell would be summed (Sum builds both cells and raises no summed warning). Consequences: the wizard asks 'Sum them into one cell' where nothing sums, and 'improvise import --no-wizard' now refuses by default a sparse-measure CSV that imported cleanly before. Decide whether a tuple collides when records share it, or only when two records reach one (tuple, measure) cell, and pin it. Also untested: the 'a record with no measure collides with nothing' rule. Injecting 'if false \u0026\u0026 measures.is_empty()' in collisions_among fails no test in the workspace.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T01:55:30Z","created_by":"Edward Langley","updated_at":"2026-09-25T02:22:13Z","closed_at":"2026-09-25T02:22:13Z","close_reason":"Landed in PR #304 (merged); reviewed PASS at 22cbfa67","labels":["adversarial-review","import","ux"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-gtn7.4","title":"Audit TUI text that truncates content the user may need: formula panel, help rows, import-wizard Preview","description":"Per the user's rule (bd memory messages-wrap-not-truncate): truncation is acceptable only for labels whose full text is available elsewhere. Sites reported by the improvise-cgqt agent: src/ui/formula_panel.rs:54 (formula text), src/ui/help.rs:238 (help rows, see also improvise-j4i9), the import wizard's Preview summary and field/label columns. Grid labels and cells truncate by design (their full text is reachable). For each site: decide wrap, or confirm the full text is reachable elsewhere and say where. Related: improvise-n8vh (panels overflow instead).","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T01:46:57Z","created_by":"Edward Langley","updated_at":"2026-09-25T01:46:57Z","labels":["tui","ux"],"dependencies":[{"issue_id":"improvise-gtn7.4","depends_on_id":"improvise-gtn7","type":"parent-child","created_at":"2026-09-25T01:46:57Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-j4no","title":"TUI draw panics on a terminal 0 or 1 row tall (index outside of buffer)","description":"Found reviewing PR #303 (improvise-gtn7.1), pre-existing and identical at gh/main ae5cf408: rendering draw() into a TestBackend of height 0 (any width \u003e=1) or height 1 (width \u003e=3), with or without side panels, panics in ratatui-core buffer.rs:251 'index outside of buffer: the area is Rect { .. height: 0 } but index is (0, 0)'. A real terminal can be resized that small; the TUI loop would crash. Probe: loop w 0..=120, h in {0,1,3,5,24}, all 8 panel masks, press Right x12 and draw; 1904 failing combos at both commits, all at h\u003c=1. Fix: find the widget that writes without checking its Rect (likely a bar or the welcome/grid block) and guard, then pin with that sweep.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T01:40:30Z","created_by":"Edward Langley","updated_at":"2026-09-25T01:40:30Z","labels":["tui"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-j7ij","title":"No test notices a side panel that is open but never drawn: App::side_panel_open can drop a flag with the suite green","description":"Found reviewing PR #303 (improvise-gtn7.1). Injected: App::side_panel_open returns category||view, omitting formula_panel_open. cargo test -p improvise -p improvise-command stays green. Effect: the formula panel is toggled open (FormulaPanel mode reachable) but draw_content skips the whole side column, so the panel is invisible; grid and keypress path agree on full width, so gtn7.1's test cannot see it. Same hole pre-dates the PR (draw_content computed the OR inline). Fix shape: a draw-level assertion that each open panel's title/border appears in the frame, one panel at a time AND with all three open, asserting the direction the launch default (category open) cannot supply.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T01:40:26Z","created_by":"Edward Langley","updated_at":"2026-09-25T01:40:26Z","labels":["tests","tui"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-felo","title":"Epic: Core model, layout and formula evaluation","description":"improvise-core and improvise-formula: Model and DataStore correctness (07i0, nwgk, vwc7, 98pd), GridLayout prune and marker logic and its docs (dw2d), formula evaluation performance (9iyv), and ADR 005's exact-decimal arithmetic (l2b8). Out of scope: stable ids (the ADR 007 epic), .improv reading and writing (the import/export epic), and records mode and drill (their own epic).","status":"open","priority":3,"issue_type":"epic","owner":"el-github@elangley.org","created_at":"2026-09-25T01:04:12Z","created_by":"Edward Langley","updated_at":"2026-09-25T01:04:12Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-hexb","title":"ADR 007 clause 12: the regression test commits UNEDITED text (a no-op commit passes it), and which side holds the captured mapping is unstated","description":"Found by the delta review of PR #210 (ADR 007, `4830fa30..fcb8f539`). None of this blocks. Clause 12 resolves the contradiction it was written for. These are details the implementer of clause 12 will hit.\n\n1. **The required regression test can pass without exercising the mapping.** It says \"Commit the unedited text\". A host or client that treats an unedited commit as a no-op (the text equals what was shown, so send nothing, or `raw == was`, so write nothing) passes both assertions without resolving any name. That is the \"a rival that fails the setup\" vacuity shape. The test should change something the user typed that is not a name, for example `Revenue - Cost` → `Revenue - Cost * 1` (Codex's original scenario: \"edit only the constant/operator\"). Then assert that the stored formula references the original item's id.\n\n2. **Which side holds the captured mapping, and what `ReplaceFormula.raw` carries on the wire, is unstated.** Clause 12 cites `OpenedCell` (client-side, in `improvise-protocol/src/client.rs`) as the model. But it also says a name not in the mapping \"is resolved against the document at commit\", which only the host can do. So either:\n - `raw` travels as id-form text for the captured names and display text for new ones (a mixed rendering, which needs a rule for telling them apart, even though ids are globally unique); or\n - the mapping travels with the command; or\n - the host keeps a per-session open-editor record.\n\n The TUI (host-local) and the browser (remote) must reach the same answer. This overlaps improvise-m56i item 6 (resolving typed names at the panel edge).\n\n3. **Clause 11 (Migration)** does not list clause 12's capture step. The Consequences bullet \"the host must resolve display names typed there back to ids\" now describes only the names that are new to the edit.\n\n4. **Minor factual drift in the revised Context paragraph.** \"The only reader is one test\" is wrong: `category.rs`'s `add_item_returns_sequential_ids` and `add_item_duplicate_returns_same_id`, and `types.rs:1385`, all read the ids. `Category::add_item` returns the id on every call, not only on the idempotent path. The claim that matters, that no production caller uses the value, holds: `parse.rs:389` discards it.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T00:21:24Z","created_by":"agent-review-210","updated_at":"2026-09-25T00:21:24Z","labels":["adversarial-review","pr-210"],"dependencies":[{"issue_id":"improvise-hexb","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T01:04:53Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-hexb","depends_on_id":"improvise-sboz","type":"discovered-from","created_at":"2026-09-25T00:21:24Z","created_by":"agent-review-210","metadata":"{}"}],"comments":[{"id":"7834165f-01be-5178-97e7-d15d6c21a9fc","issue_id":"improvise-hexb","author":"Edward Langley","text":"Second copy of item 1 (review of PR #302 at 95d4814b, Claude agent): prolog-sessions/kb.pl decision('improvise-sboz', a_formula_edit_maps_display_names_to_ids_when_the_editor_opens, ...) restates clause 12's required regression test verbatim, including 'commit unedited'. When this ticket amends the test (edit a non-name token, e.g. Revenue - Cost -\u003e Revenue - Cost * 1), that kb fact must be updated in the same change via scripts/kb replace, or the kb keeps the vacuous form after the ADR is fixed.","created_at":"2026-09-25T00:51:28Z"},{"id":"e17020bb-539a-562f-8101-9f0b977ec4fd","issue_id":"improvise-hexb","author":"agent-adr007","text":"agent-adr007 planning. Items 1, 3 and 4 go in the text fix improvise-cq1q.1. Item 2 is a USER QUESTION and blocks improvise-cq1q.9. Recommendation: the client holds the capture, as it holds OpenedCell, and resolves typed names against its own CategoryTreeCache, which clause 6 gives ids. The wire then carries only id-form text, and the host never resolves a display name. The TUI and the browser share the resolver in improvise-protocol.","created_at":"2026-09-25T04:51:34Z"}],"dependency_count":0,"dependent_count":1,"comment_count":2} +{"_type":"issue","id":"improvise-8vs2","title":"ADR 007 states stale facts about main: fdk1's RenameRecordCoordinate has landed, CellId::Coordinate exists, wpxr (#281) is merged","description":"Found by the adversarial review of PR #210 (ADR 007, head 4830fa30). The ADR's text about the current code is stale against its own base, gh/main ad217cc1:\n\n1. **improvise-fdk1 has landed** (PR #221, ticket closed 2026-09-24). `ModelCommand::RenameRecordCoordinate { record: RecordHash, category: String, item: String }` exists in `improvise-protocol/src/reduce.rs`. Yet the ADR says:\n - its header, \"Unblocks: improvise-fdk1\";\n - Context, \"A records-mode coordinate rename has no wire form (improvise-fdk1)\";\n - \"What this means for improvise-fdk1\", which proposes the operation as new (`to: ItemRef`).\n\n The section should instead revise the landed variant's operands to `CategoryId` / `ItemRef`, as it does for km71's `FormulaId`.\n2. **`CellId::Coordinate { record, category }` already exists** (render.rs). \"`CellId::Coordinate` grows `{ record, category: CategoryId }`\" should read \"its `category` becomes a `CategoryId`\".\n3. **improvise-wpxr (PR #281) has merged**, and the ADR still calls it \"open\": \"If improvise-wpxr (PR #281, open) lands first\". `SetCell { view, cell, value }` and `ClearCell { view, cell }` are on main, so they are simply on the list.\n4. Minor: \"No production code reads either value\" (`Category.id` / `Item.id`) is slightly overstated. `Model::add_category` and `Category::add_item` return them on the idempotent path. No caller uses the value, which is the claim that matters.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T22:49:31Z","created_by":"agent-review-210","updated_at":"2026-09-24T22:49:31Z","labels":["adversarial-review","pr-210"],"dependencies":[{"issue_id":"improvise-8vs2","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T01:04:53Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-8vs2","depends_on_id":"improvise-sboz","type":"discovered-from","created_at":"2026-09-24T22:49:31Z","created_by":"agent-review-210","metadata":"{}"}],"comments":[{"id":"5a61964c-18a7-5653-95b4-eb53e4685795","issue_id":"improvise-8vs2","author":"agent-adr007","text":"agent-adr007 planning: revision 8 fixed items 1 to 3. Item 4, the 'idempotent path' wording, remains and is folded into improvise-cq1q.1.","created_at":"2026-09-25T04:51:22Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} +{"_type":"issue","id":"improvise-p003","title":"ADR 007 namespace gaps: _Records/_Drill ids, _Index/_Dim items under global uniqueness, active_view, collapsed: by group name, CheckedBatch's create exemption","description":"Found by the adversarial review of PR #210 (ADR 007, head 4830fa30), against gh/main ad217cc1. These are places where the id namespace and the list of re-keyed state are incomplete:\n\n1. **Virtual views.** `view::VIRTUAL_VIEW_NAMES` is `[\"_Records\", \"_Drill\"]`. Answer 23 reserves `_` for virtual things, but clause 1 reserves only `_index`, `_dim` and `_measure`. Slugify's step 4 turns `_Drill` into `drill`, which collides with a user view named `Drill` or takes a gensym suffix. So a virtual view's id is neither reserved nor stable. They are never persisted, but they are addressed on the wire (`SwitchView`, and `_Drill` in improvise-obtu/t219).\n2. **`_Index` and `_Dim` items.** These are synthetic, such as record indices, and `_Dim` carries a column index (gotcha 1). Answer 22 makes ids unique across every kind. The ADR does not say whether these items take part. If they do, `_Index`'s `0` collides with a user item whose id is `0` (for example a display `0`).\n3. **`ViewState::active_view` and `Workbook::active_view`** are view names (`String`). Clause 6 lists cursors, expanded_cats and the view history as keyed by id, but not the active view itself.\n4. **`collapsed:` names a group.** `collapsed_line = \"collapsed: \" name \"/\" name` is category/group. Groups have no id (answer 9), but clause 2 says \"hidden and collapsed sets are keyed by ids\", and clause 5 lists `collapsed:` among references that carry the id. Only `[Coastal]` is exempted. So the collapsed set is (CategoryId, group name).\n5. **CheckedBatch's created-record exemption** (gotcha 43: a record that neither resolves nor is claimed, for `[AddItem(East), SetCell(East)]`). Clause 6 says a frame cannot address what it creates, so that exemption has no remaining producer. It should be removed or re-argued with the id vocabulary, not left as dead reasoning.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T22:49:31Z","created_by":"agent-review-210","updated_at":"2026-09-24T22:49:31Z","labels":["adversarial-review","pr-210"],"dependencies":[{"issue_id":"improvise-p003","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T01:04:55Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-p003","depends_on_id":"improvise-sboz","type":"discovered-from","created_at":"2026-09-24T22:49:31Z","created_by":"agent-review-210","metadata":"{}"}],"comments":[{"id":"26a29b15-8906-5014-a8cb-1725955f0b89","issue_id":"improvise-p003","author":"agent-adr007","text":"agent-adr007 planning: all five items are readings for improvise-cq1q.1. Item 2 (_Index/_Dim coordinates are positions outside the id namespace) is the ADR's reading and should be flagged to the user on that PR. Item 5 is implemented in improvise-cq1q.8.","created_at":"2026-09-25T04:51:36Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} +{"_type":"issue","id":"improvise-3d64","title":"renamed_away() says 'the row you were editing has changed' when only the edited cell's measure went (Opened::Gone on a value target)","description":"Codex on #274 (discussion_r4090469430), repeated on integration PR #299 at 8dd39fd5. Opened::Gone is also produced for a value target when the same record remains but the edited measure disappears or changes, yet resolve.rs renamed_away() / RECORD_RENAMED_AWAY says the ROW changed. Use target-neutral wording (the cell you were editing), and pin it with a test that reaches Gone through a measure change on a surviving record.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T19:35:31Z","created_by":"agent-train2","updated_at":"2026-09-24T19:35:31Z","labels":["browser","pr-274"],"dependencies":[{"issue_id":"improvise-3d64","depends_on_id":"improvise-6jk","type":"parent-child","created_at":"2026-09-25T01:04:40Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-xebx","title":"checkers.sh accepts a repeated --ci, and the last selector silently wins","description":"Codex P2 on #298, originally on #228 (https://github.com/fiddlerwoaroof/improvise/pull/228#discussion_r4090576827). In scripts/checkers.sh the argument loop overwrites ci_job on each --ci. So --ci js --ci build exits 0 having run only the build row. The later check rejects extra NAMES, but not a second selector. CI calls it with exactly one --ci per job today, so nothing is misrouted now. It is a malformed invocation that looks like success. Fix: return 4 on a second --ci. Add the case to scripts/checkers-test.sh and inject the fault to watch it fail.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T19:05:36Z","created_by":"agent-review-298","updated_at":"2026-09-24T19:05:36Z","labels":["adversarial-review","pr-228","pr-298","tooling"],"dependencies":[{"issue_id":"improvise-xebx","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T01:05:50Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-7298","title":"settle_relay queues every relay behind the head frame, but a relay the host applied BEFORE that frame then replays in the wrong order","description":"Codex P2 on #298, originally on #266 (https://github.com/fiddlerwoaroof/improvise/pull/266#discussion_r4090546517). ClientState::settle_relay (crates/improvise-protocol/src/client.rs) inserts a relay directly behind in_flight's head (improvise-jpba). That is right when the host processed the relay after the head frame. But a relay that another client caused can reach the host BEFORE the head frame and still arrive at this client before the head's answer. Then the host's mirror has applied relay, then head, while this client replays head, then relay. If the head is accepted and its view commands do not commute with the relay (for example SetSelected in the head and ResetCursor or SwitchView as the relay), the client and its mirror diverge. The divergence is in view state only; document data is unaffected. It is often masked because the change that emits such a relay also moves the basis, so the head is refused, and on a refusal the ordering is right. The client cannot tell the two orders apart from the stream it receives, so the fix probably needs a marker from the host, such as the count of this session's frames it had processed when it sent the relay. Codex's wording (a later local view command) does not match the code: a later unawaited frame already stays behind the relay. The real case is ordering relative to the head frame itself. Regression: a relay the host applied before an accepted head that carries a non-commuting view command.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T19:05:34Z","created_by":"agent-review-298","updated_at":"2026-09-24T19:05:34Z","labels":["adversarial-review","browser","pr-266","pr-298"],"dependencies":[{"issue_id":"improvise-7298","depends_on_id":"improvise-6jk","type":"parent-child","created_at":"2026-09-25T01:04:41Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-u7xp","title":"Wire RenameRecordCoordinate accepts a virtual category: _Measure rewrites the measure to a non-item, _Dim stores a virtual coordinate","description":"Copilot on PR #221 (inline at crates/improvise-command/src/ui/reduce.rs:160), confirmed by the adversarial reviewer at 37ff61f9. The code is the same from #221 up through #278.\n\n**Cause.** The host's wire `RenameRecordCoordinate` checks only that the source record resolves. `category` comes from the client and is passed on as `RecordCol::Category` without being validated. `RecordRewrite::of` then rewrites whatever coordinate it names.\n\n**Probe** (real Host, `three_records_host`, a frame built by `caught_up`):\n- `category: \"_Measure\", item: \"Bogus\"`: **Accepted**. North/Revenue becomes `{Region=North, _Measure=Bogus} = 1`, a measure coordinate that is not an item.\n- `category: \"_Dim\", item: \"Bogus\"`: **Accepted**. It stores `{Region=North, _Dim=Bogus, _Measure=Revenue} = 1`. `_Dim` is virtual and must never be stored (repo-map, `.improv` notes).\n- `category: \"Nope\"`: `Rejected(MutationRefused)`, and North is unchanged. So Copilot's \"unknown category is also unsafe\" does not hold at this head.\n\nOnly a hand-built frame reaches this. The shipped client takes `category` from a projected `CellId::Coordinate`, which names a regular category. It is the same class as improvise-nacd (wire well-formedness, not a security boundary: gotcha 43), so P3.\n\n**Fix direction:** refuse a rename whose category is not a regular category (`CategoryKind::Regular`) the record has as a coordinate.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T09:29:31Z","created_by":"agent-review-221-251","updated_at":"2026-09-24T09:29:31Z","labels":["adversarial-review","pr-221"],"dependencies":[{"issue_id":"improvise-u7xp","depends_on_id":"improvise-nacd","type":"related","created_at":"2026-09-24T09:29:36Z","created_by":"agent-review-221-251","metadata":"{}"},{"issue_id":"improvise-u7xp","depends_on_id":"improvise-pmt0","type":"parent-child","created_at":"2026-09-25T01:05:01Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-q98f","title":"settle_declined_editor closes a captured, untouched editor when a remote reframe puts the cursor on an aggregate (3h9w's blind spot, in the settle)","description":"Copilot on PR #274 (inline at client.rs:304), confirmed by the adversarial reviewer at #278's head 37ff61f9. The #274 × #295 merge (bce496b7) is where the two meet.\n\n**Cause.** #295's `settle_declined_editor` (5m9z) closes ANY untouched editor whose cursor cell `declined_at` refuses. It does not look at `state.opened`. So an editor that has captured a still-valid stored cell (#274, improvise-8xq0) is closed when a relayed reframe leaves the cursor on an aggregate or derived cell. That is the same blind spot 3h9w fixed in `refused_here`.\n\n**Probe** (real Host, `host()` plus `Profit = Revenue - Cost`):\n1. A presses `l i`. The editor is open on North/Cost, the capture is taken, and nothing is typed.\n2. B sets Region's axis to None. The relays are delivered, and the reset puts A's cursor on (0,0), an Aggregate.\n3. A's mode becomes **Normal**, the capture is dropped, and the status reads \"Cannot write to an aggregate…\", although A tried to write nothing.\n\nThe touched variant is fine: it stays open and Enter writes North/Cost = 42, which is 3h9w's test. The TUI keeps its editor open in the same situation, so the two ends disagree.\n\nNo wrong write is possible (the user typed nothing), so P3.\n\n**Fix direction:** skip the close when `self.opened.is_some()`, the way `refused_here`'s 3h9w arm does. Keep it for an uncaptured editor, which is the case 5m9z (an advance past the window) was written for.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T09:28:46Z","created_by":"agent-review-221-251","updated_at":"2026-09-24T09:28:46Z","labels":["adversarial-review","pr-274"],"dependencies":[{"issue_id":"improvise-q98f","depends_on_id":"improvise-3h9w","type":"related","created_at":"2026-09-24T09:28:52Z","created_by":"agent-review-221-251","metadata":"{}"},{"issue_id":"improvise-q98f","depends_on_id":"improvise-6jk","type":"parent-child","created_at":"2026-09-25T01:04:46Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-jni7","title":"Release capture still pins wasm-pack/node via web-toolchain-versions.env; since #297 nothing links those pins to the .#ci shell CI tests with","description":"## Summary\nFound reviewing PR #297 at head c29e32f1. This is follow-up hygiene, not a blocker.\n\n#297 removed the ci.yml step \"The dev shell supplies the pinned web toolchain\". That step held the dev shell's `wasm-pack` and node major equal to `scripts/web-toolchain-versions.env`. The user's ruling is that CI does not police versions, and CI now uses whatever `.#ci` provides.\n\nBut `scripts/build-web-release.sh` still sources `web-toolchain-versions.env`, and on Linux it installs **that** `wasm-pack` and node for the release artifact (release.yml). So:\n- the release's `improvise-web.tar.gz` can now be built with a different `wasm-pack`/node than any CI run exercised;\n- nothing links the pins file to `flake.nix` any more.\n\nThe file's own header says \"two copies of a pin is one of them going stale\". It is now exactly that.\n\n## Options\n- Point the release capture at the nix toolchain too (`nix build .#web`, as CI's `build` job does), and delete `web-toolchain-versions.env`.\n- Or state in `context/code/tooling.md` that release versions are deliberately independent of CI's.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-24T08:55:46Z","created_by":"agent-review-tooling-delta","updated_at":"2026-09-24T08:55:46Z","labels":["adversarial-review","pr-297","release"],"dependencies":[{"issue_id":"improvise-jni7","depends_on_id":"improvise-0s6","type":"parent-child","created_at":"2026-09-25T01:04:33Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-0acd","title":"AxisFrame::body_from adds a wire-supplied first without checking: first = usize::MAX panics in debug, wraps in release","description":"Found reviewing PR #250 at cfa8bf1e. body_from computes self.first + idx with first taken straight off the wire. A malformed or hostile frame with first near usize::MAX overflows. Pre-existing. Fix alongside #250's try_from validation: refuse a frame whose first + entries overflows, or use checked arithmetic.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T06:54:10Z","created_by":"orchestrator","updated_at":"2026-09-24T06:54:10Z","dependencies":[{"issue_id":"improvise-0acd","depends_on_id":"improvise-tr08","type":"parent-child","created_at":"2026-09-25T01:04:50Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-rv02","title":"checkers.sh check_shebangs passes vacuously when head is absent","description":"Found while fixing CI (PR #296). check_shebangs runs first=$(head -1 \"$f\") inside 'check_shebangs || rc=1' where set -e is off; with head missing every first line is empty and the shebang check passes checking nothing. A utilbin rival without head fails no checkers-test case. Fix: fail loudly if head (or the read) fails; add a case.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T06:15:22Z","created_by":"orchestrator","updated_at":"2026-09-24T06:15:22Z","dependencies":[{"issue_id":"improvise-rv02","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T01:05:49Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-txh4","title":"Type-ahead across a refused commit: text typed into the reopened editor replays into the refused cell's editor, and the retry writes it there","description":"Found reviewing PR #274 at head 8d9de94e. Not a regression: at gh/main the retry writes the cursor cell, which is the same cell here.\n\n**Steps (real Host, the `host()` fixture):**\n1. A presses `l i 4 2`, so the editor is open on North/Cost with `42` typed.\n2. B writes North/Revenue. B's relays are held.\n3. A presses Enter. The commit is sent, the advance reopens the editor on South/Cost (1,1), and the buffer is empty.\n4. **Before the answer arrives,** A types `7` into the South/Cost editor. That sends the unawaited frame `[SetBuffer edit \"7\", SetOpenedEditor touched]`, which queues behind the awaited commit.\n5. The commit is refused with `RecordMoved(north)`.\n6. `refused()` drops the commit frame and replays the settled slice plus the rest of `in_flight`. The editor is back on North/Cost (0,1), but the buffer is **\"7\"**: the SetBuffer composed for the South/Cost editor lands in the restored North/Cost editor.\n7. `opened` is `Retry(north, Cost)`. The recapture matches, so it becomes `Target`.\n8. The retry Enter is `Accepted`.\n\n**Result:** North/Cost = 7. The user's 42 is gone, and 7 (typed for South/Cost) is stored in North/Cost.\n\nThe `Retry`/`Gone` rule (9ww8, 8phw) checks that the **cell** is the refused frame's cell. It does not check that the **text** belongs to that editor. The replay of type-ahead is the #221-era `refused()` design (settled + remaining in_flight).\n\n**Options, none tried:**\n- (a) On a refused commit, drop the in-flight editor frames composed after it, since they belong to an editor the refusal unmade.\n- (b) Restore the refused frame's own typed text as the buffer.\n- (c) Decline the retry when the buffer was touched by a later editor.\n\nNeeds a keypress inside one host round trip plus a crossing write, so it is P3. Related to improvise-n4i9 (type-ahead into an awaiting editor) and improvise-8phw.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T01:39:48Z","created_by":"agent-review-221-251","updated_at":"2026-09-24T01:39:48Z","labels":["adversarial-review","pr-274"],"dependencies":[{"issue_id":"improvise-txh4","depends_on_id":"improvise-6jk","type":"parent-child","created_at":"2026-09-25T01:04:46Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-txh4","depends_on_id":"improvise-8phw","type":"related","created_at":"2026-09-24T01:39:55Z","created_by":"agent-review-221-251","metadata":"{}"},{"issue_id":"improvise-txh4","depends_on_id":"improvise-n4i9","type":"related","created_at":"2026-09-24T01:39:55Z","created_by":"agent-review-221-251","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-cz3o","title":"EnterEditAtCursor's status-not-refuse rule is unguarded, and a scripted commit whose advance lands on a declined cell prints a refusal to stderr (#277)","description":"Found in adversarial review of PR #277 (improvise-enz6), head aab62a36.\n\n1. **The status-not-refuse rule is unguarded.** `context/code/command.md` (#277) says `EnterEditAtCursor` writes a status line and not `App::refuse`, because the commit before it succeeded: \"A `commit-cell-edit` script line whose advance lands on a fold must not report its write as refused.\" Rival: `Some(refusal) =\u003e app.refuse(refusal)` in `EnterEditAtCursor::apply`. The whole workspace stays green (`cargo test --workspace --no-fail-fast`). Measured through the CLI, the rival breaks what the rule protects. With `_Measure` on rows [Revenue, Profit=formula] and Month on columns, `improvise cmd 'commit-cell-edit editing 7 Month/Jan _Measure/Revenue' -f t.improv` stores 7 and exits **1** under the rival. It exits 0 under the PR.\n\n2. **A successful scripted commit now prints a refusal-shaped line.** On the same fixture under the PR, the command exits 0 and stores 7, but stderr reads \"Profit is computed by a formula — edit the formula, not the cell\". At 202e7eac (gh/main) the same command prints nothing. The user asked to write Revenue, and the message is about a cell they did not touch. It comes from the re-entry the headless run does not need.\n\n3. **A false comment.** `resolve::AGGREGATE_DECLINE` is documented as \"What the host says of an aggregate, the fold of a collapsed group included\". The host says `FOLD_REFUSAL` (\"Collapsed group — press z to expand it first\") of a fold, and `context/code/clients.md` (#277) says so itself.\n\nAC: a test fails under the rival in (1). It can assert `!last_batch_refused()` after a commit whose advance lands on a declined cell, or the CLI exit code. Decide whether (2) is acceptable and say so in `context/code/cli.md`, or suppress the re-entry's status for a headless run. Correct the comment in (3).\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T01:29:27Z","created_by":"agent-review-277","updated_at":"2026-09-24T01:29:27Z","labels":["adversarial-review","area:cli","pr-277"],"dependencies":[{"issue_id":"improvise-cz3o","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T01:05:15Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-ik6l","title":"Browser Enter on a touched editor over a declined cell is a silent Upstream: no write, no message, editor stays open (#282's kept editor)","description":"Found in adversarial review of PR #282 (improvise-5m9z), head 942f57e5. It answers the orchestrator's question \"a touched editor survives; what happens at commit?\"\n\n#282 keeps a TOUCHED editor open on a cell the host later projects as declined (a fold, an aggregate or a formula's result). The case is an advance past the window followed by typing before the reply arrives. Enter on that editor goes through `resolve::commit`. Its `id` is Aggregate or Derived, so `written` -\u003e `record_at_cursor` -\u003e `None`, and `commit` returns `None`. The key outcome is `KeyOutcome::Upstream`. `Host::handle` answers a Key frame with nothing (gotcha 47), so Enter does nothing: no write, no message, and the editor stays open with the user's text.\n\nRepro (reviewer's scratch test on 942f57e5, not pushed): `fold_host`, window (2, 6), cursor at (1,1). `i`, `Enter`, `7` are folded and sent before any reply is delivered, and then the replies are delivered. Then `Enter`: `resolve` returns Upstream. The mode is still Editing, the status is \"\", the buffer is \"7\", and the host's cell count is unchanged.\n\nThe TUI never reaches this state, because it declines the open. Its nearest equivalent is an editor already open on a fold (`an_editor_already_open_on_a_fold_commits_nothing_there`), where Enter says \"Collapsed group — press z to expand it first\". The browser should decline out loud the same way, with `declined_at`'s text. This is the same family as improvise-l3hm and improvise-kkcy (silent Upstream where the TUI declines).\n\nThe #282 docs say \"no write can follow on that cell\". That is true, but it does not say that Enter goes dead.\n\nAC: Enter on a touched editor over a declined cell composes a SetStatus decline, not Upstream. A test drives this state, and a rival that returns `None` fails it.\n","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T01:29:13Z","created_by":"agent-review-277","updated_at":"2026-09-24T21:46:02Z","closed_at":"2026-09-24T21:46:02Z","close_reason":"Landed in main via #282 (reviewed; merged 2026-09-24).","labels":["adversarial-review","browser","pr-282"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-kimw","title":"An empty-record write naming a non-host view re-derives that view's layout (and, with formulas, clones + recomputes the model) ~5x per command","description":"Found by the adversarial review of PR #281 (improvise-wpxr) at head ba3d2875.\n\nA wire SetCell/ClearCell of an EMPTY record, naming a view the host is not on, derives that view's layout once per resolution call, not once per command. The calls are: CheckedBatch's record tier (will_be_created_by_the_frame, stale_record), broken_chain, and model_effects (unwritable -\u003e resolve_cell and aggregate_refusal, then resolve_cell again). That is five or six derivations per write, before grid_in derives the same layout once more to project it.\n\nWithout formulas each derivation is only GridLayout::with_frozen_records, which is cheap (about 2.4ms measured). But App::with_layout_of goes through project::rederived_for. When the two views' none_cats differ and the document has any formula, that CLONES THE MODEL AND RUNS recompute_formulas on every call.\n\nMEASURED (release build, Apple Silicon laptop under load, so noisy; a scratch test, discarded). Document: Region(150) x Product(120) x _Measure{Revenue, Cost}, about 18k stored Revenue cells, plus one formula (Profit = Revenue - Cost). The host sits on \"Other\", which puts Product on None. The client names \"Default\".\n- ClearCell of an empty record: best of 5 = 1.82s.\n- SetCell of a stored record, same host: 0.65s.\n- One resolve_record of the empty record alone: 227ms.\n- So about 1.1s per write is repeated derivation.\n- For contrast, with no formula the empty-record write costs the same as the host-on-Default case (about 0.24-0.34s, dominated by the pre-existing projection cost, which is improvise-swre).\n\nWho pays: the browser worker deployment most of all. There, the App's view_state is the host's own, so every client on a non-default view is \"not on the host's view\". Holding clear-cell or paste over a column of empty cells pays about 1s+ per key on a mid-size document with a formula.\n\nFix shape: derive once per command, or once per frame. For example, resolve the named view's layout once in model_effects and in the CheckedBatch record tier and pass it down. Or memoise the derived (model, layout) keyed on (view name, layout_version, document_generation), together with improvise-swre. Correctness is not in question: #281's resolution is right. This is cost only.\n","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T01:23:47Z","created_by":"agent-review-281","updated_at":"2026-09-24T21:45:56Z","closed_at":"2026-09-24T21:45:56Z","close_reason":"Landed in main via #281 (reviewed; merged 2026-09-24).","labels":["adversarial-review","performance","pr-281"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-s2gb","title":"every_silent_binding_is_on_the_list walks only Normal and search: a silent binding in a panel mode passes the whole suite","description":"Found reviewing PR #280 (improvise-oyaa) at 88e53aed. The new guard walks only ModeKey::Normal and SearchMode, but the browser also stands in the formula, category and view panels, TileSelect, Help, Editing and RecordsNormal. Rival: I added .bind(KeyCode::Char('Z'), \"commit-export\") to the FormulaPanel keymap. The client has no arm for commit-export, so Z goes Upstream, a black hole. cargo test --workspace stayed at 0 failed. The #264 help-row guard cannot see it because no help row names it, and the #280 guard cannot see it because it never walks FormulaPanel. A throwaway probe ran the PR's own every_binding and press over every AppMode's keymap, with the search query both empty and seeded. At head it finds no silent, unlisted binding in any mode the browser can enter. The only hits are modes whose door the browser refuses (COMMAND, EXPORT, SAVE AS, IMPORT) and RECORDS INSERT, whose entry edit-or-drill declines. So widening the guard to every mode except those costs nothing today and closes the gap. The exclusions should be stated as the modes declined() refuses at the door, not as a hand list.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T01:21:55Z","created_by":"agent-review-264","updated_at":"2026-09-24T21:46:01Z","closed_at":"2026-09-24T21:46:01Z","close_reason":"Landed in main via #280 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-i5z6","title":"improvise-formula lib test binary measured over the 2s tripwire under load (2.2-5.5s)","description":"Reported by two agents on 2026-09-23 (M4 Max, heavy parallel load): improvise-formula's lib test binary took 2.21s, 3.84s and 5.47s; earlier 1.77s. May be load, but design-principles §7 says compare to the run's median binary. Measure unloaded and as a ratio to the median; if it is an outlier, find the cost (likely proptest/grammar work, cf. improvise-8shu's OnceLock fix for improvise-io).","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-24T01:18:38Z","created_by":"orchestrator","updated_at":"2026-09-24T21:45:53Z","closed_at":"2026-09-24T21:45:53Z","close_reason":"Landed in main via #284 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-3j70","title":"The GIT_CONFIG_GLOBAL/NOSYSTEM nulls in run_git have no witness: vwjj's -c core.excludesFile=/dev/null masks the excludes-based one","description":"Found while doing improvise-eahe, and true at #276's head before eahe as well.\n\n`run_git` sets `GIT_CONFIG_GLOBAL=/dev/null` and `GIT_CONFIG_NOSYSTEM=1` (improvise-53zz). The witness for both halves is a global or system config whose `core.excludesFile` refuses `*.gz`. Since improvise-vwjj, `run_git` also passes `-c core.excludesFile=/dev/null`, and a `-c` argument outranks every config file. So the witness can no longer see either half.\n\nMeasured by injecting each rival into `tests/shipped_documents_load.rs` and running the binary. Both rivals are **green**, both on the per-setting tests at ddcdf93a and on eahe's combined test:\n- dropping `GIT_CONFIG_GLOBAL=/dev/null`;\n- dropping `GIT_CONFIG_NOSYSTEM=1`.\n\nThe two lines are still worth keeping, because they null every *other* config key a user might set. But no test pins them. The fix is a witness that uses a config key which no `-c` argument overrides and which breaks `git init` or `git add`. For example, `core.sharedRepository = nonsense` might fail `git init`, but that is untested; check it on git 2.55 first. Put it in the same hostile child as eahe's other settings, and show both rivals red.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-24T01:18:29Z","created_by":"agent-docs-wave","updated_at":"2026-09-24T21:45:10Z","started_at":"2026-09-24T01:24:58Z","closed_at":"2026-09-24T21:45:10Z","close_reason":"Landed in main via #285 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-5m9z","title":"Browser advance past the announced window opens the editor on an unprojected cell","description":"Found on PR #277 (improvise-enz6). resolve::reopened_at asks the landing cell's projected CellId to decline folds/formulas; an advance past the client's announced viewport lands on a cell with no projected id yet, so the browser opens the editor there regardless. No write can follow (the commit needs a projected record), but the editor state diverges from the TUI, which declines. Fix: treat an unprojected landing as await-the-host (like opened_on's awaiting seed) rather than open.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T01:03:25Z","created_by":"orchestrator","updated_at":"2026-09-24T21:45:54Z","closed_at":"2026-09-24T21:45:54Z","close_reason":"Landed in main via #282/#295 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-oyaa","title":"NEEDS_THE_HOST completeness is checked only where a help row names the key","description":"Found on PR #275 (improvise-fo0w). resolve::NEEDS_THE_HOST's two guards (#264) prove no false entries and that no help row is an unmarked dead key, but not that every dead binding is listed: removing force-quit from the list fails no test, because no help row names Ctrl-Q. Fix: derive completeness from the Normal and search-mode keymaps (every binding either resolves locally, declines out loud, or is listed), not only from help rows.","status":"closed","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-24T01:02:24Z","created_by":"orchestrator","updated_at":"2026-09-24T21:45:18Z","started_at":"2026-09-24T01:02:41Z","closed_at":"2026-09-24T21:45:18Z","close_reason":"Landed in main via #280 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-0629","title":"a_help_row_the_browser_cannot_run_is_drawn_marked does not see an ex row drawn plain","description":"Found reviewing PR #264 (improvise-uln5) at cf5c6fc7. The render test asserts the search row's kind is dim, but for the ex-command (Literal) rows it asserts only that the description contains 'not in the browser'. Rival: in RenderHelpRow's Literal arm, override kind back to 'dim' only when the macro's dim flag is set (ignoring runs_the_command_line). The non-dim ex rows (:w, :q, :add-cat ...) then render as help-keys with a marked description, and all 21 render tests pass. The production code is correct today (verified in Chromium: every ex row is help-dim); this is a missing assertion. Fix: assert row.kind == dim for every ex row too.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T00:40:35Z","created_by":"agent-review-264","updated_at":"2026-09-24T21:45:59Z","closed_at":"2026-09-24T21:45:59Z","close_reason":"Landed in main via #275 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-joed","title":"A commit declined as 'still waiting' loses the typed text: Enter's welded clear-buffer still runs","description":"Found by agent-8xq0 (PR stacked on #221). In the browser, a keyboard commit whose target is not captured yet (the host has not answered this client's own last write) returns the status 'still waiting for the host to answer your last edit' and writes nothing. Since improvise-8xq0 this applies to value cells as well as coordinate renames. But Enter in Editing/RecordsEditing is the Sequence [commit-cell-edit, clear-buffer edit], and the sequence resolver keeps the clear-buffer step, so the editor stays open with an EMPTY buffer: the text the user typed is lost. Pinned as observed in resolve::tests::a_value_commit_before_the_host_answers_the_last_one_waits_out_loud (which asserts only the status and no write). improvise-fy9o (PR #227) moves the clear to the head of the commit command; whether the waiting decline then keeps the text depends on where that clear lands relative to the decline, so check after both merge. Reachable only by pressing Enter twice inside one host round trip.","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-8xq0","owner":"el-github@elangley.org","created_at":"2026-09-24T00:36:48Z","created_by":"agent-8xq0","updated_at":"2026-09-24T21:45:13Z","started_at":"2026-09-24T00:51:06Z","closed_at":"2026-09-24T21:45:13Z","close_reason":"Landed in main via #278 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-3til","title":"App::restore's rebuild-after-view_state order is unguarded: rebuilding before view_state is restored passes the suite","description":"Found by the adversarial review of PR #261 (improvise-8wy9) at head 86b3d72c.\n\nApp::restore rebuilds the layout LAST, after view_state is restored. rebuild_layout reads view_name(), which comes from view_state.active_view, so the order matters. Nothing guards it.\n\nRIVAL THAT PASSES: move `self.rebuild_layout();` up to just after `self.model_state.workbook = snapshot.workbook;`, so it runs before `self.view_state = snapshot.view_state`. cargo test -p improvise-command --no-fail-fast stays green (648 passed), and so does #261's new test. Its frame never moves the host's view, so both orders give the same layout.\n\nWHY IT MATTERS: a client's DeleteView of the view the host is on moves the host's view_state.active_view (effect::DeleteView, `app.view_state.active_view = app.view_name()`). If that frame is then refused and rolled back, the rival builds the layout for the default view, then puts the host back on the deleted-and-restored view. The host's layout then describes a different view from the one it is on, and the next frame resolves its surrogates against that wrong grid.\n\nWITNESS (a scratch test, run and then discarded): host() plus a view \"Other\" (Region on Column, _Measure on Row, so 1 row x 2 cols), with the host switched to Other and the layout rebuilt. A client sends the frame [DeleteView(Other), RemoveItem(South), SetCell(South)]. It is refused ChainBroken. The test asserts view_name()==\"Other\" and layout.row_count()==1 and col_count()==2. It passes at 86b3d72c. Under the rival it fails at row_count, 2 against 1.\n\nCHEAP FIX: add that test next to a_frame_after_a_rolled_back_reshape_resolves_against_the_restored_grid in ui/host.rs. Better still, follow it with a write that resolves only through Other's layout.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-hzjo2","owner":"el-github@elangley.org","created_at":"2026-09-24T00:21:33Z","created_by":"agent-review-261","updated_at":"2026-09-24T21:44:53Z","started_at":"2026-09-24T00:22:36Z","closed_at":"2026-09-24T21:44:53Z","close_reason":"Landed in main via #267 (reviewed; merged 2026-09-24).","labels":["adr-001","adversarial-review","pr-261","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-qnry","title":"Tab-commit's rightmost-column clamp in the browser resolver is unasserted: dropping .min(col_max) passes the whole workspace","description":"Found in the adversarial review of PR #256 (improvise-rq3f) at head eb146bcc. This predates #256, which is test-only.\n\nIn crates/improvise-protocol/src/resolve.rs, `advanced` has the arm `Advance::Right =\u003e Some((row, (col + 1).min(col_max)))`. Dropping the clamp gives `Some((row, col + 1))` (rival `right_unclamped`). That passes the whole workspace: cargo test --workspace --no-fail-fast, 1942 passed, 0 failed.\n\n#256's new test `tab_in_an_editor_commits_and_lands_one_column_right` sits on a centred cursor on purpose, so no edge clamps the move. That makes it blind to the clamp. The host's `CommitAndAdvance` (`AdvanceDir::Right`, commit.rs:1020) clamps with `(col + 1).min(col_max)`. Without the clamp the browser, pressing Tab at the rightmost column of a non-records grid, would send `SetSelected { col: cols }` past the grid and open the editor on no cell.\n\nFix: add a lockstep case, not a unit test. Put the cursor at the rightmost column of a non-records grid at a nonzero row, press i then Tab, and compare the selection and mode with the host. Relation: improvise-3tt4's acceptance test (lockstep Tab at the rightmost column, comparing buffers) may cover this incidentally. Check it against `right_unclamped` before closing either.\n","status":"closed","priority":3,"issue_type":"task","assignee":"agent-qnry-ybum","owner":"el-github@elangley.org","created_at":"2026-09-24T00:19:59Z","created_by":"agent-review-256","updated_at":"2026-09-24T21:45:19Z","started_at":"2026-09-24T00:21:18Z","closed_at":"2026-09-24T21:45:19Z","close_reason":"Landed in main via #271 (reviewed; merged 2026-09-24).","labels":["adversarial-review","pr-256"],"dependencies":[{"issue_id":"improvise-qnry","depends_on_id":"improvise-3tt4","type":"related","created_at":"2026-09-24T00:20:09Z","created_by":"agent-review-256","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-98pd","title":"Three first-party sources still write non-item coordinates: set-cell, undeclared Data item, stale page selection","description":"Follow-up to improvise-gk51 (option (b) from that bead). gk51 made the projection and the refusal ask one predicate, `Model::first_non_item`, so a cell whose coordinate names no item now projects as `CellId::NonItem` and is offered to no one. That repairs the disagreement. It does not stop such coordinates from entering the document. Three first-party sources still create them:\n\n- **The `set-cell` script command.** `effect_cmds.rs` `SetCellCmd` writes the key as given and adds no item. For example, `set-cell 5 Region/Mars _Measure/Revenue`.\n- **A `.improv` `## Data` line naming an undeclared item.** `persistence/parse.rs` sets the cell and adds no item.\n- **A page selection naming no item.** `View::set_page_selection` does not validate. Every pivot cell's key then carries the stale item, and since gk51 the whole grid projects as `NonItem`. Found while testing gk51; the ticket did not predict it.\n\nThe fourth source, drill commits after an item is removed, is improvise-5mqm.\n\nDecide per source whether to refuse, to add the item, or to accept and let the projection decline, and write the decision down.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T00:17:27Z","created_by":"agent-viewhist","updated_at":"2026-09-24T00:17:27Z","dependencies":[{"issue_id":"improvise-98pd","depends_on_id":"improvise-felo","type":"parent-child","created_at":"2026-09-25T01:05:20Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-a61f","title":"improvise-wasm-client lib tests compile only under --workspace: test-support feature not requested in its dev-deps","description":"Found in the adversarial review of PR #259 at c349d14d. The same code is on gh/main 202e7eac, so the gap predates both PRs.\n\n`cargo test -p improvise-wasm-client --lib` does not compile:\n\nerror[E0277]: the trait bound `FormulaEntry: From\u003c\u0026str\u003e` is not satisfied\n --\u003e crates/improvise-wasm-client/src/render.rs:793\n\n`impl From\u003c\u0026str\u003e for FormulaEntry` is `#[cfg(any(test, feature = \"test-support\"))]` in improvise-protocol/src/render.rs:757. The only crate that enables `test-support` is improvise-command, in its own [dev-dependencies]. improvise-wasm-client's dev-dependencies do not request it. `cargo test --workspace` compiles only because feature unification pulls the feature in from improvise-command's test build, which is the same shape repo-map's Wasm section warns about for the wasm job.\n\nCI runs only --workspace, so it cannot see this. It bites anyone who runs one crate's tests, and it would bite a per-crate CI split.\n\nFix candidate: add `improvise-protocol = { path = \"../improvise-protocol\", features = [\"test-support\"] }` to improvise-wasm-client's [dev-dependencies]. Check: `cargo test -p improvise-wasm-client --lib` compiles standalone. It is also worth a sweep: run `cargo test -p \u003ccrate\u003e --no-run` for every workspace member.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-24T00:14:26Z","created_by":"agent-review-259-260","updated_at":"2026-09-24T21:45:05Z","started_at":"2026-09-24T00:20:20Z","closed_at":"2026-09-24T21:45:05Z","close_reason":"Landed in main via #270 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-a0i3","title":"shipped_documents_load: GIT_LITERAL_PATHSPECS or GIT_GLOB_PATHSPECS in the caller's env fail 9 of 10 tests","description":"Found in the adversarial review of PR #260 at 322de370 (git 2.55.0). tests/shipped_documents_load.rs::git() removes only the names that git rev-parse --local-env-vars lists, and the pathspec-magic variables are not in that list. shipped_documents() runs ls-files -- '*.improv' '*.improv.gz'.\n\nMeasured:\n- GIT_LITERAL_PATHSPECS=1 cargo test --test shipped_documents_load: 1 passed, 9 failed. every_shipped_document_loads fails its \"at least one shipped document\" precondition (line 33), because the globs are read literally.\n- GIT_GLOB_PATHSPECS=1: the same 1 passed, 9 failed, because '*' does not cross '/' under glob magic.\n\nThe failures are loud, not vacuous passes. This is the same spurious-red class as improvise-53zz, improvise-vwjj and improvise-pr3i, reached through pathspec parsing rather than through excludes.\n\nNot measured: GIT_NOGLOB_PATHSPECS, GIT_ICASE_PATHSPECS. GIT_ATTR_SOURCE=HEAD also fails loudly, with 8 of 10 failing on \"bad --attr-source\", as the pr3i ticket already notes.\n\nFix candidate: env_remove GIT_LITERAL_PATHSPECS, GIT_GLOB_PATHSPECS, GIT_NOGLOB_PATHSPECS, GIT_ICASE_PATHSPECS (and GIT_ATTR_SOURCE) in git(). Test: rerun_with_env with each one set, for a scratch-repo shape test AND for every_shipped_document_loads.\n","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T00:14:25Z","created_by":"agent-review-259-260","updated_at":"2026-09-24T21:45:56Z","closed_at":"2026-09-24T21:45:56Z","close_reason":"Landed in main via #269/#289 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-opbo","title":"GridLayout::evaluate_in/column_total accept a FoldMemo built by another layout and silently use its aggregation and hidden set","description":"Found reviewing PR #258 (improvise-pntu/0jw5, head 6b3577a0). ChatGPT Codex raised the same P2 on #258. After pntu, FoldMemo owns agg+covers, and GridLayout::evaluate_in(model, key, memo) / column_total(model, col, memo) accept ANY \u0026FoldMemo and ignore self's none_cats and hidden set. So layout_b.evaluate_in(.., \u0026layout_a.fold_memo()) evaluates under layout A's aggregation and hidden predicate.\n\nDemonstrated (scratch test, reverted): margin_book([\"R = Revenue / Cost\"]) with Q1 collapsed. View A shows all months; view B is A with Feb hidden. Key {Month=Q1,_Measure=R}. b.evaluate(key) = 2.0 (10/5); b.evaluate_in(key, \u0026a.fold_memo()) = 5.0 (40/8).\n\nIt is wrong on the memo's FIRST use, so here #258 is wider than #247, where a fresh foreign memo was harmless. Not reachable from production today: the only caller is viewport::grid_body, which builds layout.fold_memo() for the same layout. context/code/core.md section 14 names it as open (\"a memo built for one layout and passed to another's evaluate_in\"), but no ticket existed.\n\nFix shape: make the pairing unrepresentable. For example, layout.fold_memo() returns a per-build evaluator borrowing \u0026self that exposes evaluate(key)/column_total(col), and the memo-taking GridLayout methods go away. Test: the two-layout repro above, asserting b's reading through the shared evaluator equals b.evaluate.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-mergetrain","owner":"el-github@elangley.org","created_at":"2026-09-24T00:14:23Z","created_by":"agent-review-258","updated_at":"2026-09-24T21:45:15Z","started_at":"2026-09-24T00:30:38Z","closed_at":"2026-09-24T21:45:15Z","close_reason":"Landed in main via #268 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-n4i9","title":"Type-ahead into an awaiting cell editor replaces the value the TUI extends: fast Tab Tab 5 stores 5 where the TUI stores 5095 (#227)","description":"Found while verifying improvise-mdfc on #227 (head 3fcd407b), in Chromium over the worker, with tall.improv and R01/Cost = 50 in the rightmost column.\n\nKeys `i 9 Tab Tab 5 Enter`, pressed back to back by Playwright, store 5095 or 5, depending on whether `5` arrives before the worker's answer. In 5 runs: 5095 twice, 5 three times. The TUI always stores 5095. With a 200 ms pause after the second Tab, the editor shows `509` and the browser stores 5095 in 5 of 5 runs. So the seed itself is right after mdfc; this is the type-ahead half.\n\nCause: the reopened editor is `OpenedEditor::awaiting()`, with an empty buffer. A key typed before the answer marks it touched, and `settle_awaited_seed` then never seeds it. The key REPLACES the value the TUI would have extended. #227 accepted this as a cost (\"what the user sees is what gets written\", protocol.md §3). It is never another record's number, but it does diverge from the TUI whenever the answer is slower than the next key: a held key, or any `?ws=` socket.\n\nOptions, none tried:\n(a) Record the characters typed while awaiting, and on settle write seed + typed. Backspace while awaiting needs a rule.\n(b) Paint the cached value dimmed as a placeholder while awaiting, as #227's review suggested, so the user at least sees that the value will be replaced.\n(c) Hold keys typed while an awaiting editor is unsettled until the answer arrives.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T00:06:09Z","created_by":"agent-227-fixes","updated_at":"2026-09-24T00:06:09Z","dependencies":[{"issue_id":"improvise-n4i9","depends_on_id":"improvise-6jk","type":"parent-child","created_at":"2026-09-25T01:04:44Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-29w2","title":"pr-watch-test: the CLOSED case no longer exercises the wait; closed PR #46 reports green MERGEABLE/BLOCKED and a rival passes 52/52","description":"Found by the delta review of PR #253 at 8cdea3a4 (b13355c7..8cdea3a4), which answers improvise-24lz.\n\nAt 8cdea3a4 the CLOSED fixture was changed from UNKNOWN/UNKNOWN to CONFLICTING/DIRTY, the shape closed PR #74 returns live. That shape is real, but it has a side effect. `mergeable == CONFLICTING` breaks the loop on the first poll whether the state check exists or not. So the CLOSED \"read once, not waited on\" case now holds by the loop's structure: no rival can fail it without also failing the CLOSED exit-code case.\n\nA different live closed shape does need the fix. Closed PR #46 has every check green and reports MERGEABLE/BLOCKED. A green BLOCKED rollup is waited on, and no test has that shape.\n\nRival (R8) that passes pr-watch-test.sh 52/52: exit 5 at the top of the loop for MERGED only, and exit 5 for CLOSED only after the loop breaks. Live against #46 with --timeout 15:\n- base 202e7eac: exit 3, \"timed out ... MERGEABLE/BLOCKED\"\n- R8: exit 3\n- 8cdea3a4: exit 5\n\nSo the CLOSED half of improvise-1qaa can regress with the suite green. Before 8cdea3a4, the rival \"state checked only at timeout\" also failed the CLOSED poll-count case. It no longer does: that case now passes, and only the exit-code case catches the rival.\n\nThe doc claim is also wrong. tooling.md at 8cdea3a4 says: \"A closed one need not: #74 reads CONFLICTING/DIRTY, which already ended the wait\". #46 shows a closed PR can hit the green-BLOCKED wait.\n\n## Steps to Reproduce\nApply R8 to scripts/pr-watch. Run scripts/pr-watch-test.sh and get 52 passed. Then run `scripts/pr-watch 46 --timeout 15`: it exits 3.\n\n## Acceptance Criteria\n- Add a CLOSED fixture in the #46 shape: MERGEABLE/BLOCKED with a green rollup, asserting exit 5 and one poll. Keep the #74 shape as well.\n- Confirm that R8 and \"state checked only at timeout\" each fail the new case.\n- Correct the tooling.md sentence.\n","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T00:06:06Z","created_by":"agent-review-253","updated_at":"2026-09-24T21:45:57Z","closed_at":"2026-09-24T21:45:57Z","close_reason":"Landed in main via #253 (reviewed; merged 2026-09-24).","labels":["adversarial-review-pr-253"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-sx7h","title":"#257's su4u restore survives commit-and-advance: Editing/RecordsEditing re-enter an equal mode, so a stale message follows the editor onto the next cell","description":"Found in the adversarial review of PR #257 (improvise-su4u) at head 2b1c5bf7.\n\n#257's `draw.rs::press` puts a stale status message back when the mode before a key has a minibuffer and the mode after it is EQUAL. The PR body and `context/code/tui.md` say that leaving the minibuffer, \"by a successful commit or by Esc\", drops the message. For two of the eight text-entry modes that is false. `Editing` and `RecordsEditing` commit with `commit-cell-edit` and `commit-and-advance-right` (`CommitAndAdvance`). That command drops to Normal, commits, advances, and then `EnterEditAtCursor` re-enters the same `AppMode::editing()`/`records_editing()` value. So the mode after an Enter or Tab that committed and moved to ANOTHER cell equals the mode before it. The commit writes no message, so the stale one is restored beside a different cell's editor. It keeps being restored across every commit until Esc or some key writes a message.\n\nReproduced with a scratch test driving `press`, not committed. Workbook: Region{North}, measures Cost/Revenue, one cell. Keys: `R`, `o`, `5`, Tab, `7`. After `o` the status is \"Added new record row\", in RecordsEditing at (1,0). Tab commits \"5\" and moves to (1,1). \"Added new record row\" is still `status_msg` and is still drawn beside `edit: 7`. With a second `o` on the existing row, the refusal \"That record row is already here — ... fill this one in first\" survives the Tab that filled the coordinate in. At base 202e7eac (#233) both vanish on the first key.\n\nWhy the test misses it: `a_minibuffer_refusal_stands_while_the_user_fixes_it` uses only FormulaEdit. That is the one-category fixture shape. FormulaEdit's successful commit leaves the mode AND writes \"Formula added\", so the test cannot tell \"same mode\" from \"same editor session\".\n\nMode equality stands in for \"the same minibuffer session\", and a commit that re-enters the same mode breaks it. Candidate discriminators, for the author to judge: the cursor/`opened_editor` also unchanged, or a restore only when the key did not reach a commit. The second is the keymap copy the PR rightly avoided. The test for the fix must press Tab in (Records)Editing with a stale message and assert that it is gone.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-24T00:01:18Z","created_by":"agent-review-257","updated_at":"2026-09-24T21:45:21Z","started_at":"2026-09-24T00:10:48Z","closed_at":"2026-09-24T21:45:21Z","close_reason":"Landed in main via #257 (reviewed; merged 2026-09-24).","labels":["adversarial-review","area:tui","pr-257"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-gk51","title":"Wire writes to a records-mode row with a non-item coordinate are projected as Record and then refused (#252 regression)","description":"Found in adversarial review of PR #252 (improvise-pr8t/nacd), head 47b166ee. Introduced by that PR.\n\n#252 makes App::non_item_refusal refuse a wire SetCell/ClearCell unless every coordinate is in Model::effective_item_names. The projection was not changed. In records mode, project::cell_id projects every measure cell of every stored record as CellId::Record. That includes a record whose category coordinate names no item. So the host now offers a cell as editable and then refuses the write. ADR 002 says the kind of identity is the permission, and command.md says the refusal asks \"the projection's own question, never a second derivation of it\". This is a second derivation, and it disagrees.\n\nSuch records exist, and first-party paths make them:\n- the script/`:` command `set-cell 5 Region/Mars _Measure/Revenue` (effect_cmds.rs SetCellCmd) adds no item;\n- a `.improv` `## Data` line naming an undeclared item (parse.rs sets the cell, adds no item);\n- drill commits after an item is removed (improvise-5mqm).\n\nProbe (reviewer's, not pushed): records mode (_Index Row, _Dim Column), Region={East}, _Measure={Revenue}, stored {Region=East,Revenue}=1 and {Region=Mars,Revenue}=5.\n- At 47b166ee, cell_id gives Record for (1,2), and unwritable returns \"Mars is no item of Region\". A caught-up wire SetCell is answered Rejected(MutationRefused).\n- Under the b114 rule (base 202e7eac), the same write is Accepted.\nThe TUI key path still edits the same cell, so the two front ends now disagree.\n\nFix shape: make the projection and the refusal ask one predicate. Either records-mode cell_id projects such a row as not-a-Record (e.g. Coordinate/Aggregate), or both call a shared \"names only items\" helper. Alternatively, close the root: set-cell and parse add the item, and 5mqm stops writing orphans. Pin it with an enumeration: every CellId::Record the host projects, in every view and in records mode, has unwritable == None.\n","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T23:59:37Z","created_by":"agent-review-252","updated_at":"2026-09-24T21:45:52Z","closed_at":"2026-09-24T21:45:52Z","close_reason":"Landed in main via #265/#292 (reviewed; merged 2026-09-24).","labels":["adversarial-review","pr-252"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-4v9f","title":"A browser rename onto an existing destination outside the grid window is refused RecordUnstated on every retry and reopen (milr)","description":"Found reviewing PR #251 at head 29d2adef (improvise-milr's fix). The server now checks a rename's destination (`records_at_stake`), and the client claims `seen`, which is the Basis it held at open. The server publishes that Basis only for the records in the client's current grid window (`published_basis(app, \u0026self.grid)`), and `BasisCache::apply` replaces the whole cache each time. So a destination outside the window is never claimed, and the rename is refused `RecordUnstated`.\n\n`context/code/clients.md` accepts that refusal as the right direction to err. What it does not say: **no retry, and no reopen of the editor, can ever succeed** unless the source row and the destination row are in the window at the same moment. The cursor must be on the source when the editor opens, so for records that sort far apart (for example with 100 regions, renaming Aardvark onto Zulu) a browser can never merge the two. The TUI key path can. After this PR the only way out is resizing the window.\n\nProbe (real Host, `three_records_host`, a viewport of 1 row, rename North onto the existing West): `basis records known: 1`, then `Rejected(RecordUnstated(West))` three times in a row, with the text retyped between attempts. North is never renamed.\n\nThis needs a decision, not necessarily code:\n- (a) Accept it and document it in `clients.md`, next to kndq's policy. It may even be the right answer if kndq decides to refuse such merges anyway.\n- (b) Let the host publish the destination's counter on demand.\n- (c) Make `RecordUnstated` on a rename's destination forceable.\n\nThe ticket is linked to kndq because kndq's policy decides whether merging onto an existing record should be allowed at all.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T23:59:05Z","created_by":"agent-review-221-251","updated_at":"2026-09-23T23:59:05Z","labels":["adversarial-review","pr-251"],"dependencies":[{"issue_id":"improvise-4v9f","depends_on_id":"improvise-5krd","type":"parent-child","created_at":"2026-09-25T01:04:58Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-4v9f","depends_on_id":"improvise-kndq","type":"related","created_at":"2026-09-23T23:59:12Z","created_by":"agent-review-221-251","metadata":"{}"},{"issue_id":"improvise-4v9f","depends_on_id":"improvise-milr","type":"related","created_at":"2026-09-23T23:59:14Z","created_by":"agent-review-221-251","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-pr3i","title":"Scratch repos inherit info/exclude from GIT_TEMPLATE_DIR","description":"tests/shipped_documents_load.rs's scratch repositories still take a user-level `info/exclude` from `GIT_TEMPLATE_DIR`. `git init` copies the template directory into the new `.git`, and `git rev-parse --local-env-vars` does not list `GIT_TEMPLATE_DIR`, so `git()` does not remove it. `GIT_CONFIG_GLOBAL=/dev/null` nulls `init.templateDir` in config, but the env var bypasses config.\n\nReproduced at PR #255 head 56ac72a2 (git 2.55.0): with `GIT_TEMPLATE_DIR=\u003cdir\u003e`, where `\u003cdir\u003e/info/exclude` holds `*.gz`, `a_gzipped_document_is_walked` FAILS at `git add` (\"Use -f if you really want to add them\"). This is the same symptom as improvise-53zz and improvise-vwjj, reached by a third path.\n\nFix candidates: `env_remove(\"GIT_TEMPLATE_DIR\")` in `run_git`, or `init --template=` with an empty value in `scratch_repo`. Test: rerun_with_env with GIT_TEMPLATE_DIR pointing at a dir whose info/exclude holds `*.gz`.\n\nAdjacent, not reproduced as user-level: the system gitattributes (`git var GIT_ATTR_SYSTEM`) is not disabled by GIT_CONFIG_NOSYSTEM; it needs GIT_ATTR_NOSYSTEM=1. GIT_ATTR_SOURCE fails loudly (\"bad --attr-source\"), not silently.\n\nFound in the adversarial review of #255.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T23:56:07Z","created_by":"agent-review-255","updated_at":"2026-09-24T21:45:19Z","started_at":"2026-09-23T23:58:13Z","closed_at":"2026-09-24T21:45:19Z","close_reason":"Landed in main via #260 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-6u6s","title":"e265's enumeration has no non-contiguous group, so remark's run identity by header position (h0iv) is unguarded: a name-keyed rival passes all three tests","description":"Found reviewing PR #250 (improvise-e265) at d6e366dc.\n\nremark (prune_axis_items) keys each open run by its header's POSITION, so two runs under two equal headers stay two (context/code/core.md, the e265 section, claims this for h0iv). Nothing tests it. The TWO/THREE fixtures in layout.rs group_membership have every group's items contiguous under each parent, and a same-name run under a different parent is always separated by an outer-level change, which truncates the run stack anyway.\n\nRival, injected and run: key the input's open run by hash(level, cat_name, group_name) in place of the header's position. All three group_membership tests pass. A scratch test with the fixture Month = [Jan in Q1, Mar ungrouped, Feb in Q1] under North in Upper, which checks per-level run identity between consecutive surviving rows (not just innermost), fails at 'mask 0b101, collapsed []: run identity at level 1, row 1': pruning Mar merges North's two Q1 runs into one.\n\nThe ticket's own enumeration (300,580 cases) named non-contiguous groups; the PR's dropped them. Two gaps to close: (1) add a non-contiguous fixture, and ideally a hole level (a middle category with no groups); (2) assert the whole open-run stack per level, plus same-run-as-previous-row per level, not only innermost_group. The real code passes both on all five fixtures (TWO, THREE, NONCONTIG, HOLE, and an outer axis with two adjacent groups), 5,856 cases.\n\nCost note: the enumeration is already ~0.9-1.7s of improvise-core's lib binary (the rest is 0.16-0.18s, M4 Max under load avg 60-160). Adding fixtures should come with a cheaper construction: build each fixture's layout once per collapse subset and drive prune_axis_items with a keep predicate per mask, not two Workbook builds per mask.","status":"closed","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T23:54:33Z","created_by":"agent-review-250","updated_at":"2026-09-24T21:45:00Z","started_at":"2026-09-23T23:57:15Z","closed_at":"2026-09-24T21:45:00Z","close_reason":"Landed in main via #263 (reviewed; merged 2026-09-24).","dependencies":[{"issue_id":"improvise-6u6s","depends_on_id":"improvise-e265","type":"discovered-from","created_at":"2026-09-23T23:54:38Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-24lz","title":"pr-watch: a PR whose head moved and then merged exits 5, not 2; exit-5 ordering against the head checks is unpinned","description":"Found by the adversarial review of PR #253 at b13355c7 (improvise-1qaa).\n\npr-watch checks `state` AFTER the --expect check but BEFORE the head-pin check. The two head checks guard the same hazard: a verdict about a tree that no longer exists (PR #67 merged at a head that predated its fix). Exit 5 now wins over one of them and not the other:\n\n- merged at another head, with --expect aaaa: exit 2 (correct; also confirmed live on #216 with --expect deadbeef)\n- no --expect, first poll head a OPEN and running, next poll head b MERGED: exit 5, with the message \"PR 1 is MERGED, nothing to wait for\". The watcher never saw b, and the message does not name the head.\n\nSKILL.md and tooling.md describe exit 5 as \"someone else already merged it\", a normal answer the caller should hear as that. So a caller following the docs accepts a merge at a head nobody watched. That is exactly what the pin exists to refuse.\n\nFix: move the state check below the pin block. On the first poll the pin block only assigns, so \"ends on the first poll\" still holds. Consider printing the head in the exit-5 message too.\n\nNeither ordering is pinned by a test. Both rivals pass pr-watch-test.sh 50/50: state check moved above --expect, and state check moved below the pin. Add two cases: moved-then-merged, and merged-at-another-head with --expect.\n\nDoc claims to correct in the same change:\n(1) tooling.md says GitHub reports a MERGED **or CLOSED** PR as UNKNOWN/UNKNOWN permanently. Live, closed PR #74 reports CONFLICTING/DIRTY, so the CLOSED fixtures are an invented shape. The behaviour is unaffected, because state is read first.\n(2) SKILL.md says exit 5 \"ends on the first poll\". It ends on the first poll that SEES the PR closed, which may be mid-wait.\n","notes":"REGRESSION introduced by PR #253, not a pre-existing gap. Ran the same moved-then-merged fixture against the base script at 202e7eac: exit 2, 'head moved aaaaaaaaaaaa -\u003e bbbbbbbbbbbb during the wait'. At b13355c7: exit 5.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T18:33:04Z","created_by":"agent-review-253","updated_at":"2026-09-24T21:45:57Z","closed_at":"2026-09-24T21:45:57Z","close_reason":"Landed in main via #253 (reviewed; merged 2026-09-24).","labels":["adversarial-review-pr-253"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-8wy9","title":"App::restore's layout rebuild is unguarded: removing it leaves the workspace green","description":"Found by the adversarial review of PR #254 (improvise-4h8a) at head 67438b8f.\n\n`App::restore` ends with `self.rebuild_layout()`. `app.layout` is derived state, not an `AppSnapshot` field, so the new `snapshot_restore` tests never compare it.\n\nRIVAL THAT PASSES: delete the `self.rebuild_layout();` line from `App::restore`. `cargo test --workspace --no-fail-fast` stays fully green (39 binaries ok, exit 0).\n\nWHY IT MATTERS: `Host::undo_frame` calls `restore` and does not rebuild afterwards. `App::resolve_aggregate` and `record_in_layout` read `self.layout` through `layout_cell_keys`. So after a refused frame, the next frame's `Checked::against` would resolve surrogates against the grid of the frame that was rolled back, until some later `apply_effects` rebuilds it. The code is correct today. This is the same class as 4h8a: nothing stops a simplification from removing the rebuild.\n\nCHEAP FIX: in `snapshot_restore::restore_puts_back_every_field_the_snapshot_took` (crates/improvise-command/src/ui/app.rs), have the fixture change the grid's shape (for example, add an item under the added category, or set an axis), rebuild, and then after `restore` assert that the layout is equal to a freshly rebuilt one. `format!(\"{:?}\", app.layout)` works, or compare `row_count`/`col_count` and the labels. Then run the rival above and watch it fail.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T18:31:21Z","created_by":"Edward Langley","updated_at":"2026-09-24T21:45:04Z","started_at":"2026-09-23T23:54:31Z","closed_at":"2026-09-24T21:45:04Z","close_reason":"Landed in main via #261 (reviewed; merged 2026-09-24).","labels":["adr-001","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-cota","title":"bd-audit-ids skips an absent --assume-unchanged file as deleted: ls-files -d lists it, git status does not","description":"Found in adversarial delta review of PR #244 at head 33d563a9 (fix acecc63d, improvise-tdfd).\n\nThe tdfd fix skips an absent tracked file only when `git ls-files -d` lists it. But `ls-files -d` also lists an absent file marked `--assume-unchanged`. `git status` shows nothing for that file, so `commit -a` never records the deletion.\n\nThe audit therefore skips a file whose citations stay in HEAD, and exits 0. This is the same shape as tdfd: absent, not a deletion git will record, never read.\n\nReproduce in a throwaway repo:\n1. Commit `context/dead.md` citing an unknown id.\n2. Run `git update-index --assume-unchanged context/dead.md`, then `rm context/dead.md`.\n3. `git status --short` is empty. `git ls-files -d` prints `context/dead.md`.\n4. The audit prints \"all 1 cited ids resolve\" and exits 0.\n5. Control: without step 2 (a plain unstaged delete), the audit also exits 0. That is by design.\n\nRare, and CI checks out fresh, so P3. Fix: ask a deletion oracle that honours the index flags, such as `git diff --name-only --diff-filter=D`, which assume-unchanged hides. Alternatively, exclude `h`-flagged entries of `ls-files -v`. Add a case beside the skip-worktree case.","status":"closed","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T18:29:15Z","created_by":"agent-review-tooling-delta","updated_at":"2026-09-24T21:45:08Z","started_at":"2026-09-24T00:02:35Z","closed_at":"2026-09-24T21:45:08Z","close_reason":"Landed in main via #262/#290 (reviewed; merged 2026-09-24).","labels":["adversarial-review","pr-244"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-rq3f","title":"Browser Tab-commit's advance-right and the tile cursor's upper clamp are unasserted: breaking either passes the whole workspace","description":"Found in adversarial delta review of PR #246 at 790a33ba. This predates #246. The resolver code is untouched by that PR.\n\nTwo faults injected into `crates/improvise-protocol/src/resolve.rs` each pass the whole workspace (`cargo test --workspace --no-fail-fast`: 1945 passed, 0 failed):\n\n- `advanced`: `Advance::Right =\u003e Some((row, (col + 1).min(col_max)))` changed to `Some((row, col))`. So `commit-and-advance-right` (Tab in an editor) in the browser no longer advances, and nothing notices.\n- `tile_cursor`: the upper clamp `(count - 1) as i32` changed to `count as i32`. So moving right from the last tile selects an index past the end, and nothing notices.\n\nFor comparison, five other resolver faults (page_rows, scroll offset, panel cursor, gg, G) are each caught by a named lockstep test. So these two are gaps in coverage, not a sign that lockstep is blind in general.\n\nFix: add a lockstep case for each, with a cursor at a nonzero index. Tab-commit should land where the host's CommitAndAdvance lands. l or Right on the last tile should agree with the host's MoveTileCursor.\n","status":"closed","priority":3,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T18:20:29Z","created_by":"agent-review-246b","updated_at":"2026-09-24T21:45:20Z","started_at":"2026-09-23T18:23:45Z","closed_at":"2026-09-24T21:45:20Z","close_reason":"Landed in main via #256 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-xoxh","title":"lockstep.rs deliver() folds host-sent frames through fold_outgoing, and one merged-in test still folds its own commands through fold","description":"Found in adversarial delta review of PR #246 at 790a33ba.\n\n1. `crates/improvise-wasm-client/tests/lockstep.rs::deliver` folds the HOST's `ServerFrame::Commands` through `client.fold_outgoing(\u0026cmds)`. e0dec966 changed it from `fold` along with the 19 loops that fold the client's own resolved commands. But `deliver` models what the host sends, which `context/code/clients.md` says is `fold`'s door. The PR comment says \"the two remaining `fold` calls model what the host sends\", but `deliver` is a third host-side call, and it is on the wrong door.\n\n Latent today: `deliver`'s only caller (`switching_between_a_records_view_and_a_pivot_view_settles_both_ways`) sends view-only frames, so nothing is awaited and both doors settle immediately. With an awaited frame in flight, `fold_outgoing` would queue the host's relays at the BACK as the client's own frame, not directly behind the head as `settle_relay` does. That is the pre-jpba ordering, so a test built on `deliver` could not see a jpba regression.\n\n2. `renaming_a_formula_from_the_panel_lands_the_same_document_as_the_host`, which arrived from gh/main in the merge 790a33ba, still folds the client's own resolved commands through `client.fold(cmd)`. So after the merge, \"every loop over the client's own commands uses fold_outgoing\" is not true at the head.\n\nFix: `deliver` goes back to `fold` for `ServerFrame::Commands`, and the renaming test uses `fold_outgoing`.\n","status":"closed","priority":3,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T18:20:19Z","created_by":"agent-review-246b","updated_at":"2026-09-24T21:45:26Z","started_at":"2026-09-23T18:29:43Z","closed_at":"2026-09-24T21:45:26Z","close_reason":"Landed in main via #259 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-vwjj","title":"The scratch-repo tests still read the user's default excludes file (XDG git/ignore), so a *.gz there fails a_gzipped_document_is_walked despite GIT_CONFIG_GLOBAL=/dev/null","description":"Found reviewing PR #248 (improvise-53zz) at head f5cfc7ed.\n\n`run_git` sets `GIT_CONFIG_GLOBAL=/dev/null` and `GIT_CONFIG_NOSYSTEM=1`, which stops git reading the user's global and system config files. It does not stop git reading the user's **default** excludes file. When `core.excludesFile` is unset, git falls back to `$XDG_CONFIG_HOME/git/ignore`, or `~/.config/git/ignore` when XDG_CONFIG_HOME is unset. That fallback does not depend on any config file, so both variables leave it in force. It is also the most common place for a global ignore, because it needs no config line.\n\nMeasured on git 2.55.0 at f5cfc7ed. A probe test re-ran `a_gzipped_document_is_walked` through the PR's own `rerun_with_env` with `XDG_CONFIG_HOME=\u003ctmp\u003e` and `\u003ctmp\u003e/git/ignore` containing `*.gz`. The child failed at `git add`: \"The following paths are ignored by one of your .gitignore files: docs/model.improv.gz\". Setting `HOME=\u003ctmp\u003e` with `\u003ctmp\u003e/.config/git/ignore` fails the same way. That is 53zz's symptom 2 on the path the fix does not cover. The PR's test sets `core.excludesFile` explicitly inside a config file, so it cannot see this.\n\nFix options, cheapest first: pass `-c core.excludesFile=/dev/null` in `run_git` (a `-c` argument, which `--local-env-vars` does not strip), or point `XDG_CONFIG_HOME` and `HOME` at an empty temp dir. Also consider `core.attributesFile`, which has the same XDG default. Rival: run the gzip shape test in a child with `XDG_CONFIG_HOME` pointing at a dir whose `git/ignore` holds `*.gz`, and assert it passes.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T18:17:06Z","created_by":"agent-review-248","updated_at":"2026-09-24T21:45:24Z","started_at":"2026-09-23T18:24:02Z","closed_at":"2026-09-24T21:45:24Z","close_reason":"Landed in main via #255 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-metf","title":"prune_empty evaluates every cell with a fresh fold memo","description":"Found reviewing #247 (improvise-h6z1): GridLayout::prune_empty (view/layout.rs ~620) evaluates each cell with its own fresh memo — the same shape h6z1 fixed in grid_body, on the same keypress path. Measure first on the folded-chain bench; share one memo per build if it pays.","status":"closed","priority":3,"issue_type":"task","assignee":"agent-mergetrain","owner":"el-github@elangley.org","created_at":"2026-09-23T18:14:55Z","created_by":"orchestrator","updated_at":"2026-09-24T21:45:14Z","started_at":"2026-09-24T00:05:23Z","closed_at":"2026-09-24T21:45:14Z","close_reason":"Landed in main via #268 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-0jw5","title":"Nothing guards that viewport::grid_body shares one FoldMemo: a per-cell fresh memo there passes the whole suite","description":"Found reviewing PR #247 (improvise-h6z1, head 4f75fffc). The PR's whole effect is grid_body making one FoldMemo and passing it to cell_value and column_total. Rival: in viewport.rs cell_value, replace the memo with FoldMemo::default() per cell. cargo test --workspace --no-fail-fast: zero failures. a_fold_row_evaluates_each_formula_once_across_the_row calls GridLayout::evaluate_in directly with a memo of its own, so it pins the layout-level API and not the build that is supposed to use it; the totals' memo is likewise unguarded. Only benches/grid_projection.rs (folded chain ~18k: 1.19s -\u003e 0.52s measured) can see a regression, and it is not a gate. Add a test in improvise-command that counts FOLD_FORMULA_EVALUATIONS (or an equivalent observable) across one grid_body call on a folded chain and asserts 9 per fold row, with the per-cell rival shown failing. Note the counter is #[cfg(test)] pub(crate) in improvise-core, so it is not visible from improvise-command as-is.","status":"closed","priority":3,"issue_type":"task","assignee":"agent-mergetrain","owner":"el-github@elangley.org","created_at":"2026-09-23T18:13:31Z","created_by":"agent-review-247","updated_at":"2026-09-24T21:44:40Z","started_at":"2026-09-23T18:24:44Z","closed_at":"2026-09-24T21:44:40Z","close_reason":"Landed in main via #258 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-pntu","title":"FoldMemo's key omits the aggregation and the covers predicate: one memo shared across two covers returns the first answer","description":"Found reviewing PR #247 (improvise-h6z1, head 4f75fffc); also ChatGPT Codex's P2 on that PR. FoldMemo is pub and keyed by (CellKey, span.over). evaluate_covering_in / evaluate_spanning_in are pub and take any agg and covers. Demonstrated: margin_book(['R = Revenue / Cost']), key {Month=Q1, _Measure=R}, Aggregation::Exact. evaluate_covering_in with covers=all then with covers=hide-Feb on ONE memo returns 5.0 for the second call; a fresh memo returns 2.0. Not reachable from viewport::grid_body today (agg and covers are constant per build), which is why the suite is green. The PR includes span in the key precisely because the _in functions are public; agg and covers are the same argument left unanswered. Fix shape: bind agg+covers into the memo (e.g. a per-build evaluator object that owns memo, agg and covers, so the _in functions cannot be handed a mismatched pair), rather than widening the key with a closure identity. Test: the one above, asserting the second call equals a fresh evaluation.","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-mergetrain","owner":"el-github@elangley.org","created_at":"2026-09-23T18:13:20Z","created_by":"agent-review-247","updated_at":"2026-09-24T21:44:41Z","started_at":"2026-09-23T18:24:44Z","closed_at":"2026-09-24T21:44:41Z","close_reason":"Landed in main via #258 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-nmm5","title":"Lockstep never exercises a refusal: a ClientState refusal-ordering regression is visible only to client.rs unit tests","description":"Found while fixing improvise-3kfc (PR #246). tests/lockstep.rs now folds the client's own commands through fold_outgoing, which is the right door, but it never refuses a frame. So a regression in how ClientState orders a refusal against queued relays or view-only frames is caught only by the client.rs unit tests and the two Host-level jpba tests. Add at least one lockstep case that sends two frames, has the host refuse the first, and compares client and mirror after both answers.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-23T18:10:26Z","created_by":"orchestrator","updated_at":"2026-09-23T18:10:26Z","dependencies":[{"issue_id":"improvise-nmm5","depends_on_id":"improvise-pmt0","type":"parent-child","created_at":"2026-09-25T01:05:02Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-tdfd","title":"bd-audit-ids silently skips a tracked file absent by skip-worktree or sparse checkout, not only a deleted one","description":"PR #244 head e2649820 (ChatGPT Codex P2, confirmed). The deleted-file filter keeps a path only if `[ -e \"$f\" ] || [ -L \"$f\" ]`, so ANY tracked path absent from the working tree is dropped, not only a deleted one.\n\nReproduced in a detached worktree at e2649820: context/adr/001-concurrent-edits.md holds the only citation of improvise-k2i4. With a stub bd (BD_AUDIT_BD) that knows every other cited id, the audit exits 1. After marking that file skip-worktree and moving it out of the tree, it exits 0 (\"all 498 cited ids resolve\").\n\n`ls-files -d` does NOT list the skip-worktree path, so skipping only what `ls-files -d` reports, and treating any other missing tracked path as exit 4, discriminates. Needs a skip-worktree case beside the deleted-file case.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-mergetrain","owner":"el-github@elangley.org","created_at":"2026-09-23T17:59:48Z","created_by":"agent-review-tooling4","updated_at":"2026-09-24T21:45:22Z","started_at":"2026-09-23T18:16:08Z","closed_at":"2026-09-24T21:45:22Z","close_reason":"Landed in main via #244 (reviewed; merged 2026-09-24).","labels":["adversarial-review","pr-244"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-fd0j","title":"pr-reviews-test watchdog needs bash 5.1 but checkers.sh pins only major 5","description":"PR #237 head 785202b8 (ChatGPT Codex P2, confirmed). bounded_reviews runs `wait -n -p done \"$pid\" \"$dog\"`; the -p option is bash 5.1. find_modern_bash in scripts/checkers.sh accepts any BASH_VERSINFO[0] \u003e= 5, so a bash 5.0 host is accepted and the suite dies (`done` unbound under set -u).\n\nDemonstrated shape under macOS /bin/bash 3.2 run directly: 2 passed, 23 failed, where gh/main's suite is 25/25 under 3.2. CI (5.2) and nix (5.3) are fine, so this is a contract mismatch, not a live CI failure.\n\nFix: raise find_modern_bash's floor to 5.1 (with a checkers-test case for 5.0), or detect the watchdog winner without -p.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-mergetrain","owner":"el-github@elangley.org","created_at":"2026-09-23T17:59:47Z","created_by":"agent-review-tooling4","updated_at":"2026-09-24T21:45:02Z","started_at":"2026-09-23T18:16:06Z","closed_at":"2026-09-24T21:45:02Z","close_reason":"Landed in main via #237 (reviewed; merged 2026-09-24).","labels":["adversarial-review","pr-237"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-bbwa","title":"check-ci-graph counts a --ci step in a job that needs: a job which can be skipped; the dependent job is skipped and a skipped check reads as passing","description":"At PR #228 head d6706774: add a job 'gate' with job-level 'if: false' (no checkers.sh step, so nothing disqualifies it), give kb 'needs: [gate]', and add gate to pages-deploy's needs. check-ci-graph exits 0 with all 9 suites counted, but on a PR GitHub skips kb (its dependency was skipped) and a skipped required check counts as success. Same class as improvise-tvte (a condition written around the call), one hop away: the disqualifiers are read only on the counted job. build/js/kb have no needs: today. Cheapest close: treat a job-level needs: as a disqualifier for the counted step, or require every needed job to be unconditional.","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-mergetrain","owner":"el-github@elangley.org","created_at":"2026-09-23T17:59:22Z","created_by":"agent-review-tooling4","updated_at":"2026-09-24T21:44:43Z","started_at":"2026-09-23T18:02:24Z","closed_at":"2026-09-24T21:44:43Z","close_reason":"Landed in main via #228 (reviewed; merged 2026-09-24).","labels":["adversarial-review","pr-228"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-nacd","title":"Wire SetCell on a stored record with a _Measure that is no item (unknown name, or a parent-only group) is accepted and stores a non-item coordinate","description":"Found in adversarial review of PR #235 (improvise-b114/xur5), head 5c19fdd4. Pre-existing: the PR does not introduce it, and it does not close it.\n\nPR #235's rule is \"no coordinate names a group\". The invariant it protects\n(improvise-gw18) is wider: \"every coordinate names an item\". Two writes fall in\nthe gap. Both are accepted at 5c19fdd4 and store a non-item coordinate:\n\n- A caught-up wire SetCell on the stored record `{Month=Feb}` with measure\n `Bogus`, which is no item and no group of `_Measure`. It stores\n `Month=Feb, _Measure=Bogus = 7`.\n- The same write with measure `Totals`, where `Totals` is a PARENT group\n (`Group::new(\"Money\").with_parent(\"Totals\")`) that holds no item directly.\n `items_in_group(\"Totals\")` is empty, so `group_refusal` does not see it.\n It stores `_Measure=Totals = 7`.\n\nNeither cell is drawn in any pivot view, which is the gw18 shape: invisible data\nthat persists through format_md and counts in records mode. The record half is\nsafe, because an unknown record does not resolve. The measure half is unchecked\nwhenever the record is stored.\n\nThreat model is the same as b114/xur5: a hand-rolled or stale client. No honest\nclient composes these writes.\n\nProbe: `review_235::probe_parent_only_group_and_unknown_measure` (reviewer's,\nnot pushed). Fix shape: refuse a `_Measure` coordinate that is not in\n`measure_item_names()` (items plus formula targets; formula targets are refused\nanyway by the Derived rule). Or generalise `group_refusal` to \"names no item of\nits category\".\n","status":"closed","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T16:16:42Z","created_by":"agent-review-235-239","updated_at":"2026-09-24T21:45:17Z","started_at":"2026-09-23T18:11:47Z","closed_at":"2026-09-24T21:45:17Z","close_reason":"Landed in main via #252 (reviewed; merged 2026-09-24).","labels":["adversarial-review","pr-235"],"comments":[{"id":"ec47fc8d-7311-511f-a2ee-dd5c6345723d","issue_id":"improvise-nacd","author":"agent-review-221-251","text":"Reachable by an ordinary race since PR #274 (seen at 3dad3b09), not only by a hand-built frame. A captures a keyboard editor on North/Cost. B sends RemoveItem(_Measure, Cost), accepted and delivered. A presses Enter. The captured OpenedCell still names measure Cost, the record basis passes because North's version did not move, and the host Accepts SetCell North/Cost = 42 with Cost no longer an item. The old cursor guard would not have caught it either: the reset leaves the cursor on a Record.","created_at":"2026-09-24T03:24:26Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} +{"_type":"issue","id":"improvise-vwc7","title":"An item may share its name with a group of its category, so a key naming it is ambiguous: a wire write to the item is refused in rows and accepted in _Measure","description":"Found in adversarial review of PR #235 (improvise-b114/xur5), head 5c19fdd4. Pre-existing model gap. #235 makes it matter, and PR #221 (improvise-fdk1) will add a second way in.\n\n`Category` lets an item and a group share a name. `add_item` and\n`add_item_in_group` check only `items`, and `add_group` checks only `groups`.\nOnce they share a name, a key naming it is ambiguous: `Month=Q2` is both the\nitem Q2's row and the collapsed group Q2's fold row.\n\nThe wire behaves inconsistently, as measured at 5c19fdd4 with\n[AddItem, SetCell] frames that carry the SetCell's basis:\n\n- `_Measure`: AddItem `Money` (Money is already a group of Revenue and Cost),\n then SetCell `{Feb, Money}`. The frame is **accepted** and stored.\n- Row category: in `host_on_a_fold` (Q2 collapsed), AddItem `Month=Q2`, then\n SetCell `{Q2, Revenue}`. The frame is **refused** (`MutationRefused`) and\n undone. The new record is unstored, so `aggregate_refusal` finds the key by\n position, finds the FOLD row, and sees an Aggregate. A write to a real item\n was refused because the fold shares its key.\n\n#235's `group_refusal` exempts a name that is also an item, which is the right\ncall given the model. But the root is the namespace, not the refusal.\nimprovise-fdk1's `RenameRecordCoordinate` (PR #221) creates the target item\n\"if need be\", so a rename onto a group's name will create exactly this\nnamesake. So will the TUI's records-mode category edit.\n\nDecide one of: (a) the model refuses an item named like a group of its category,\nand a group named like an item (smart constructors); or (b) keys disambiguate a\nfold from an item. (a) is the smaller change. Probe:\n`review_235::probe_add_item_then_write_in_one_frame` (reviewer's, not pushed).\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T16:16:42Z","created_by":"agent-review-235-239","updated_at":"2026-09-23T16:16:42Z","labels":["adversarial-review","pr-235"],"dependencies":[{"issue_id":"improvise-vwc7","depends_on_id":"improvise-felo","type":"parent-child","created_at":"2026-09-25T01:05:19Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-pr8t","title":"#235's same-name-item exemption in group_refusal is unpinned: removing it refuses a real item and the workspace stays green","description":"Found in adversarial review of PR #235 (improvise-b114/xur5), head 5c19fdd4.\n\n`App::group_refusal` exempts a name that is also an item of the same category\n(`!category.items.contains_key(name)`). Nothing pins that exemption. With it\nremoved (`let group = true \u0026\u0026 !items_in_group(name).is_empty()`), the full\nworkspace suite stays green (`cargo test --workspace --no-fail-fast`, 0 failed).\n\nUnder that rival, a real item is refused. Fixture: `_Measure` has Revenue and\nCost in group Money, and ALSO an ungrouped item named Money\n(`Category` permits that: `add_item` checks only `items`). The stored cell\n`{Month=Feb, _Measure=Money}` is a legitimate item cell. At 5c19fdd4 a caught-up\nwire SetCell on it is accepted (correct). With the rival it is refused.\n\nFix: one test that writes to an item whose name is also a group of its category\nand asserts Accepted and the stored value. The reviewer's probe is\n`review_235::probe_item_named_like_its_categorys_group_is_writable` (not pushed).\n","status":"closed","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T16:16:40Z","created_by":"agent-review-235-239","updated_at":"2026-09-24T21:45:18Z","started_at":"2026-09-23T18:11:44Z","closed_at":"2026-09-24T21:45:18Z","close_reason":"Landed in main via #252 (reviewed; merged 2026-09-24).","labels":["adversarial-review","pr-235"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-su4u","title":"A minibuffer refusal (formula parse error) vanishes on the first correcting keystroke under #233's status expiry","description":"Found reviewing PR #233 (improvise-cq3d) at head d9ea5946.\n\n#233 clears `status_msg` at the start of every TUI keypress (`draw.rs::press`). In a\nminibuffer mode `minibuffer_line` draws the message on the right of the prompt, and\nsince improvise-v4hg a formula that does not parse keeps the user in `FormulaEdit`\nwith the error. The error now disappears on the first correcting keystroke:\n\n- type `Total = (` in the formula editor, press Enter: the row reads\n `formula: Total = (▌ ... Formula error: --\u003e 1:10`\n- press Backspace once: the row reads `formula: Total = ▌`, and the error is gone.\n\nThe user is shown the error once, but it is gone before they finish fixing the thing\nit describes. The same applies to any refusal that leaves the user in a minibuffer.\nBefore #233 the error stayed while they edited, and then stayed forever (the cq3d bug).\n\nPossible rule: in a text-entry mode, expire the message only when the key leaves the\nmode, or only on the commit key. Decide whether that is worth the extra rule; it is a\ndesign choice, not a crash. Reproduce it through `press`, not `handle_key`, because\nthe expiry is in the TUI loop only.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T16:14:43Z","created_by":"agent-review-233-242","updated_at":"2026-09-24T21:45:21Z","started_at":"2026-09-23T18:23:44Z","closed_at":"2026-09-24T21:45:21Z","close_reason":"Landed in main via #257 (reviewed; merged 2026-09-24).","labels":["adversarial-review","area:tui","pr-233"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-m6op","title":"Cross-instance Debug fingerprint: Model.measure_agg's sort is pinned by no test","description":"Found reviewing PR #241 (head 3a4b0eac). Model's Debug sorts measure_agg (HashMap\u003cString, AggFunc\u003e) into a BTreeMap so two equal documents print alike. No committed test sees it: replacing .field(\"measure_agg\", \u0026aggs) with the raw HashMap left cargo test --workspace --no-fail-fast green in 3 of 3 runs. The only cross-instance comparison, a_model_command_lands_where_its_keypress_lands, has an empty measure_agg, so a HashMap of zero entries has one order (the same vacuity djcn fixed for View/DataStore). formula_cache's sort IS pinned (recomputing_formulas_leaves_the_model_fingerprint_alone fails when it is unsorted). A reviewer scratch test with two measure_agg entries failed 3/3 under the injection. Fix: give the pivoted() fixture two or more measure_agg entries (and ideally a formula so formula_cache is non-empty across instances too), then re-inject.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T16:05:41Z","created_by":"agent-review-241","updated_at":"2026-09-24T21:45:54Z","closed_at":"2026-09-24T21:45:54Z","close_reason":"Landed in main via #249 (reviewed; merged 2026-09-24).","dependencies":[{"issue_id":"improvise-m6op","depends_on_id":"improvise-djcn","type":"discovered-from","created_at":"2026-09-23T16:05:41Z","created_by":"agent-review-241","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-cwfj","title":"The remaining set_status declines: panel.rs (3) and keymap dispatch's arg-error arm should refuse","description":"Remainder of improvise-i3o5 after PR #236. cmd/panel.rs:708 'Move cursor to a category header to change axis', :726 'No category selected…' and :818 'No category to delete' decline with set_status, so they exit 0 when run headless. command/keymap.rs::dispatch writes set_status(e) for a bound key whose constructor returns Err. ORCHESTRATOR RULING: that Err is a refusal (cli.md §10), so dispatch should refuse(e). Before converting dispatch, check nothing depends on a bound key's Err leaving last_batch_refused false. Assigned to agent-refuse-fu.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T15:47:49Z","created_by":"orchestrator","updated_at":"2026-09-25T00:59:59Z","closed_at":"2026-09-25T00:59:59Z","close_reason":"Landed in #238 (on gh/main)","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-tvte","title":"check-ci-graph counts a checkers.sh invocation that never runs on a PR: step/job if: conditions and shell control flow are ignored","description":"Found by adversarial re-review of PR #228 at head c1417359.\n\ncheck-ci-graph.py counts an invocation that never runs on a PR. The count is silent, so a suite can be reported as run in CI when it is not. Two sources:\n\n1. **YAML conditions are ignored.** A step's `if:` and a job's `if:` are never read. A suite whose only invocation is in a `- if: failure()` step, or in a job gated like pages-deploy (`if: github.event_name == 'push' \u0026\u0026 github.ref == 'refs/heads/main'`), counts as run on every PR.\n2. **Shell control flow is ignored.** Each of these counts the suite, although it never runs:\n - `true || ./scripts/checkers.sh X`\n - `false \u0026\u0026 ./scripts/checkers.sh X`\n - `exit 0` on the line before the invocation, in a block\n - `if false; then` … `fi` around the invocation\n - the invocation inside a shell function that is never called\n\nEach was demonstrated with a probe over suite_verdict/suites_run, requiring pr-watch where the shape is its only invocation.\n\nThe shell half cannot be fully closed by static analysis. The YAML-condition half can be: refuse, or refuse to count, any invocation in a step or job that carries an `if:`. Whatever is not closed should be stated as a residual in context/code/tooling.md. At present that section says only what is caught.\n\nLower priority than the multi-line/heredoc fail-open. Every shape here needs someone to write a condition around the invocation. That filed ticket needs only a prose mention of the script.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T15:41:52Z","created_by":"agent-review-228b","updated_at":"2026-09-25T00:59:37Z","started_at":"2026-09-23T15:48:09Z","closed_at":"2026-09-25T00:59:37Z","close_reason":"Landed in #228 (d6706774 on gh/main: 'CI runs checkers.sh --ci \u003cjob\u003e; the gate reads a table, not shell')","labels":["adversarial-review","pr-228"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-c0aj","title":"The shipped-documents walk reads only root !dir/*.improv lines, so a single-file un-ignore, .improv.gz or nested un-ignore is skipped silently","description":"Found reviewing PR #234 at head 91c80cb7 (improvise-qlrh). It does not block #234: today the derived set (examples, web, harness/fixtures) equals `git ls-files '*.improv'` exactly.\n\n`tests/shipped_documents_load.rs::shipped_directories` defines \"shipped\" as the directories named by root `.gitignore` lines of the form `!\u003cdir\u003e/*.improv`. Several ways of shipping a document fall outside that shape, and each one is missed silently:\n\n- **A single-file un-ignore.** Injected: I changed `!web/*.improv` to `!web/demo.improv` and replaced web/demo.improv with garbage. `git check-ignore` confirms the file is still un-ignored, and it stays tracked. The test PASSES, because examples/ and harness/fixtures still satisfy both preconditions.\n- **`.improv.gz`.** `*.improv` never ignores it, so a gzipped document can be tracked in any directory without an un-ignore line, and the walk never visits that directory.\n- **A `!` line in a nested .gitignore.** harness/.gitignore and web/.gitignore exist. Such a line would be missed, and its path is relative to that file, not to the root.\n- **A force-added (`add -f`) ignored document.**\n\nPatterns like `!/examples/*.improv` or `!**/x/*.improv` fail loudly (read_dir panics), so they are not silent. CRLF, comments and leading whitespace are handled (`lines()` + `trim()`).\n\nA direction that closes this: take the ground truth from the index (`ls-files '*.improv' '*.improv.gz'`) when the test runs in a checkout, or assert that the derived directories cover every tracked document.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-refuse-fu","owner":"el-github@elangley.org","created_at":"2026-09-23T15:40:00Z","created_by":"agent-review-224b-234","updated_at":"2026-09-23T17:52:49Z","started_at":"2026-09-23T15:52:32Z","closed_at":"2026-09-23T17:52:49Z","close_reason":"Fixed in PR #243, merged as c26f016 after adversarial review at 228f130. Follow-up: improvise-53zz.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-djcn","title":"Model's hand-written Debug is not exhaustive, and the Debug fingerprint is not canonical across instances (DataStore/View HashMaps)","description":"Found reviewing PR #224 at head 38ec6061 (improvise-tt22 / improvise-pc3b). Neither half blocks #224.\n\n1. The hand-written `impl Debug for Model` (crates/improvise-core/src/model/types.rs) names its fields one by one through `debug_struct`, so a field added later is left out and no compiler error says so. Before #224, `#[derive(Debug)]` covered new fields automatically. Injected: I added `pub probe_field: u64` to `Model` and made `RebuildLayout` (ViewOnly) increment it. The workspace compiled and `view_only_effects_leave_shared_state_alone` passed. Fix: destructure `let Model { name, categories, data, formulas, next_category_id, measure_agg, formula_cache } = self;` so a new field is a compile error.\n\n2. The Debug fingerprint is canonical within one instance but not across two equal instances. `DataStore` (`index: HashMap\u003c_, HashSet\u003c_\u003e\u003e`, `records: HashMap`, `symbols.to_id: HashMap`) and `View` (`page_selections`, `hidden_items`, `collapsed_groups`) still derive Debug over HashMaps. A probe that built two equal data-bearing, view-bearing Apps got a different `format!(\"{:?}\", model)` in 32/32 runs, and a different `format!(\"{:?}\", views)` in 32/32. The effect-classification tests compare before and after on ONE instance, so they are sound: with a view-bearing fixture, all ten ViewOnly effects times 32 runs were stable. But `ui/reduce.rs::a_model_command_lands_where_its_keypress_lands` compares `format!(\"{:?}\", workbook)` across TWO Apps (`by_key` vs `by_command`). It is green only because its fixture has no cells and no multi-entry view maps. Give that fixture data and it flakes, the same shape as improvise-hzjo. Fix options: canonical (sorted) Debug for DataStore and View, or a real equality/fingerprint for cross-instance comparisons.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-uo2p","owner":"el-github@elangley.org","created_at":"2026-09-23T15:39:42Z","created_by":"agent-review-224b-234","updated_at":"2026-09-23T17:52:50Z","started_at":"2026-09-23T15:41:47Z","closed_at":"2026-09-23T17:52:50Z","close_reason":"Fixed in PR #241, merged as d3f03f8 after adversarial review at 3a4b0ea. Follow-up: improvise-m6op.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-hv0r","title":"#230's echo is unpinned on its audience and its offset arms: echoing to every session, or only SetSelected, leaves the workspace green","description":"Found by the adversarial review of PR #230 (improvise-wdmp) at head 93c455db.\n\n#230's only test, `ui::host::tests::a_frame_that_reframes_and_then_places_the_cursor_leaves_the_asker_placed`, places with `SetSelected` alone. It checks that the bystander received `ResetCursor`, but never that it received nothing more. Two injected faults survive every test in improvise-command, improvise-ws-server, improvise-wasm-client and improvise-worker-server:\n\n1. **The echo addressed to every session**, not just the asker. The asker's position would then reach a bystander, which violates improvise-g0f (\"never a position\") and gotcha 36's rule that a bystander must be seated where a wrong relay can move it.\n2. **`places_cursor` narrowed to `SetSelected`**, dropping `SetRowOffset`/`SetColOffset`. This is reachable. A records `+` commit (`resolve::create_column` then `advanced` then `scrolled_to`) can emit offsets after a reframing `AddItem`. The client's offsets would then stay reset while its mirror's are placed.\n\nThe reviewer's `rv_place_reframe_place_echoes_only_the_later_placements` catches both. It sends `[SetRowOffset(1), ChangeMode, AddCategory, SetSelected(1,0), SetColOffset(1)]` and asserts three things:\n- the asker's echoes are exactly `[SetSelected(1,0), SetColOffset(1)]`, so the pre-reset `SetRowOffset` is not echoed;\n- the bystander hears no placement;\n- the echo follows the reset.\nIt also asserts client == mirror. Acceptance: tests of that shape land with #230, or right after it.\n","status":"in_progress","priority":3,"issue_type":"bug","assignee":"agent-viewhist","owner":"el-github@elangley.org","created_at":"2026-09-23T15:31:43Z","created_by":"agent-review-227-230","updated_at":"2026-09-23T15:34:13Z","started_at":"2026-09-23T15:34:13Z","lease_expires_at":"2026-09-23T15:39:13Z","heartbeat_at":"2026-09-23T15:34:13Z","labels":["adversarial-review","pr-230","test-gap"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-hv0r","title":"#230's echo is unpinned on its audience and its offset arms: echoing to every session, or only SetSelected, leaves the workspace green","description":"Found by the adversarial review of PR #230 (improvise-wdmp) at head 93c455db.\n\n#230's only test, `ui::host::tests::a_frame_that_reframes_and_then_places_the_cursor_leaves_the_asker_placed`, places with `SetSelected` alone. It checks that the bystander received `ResetCursor`, but never that it received nothing more. Two injected faults survive every test in improvise-command, improvise-ws-server, improvise-wasm-client and improvise-worker-server:\n\n1. **The echo addressed to every session**, not just the asker. The asker's position would then reach a bystander, which violates improvise-g0f (\"never a position\") and gotcha 36's rule that a bystander must be seated where a wrong relay can move it.\n2. **`places_cursor` narrowed to `SetSelected`**, dropping `SetRowOffset`/`SetColOffset`. This is reachable. A records `+` commit (`resolve::create_column` then `advanced` then `scrolled_to`) can emit offsets after a reframing `AddItem`. The client's offsets would then stay reset while its mirror's are placed.\n\nThe reviewer's `rv_place_reframe_place_echoes_only_the_later_placements` catches both. It sends `[SetRowOffset(1), ChangeMode, AddCategory, SetSelected(1,0), SetColOffset(1)]` and asserts three things:\n- the asker's echoes are exactly `[SetSelected(1,0), SetColOffset(1)]`, so the pre-reset `SetRowOffset` is not echoed;\n- the bystander hears no placement;\n- the echo follows the reset.\nIt also asserts client == mirror. Acceptance: tests of that shape land with #230, or right after it.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-viewhist","owner":"el-github@elangley.org","created_at":"2026-09-23T15:31:43Z","created_by":"agent-review-227-230","updated_at":"2026-09-24T21:45:12Z","started_at":"2026-09-23T15:34:13Z","closed_at":"2026-09-24T21:45:12Z","close_reason":"Landed in main via #246 (reviewed; merged 2026-09-24).","labels":["adversarial-review","pr-230","test-gap"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-xur5","title":"Wire SetCell on a collapsed-fold RECORD with an undrawn measure (hidden, unknown or none) is accepted and stores the group name as a coordinate: #217's whole-key lookup reads 'not drawn' as 'not refused'","description":"Found in the adversarial delta re-review of PR #217 (improvise-ciw8) at head 6026768c, which includes the sjdb fix.\n\nThe sjdb fix gives back part of what bc060f67 closed. The fix is `cell_position(key)` in place of `record_position(hash)`. Relative to gh/main this is NOT a regression, because before ciw8 nothing was refused.\n\n**Setup:** Month={Jan,Feb in Q1; Apr,May in Q2} on Row, with Q2 collapsed. `_Measure`={Revenue,Cost} on Column, with every month stored. The record {Month=Q2} is not stored, so `resolve_record` finds it only through the layout, at the Q2 fold row.\n\n**The write:** a caught-up wire `SetCell { record: RecordHash({Month=Q2}), measure: M }`. Results:\n\n| M | at bc060f67 (by record) | at 6026768c (by whole key) |\n|---|---|---|\n| Revenue (drawn: the fold cell) | refused | refused |\n| Cost, hidden in the host's view | refused | ACCEPTED, stores {Month=Q2,_Measure=Cost}=7 |\n| Bogus (no such measure) | refused | ACCEPTED, stores {Month=Q2,_Measure=Bogus}=7 |\n| None | refused | ACCEPTED, stores {Month=Q2}=7 |\n\nEach accepted write stores `Month=Q2`, a group name, as a coordinate. That is the improvise-gw18 / improvise-ciw8 shape. The cause is that `cell_position(key)` returns None for a whole key the host does not draw, and \"not drawn\" is read as \"not refused\". The fold is visible in the RECORD half, `{Month=Q2}`, and the old by-record lookup saw it there. No honest client composes these writes, since every client that sees Q2 as a fold sees an Aggregate there. They are hand-rolled or forged, but that also describes b114.\n\n**Repro** (reviewer probe, `ui::host::tests::review_217b::probe_fold_hidden_measure`, not pushed): build the fixture above with `hide_item(\"_Measure\",\"Cost\")`, then `wire`-write {Month=Q2} with measure Cost and assert `refused`. It fails at 6026768c and passes with bc060f67's app.rs.\n\n**Fix shape:** the same view-independent rule improvise-b114 proposes, \"no coordinate of the resolved key names a group rather than an item\", would close this, b114 and the sjdb case together, without asking the host's layout at all. Falling back to `record_position` when the whole key is undrawn also closes this row. But it would refuse Feb x Revenue beside a collapsed Money group, which is a real item cell and arguably legitimate.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-collapse-fu","owner":"el-github@elangley.org","created_at":"2026-09-23T15:30:17Z","created_by":"agent-review-232-217b","updated_at":"2026-09-23T17:52:52Z","started_at":"2026-09-23T15:39:27Z","closed_at":"2026-09-23T17:52:52Z","close_reason":"Fixed in PR #235, merged as 799a81a after adversarial review at 5c19fdd. Follow-ups: improvise-pr8t, improvise-nacd, improvise-vwc7.","labels":["adversarial-review","pr-217"],"dependencies":[{"issue_id":"improvise-xur5","depends_on_id":"improvise-b114","type":"related","created_at":"2026-09-23T15:30:23Z","created_by":"agent-review-232-217b","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-29m7","title":"The ModelCommand variant count is stated wrongly in three places once #214 and #221 both land (26, not 24 or 25)","description":"Found reviewing PRs #214 (improvise-km71) and #223 against #221 (improvise-fdk1).\n\nEach PR adds one `ModelCommand` variant: #214 adds `ReplaceFormula` and #221 adds `RenameRecordCoordinate`. I materialised gh/main 705c92d9 + #221 + #214 + #223. The enum then has **26** variants, and it builds and passes. Nothing counts the variants in a test, so no guard fails. The prose counts are wrong in every order:\n\n- `crates/improvise-protocol/src/reduce.rs:78` says \"25 variants\" on #214 and \"24 variants\" on #221. So #221 alone is already stale, since it added a variant without updating the count.\n- `context/code/protocol.md` (§2, \"The count is ...\") says \"27 model effects, minus two ... leaves 25\" on #214. On #221 it still says \"29 model effects, minus three ... 26\", the pre-km71 text.\n- Two older claims are stale and become right only by accident after both PRs land:\n - `reduce.rs:525`, \"The exhaustive 26-variant walk is in the host crate\";\n - `context/test-evidence/protocol.md:351`, \"over the 26 `ModelCommand` variants\".\n- `context/repo-map.md` gotchas 3 and 38 say 24. That file is the orchestrator's.\n\n**Fix,** by whichever of #214 and #221 merges second:\n- 26 at `reduce.rs:78`;\n- `context/code/protocol.md` recounted by brace-matching the model effects (`doctrine(derive_then_verify)`);\n- the repo-map lines, by the orchestrator.\n\nA test that pins the count would make this a compile-or-test failure rather than prose.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T15:29:15Z","created_by":"agent-review-214b-223","updated_at":"2026-09-23T15:29:15Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-9trl","title":"The script remove-formula refusing a missing formula is untested, and it turns a script's exit status from 0 to 1","description":"Found reviewing PR #223 at 1af56ecf (improvise-tej1).\n\n`RemoveFormulaCmd` in `crates/improvise-command/src/command/cmd/effect_cmds.rs` now resolves `\u003ccategory\u003e \u003ctarget\u003e` to a `FormulaId`. When nothing matches, it returns `effect::refuse(\"No formula for T in C\")`. Before this change it was a silent no-op.\n\n**Rival:** replace that `None` arm with `vec![]`. `cargo test --workspace --no-fail-fast` stays fully green, so the new branch has no test.\n\n**The change is user-visible.** `src/main.rs` sets `exit_code = 1` on `last_batch_refused()`. So a headless `remove-formula` of a missing formula now exits 1, where it exited 0. A script that uses the line as an idempotent cleanup now fails.\n\nNo shipped script or example uses `remove-formula`. I grepped examples, docs, web, harness and tests.\n\n`context/code/clients.md` states the refusal. `context/code/cli.md`, which owns what a headless command reports, does not.\n\n**Fix:**\n- a command-level test that a missing formula refuses and names it;\n- a headless test of the exit status;\n- a line in `context/code/cli.md`.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T15:29:02Z","created_by":"agent-review-214b-223","updated_at":"2026-09-23T15:29:02Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-e9ay","title":"command.md's rtud paragraph is false once #229 lands: it says three refuse_and_stop sites are not in the key-path table, and #229 adds all three","description":"Found in adversarial review of PR #229 (head a1d27cc1), composed with #225 (7da905cd, the rtud prose, now on gh/main). Materialising the merge of #229 with gh/main is clean textually, and the suite passes (1873/0). But context/code/command.md's \"Every refusal site has a test that fails when it is reverted\" bullet then says: \"Three sites are not in the key-path table (improvise-rtud). The quit test's 'setup:' assertion holds the refusal of `load` and `import-json absent.json`. `a_formula_that_does_not_parse_...` holds `AddFormula`'s parse arm... The other five import arms are in `every_refusal_site_refuses_its_failure_and_only_its_failure`.\"\n\n#229 adds exactly those three rows to that table (\"load of an absent file\", \"import-json of an absent JSON\", \"a formula that does not parse\"). So after the merge all eight `refuse_and_stop` sites are in the key-path table and the paragraph is false. Also \"The wire table cannot see the refusal half of a refuse_and_stop site\" is still true, but it should now point at `every_failing_model_effect_that_aborts_also_refuses`, which does see it.\n\nFix: in #229, after merging gh/main, rewrite that bullet. All eight sites are in the key-path table (their abort is still `a_failed_load_or_import_does_not_run_the_quit_after_it`), and the effect-level we1a table holds the refusal half the wire table cannot see.\n","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-23T15:25:21Z","created_by":"agent-review-228-229","updated_at":"2026-09-23T15:25:21Z","labels":["adversarial-review","pr-229"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-lasd","title":"we1a's abort-implies-refuse table is hand-listed: a new Model effect that aborts without refusing leaves the workspace green","description":"Found in adversarial review of PR #229 (head a1d27cc1). `every_failing_model_effect_that_aborts_also_refuses` is a hand-listed table of 13 failing ModelCommands. It is complete TODAY: the Model effects that set `abort_effects` are AddCategory (its own arm), AddFormula's parse arm, LoadModel, and ImportJsonHeadless's six `refuse_and_stop` arms, i.e. 9 sites, and the table has exactly 9 aborting rows, one per site. (Save/SaveAs/ExportCsv abort through `report_file_write` and AbortChain aborts, but all four are ViewOnly and off the wire.)\n\nBut it cannot see a NEW aborting site. Injected: `on_view`'s missing-view branch changed from `app.refuse(why)` to `app.abort_effects = true` without refusing. That makes nine wire geometry variants (SetAxis, SetPageSelection, ToggleGroup, HideItem, ShowItem, TransposeAxes, CycleAxis, SetNumberFormat, TogglePruneEmpty naming an absent view) abort without refusing, which is exactly the invariant the test names. Result: `cargo test --workspace` 1858 passed, 0 failed. The PR's context/code/command.md says of the test \"it will fail when that stops being true\", and that is false. Only with the `|| last_batch_aborted()` arm ALSO deleted does one test fail (`a_frame_writing_a_view_its_own_first_command_deleted_is_refused`), so today the arm, not the new test, is what holds.\n\nDerivation instead of a list: observe it in `App::apply_effects`, which already reads each effect's `EffectKind`. After an `EffectKind::Model(_)` effect, `abort_effects \u0026\u0026 !refused` is the violation. It can be a debug_assert (every test that exercises any Model abort then checks it) or be made unrepresentable (a Model abort counts as a refusal). Scoping by kind exempts AbortChain for free, since it is ViewOnly, which answers the PR's first objection to option 2. This is `doctrine(observe, not predict)`, the same move as gotcha 24's frame observation. At minimum, correct the prose to say the table pins the nine listed sites and not the invariant.\n","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-23T15:25:10Z","created_by":"agent-review-228-229","updated_at":"2026-09-23T15:25:10Z","labels":["adversarial-review","pr-229"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-5wai","title":"#227's browser +cat commit clear is unpinned: dropping cleared() from create_column leaves the workspace green","description":"Found by the adversarial review of PR #227 (improvise-fy9o) at head 8ee5fade.\n\n#227 moves each cell-editing commit's buffer clear into the commit: `resolve::cleared`, called from both `commit` and `create_column`. The PR body claims that \"a commit that reopens nothing (the corner, `+cat`) still leaves no text behind\". Only the corner is tested.\n\nInjected fault: I replaced `commands.extend(cleared(\u0026edit_mode));` in `create_column` with `let _ = cleared(\u0026edit_mode);`. The browser's `+cat` commit then leaves the typed category name in the `edit` buffer, while the TUI's head clear empties it. `cargo test -p improvise-command -p improvise-protocol -p improvise-wasm-client -p improvise-worker-server -p improvise-ws-server --no-fail-fast` stays green.\n\nAcceptance: a lockstep test that commits a name at `+cat` and compares both ends' buffers, which must fail under that injection.\n","status":"in_progress","priority":3,"issue_type":"bug","assignee":"agent-ixxx","owner":"el-github@elangley.org","created_at":"2026-09-23T15:24:10Z","created_by":"agent-review-227-230","updated_at":"2026-09-23T15:35:11Z","started_at":"2026-09-23T15:35:11Z","lease_expires_at":"2026-09-23T15:40:11Z","heartbeat_at":"2026-09-23T15:35:11Z","labels":["adversarial-review","pr-227","test-gap"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-q69m","title":"Two context/code passages still describe tile select opening at tile 0 after improvise-oplp","description":"Found reviewing PR #231 (improvise-oplp), head 2383c37f, which does not touch context/code. (1) context/code/command.md, the paragraph beginning 'What the default cursor does cost, stated': it says one cycle key on tile 0 puts _Index on Row, and that the fix 'is the order the tile bar lists categories in'. #231 fixes it with the cursor's opening position (opening_tile) instead, and keeps the order. That paragraph should now say what was done, and why cursor placement beat reordering: click positions and #192's characterization of the offered order both stay unchanged. (2) context/code/clients.md, click Rule 3 ('State the rule this precisely'): 'T enters TileSelect leaving tile_cat_idx at 0, so a click on the leftmost tile ... cycles on the first gesture'. After #231 the tile that cycles on the first click is the first REGULAR tile, not the leftmost. The rule itself is unchanged; its counterexample now names the wrong tile. Gotcha 48 says it plainly: when a fact is stated in more than one place, changing it is a grep.","status":"in_progress","priority":3,"issue_type":"bug","assignee":"agent-docs-wave","owner":"el-github@elangley.org","created_at":"2026-09-23T15:23:37Z","created_by":"agent-review-231","updated_at":"2026-09-23T15:27:32Z","started_at":"2026-09-23T15:27:32Z","lease_expires_at":"2026-09-23T15:32:32Z","heartbeat_at":"2026-09-23T15:27:32Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-bqm0","title":"opening_tile falls back to _Index when a view has no Regular category, so T Enter still empties that grid","description":"Found reviewing PR #231 (improvise-oplp), head 2383c37f. improvise_protocol::opening_tile is tiles.iter().position(|t| t.regular).unwrap_or(0), and tile 0 is _Index. So in a document with no CategoryKind::Regular category (only _Measure data, or only Label categories beside measures), tile select still opens on _Index, and T Enter reproduces the oplp bug exactly. CONFIRMED with a scratch test at 2383c37f: Workbook::new with one _Measure item Revenue=5, App::new, handle_key T then Enter: layout went rows=1 cols=1 -\u003e rows=0 cols=1, _Index None -\u003e Row. Proposed fix: the fallback should be the first tile that is neither _Index nor _Dim (in practice _Measure), not index 0. A bool cannot express that without the client sniffing names, so carry the CategoryKind (already Serialize, and in improvise-core, which protocol depends on) in TileEntry in place of regular: bool, and let opening_tile rank Regular, then VirtualMeasure/Label, then the rest. _Measure should NOT be the primary opening: it is on an axis in every shipped document, and T Enter reads as confirm on the user's own dimension. It is the right fallback, though. Guard: a lockstep case on a no-regular document asserting that _Index stays off Row after T Enter.","status":"in_progress","priority":3,"issue_type":"bug","assignee":"agent-docs-wave","owner":"el-github@elangley.org","created_at":"2026-09-23T15:23:21Z","created_by":"agent-review-231","updated_at":"2026-09-23T15:27:33Z","started_at":"2026-09-23T15:27:33Z","lease_expires_at":"2026-09-23T15:32:33Z","heartbeat_at":"2026-09-23T15:27:33Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-lqs0","title":"bd-audit-ids: a file grep cannot read passes the audit (xargs 123 read as no-match); two exit-code mismatches","description":"Found in the adversarial review of PR #226 (improvise-52eq) at head 168a28d2.\n\nscripts/bd-audit-ids treats xargs exit 123 as \"one grep batch matched nothing\". But xargs also returns 123 when grep exits 2 on a read error. So a file the scan could not read makes the audit pass, and any dead id in that file is missed. The usage text promises exit 4 when \"the file scan itself failed\".\n\nReproduced in a throwaway repo with two tracked files: a.md cites a known id, b.md cites a dead id.\n- Control, both readable: exit 1, names the dead id.\n- b.md chmod 000: prints `grep: context/b.md: Permission denied`, then \"all 1 cited ids resolve\", exit 0.\n- b.md deleted from the worktree but still tracked (ls-files --cached lists it): prints \"No such file or directory\", then exit 0. This case is arguably right: the file's ids are leaving the tree. The fix must decide it on purpose, not by accident.\n\nTwo smaller exit-code mismatches with the stated contract. Both fail loudly, not silently:\n- Run outside a git repo: `cd: null directory`, exit 1. Exit 1 is the \"dead id\" code; the contract says 4.\n- bd prints a JSON array of objects with no `id` field: jq yields `null`, so `known` is non-empty. Every cited id is then reported dead, exit 1. It should be exit 3, \"bd did not answer\".\n\nThis is not a regression. The old recipe ignored grep errors the same way. It is the same shape 52eq fixed on the bd side: a failure that reads as a clean audit.\n\nSuggested fix: send grep's stderr to a file, and exit 4 if it names anything other than an intended skip. Also check `git rev-parse` before `cd`, and filter `null` out of the known ids. Each case needs a check-the-checker case in scripts/bd-audit-ids-test.sh.\n","status":"in_progress","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T15:08:30Z","created_by":"agent-review-211-226","updated_at":"2026-09-23T16:00:02Z","started_at":"2026-09-23T16:00:02Z","lease_expires_at":"2026-09-23T16:05:02Z","heartbeat_at":"2026-09-23T16:00:02Z","labels":["adversarial-review","pr-226","tooling"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-zs2r","title":"A manifest-only change to a path dependency stales harness/opfs-contract/Cargo.lock, and no fast gate sees it","description":"Found by the adversarial review of PR #224 (improvise-tt22), head e62a289c.\n\nPR #224 changed only workspace manifests. It removed indexmap's serde feature and improvise-formula's serde dependency, and updated the root Cargo.lock. harness/opfs-contract is its own workspace, with its own Cargo.lock, and it depends on improvise-io by path. That lock went stale: `cargo metadata` there drops \"serde\" from improvise-formula and \"serde\"/\"serde_core\" from indexmap. So CI \"Build all bundles\" failed at e62a289c on `--locked`, and \"Browser harness\" was skipped.\n\nThe agent-rules gate list did not send the author there. It runs harness/build.sh only for a change to web/, harness/ or the page's wasm surface. A manifest change to any crate opfs-contract reaches (improvise-io, improvise-core, improvise-formula) stales the lock too. Every host gate stayed green.\n\nTwo cheap closes:\n(a) agent-rules.md: extend the build/browser bullet to \"or any Cargo.toml a crate in harness/opfs-contract's dependency graph reads\".\n(b) Better: have the host `test` job run `cargo metadata --locked --format-version 1 \u003e/dev/null` in harness/opfs-contract. It takes seconds and fails in the fast job rather than the bundle job.\n\nThe message itself is improvise-lnsh.\n","status":"in_progress","priority":3,"issue_type":"bug","assignee":"agent-refuse-fu","owner":"el-github@elangley.org","created_at":"2026-09-23T15:08:04Z","created_by":"agent-review-224","updated_at":"2026-09-23T15:59:47Z","started_at":"2026-09-23T15:59:47Z","lease_expires_at":"2026-09-23T16:04:47Z","heartbeat_at":"2026-09-23T15:59:47Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-7fl8","title":"pr-reviews-test: timeout case asserts \u003e=5 polls where the fake clock makes it exactly 6; a loop stopping one tick early passes","description":"Found reviewing PR #218 at ebf3dd2b (improvise-7epn). The new case \"--wait keeps polling until its deadline\" asserts at least 5 polls. On the one-tick-per-read fake clock the correct loop polls exactly 6 times for --wait 5 (1 initial + 5 re-polls; deadline 1006, post-poll checks read 1002..1006). Two rivals pass the whole suite at 25/25, both injected and run:\n- a loop capped at 5 total polls regardless of the deadline (counter after the deadline check, break after 4 re-polls);\n- an off-by-one deadline, deadline=$(( $(date +%s) + WAIT - 1 )), which stops one second short (5 polls).\nThe evidence entry in context/test-evidence/tooling.md says the poll count tells \"a loop that stopped early from one that reached the deadline\"; it does so only for a loop that stops 2+ ticks early. Fix: assert polls = 6 (the fake clock makes the count exact, WAIT+1), which also catches an over-polling -le. Still open from 7epn's own description: deleting the deadline check makes the timeout case hang rather than FAIL; a bounded run (a poll ceiling in the fixture, or timeout) would turn that into a failure.\n","status":"in_progress","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T15:06:58Z","created_by":"agent-review-218","updated_at":"2026-09-23T15:37:47Z","started_at":"2026-09-23T15:37:47Z","lease_expires_at":"2026-09-23T15:42:47Z","heartbeat_at":"2026-09-23T15:37:47Z","labels":["adversarial-review-pr-218"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-cp76","title":"vndi's swap test has no control: an EditTarget that captures generation 0 always passes the suite","description":"Found in adversarial review of PR #219 (improvise-vndi) at head 5690903d.\n\nan_edit_opened_before_a_document_swap_is_refused_in_the_new_document pins only the REFUSE direction, and its fixture opens the editor at document_generation 0. Injected rival (run, then reverted): socket.rs::edit_target_at captures `document_generation = 0` instead of the basis's. The whole workspace stays green under it (cargo test --workspace --no-fail-fast, no FAILED). That rival refuses every inline edit opened after ANY LoadModel in a session with DocumentReplaced, forever, and nothing sees it: the one test that sets a nonzero generation asserts a refusal, which the rival also produces. Vacuity shape: a fixture that is right by accident (captured value 0 = the default).\n\nFix: a control in the same test or beside it — after the swap has been folded, open a FRESH editor (generation 1) on the same cell and commit; assert it is accepted and the value lands. That separates \"captures the generation at open\" from \"captures a constant\" and from \"refuses after any swap\". The primary rival (claim_as_read ignoring its argument) still fails the existing test, as the evidence entry says.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T15:05:28Z","created_by":"agent-review-219-220","updated_at":"2026-09-23T15:05:28Z","labels":["adversarial-review","pr-219","wasm-client"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-29m7","title":"The ModelCommand variant count is stated wrongly in three places once #214 and #221 both land (26, not 24 or 25)","description":"Found reviewing PRs #214 (improvise-km71) and #223 against #221 (improvise-fdk1).\n\nEach PR adds one `ModelCommand` variant: #214 adds `ReplaceFormula` and #221 adds `RenameRecordCoordinate`. I materialised gh/main 705c92d9 + #221 + #214 + #223. The enum then has **26** variants, and it builds and passes. Nothing counts the variants in a test, so no guard fails. The prose counts are wrong in every order:\n\n- `crates/improvise-protocol/src/reduce.rs:78` says \"25 variants\" on #214 and \"24 variants\" on #221. So #221 alone is already stale, since it added a variant without updating the count.\n- `context/code/protocol.md` (§2, \"The count is ...\") says \"27 model effects, minus two ... leaves 25\" on #214. On #221 it still says \"29 model effects, minus three ... 26\", the pre-km71 text.\n- Two older claims are stale and become right only by accident after both PRs land:\n - `reduce.rs:525`, \"The exhaustive 26-variant walk is in the host crate\";\n - `context/test-evidence/protocol.md:351`, \"over the 26 `ModelCommand` variants\".\n- `context/repo-map.md` gotchas 3 and 38 say 24. That file is the orchestrator's.\n\n**Fix,** by whichever of #214 and #221 merges second:\n- 26 at `reduce.rs:78`;\n- `context/code/protocol.md` recounted by brace-matching the model effects (`doctrine(derive_then_verify)`);\n- the repo-map lines, by the orchestrator.\n\nA test that pins the count would make this a compile-or-test failure rather than prose.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T15:29:15Z","created_by":"agent-review-214b-223","updated_at":"2026-09-23T15:29:15Z","dependencies":[{"issue_id":"improvise-29m7","depends_on_id":"improvise-pmt0","type":"parent-child","created_at":"2026-09-25T01:05:01Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-9trl","title":"The script remove-formula refusing a missing formula is untested, and it turns a script's exit status from 0 to 1","description":"Found reviewing PR #223 at 1af56ecf (improvise-tej1).\n\n`RemoveFormulaCmd` in `crates/improvise-command/src/command/cmd/effect_cmds.rs` now resolves `\u003ccategory\u003e \u003ctarget\u003e` to a `FormulaId`. When nothing matches, it returns `effect::refuse(\"No formula for T in C\")`. Before this change it was a silent no-op.\n\n**Rival:** replace that `None` arm with `vec![]`. `cargo test --workspace --no-fail-fast` stays fully green, so the new branch has no test.\n\n**The change is user-visible.** `src/main.rs` sets `exit_code = 1` on `last_batch_refused()`. So a headless `remove-formula` of a missing formula now exits 1, where it exited 0. A script that uses the line as an idempotent cleanup now fails.\n\nNo shipped script or example uses `remove-formula`. I grepped examples, docs, web, harness and tests.\n\n`context/code/clients.md` states the refusal. `context/code/cli.md`, which owns what a headless command reports, does not.\n\n**Fix:**\n- a command-level test that a missing formula refuses and names it;\n- a headless test of the exit status;\n- a line in `context/code/cli.md`.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T15:29:02Z","created_by":"agent-review-214b-223","updated_at":"2026-09-23T15:29:02Z","dependencies":[{"issue_id":"improvise-9trl","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T01:05:14Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-e9ay","title":"command.md's rtud paragraph is false once #229 lands: it says three refuse_and_stop sites are not in the key-path table, and #229 adds all three","description":"Found in adversarial review of PR #229 (head a1d27cc1), composed with #225 (7da905cd, the rtud prose, now on gh/main). Materialising the merge of #229 with gh/main is clean textually, and the suite passes (1873/0). But context/code/command.md's \"Every refusal site has a test that fails when it is reverted\" bullet then says: \"Three sites are not in the key-path table (improvise-rtud). The quit test's 'setup:' assertion holds the refusal of `load` and `import-json absent.json`. `a_formula_that_does_not_parse_...` holds `AddFormula`'s parse arm... The other five import arms are in `every_refusal_site_refuses_its_failure_and_only_its_failure`.\"\n\n#229 adds exactly those three rows to that table (\"load of an absent file\", \"import-json of an absent JSON\", \"a formula that does not parse\"). So after the merge all eight `refuse_and_stop` sites are in the key-path table and the paragraph is false. Also \"The wire table cannot see the refusal half of a refuse_and_stop site\" is still true, but it should now point at `every_failing_model_effect_that_aborts_also_refuses`, which does see it.\n\nFix: in #229, after merging gh/main, rewrite that bullet. All eight sites are in the key-path table (their abort is still `a_failed_load_or_import_does_not_run_the_quit_after_it`), and the effect-level we1a table holds the refusal half the wire table cannot see.\n","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-23T15:25:21Z","created_by":"agent-review-228-229","updated_at":"2026-09-23T15:25:21Z","labels":["adversarial-review","pr-229"],"dependencies":[{"issue_id":"improvise-e9ay","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T01:05:12Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-lasd","title":"we1a's abort-implies-refuse table is hand-listed: a new Model effect that aborts without refusing leaves the workspace green","description":"Found in adversarial review of PR #229 (head a1d27cc1). `every_failing_model_effect_that_aborts_also_refuses` is a hand-listed table of 13 failing ModelCommands. It is complete TODAY: the Model effects that set `abort_effects` are AddCategory (its own arm), AddFormula's parse arm, LoadModel, and ImportJsonHeadless's six `refuse_and_stop` arms, i.e. 9 sites, and the table has exactly 9 aborting rows, one per site. (Save/SaveAs/ExportCsv abort through `report_file_write` and AbortChain aborts, but all four are ViewOnly and off the wire.)\n\nBut it cannot see a NEW aborting site. Injected: `on_view`'s missing-view branch changed from `app.refuse(why)` to `app.abort_effects = true` without refusing. That makes nine wire geometry variants (SetAxis, SetPageSelection, ToggleGroup, HideItem, ShowItem, TransposeAxes, CycleAxis, SetNumberFormat, TogglePruneEmpty naming an absent view) abort without refusing, which is exactly the invariant the test names. Result: `cargo test --workspace` 1858 passed, 0 failed. The PR's context/code/command.md says of the test \"it will fail when that stops being true\", and that is false. Only with the `|| last_batch_aborted()` arm ALSO deleted does one test fail (`a_frame_writing_a_view_its_own_first_command_deleted_is_refused`), so today the arm, not the new test, is what holds.\n\nDerivation instead of a list: observe it in `App::apply_effects`, which already reads each effect's `EffectKind`. After an `EffectKind::Model(_)` effect, `abort_effects \u0026\u0026 !refused` is the violation. It can be a debug_assert (every test that exercises any Model abort then checks it) or be made unrepresentable (a Model abort counts as a refusal). Scoping by kind exempts AbortChain for free, since it is ViewOnly, which answers the PR's first objection to option 2. This is `doctrine(observe, not predict)`, the same move as gotcha 24's frame observation. At minimum, correct the prose to say the table pins the nine listed sites and not the invariant.\n","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-23T15:25:10Z","created_by":"agent-review-228-229","updated_at":"2026-09-23T15:25:10Z","labels":["adversarial-review","pr-229"],"dependencies":[{"issue_id":"improvise-lasd","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T01:05:12Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-5wai","title":"#227's browser +cat commit clear is unpinned: dropping cleared() from create_column leaves the workspace green","description":"Found by the adversarial review of PR #227 (improvise-fy9o) at head 8ee5fade.\n\n#227 moves each cell-editing commit's buffer clear into the commit: `resolve::cleared`, called from both `commit` and `create_column`. The PR body claims that \"a commit that reopens nothing (the corner, `+cat`) still leaves no text behind\". Only the corner is tested.\n\nInjected fault: I replaced `commands.extend(cleared(\u0026edit_mode));` in `create_column` with `let _ = cleared(\u0026edit_mode);`. The browser's `+cat` commit then leaves the typed category name in the `edit` buffer, while the TUI's head clear empties it. `cargo test -p improvise-command -p improvise-protocol -p improvise-wasm-client -p improvise-worker-server -p improvise-ws-server --no-fail-fast` stays green.\n\nAcceptance: a lockstep test that commits a name at `+cat` and compares both ends' buffers, which must fail under that injection.\n","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T15:24:10Z","created_by":"agent-review-227-230","updated_at":"2026-09-25T01:00:00Z","closed_at":"2026-09-25T01:00:00Z","close_reason":"Landed in 85c483bd on gh/main (pin the +cat clear)","labels":["adversarial-review","pr-227","test-gap"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-q69m","title":"Two context/code passages still describe tile select opening at tile 0 after improvise-oplp","description":"Found reviewing PR #231 (improvise-oplp), head 2383c37f, which does not touch context/code. (1) context/code/command.md, the paragraph beginning 'What the default cursor does cost, stated': it says one cycle key on tile 0 puts _Index on Row, and that the fix 'is the order the tile bar lists categories in'. #231 fixes it with the cursor's opening position (opening_tile) instead, and keeps the order. That paragraph should now say what was done, and why cursor placement beat reordering: click positions and #192's characterization of the offered order both stay unchanged. (2) context/code/clients.md, click Rule 3 ('State the rule this precisely'): 'T enters TileSelect leaving tile_cat_idx at 0, so a click on the leftmost tile ... cycles on the first gesture'. After #231 the tile that cycles on the first click is the first REGULAR tile, not the leftmost. The rule itself is unchanged; its counterexample now names the wrong tile. Gotcha 48 says it plainly: when a fact is stated in more than one place, changing it is a grep.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T15:23:37Z","created_by":"agent-review-231","updated_at":"2026-09-25T01:00:00Z","closed_at":"2026-09-25T01:00:00Z","close_reason":"Landed in #242 (66bd3fcb on gh/main)","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-bqm0","title":"opening_tile falls back to _Index when a view has no Regular category, so T Enter still empties that grid","description":"Found reviewing PR #231 (improvise-oplp), head 2383c37f. improvise_protocol::opening_tile is tiles.iter().position(|t| t.regular).unwrap_or(0), and tile 0 is _Index. So in a document with no CategoryKind::Regular category (only _Measure data, or only Label categories beside measures), tile select still opens on _Index, and T Enter reproduces the oplp bug exactly. CONFIRMED with a scratch test at 2383c37f: Workbook::new with one _Measure item Revenue=5, App::new, handle_key T then Enter: layout went rows=1 cols=1 -\u003e rows=0 cols=1, _Index None -\u003e Row. Proposed fix: the fallback should be the first tile that is neither _Index nor _Dim (in practice _Measure), not index 0. A bool cannot express that without the client sniffing names, so carry the CategoryKind (already Serialize, and in improvise-core, which protocol depends on) in TileEntry in place of regular: bool, and let opening_tile rank Regular, then VirtualMeasure/Label, then the rest. _Measure should NOT be the primary opening: it is on an axis in every shipped document, and T Enter reads as confirm on the user's own dimension. It is the right fallback, though. Guard: a lockstep case on a no-regular document asserting that _Index stays off Row after T Enter.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T15:23:21Z","created_by":"agent-review-231","updated_at":"2026-09-25T01:00:00Z","closed_at":"2026-09-25T01:00:00Z","close_reason":"Landed in #242 (66bd3fcb on gh/main)","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-lqs0","title":"bd-audit-ids: a file grep cannot read passes the audit (xargs 123 read as no-match); two exit-code mismatches","description":"Found in the adversarial review of PR #226 (improvise-52eq) at head 168a28d2.\n\nscripts/bd-audit-ids treats xargs exit 123 as \"one grep batch matched nothing\". But xargs also returns 123 when grep exits 2 on a read error. So a file the scan could not read makes the audit pass, and any dead id in that file is missed. The usage text promises exit 4 when \"the file scan itself failed\".\n\nReproduced in a throwaway repo with two tracked files: a.md cites a known id, b.md cites a dead id.\n- Control, both readable: exit 1, names the dead id.\n- b.md chmod 000: prints `grep: context/b.md: Permission denied`, then \"all 1 cited ids resolve\", exit 0.\n- b.md deleted from the worktree but still tracked (ls-files --cached lists it): prints \"No such file or directory\", then exit 0. This case is arguably right: the file's ids are leaving the tree. The fix must decide it on purpose, not by accident.\n\nTwo smaller exit-code mismatches with the stated contract. Both fail loudly, not silently:\n- Run outside a git repo: `cd: null directory`, exit 1. Exit 1 is the \"dead id\" code; the contract says 4.\n- bd prints a JSON array of objects with no `id` field: jq yields `null`, so `known` is non-empty. Every cited id is then reported dead, exit 1. It should be exit 3, \"bd did not answer\".\n\nThis is not a regression. The old recipe ignored grep errors the same way. It is the same shape 52eq fixed on the bd side: a failure that reads as a clean audit.\n\nSuggested fix: send grep's stderr to a file, and exit 4 if it names anything other than an intended skip. Also check `git rev-parse` before `cd`, and filter `null` out of the known ids. Each case needs a check-the-checker case in scripts/bd-audit-ids-test.sh.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T15:08:30Z","created_by":"agent-review-211-226","updated_at":"2026-09-24T21:45:23Z","started_at":"2026-09-23T16:00:02Z","closed_at":"2026-09-24T21:45:23Z","close_reason":"Landed in main via #244 (reviewed; merged 2026-09-24).","labels":["adversarial-review","pr-226","tooling"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-zs2r","title":"A manifest-only change to a path dependency stales harness/opfs-contract/Cargo.lock, and no fast gate sees it","description":"Found by the adversarial review of PR #224 (improvise-tt22), head e62a289c.\n\nPR #224 changed only workspace manifests. It removed indexmap's serde feature and improvise-formula's serde dependency, and updated the root Cargo.lock. harness/opfs-contract is its own workspace, with its own Cargo.lock, and it depends on improvise-io by path. That lock went stale: `cargo metadata` there drops \"serde\" from improvise-formula and \"serde\"/\"serde_core\" from indexmap. So CI \"Build all bundles\" failed at e62a289c on `--locked`, and \"Browser harness\" was skipped.\n\nThe agent-rules gate list did not send the author there. It runs harness/build.sh only for a change to web/, harness/ or the page's wasm surface. A manifest change to any crate opfs-contract reaches (improvise-io, improvise-core, improvise-formula) stales the lock too. Every host gate stayed green.\n\nTwo cheap closes:\n(a) agent-rules.md: extend the build/browser bullet to \"or any Cargo.toml a crate in harness/opfs-contract's dependency graph reads\".\n(b) Better: have the host `test` job run `cargo metadata --locked --format-version 1 \u003e/dev/null` in harness/opfs-contract. It takes seconds and fails in the fast job rather than the bundle job.\n\nThe message itself is improvise-lnsh.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-refuse-fu","owner":"el-github@elangley.org","created_at":"2026-09-23T15:08:04Z","created_by":"agent-review-224","updated_at":"2026-09-24T21:45:26Z","started_at":"2026-09-23T15:59:47Z","closed_at":"2026-09-24T21:45:26Z","close_reason":"Landed in main via #245 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-7fl8","title":"pr-reviews-test: timeout case asserts \u003e=5 polls where the fake clock makes it exactly 6; a loop stopping one tick early passes","description":"Found reviewing PR #218 at ebf3dd2b (improvise-7epn). The new case \"--wait keeps polling until its deadline\" asserts at least 5 polls. On the one-tick-per-read fake clock the correct loop polls exactly 6 times for --wait 5 (1 initial + 5 re-polls; deadline 1006, post-poll checks read 1002..1006). Two rivals pass the whole suite at 25/25, both injected and run:\n- a loop capped at 5 total polls regardless of the deadline (counter after the deadline check, break after 4 re-polls);\n- an off-by-one deadline, deadline=$(( $(date +%s) + WAIT - 1 )), which stops one second short (5 polls).\nThe evidence entry in context/test-evidence/tooling.md says the poll count tells \"a loop that stopped early from one that reached the deadline\"; it does so only for a loop that stops 2+ ticks early. Fix: assert polls = 6 (the fake clock makes the count exact, WAIT+1), which also catches an over-polling -le. Still open from 7epn's own description: deleting the deadline check makes the timeout case hang rather than FAIL; a bounded run (a poll ceiling in the fixture, or timeout) would turn that into a failure.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T15:06:58Z","created_by":"agent-review-218","updated_at":"2026-09-24T21:45:03Z","started_at":"2026-09-23T15:37:47Z","closed_at":"2026-09-24T21:45:03Z","close_reason":"Landed in main via #237 (reviewed; merged 2026-09-24).","labels":["adversarial-review-pr-218"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-cp76","title":"vndi's swap test has no control: an EditTarget that captures generation 0 always passes the suite","description":"Found in adversarial review of PR #219 (improvise-vndi) at head 5690903d.\n\nan_edit_opened_before_a_document_swap_is_refused_in_the_new_document pins only the REFUSE direction, and its fixture opens the editor at document_generation 0. Injected rival (run, then reverted): socket.rs::edit_target_at captures `document_generation = 0` instead of the basis's. The whole workspace stays green under it (cargo test --workspace --no-fail-fast, no FAILED). That rival refuses every inline edit opened after ANY LoadModel in a session with DocumentReplaced, forever, and nothing sees it: the one test that sets a nonzero generation asserts a refusal, which the rival also produces. Vacuity shape: a fixture that is right by accident (captured value 0 = the default).\n\nFix: a control in the same test or beside it — after the swap has been folded, open a FRESH editor (generation 1) on the same cell and commit; assert it is accepted and the value lands. That separates \"captures the generation at open\" from \"captures a constant\" and from \"refuses after any swap\". The primary rival (claim_as_read ignoring its argument) still fails the existing test, as the evidence entry says.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T15:05:28Z","created_by":"agent-review-219-220","updated_at":"2026-09-23T15:05:28Z","labels":["adversarial-review","pr-219","wasm-client"],"dependencies":[{"issue_id":"improvise-cp76","depends_on_id":"improvise-6jk","type":"parent-child","created_at":"2026-09-25T01:04:41Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-cq3d","title":"A standing status_msg hides the derived key hint in every TUI mode: nothing clears it","description":"Found in adversarial review of PR #220 (improvise-17ak) at head 5e37973f, while checking that PR's reason for keeping the help footer.\n\nCONFIRMED with a scratch integration test (not committed): App::new over a one-category workbook, status_msg = \"Saved to x.improv\", then keys j k l h ? (enters Help), l, q (back to Normal). status_msg is still \"Saved to x.improv\" after every key and both mode changes.\n\nsrc/draw.rs::draw_status prints status_msg INSTEAD OF app.hint_text() whenever the message is non-empty, and no production code clears it: the only `status_msg.clear()` / `= String::new()` sites are in tests (improvise-command ui/app.rs ~2303, ui/effect.rs ~2313). A message is only ever replaced by another message. So after the first \"Saved\", \"Loaded\", or error of a session, the derived key hint (hint_line / hint_text, the table rr8n made the one true key table on screen) is never drawn again in ANY mode, until something else writes a message.\n\nThis is why PR #220 had to keep a key table inside the help popup footer: the status row that rr8n's rule would otherwise rely on is hidden by a stale message. Fix shape is a design question: clear status_msg on mode change or on the next keypress (vim-style), or compose message and hint on one line with gotcha 50's Keeps. Test must assert the hint is drawn after a message, in a mode other than the one the message was written in.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-docs-wave","owner":"el-github@elangley.org","created_at":"2026-09-23T15:05:02Z","created_by":"agent-review-219-220","updated_at":"2026-09-23T17:52:51Z","started_at":"2026-09-23T15:14:38Z","closed_at":"2026-09-23T17:52:51Z","close_reason":"Fixed in PR #233, merged as b8a4849 after adversarial review at d9ea594. Follow-up: improvise-su4u.","labels":["adversarial-review","pr-220","tui"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-qlrh","title":"every_shipped_document_loads does not walk harness/fixtures","description":"Raised by the author of PR #224 (improvise-tt22) and confirmed by its adversarial review at head e62a289c.\n\ntests/shipped_documents_load.rs::every_shipped_document_loads walks only examples/ and web/. harness/fixtures/grouped.improv and harness/fixtures/tall.improv are not walked. Both start with the v2025-04-09 version line and load today.\n\nTheir only loader is the Playwright browser job. harness/build.sh copies them beside the demo, and grouped.spec.js, narrow.spec.js and contract.spec.js assert labels the fixtures carry. So a format change that stops them loading is caught, but only by the slow browser job, and only as a missing-label failure. A missing seed degrades to an EMPTY model (web/worker.js), not a load error, so that failure does not name the cause. `cargo test` never sees it.\n\nFix: add \"harness/fixtures\" to the directory list in shipped_documents(). Keep the precondition count honest: it asserts \u003e= 2 today, so raise it or assert per directory. Otherwise a walk that silently lost one directory would still pass.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-refuse-fu","owner":"el-github@elangley.org","created_at":"2026-09-23T15:04:26Z","created_by":"agent-review-224","updated_at":"2026-09-23T15:42:06Z","started_at":"2026-09-23T15:24:00Z","closed_at":"2026-09-23T15:42:06Z","close_reason":"Fixed in PR #234, merged after adversarial review at 91c80cb. Follow-up: improvise-c0aj.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-o9q7","title":"A drill commit refused for a removed category reports a later row's blank-coordinate refusal instead, so the user is never told every staged edit was discarded","description":"Found reviewing PR #222 (improvise-qz9p) at 8490f34f. merged() keeps ALL rows' refusals when any row is unwritable, and effects() applies them in row order, so status_msg shows the LAST. Probe: drill of row 0 {Type=Food,Month=Jan} and row 1 {Month=Jan} (no Type); remove_category(Type); stage row 0 Cost=5, row 1 Month='' and row 1 Cost=999; ApplyAndClearDrill. Status: 'Record coordinates cannot be empty'. Store: untouched. The user reads that their blank was refused, and command.md says a blank coordinate is 'refused alone, and the row's other edits still land', so they expect row 1's Cost=999 and row 0's Cost=5 to have landed. Neither did, and 'Unknown category Type' was overwritten. Two further gaps in the message even without the mask: it says WHY, never WHAT was lost (every staged edit in the drill, including writable rows'), and the edits are not recoverable (drill_state is taken; re-entering gives a fresh snapshot). Fix candidates: when unwritable, keep only the unwritable refusals (the blank ones are moot, nothing is written), and word it as a discard, e.g. \"Drill edits discarded: unknown category 'Type'\".","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T15:04:10Z","created_by":"agent-review-222","updated_at":"2026-09-23T15:04:10Z","labels":["adversarial-review","pr-222"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-6r2y","title":"RecordRewrite::of's unwritable check over RENAMED categories is unpinned: dropping .chain(renames.keys()) leaves the suite green","description":"Found reviewing PR #222 (improvise-qz9p) at 8490f34f. The check is documented (context/code/command.md, and the PR body) as covering 'both its coordinates and its renames'. Injected rival: comment out .chain(renames.keys().copied()) in RecordRewrite::of. improvise-command lib: 607 passed, 0 of the PR's tests failed. The rename test (a_drill_rename_into_a_removed_category_is_refused_and_writes_nothing) is vacuous for that half because row 1's COORDINATES already name Type, so the coords half refuses first. The half is only reachable on a SPARSE row (one with no Type coordinate) that stages a Type rename after Type is removed. Discriminating probe: a drill of one untyped record {Month=Jan,_Measure=Cost}=17; remove_category(Type); stage Category(Type)=Drink; ApplyAndClearDrill; assert store == {-/Jan/Cost = 17} and refused. Passes at 8490f34f, fails under the rival (writes Drink/Jan/Cost, AddItem refusing beside it: the qz9p shape again). Fix: add that test in #222.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T15:04:06Z","created_by":"agent-review-222","updated_at":"2026-09-23T15:04:06Z","labels":["adversarial-review","pr-222"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-dak5","title":"Nothing checks that every checkers.sh suite runs in CI: ci.yml names the suites by hand","description":"Found by agent-mergetrain on improvise-52eq (PR #226). The CI step runs 'checkers.sh pr-watch release-preflight pr-reviews fmt-workspaces', an explicit list. A new scripts/*-test.sh is picked up by checkers.sh locally but never runs in CI unless someone remembers to add it. Derive the CI list from 'checkers.sh --list', or add a check (in check-ci-graph.py or similar) that every suite checkers.sh knows appears in ci.yml. Plant a fake suite and show the check fails.","status":"in_progress","priority":3,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T15:01:41Z","created_by":"orchestrator","updated_at":"2026-09-23T15:02:20Z","started_at":"2026-09-23T15:02:20Z","lease_expires_at":"2026-09-23T15:07:20Z","heartbeat_at":"2026-09-23T15:02:20Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-mv1d","title":"Browser help overlay overflows horizontally at phone width; #215's sticky footer scrolls away with it","description":"Found by the adversarial review of PR #215 at head a8737f3f. Measured in Chromium through the harness on the merge of a8737f3f into gh/main (52c6a053).\n\n**Part one predates #215.** At a 320px viewport, `#help` overflows horizontally on every page:\n\n- Welcome: scrollWidth 692 against clientWidth 254.\n- Navigation: scrollWidth 510 against clientWidth 254. The first `dd` starts at x=329, past the right edge of the 320px viewport.\n\nThe same numbers come out with gh/main's web/app.js and style.css, so #215 did not cause this. On a phone, every key description sits off-screen to the right, and the only way to read them is to scroll the overlay sideways.\n\n**Part two is new in #215.** `.help-footer` (improvise-1c5m) is `position: sticky; bottom: -1.25rem`. It sticks to the bottom only. So when the user scrolls the overlay sideways to read those descriptions, the footer scrolls away with the content. With scrollLeft at its maximum on Navigation, the footer's box is at x = -199..7, and `elementFromPoint` does not find it. This affects only 1c5m's narrow-width case, and only while the overlay is scrolled sideways. At scrollLeft 0 the footer is visible and topmost, at 320x568, 320x300 and 320x200 alike.\n\n**Suggested fix.** Remove the horizontal overflow at phone width, for example by collapsing the help-body grid to one column below a breakpoint. That fixes both parts. Adding `left: 0` to the footer alone fixes only part two.\n\n**How to reproduce.** Add a Playwright case at 320x568 that opens help, presses `l`, and sets `#help.scrollLeft = scrollWidth`. Then check `elementFromPoint` on `.help-footer`, and check the first `dd`'s bounding rect against `innerWidth`.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T14:57:36Z","created_by":"agent-review-215","updated_at":"2026-09-23T14:57:36Z","labels":["adversarial-review","browser","help","ux"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-o9q7","title":"A drill commit refused for a removed category reports a later row's blank-coordinate refusal instead, so the user is never told every staged edit was discarded","description":"Found reviewing PR #222 (improvise-qz9p) at 8490f34f. merged() keeps ALL rows' refusals when any row is unwritable, and effects() applies them in row order, so status_msg shows the LAST. Probe: drill of row 0 {Type=Food,Month=Jan} and row 1 {Month=Jan} (no Type); remove_category(Type); stage row 0 Cost=5, row 1 Month='' and row 1 Cost=999; ApplyAndClearDrill. Status: 'Record coordinates cannot be empty'. Store: untouched. The user reads that their blank was refused, and command.md says a blank coordinate is 'refused alone, and the row's other edits still land', so they expect row 1's Cost=999 and row 0's Cost=5 to have landed. Neither did, and 'Unknown category Type' was overwritten. Two further gaps in the message even without the mask: it says WHY, never WHAT was lost (every staged edit in the drill, including writable rows'), and the edits are not recoverable (drill_state is taken; re-entering gives a fresh snapshot). Fix candidates: when unwritable, keep only the unwritable refusals (the blank ones are moot, nothing is written), and word it as a discard, e.g. \"Drill edits discarded: unknown category 'Type'\".","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T15:04:10Z","created_by":"agent-review-222","updated_at":"2026-09-23T15:04:10Z","labels":["adversarial-review","pr-222"],"dependencies":[{"issue_id":"improvise-o9q7","depends_on_id":"improvise-n28d","type":"parent-child","created_at":"2026-09-25T01:05:23Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-6r2y","title":"RecordRewrite::of's unwritable check over RENAMED categories is unpinned: dropping .chain(renames.keys()) leaves the suite green","description":"Found reviewing PR #222 (improvise-qz9p) at 8490f34f. The check is documented (context/code/command.md, and the PR body) as covering 'both its coordinates and its renames'. Injected rival: comment out .chain(renames.keys().copied()) in RecordRewrite::of. improvise-command lib: 607 passed, 0 of the PR's tests failed. The rename test (a_drill_rename_into_a_removed_category_is_refused_and_writes_nothing) is vacuous for that half because row 1's COORDINATES already name Type, so the coords half refuses first. The half is only reachable on a SPARSE row (one with no Type coordinate) that stages a Type rename after Type is removed. Discriminating probe: a drill of one untyped record {Month=Jan,_Measure=Cost}=17; remove_category(Type); stage Category(Type)=Drink; ApplyAndClearDrill; assert store == {-/Jan/Cost = 17} and refused. Passes at 8490f34f, fails under the rival (writes Drink/Jan/Cost, AddItem refusing beside it: the qz9p shape again). Fix: add that test in #222.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T15:04:06Z","created_by":"agent-review-222","updated_at":"2026-09-23T15:04:06Z","labels":["adversarial-review","pr-222"],"dependencies":[{"issue_id":"improvise-6r2y","depends_on_id":"improvise-n28d","type":"parent-child","created_at":"2026-09-25T01:05:23Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-dak5","title":"Nothing checks that every checkers.sh suite runs in CI: ci.yml names the suites by hand","description":"Found by agent-mergetrain on improvise-52eq (PR #226). The CI step runs 'checkers.sh pr-watch release-preflight pr-reviews fmt-workspaces', an explicit list. A new scripts/*-test.sh is picked up by checkers.sh locally but never runs in CI unless someone remembers to add it. Derive the CI list from 'checkers.sh --list', or add a check (in check-ci-graph.py or similar) that every suite checkers.sh knows appears in ci.yml. Plant a fake suite and show the check fails.","status":"closed","priority":3,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T15:01:41Z","created_by":"orchestrator","updated_at":"2026-09-24T21:44:47Z","started_at":"2026-09-23T15:02:20Z","closed_at":"2026-09-24T21:44:47Z","close_reason":"Landed in main via #228 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-mv1d","title":"Browser help overlay overflows horizontally at phone width; #215's sticky footer scrolls away with it","description":"Found by the adversarial review of PR #215 at head a8737f3f. Measured in Chromium through the harness on the merge of a8737f3f into gh/main (52c6a053).\n\n**Part one predates #215.** At a 320px viewport, `#help` overflows horizontally on every page:\n\n- Welcome: scrollWidth 692 against clientWidth 254.\n- Navigation: scrollWidth 510 against clientWidth 254. The first `dd` starts at x=329, past the right edge of the 320px viewport.\n\nThe same numbers come out with gh/main's web/app.js and style.css, so #215 did not cause this. On a phone, every key description sits off-screen to the right, and the only way to read them is to scroll the overlay sideways.\n\n**Part two is new in #215.** `.help-footer` (improvise-1c5m) is `position: sticky; bottom: -1.25rem`. It sticks to the bottom only. So when the user scrolls the overlay sideways to read those descriptions, the footer scrolls away with the content. With scrollLeft at its maximum on Navigation, the footer's box is at x = -199..7, and `elementFromPoint` does not find it. This affects only 1c5m's narrow-width case, and only while the overlay is scrolled sideways. At scrollLeft 0 the footer is visible and topmost, at 320x568, 320x300 and 320x200 alike.\n\n**Suggested fix.** Remove the horizontal overflow at phone width, for example by collapsing the help-body grid to one column below a breakpoint. That fixes both parts. Adding `left: 0` to the footer alone fixes only part two.\n\n**How to reproduce.** Add a Playwright case at 320x568 that opens help, presses `l`, and sets `#help.scrollLeft = scrollWidth`. Then check `elementFromPoint` on `.help-footer`, and check the first `dd`'s bounding rect against `innerWidth`.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T14:57:36Z","created_by":"agent-review-215","updated_at":"2026-09-23T14:57:36Z","labels":["adversarial-review","browser","help","ux"],"dependencies":[{"issue_id":"improvise-mv1d","depends_on_id":"improvise-fhj0","type":"parent-child","created_at":"2026-09-25T01:05:39Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-b114","title":"Wire SetCell to a collapsed _Measure-group fold is accepted and stores a non-item _Measure coordinate (#217's stored arm skips the projection)","description":"Found in adversarial review of PR #217 (improvise-ciw8), head bc060f67. Pre-existing, and NOT closed by #217, although it is the same class #217 claims to close.\n\nWith `_Measure` on Column and Revenue,Cost in a collapsed group Money, the host projects `{Month=Feb, _Measure=Money}` as `CellId::Aggregate`. A hand-rolled wire `SetCell { record: RecordHash({Month=Feb}), measure: Some(\"Money\") }` is answered `Accepted` and stores `Month=Feb, _Measure=Money = 7` — a non-item coordinate (the improvise-gw18 shape). The record `{Month=Feb}` is in the stored index, so #217's stored arm accepts it without asking the projection; the fold lives entirely in the measure half, which `RecordHash` drops.\n\nRepro: reviewer's `r3_a_wire_write_to_a_collapsed_measure_group_is_rejected` (host.rs), which fails at bc060f67 and with `unwritable` disabled.\n\nCare: simply sending stored records through `cell_id` (or a full-key lookup in the host's layout) would refuse a legit records-mode write of a stored record the HOST's view aggregates over a None-axis category (see improvise-sjdb's pinning test). A fold is a non-item coordinate in any view; a None-axis aggregate is view-dependent. The rule likely wants: refuse a cell whose measure (or any coordinate) is a group name rather than an item, independent of the host's view.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-collapse-fu","owner":"el-github@elangley.org","created_at":"2026-09-23T14:55:43Z","created_by":"Edward Langley","updated_at":"2026-09-23T17:52:52Z","started_at":"2026-09-23T15:27:12Z","closed_at":"2026-09-23T17:52:52Z","close_reason":"Fixed in PR #235, merged as 799a81a after adversarial review at 5c19fdd. Follow-ups: improvise-pr8t, improvise-nacd, improvise-vwc7.","labels":["adversarial-review","pr-217"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-ywj2","title":"PasteAt::Resolved is a pub variant over a raw CellKey: 'came from a cursor door' is a convention, not a type","description":"Found by the adversarial delta re-review of PR #197 at 087aa854 (agent-review-197-203b).\n\n`PasteAt::Resolved(CellKey)` (crates/improvise-command/src/command/cmd/cell.rs) is a public variant wrapping a raw key, and `PasteCell::execute` writes a `Resolved` key with `SetCell` and no checks. The only production constructor today is the paste fallback closure in `registry.rs`, which builds it from `ctx.value_cell_key()`. That closure runs only when `parse` refuses, and `parse` refuses only an empty argument list. So nothing is broken today. But the rule \"a Resolved key came from a cursor door\" is a naming convention: any code in the crate, or outside it through the pub path, can write `PasteAt::Resolved(anything)` and skip every check.\n\nThis is the shape `Checked\u003c'_\u003e` was built to close (gotcha 43, `doctrine(an_invariant_is_made_unrepresentable_not_validated)`). The proposed fix is a private-field newtype such as `DoorKey(CellKey)`. `value_cell_key` (and `committable_cell_key` if it helps) returns it, and it is the only way to build one. Then `PasteAt::Resolved` carries a `DoorKey`, and a raw key cannot reach the unchecked arm. The guard is the signature.\n\nThe alternatives: keep the enum and make `Resolved` constructible only through a `pub(super)` fn. That is weaker, for the reason gotcha 43 gives against visibility.\n","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-23T14:54:15Z","created_by":"Edward Langley","updated_at":"2026-09-23T14:54:15Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-tej1","title":"Browser can now compose delete-formula-at-cursor: FormulaCache carries ids since km71","description":"Found by agent-km71 (PR #214). Since the formula projection carries FormulaEntry{id, raw}, the browser can identify a formula, so delete-formula-at-cursor no longer has to be declined in resolve.rs. Compose it, add a lockstep case, and remove it from the 'still needs the host' list.","status":"in_progress","priority":3,"issue_type":"task","assignee":"agent-km71","owner":"el-github@elangley.org","created_at":"2026-09-23T14:42:22Z","created_by":"orchestrator","updated_at":"2026-09-23T14:43:05Z","started_at":"2026-09-23T14:43:05Z","lease_expires_at":"2026-09-23T14:48:05Z","heartbeat_at":"2026-09-23T14:43:05Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-ywj2","title":"PasteAt::Resolved is a pub variant over a raw CellKey: 'came from a cursor door' is a convention, not a type","description":"Found by the adversarial delta re-review of PR #197 at 087aa854 (agent-review-197-203b).\n\n`PasteAt::Resolved(CellKey)` (crates/improvise-command/src/command/cmd/cell.rs) is a public variant wrapping a raw key, and `PasteCell::execute` writes a `Resolved` key with `SetCell` and no checks. The only production constructor today is the paste fallback closure in `registry.rs`, which builds it from `ctx.value_cell_key()`. That closure runs only when `parse` refuses, and `parse` refuses only an empty argument list. So nothing is broken today. But the rule \"a Resolved key came from a cursor door\" is a naming convention: any code in the crate, or outside it through the pub path, can write `PasteAt::Resolved(anything)` and skip every check.\n\nThis is the shape `Checked\u003c'_\u003e` was built to close (gotcha 43, `doctrine(an_invariant_is_made_unrepresentable_not_validated)`). The proposed fix is a private-field newtype such as `DoorKey(CellKey)`. `value_cell_key` (and `committable_cell_key` if it helps) returns it, and it is the only way to build one. Then `PasteAt::Resolved` carries a `DoorKey`, and a raw key cannot reach the unchecked arm. The guard is the signature.\n\nThe alternatives: keep the enum and make `Resolved` constructible only through a `pub(super)` fn. That is weaker, for the reason gotcha 43 gives against visibility.\n","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-23T14:54:15Z","created_by":"Edward Langley","updated_at":"2026-09-23T14:54:15Z","dependencies":[{"issue_id":"improvise-ywj2","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T01:05:13Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-tej1","title":"Browser can now compose delete-formula-at-cursor: FormulaCache carries ids since km71","description":"Found by agent-km71 (PR #214). Since the formula projection carries FormulaEntry{id, raw}, the browser can identify a formula, so delete-formula-at-cursor no longer has to be declined in resolve.rs. Compose it, add a lockstep case, and remove it from the 'still needs the host' list.","status":"closed","priority":3,"issue_type":"task","assignee":"agent-km71","owner":"el-github@elangley.org","created_at":"2026-09-23T14:42:22Z","created_by":"orchestrator","updated_at":"2026-09-24T21:45:23Z","started_at":"2026-09-23T14:43:05Z","closed_at":"2026-09-24T21:45:23Z","close_reason":"Landed in main via #223 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-lnsh","title":"harness/build.sh: a stale opfs-contract lock prints cargo's hint to remove --locked, not the real remedy","description":"Found reviewing PR #212 at 1550d390 (improvise-mkbp). With a stale harness/opfs-contract/Cargo.lock, build.sh correctly exits 101 in about 4s and leaves the lock untouched. But the only text it prints is cargo's: 'cannot update the lock file ... because --locked was passed' then 'help: ... remove the --locked flag and use --offline instead.' That hint points at deleting the guard. The real remedy, 'cargo update -w in harness/opfs-contract, and commit it' (verified to repair both the pest case and a path-dep version bump), is only in a source comment. Fix: '|| { echo \"harness/build.sh: harness/opfs-contract/Cargo.lock is stale; run cargo update -w there and commit it (improvise-mkbp)\" \u003e\u00262; exit 1; }'.","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-mergetrain","owner":"el-github@elangley.org","created_at":"2026-09-23T06:45:04Z","created_by":"agent-review-212","updated_at":"2026-09-23T15:15:49Z","started_at":"2026-09-23T14:41:46Z","closed_at":"2026-09-23T15:15:49Z","close_reason":"Fixed in PR #218, merged as 0a4f906 after adversarial review at ebf3dd2. Follow-up: improvise-7fl8.","labels":["adversarial-review-pr-212"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-7epn","title":"pr-reviews --wait: no test pins polling past the second poll (post-poll deadline check replaceable by break)","description":"Found reviewing PR #212 at 1550d390 (improvise-qnpi). Replacing the new post-poll deadline check in scripts/pr-reviews ('[ \"$(date +%s)\" -lt \"$deadline\" ] || break') with an unconditional 'break' leaves scripts/pr-reviews-test.sh at 23 passed, 0 failed. So --wait N polls at most twice and nothing notices. Every multi-poll fixture resolves on poll 2, and the timeout case expects exit 1 either way. The gap predates #212 (the old loop had the same blind spot), but the line #212 adds is the one no case can see. Fix: a fixture that resolves on poll 3 or later, under the fake clock with --wait large enough for several ticks. Also a case that the loop terminates: deleting the check makes 'times out' hang rather than fail.","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-mergetrain","owner":"el-github@elangley.org","created_at":"2026-09-23T06:45:02Z","created_by":"agent-review-212","updated_at":"2026-09-23T15:15:49Z","started_at":"2026-09-23T14:41:45Z","closed_at":"2026-09-23T15:15:49Z","close_reason":"Fixed in PR #218, merged as 0a4f906 after adversarial review at ebf3dd2. Follow-up: improvise-7fl8.","labels":["adversarial-review-pr-212"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-8id2","title":"An old record surrogate resolves through the HOST's layout: [RenameRecordCoordinate(r), SetCell(r)] re-creates r on a pivot host, ChainBroken on a records host","description":"Found while building improvise-fdk1: #204's author asked what the frame [RenameRecordCoordinate(r), SetCell(r's old surrogate)] means. The answer depends on the HOST's active view, not on anything the frame says.\n\nRepro, with the ui/host.rs test scaffolding: North/Revenue = 1. Send ClientFrame::Commands([caught_up(RenameRecordCoordinate { record: North, category: Region, item: West }), caught_up(SetCell { cell: North/Revenue, value: 7 })]).\n\n* **Pivot host** (Region on Row, _Measure on Column): the frame is Accepted. West = 1, and North = 7 is re-created. After the rename, North's surrogate still resolves, because App::resolve_record falls back to record_in_layout, and the pivot layout still draws the empty North/Revenue cell.\n* **Records host** (R pressed): the frame is Rejected(ChainBroken(North)) and nothing applies. record_in_layout excludes records mode, since its keys carry positions.\n\nPinned for the records host only, by ui::host::tests::a_write_to_a_record_its_own_frame_renamed_is_a_broken_chain. Measured for the pivot host, and not pinned.\n\nTwo concerns:\n\n1. A surrogate names COORDINATES, not an object. After a rename, r names the vacated coordinates, so a later write re-creates a record there. That may be acceptable, but it is nowhere stated.\n2. Whether that happens depends on which view the host is on, and a connected client has no say in that (gotcha 29: the model is shared, the UI is not).\n\nReach: the shipped browser composes one rename per frame, so only a hand-rolled client reaches this.\n\nSame fallback as improvise-ciw8 (a collapsed fold resolving through record_in_layout); a fix that makes resolution independent of the host's layout may close both.\n","notes":"ORCHESTRATOR RULING 2026-09-23: option (B), object semantics. A write to a record that an earlier command in its OWN frame renamed is ChainBroken on every host, whatever layout the host has. broken_chain also refuses any record named by an earlier RenameRecordCoordinate in the frame. This extends the rule #221 already pins on the records host. Revisit when ADR 007 (stable ids) lands. Build after #221 merges.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T06:41:43Z","created_by":"agent-hzjo2","updated_at":"2026-09-23T15:17:41Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-h6z1","title":"Fold evaluation still ~6x gh/main: the per-cell memo re-derives the formula chain for every column","description":"Follow-up to improvise-o1y0 (PR #203). The per-fold-evaluation memo cut a doubling chain from 511 evaluations to 9. On 4,000 cells with F0..F8 as columns the time went from ~1 s to ~60 ms, against ~10 ms on gh/main, which read cached member results and got ratios wrong. The remaining gap: each column's cell re-derives the chain below it, 45 evaluations per row where 9 would do, and each leaf's fold_stored scan repeats for every formula that references it. The fix is a memo shared across one grid build (grid_of / GridLayout::evaluate), keyed by (fold key, formula). Add a test that counts evaluations across a whole row.","status":"in_progress","priority":3,"issue_type":"task","assignee":"agent-collapse-fu","owner":"el-github@elangley.org","created_at":"2026-09-23T06:41:03Z","created_by":"orchestrator","updated_at":"2026-09-23T16:07:31Z","started_at":"2026-09-23T16:07:31Z","lease_expires_at":"2026-09-23T16:12:31Z","heartbeat_at":"2026-09-23T16:07:31Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-8id2","title":"An old record surrogate resolves through the HOST's layout: [RenameRecordCoordinate(r), SetCell(r)] re-creates r on a pivot host, ChainBroken on a records host","description":"Found while building improvise-fdk1: #204's author asked what the frame [RenameRecordCoordinate(r), SetCell(r's old surrogate)] means. The answer depends on the HOST's active view, not on anything the frame says.\n\nRepro, with the ui/host.rs test scaffolding: North/Revenue = 1. Send ClientFrame::Commands([caught_up(RenameRecordCoordinate { record: North, category: Region, item: West }), caught_up(SetCell { cell: North/Revenue, value: 7 })]).\n\n* **Pivot host** (Region on Row, _Measure on Column): the frame is Accepted. West = 1, and North = 7 is re-created. After the rename, North's surrogate still resolves, because App::resolve_record falls back to record_in_layout, and the pivot layout still draws the empty North/Revenue cell.\n* **Records host** (R pressed): the frame is Rejected(ChainBroken(North)) and nothing applies. record_in_layout excludes records mode, since its keys carry positions.\n\nPinned for the records host only, by ui::host::tests::a_write_to_a_record_its_own_frame_renamed_is_a_broken_chain. Measured for the pivot host, and not pinned.\n\nTwo concerns:\n\n1. A surrogate names COORDINATES, not an object. After a rename, r names the vacated coordinates, so a later write re-creates a record there. That may be acceptable, but it is nowhere stated.\n2. Whether that happens depends on which view the host is on, and a connected client has no say in that (gotcha 29: the model is shared, the UI is not).\n\nReach: the shipped browser composes one rename per frame, so only a hand-rolled client reaches this.\n\nSame fallback as improvise-ciw8 (a collapsed fold resolving through record_in_layout); a fix that makes resolution independent of the host's layout may close both.\n","notes":"ORCHESTRATOR RULING 2026-09-23: option (B), object semantics. A write to a record that an earlier command in its OWN frame renamed is ChainBroken on every host, whatever layout the host has. broken_chain also refuses any record named by an earlier RenameRecordCoordinate in the frame. This extends the rule #221 already pins on the records host. Revisit when ADR 007 (stable ids) lands. Build after #221 merges.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T06:41:43Z","created_by":"agent-hzjo2","updated_at":"2026-09-23T15:17:41Z","dependencies":[{"issue_id":"improvise-8id2","depends_on_id":"improvise-pmt0","type":"parent-child","created_at":"2026-09-25T01:05:05Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-h6z1","title":"Fold evaluation still ~6x gh/main: the per-cell memo re-derives the formula chain for every column","description":"Follow-up to improvise-o1y0 (PR #203). The per-fold-evaluation memo cut a doubling chain from 511 evaluations to 9. On 4,000 cells with F0..F8 as columns the time went from ~1 s to ~60 ms, against ~10 ms on gh/main, which read cached member results and got ratios wrong. The remaining gap: each column's cell re-derives the chain below it, 45 evaluations per row where 9 would do, and each leaf's fold_stored scan repeats for every formula that references it. The fix is a memo shared across one grid build (grid_of / GridLayout::evaluate), keyed by (fold key, formula). Add a test that counts evaluations across a whole row.","status":"closed","priority":3,"issue_type":"task","assignee":"agent-collapse-fu","owner":"el-github@elangley.org","created_at":"2026-09-23T06:41:03Z","created_by":"orchestrator","updated_at":"2026-09-24T21:45:11Z","started_at":"2026-09-23T16:07:31Z","closed_at":"2026-09-24T21:45:11Z","close_reason":"Landed in main via #247 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-y5eo","title":"A document that opens on a records view starts the host in Normal: App::with_storage never settles the mode, and a fresh client gets RecordsNormal","description":"Found reviewing PR #207 (improvise-v8ab) at 13b998d7.\n\n`App::with_storage` sets `view_state.mode` to `Normal` and never settles it against the layout. The host settles only at the end of a batch (`settle_normal_mode` in `apply_effects`). A session's mirror settles in `project_diff` (`settle_mode`, project.rs), so `Sessions::connect` hands a client `RecordsNormal`.\n\nReproduced at 13b998d7 with a document whose persisted view puts `_Index: row` and `_Dim: column` (the `.improv` writer does persist those axes):\n- `App::new(wb).view_state.mode == Normal`, while `app.layout.is_records_mode() == true`.\n- `Sessions::connect(\u0026app).1.view.mode == RecordsNormal`, so the host's startup slice and a fresh client's differ in `mode` and nothing else.\n- The host's first `i` resolves through Normal's keymap and lands in plain `Editing`, not `RecordsEditing`.\n\nFix shape: settle the mode in `App::with_storage` (and check `install_workbook`), using the same `improvise_protocol::settled_mode` both ends use. Then add a records-opening document to `a_fresh_sessions_slice_is_the_hosts_startup_slice`, which currently covers pivot documents only and so cannot see this.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-viewhist","owner":"el-github@elangley.org","created_at":"2026-09-23T06:40:07Z","created_by":"agent-review-207","updated_at":"2026-09-23T14:56:20Z","started_at":"2026-09-23T06:41:36Z","closed_at":"2026-09-23T14:56:20Z","close_reason":"Fixed in PR #216, merged as cd55944 after adversarial review at 08b87ed.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-oga4","title":"Review-reply rules stated twice (AGENTS.md + agent-rules); code-review skill lacks the reviewer rule; ADR merge gate ambiguous vs 'merge only once a review has passed'","description":"Found by the adversarial delta review of PR #201 at head 141be9fc.\n\n1. Two rules are stated in two places.\n - Reviewers answer every Codex review on its thread and state the head SHA they reviewed. This is in agent-rules.md \"GitHub\" and also in AGENTS.md \"Shipping a wave\": \"answer it on the thread even if the PR has already merged\" and \"it must name the head SHA it reviewed\".\n - Authors reply with a reason. This is in agent-rules \"GitHub\" and also in AGENTS.md's Copilot paragraph: \"when you decline, reply on the thread with the reason\".\n - Neither file points to the other for these rules. agent-rules' preamble says the rule lives there and AGENTS.md carries the history, so AGENTS.md should point rather than restate.\n2. There is a missing pointer. .agents/skills/code-review/SKILL.md, the skill a reviewer actually loads, does not mention reading the existing reviews first (scripts/pr-reviews), answering Codex, or stating the head SHA. Only a wave brief that names agent-rules \"GitHub\" delivers that rule.\n3. Question for Edward: does an ADR PR still need CI and an adversarial review? AGENTS.md \"ADR pull requests\" says an ADR \"merges on the user's approval, not on a review\". \"Repository policy over the Beads blocks\" says \"the merge is the orchestrator's, after an adversarial review passes the head SHA\", and \"Shipping a wave\" says \"Merge only once a review has passed\". One reading makes \"Approved\" replace review. The other makes it an extra gate.\n - #122 had its Codex reviews answered and CI green before \"Approved\".\n - The user's own review on #122 (\"Keep full color support in themes\") landed as the PR merged. That argues for re-reading reviews after \"Approved\".\n - Suggested wording: \"on the user's approval, in addition to green CI and every review answered\".\n\nAcceptance: each rule has one home and every other mention points to it; the code-review skill points to the reviewer rule; the ADR gating is stated unambiguously per Edward's answer.\n","notes":"Orchestrator's reading of D, pending the user: an ADR PR needs ALL of green CI, every review answered on the thread, an adversarial review, AND the user's 'Approved'. The user's approval is required, but it doesn't replace the other gates. The fix is the reviewer's wording, 'in addition to green CI and every review answered'.","status":"closed","priority":3,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T06:28:34Z","created_by":"agent-review-201b","updated_at":"2026-09-23T15:15:50Z","started_at":"2026-09-23T06:30:18Z","closed_at":"2026-09-23T15:15:50Z","close_reason":"Fixed in PR #211, merged as 968f65a after adversarial review (delta at 41aa407).","comments":[{"id":"d5acc6ff-e6f1-5810-8c52-84e341c7bcf8","issue_id":"improvise-oga4","author":"agent-review-211","text":"Review of PR #211 at 6ccad4b8 (agent-review-211). The ADR section and the wave SKILL now state the ruling. But two general statements of the merge condition in AGENTS.md still read as sufficient, and neither points to the ADR exception:\n\n- \"Shipping a wave\", the opening paragraph: work \"lands on `main` by being merged there, once CI is green and an adversarial review agent has passed the PR's head SHA\".\n- \"Repository policy over the Beads blocks\": \"the merge is the orchestrator's, after an adversarial review passes the head SHA\".\n\nAn orchestrator reading either one without reaching \"ADR pull requests\" 130 lines later has a green-plus-review merge rule that covers ADR PRs. A parenthetical pointer in each fixes it: \"(an ADR PR also needs the user's approval: 'ADR pull requests')\".\n\nAlso, `.agents/skills/wave/SKILL.md` line 84 now wraps the pointer `\"ADR pull` / `requests\"` across two lines, so a grep for the section name misses it. It was on one line before this PR. And \"the user's explicit approval accepts or merges an ADR PR\" says the approval *merges* the PR. AGENTS.md says it *lets* the PR merge.\n","created_at":"2026-09-23T14:48:02Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} {"_type":"issue","id":"improvise-q2mq","title":"RecordRewrite: a blank edit on a measure a renamed drill row lacks clears at the renamed key, and no test can fail on it","description":"Found by adversarial review of PR #209 (improvise-phg1) at head ccb517c6.\n\nThe code is correct. What is missing is a test that can fail. In `RecordRewrite::of`, the second loop handles measures the row has no cell for. A **blank** edit there pushes a clear at the *renamed* key (`None =\u003e rewrite.clears.push(key)`). Change that one arm to clear the unrenamed key instead, `row.cell_key(measure)`. The whole workspace stays green (`cargo test --workspace --no-fail-fast`, exit 0).\n\nphg1(b)'s test covers only the `Some` arm of that loop. phg1(c)'s test does run the `None` arm, but the clear it pushes has no visible effect there, because row 1 has already read and moved the destination's `Profit`. The unrenamed key is the row's own old record, which by construction lacks the measure. So the rival clears nothing, and the difference shows only when the rename destination already holds that measure.\n\nA probe that separates them passes at ccb517c6 and fails under the rival. It uses the `assert_drill_over` helper in `ui/effect.rs` tests:\n\n cells: Food/Jan/Revenue=1, Clothing/Jan/Revenue=42, Clothing/Jan/Profit=5\n edits: (0, Type, \"Clothing\"), (0, Profit, \"\")\n expected: {\"Clothing/Jan/Revenue = 1\"}\n\nUnder the rival the result is `{\"Clothing/Jan/Profit = 5\", \"Clothing/Jan/Revenue = 1\"}`. A row renamed onto an existing record, blanking a measure it lacks, fails to blank that measure on the merged record.\n\nFix: add the probe as a test, and add evidence in context/test-evidence/command.md.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-uo2p","owner":"el-github@elangley.org","created_at":"2026-09-23T06:21:11Z","created_by":"agent-review-209","updated_at":"2026-09-23T06:39:04Z","started_at":"2026-09-23T06:25:51Z","closed_at":"2026-09-23T06:39:04Z","close_reason":"Fixed in PR #209, merged as ea119bd after adversarial review at ccb517c. q2mq was the reviewer-requested fourth test; the merge touched evidence only.","labels":["adversarial-review","pr-209"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-lnkb","title":"IS on a folded category is true on the fold row and false on every row it replaces and on Axis::None","description":"Found by the adversarial review of PR #203 at head 2a4f35d7. context/code/core.md §14 documents this as a residual difference. This ticket argues that it is wrong, not merely different.\n\nOn a fold row, the key carries the group name (`Month=Q1`), and `eval_is` compares coordinates by string. So:\n\n- `I = Month IS Q1` gives **1** on the collapsed Q1 row. It gives **0** on Jan and on Feb, the rows the fold replaces. It gives **0** with Month on `Axis::None`.\n- `I = IF(Month IS Q1 \u003e 0.5, Revenue, 0)` gives **64** on the fold. It gives 0 on every member row, and 0 on None.\n- `Month IS Jan` gives 0 on the fold, which agrees with None. `SUM(Month IS Jan)` gives 3 on both, which also agrees.\n\nThe same PR states two rules: a fold is exactly the rows it replaces (f2oo), and a formula on a fold equals the None-axis answer (eccl). A group name is not an item, and `Month=Q1` exists only as a presentation device of a collapsed view. So `IS` lets a formula observe collapse state, and its value flips when the user presses `z`. There are two principled answers:\n\n1. It is false, as on None. `IS` names items, and no record carries Q1.\n2. It is membership, true on the fold **and** on Jan and Feb.\n\nEither answer would make the fold agree with the rows it replaces. The current answer agrees with nothing. The inline `WHERE Month = Q1` already behaves like (1): it is 0 on the fold and 0 on None.\n\nLow priority: the case needs a formula that names a group in `IS`.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-collapse-fu","owner":"el-github@elangley.org","created_at":"2026-09-23T06:07:06Z","created_by":"agent-review-203","updated_at":"2026-09-23T15:28:30Z","started_at":"2026-09-23T06:13:13Z","closed_at":"2026-09-23T15:28:30Z","close_reason":"Fixed in PR #203, merged after adversarial re-review at b3379d1; the later changes were evidence-only merges and a docs-only fix to §14. Follow-ups: improvise-h6z1, improvise-d7aj (#232), improvise-wm20.","labels":["adversarial-review pr-203"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-fz77","title":"Categories and items cannot be renamed from the category panel","description":"Reported by the user, 2026-09-23 ('maybe a similar issue in the category pane, but it's less annoying there'). The category panel can add a category or item and delete one, but has no rename. The only way to rename is to delete and recreate, which loses the data under the category or item. Expected: a rename key on the entry under the cursor opens a minibuffer seeded with its name, and the commit renames it everywhere: data keys, views' axis maps, page selections, hidden items and groups, and formulas that reference it. Check first whether Workbook/Model already has a rename operation. This needs a ModelCommand for the wire, and both front ends.","notes":"Orchestrator context, 2026-09-23: identity is the name. Category and Item have a usize 'id' (category.rs), but nothing keys on it. It is returned by add_category/add_item and never persisted in .improv. Model.categories is an IndexMap keyed by name, CellKey and DataStore symbols are names, and views, hidden/collapsed and formulas all refer by name. A Formula has no id at all: {raw, target, target_category, expr, filter} in a Vec, in effect identified by (target, target_category). So a rename must rewrite every name reference, or the design must first introduce a stable identity. Weigh the two before implementing.","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-23T06:03:25Z","created_by":"orchestrator","updated_at":"2026-09-23T06:03:47Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-fz77","title":"Categories and items cannot be renamed from the category panel","description":"Reported by the user, 2026-09-23 ('maybe a similar issue in the category pane, but it's less annoying there'). The category panel can add a category or item and delete one, but has no rename. The only way to rename is to delete and recreate, which loses the data under the category or item. Expected: a rename key on the entry under the cursor opens a minibuffer seeded with its name, and the commit renames it everywhere: data keys, views' axis maps, page selections, hidden items and groups, and formulas that reference it. Check first whether Workbook/Model already has a rename operation. This needs a ModelCommand for the wire, and both front ends.","notes":"Orchestrator context, 2026-09-23: identity is the name. Category and Item have a usize 'id' (category.rs), but nothing keys on it. It is returned by add_category/add_item and never persisted in .improv. Model.categories is an IndexMap keyed by name, CellKey and DataStore symbols are names, and views, hidden/collapsed and formulas all refer by name. A Formula has no id at all: {raw, target, target_category, expr, filter} in a Vec, in effect identified by (target, target_category). So a rename must rewrite every name reference, or the design must first introduce a stable identity. Weigh the two before implementing.","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-23T06:03:25Z","created_by":"orchestrator","updated_at":"2026-09-23T06:03:47Z","dependencies":[{"issue_id":"improvise-fz77","depends_on_id":"improvise-cq1q.4","type":"blocks","created_at":"2026-09-25T04:51:04Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-fz77","depends_on_id":"improvise-cq1q.8","type":"blocks","created_at":"2026-09-25T04:51:05Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-fz77","depends_on_id":"improvise-gtn7","type":"parent-child","created_at":"2026-09-25T01:05:32Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-ciw8","title":"Host resolve_cell resolves a collapsed fold's surrogate via record_in_layout: a wire SetCell stores Month=Q2 and the frame is Accepted","description":"Found in the adversarial review of PR #197 at head 097533e5. This is pre-existing on gh/main and is not introduced by #197. It is the host-side door that #197's cursor doors do not cover.\n\nRepro (a Host frame, `ui/host.rs` test scaffolding): Month in Q1/Q2 groups on Row, _Measure on Column, Q2 collapsed. Send `ClientFrame::Commands([caught_up(SetCell { cell: RecordCell { record: RecordHash::of({Month=Q2, _Measure=Cost}) or RecordHash::of({Month=Q2}), measure: Some(\"Cost\") }, value: 7 })])`. The answer is `Accepted`, and the store now holds `Month=Q2, _Measure=Cost = 7`. That is exactly the non-item coordinate that improvise-gw18/l4mp exist to prevent.\n\nCause: `App::resolve_cell` -\u003e `resolve_record` falls back to `record_in_layout`, which accepts ANY key the layout names, including a collapsed group's fold. `cell_id` projects that same position as `Aggregate`. The host therefore resolves a surrogate that its own projection says is not a record.\n\nReach: the shipped browser never composes this, because `record_cell_at_cursor` sees `Aggregate`. So it takes a hand-rolled or buggy client. Basis is not a security boundary (gotcha 43), but this is a resolution question and not an anti-staleness one: a record surrogate should resolve only to a position `cell_id` would call `Record`.\n\nFix direction: `record_in_layout` should skip positions where `layout.folds_a_group(r, c)` holds, or ask `project::cell_id`. A refused resolution should also reject the frame (the `vec![]` arm in `model_effects` is accepted silently today).\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-collapse-fu","owner":"el-github@elangley.org","created_at":"2026-09-23T05:55:55Z","created_by":"agent-review-197","updated_at":"2026-09-23T15:35:48Z","started_at":"2026-09-23T06:41:31Z","closed_at":"2026-09-23T15:35:48Z","close_reason":"Fixed in PR #217, merged as cc9bc41 after adversarial review (bc060f6, then the sjdb delta at 52fa397). Follow-ups: improvise-b114, improvise-xur5.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-e265","title":"prune_empty changes a surviving row's group on a two-level grouped axis: GroupEnd carries no level, so #200's pruned axis puts North/Mar in Upper where the unpruned one says no group","description":"Found reviewing PR #200 (improvise-b5zh) at 561cff47.\n\n`AxisEntry::GroupEnd` carries no level, and every consumer of membership is flat: `GridLayout::row_group_for`/`col_group_for` (what `z` / toggle-group-under-cursor collapses) and `AxisFrame::groups`/`group_runs` (what the TUI column band and the browser draw). On an axis with two grouped categories, pruning now changes a surviving row's group.\n\nRepro (Region outer: North in Upper; Month inner: Jan in Q1, Mar ungrouped; only North/Mar funded):\n- prune off: `[Upper, Q1, North/Jan, GroupEnd, North/Mar]`, so `row_group_for(North/Mar)` is `None`\n- prune on at #200: `[Upper, North/Mar]`, so it is `Some(Region, Upper)`\n- prune on at gh/main 98d10ca5: `[Upper, GroupEnd, North/Mar]`, so it is `None`. The layouts agree there, but Upper is a header drawn over nothing.\n\nEnumerated over 300,580 (axis x collapse x prune-mask) cases (2- and 3-level, collapsed and non-contiguous groups):\n- gh/main: 274,584 header-over-nothing, 0 membership changes.\n- #200: 0 header-over-nothing, 45,806 membership changes. Every one is this shape: the inner header was pruned, so its GroupEnd is dropped too, and the ungrouped inner item joins the outer group.\n\nUnder a flat membership model the two properties cannot both hold. In the unpruned layout the outer group has no flat members (the inner header takes over at once), so the unpruned answer is the lossy one. #200's pruned answer, that North/Mar is in Upper, is arguably the right one per level. The fix is a levelled GroupEnd, or per-level membership, so that prune on and prune off give the same answer. Choose which before fixing.\n\nA related cosmetic issue, on gh/main and #200 both: a pruned axis can end in a trailing GroupEnd (75k cases at #200, 57k on main), which `cross_product` strips from the unpruned axis.\n","notes":"USER RULING 2026-09-23: membership is per level (option A, levelled group markers), and z toggles the innermost group, the one closest to the cursor.","status":"in_progress","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T05:53:36Z","created_by":"agent-review-191-200-198","updated_at":"2026-09-23T18:00:32Z","started_at":"2026-09-23T18:00:32Z","lease_expires_at":"2026-09-23T18:05:32Z","heartbeat_at":"2026-09-23T18:00:32Z","comments":[{"id":"b83ba7c3-248b-5435-a96f-a222009d197f","issue_id":"improvise-e265","author":"agent-docs-wave","text":"_Posted by Claude (Opus 5.5), an agent in Edward Langley's session. This is a design write-up; nothing is implemented._\n\n# improvise-e265: design options\n\n## Where things stand (checked at gh/main 5932ad74)\n\n#200 has merged, so the drift in membership is what `main` does now. The ticket's example, run through a scratch probe (not committed) against `GridLayout::with_frozen_records`:\n\n| prune | `row_items` | `row_group_for(North/Mar)` |\n|---|---|---|\n| off | `[Upper, Q1, North/Jan, GroupEnd, North/Mar]` | `None` |\n| on | `[Upper, North/Mar]` | `Some(Region, Upper)` |\n\nNeither answer is correct at every level. North/Mar is in `Upper` at level 0 and in no `Month` group at level 1.\n\nThe cause is not really pruning. It is the flat interpretation of the markers. `Month`'s `GroupEnd` is read as \"close everything\", so `Upper`'s run ends at an inner boundary. A flat reading can't reconcile the two layouts: whichever one it matches, the other disagrees.\n\n## Who reads membership\n\n- **Core:** `GridLayout::row_group_for` / `col_group_for`, which decide what `z` (`ToggleGroupAtCursor`) collapses.\n- **Protocol:** `AxisFrame::groups`, `group_of`, `group_runs`, `group_run_heads`.\n- **TUI:** the column group band (`src/ui/grid.rs` uses `groups()` and `group_run_heads()`). Rows draw headers inline from the entries and never ask about membership.\n- **Browser:** `col_groups` in `wasm-client/src/render.rs` (uses `group_runs()`). `z` is a no-op in the browser (improvise-uln5), so it reads no row membership.\n- **Also affected:** `app.rs`'s layout fingerprint hashes `GroupEnd`; `axis_frame` (projection windowing) and `prune_axis_items` both produce marker sequences.\n\n## Options\n\n### A. Levelled markers with stack membership (recommended)\n\nEach marker says which level it belongs to:\n- core: `AxisEntry::GroupHeader { cat_name, group_name }` is unchanged, since its level is `cats.position(cat_name)`, and `GroupEnd` becomes `GroupEnd { level }`;\n- wire: `FrameGroup { level, name, collapsed }` and `FrameEntry::GroupEnd { level }`.\n\nMembership becomes a per-level stack:\n- a header at level L sets `open[L]` and clears every deeper level;\n- `GroupEnd { level: L }` clears level L and every deeper level.\n\nAn item's membership is the whole stack. The single answer `row_group_for` and `groups()` return is the **innermost open level**. For North/Jan that is still `Q1`, as today. For North/Mar it becomes `Upper`, whether or not pruning is on.\n\nPruning keeps a `GroupEnd` only when the header it closes was kept. Matching is by level, so dropping an inner pair can't close an outer run. The same trailing-`GroupEnd` strip `cross_product` applies runs after pruning too, which fixes the cosmetic issue in the ticket.\n\nCosts:\n- **Core `layout.rs`:**\n - `expand_category` sets `level` on `GroupEnd`.\n - `prune_axis_items`: its `pending` becomes a per-level stack, and `close_runs_of` already truncates by category.\n - `row_group_for` / `col_group_for` walk a stack instead of finding the nearest marker.\n - The trailing-strip becomes one helper that both `cross_product` and prune call.\n - `run_start` is untouched.\n - The about 12 `GroupEnd` literals in tests gain a level.\n- **`app.rs` fingerprint:** hash the level as well. One line.\n- **`project.rs::axis_frame`:** a windowing rule changes. Before the window opens it now keeps the nearest header **per level** (at most `cats.len()` entries), not just the single nearest one. Otherwise a window opening inside `North/Q1` loses `Upper`. The payload stays O(levels), not O(groups), so the reason in `command.md` (\"only the nearest, or the payload grows\") still holds with \"per level\" added.\n- **Protocol `render.rs`:**\n - `groups()` and `group_of` keep a stack (one pass still).\n - `group_runs` / `group_run_heads` are unchanged: they coalesce whatever `groups()` returns, and h0iv's `std::ptr::eq` rule still applies.\n - About 13 `FrameGroup` literals and 6 `GroupEnd` literals gain a level.\n - `body_from` (0o6x) needs checking: at the top of a windowed axis it must drop every carried header, not only one.\n- **TUI:** no code change beyond constructing the new variants. The band still shows one group per column, now the innermost open one. In the example, the band over North/Mar reads `Upper` where it now reads nothing.\n- **Browser:** the same. `col_groups` is unchanged.\n- **Wire:** a serde shape change to `FrameGroup` and `FrameEntry::GroupEnd`. Both ends are one bundle, and the cache-bust stamp covers a stale page.\n- **A side effect worth having:** `level` indexes `AxisFrame::cats`, so a client can name the category of any group. That is the piece a browser `z` would need to compose `ToggleGroup` (improvise-uln5), and it does not bring back the dead field improvise-7xyx removed. `level` is read by `groups()`, so it is live, and the category *name* comes from `cats` rather than being carried a second time.\n\nWhy the level goes on `GroupEnd` and not only on the header: in principle a stack could pair an unlevelled `GroupEnd` with its header by position. That only works if every producer emits a balanced sequence, and there are three producers: `expand_category`, `prune_axis_items` and `axis_frame`'s windowing. The last two both cut sequences. A levelled marker can be read correctly on its own, even inside a pruned or windowed fragment. Every bug in this area (gd83, u6gc, lhi4, b5zh, and now e265) has come from a producer changing marker placement, so a marker that can be read without its neighbours is the property to buy.\n\n### B. Membership stored on the item; markers derived\n\n`DataItem` carries `groups: Vec\u003cOption\u003cString\u003e\u003e`, parallel to `tuple`, filled in `expand_category`, which is the one place it is known. Membership becomes an attribute of the item, so pruning can't change it. `prune_axis_items` becomes: filter the data items, then **re-derive** the markers from the survivors with the same function expansion uses. That removes the `pending`/`run_open`/`close_runs_of` machinery, which is where the bugs listed above lived. It also removes `GroupEnd` completely, since it exists only so that membership is not inherited. Both \"header over nothing\" and \"trailing `GroupEnd`\" become impossible to represent.\n\nCosts:\n- **Core:** the largest rewrite of the three. The `DataItem` shape, `expand_category`, `prune_axis_items`, `cross_product`, `run_start`, `row_group_for`, the fingerprint, and every `AxisEntry` literal in the tests.\n- **Wire:** `FrameEntry::Item` would carry per-level groups. That is O(items × levels) in the payload where A is O(markers). Otherwise the host still has to send levelled markers, and the client-side membership walk is option A anyway. `FrameEntry::GroupEnd` would be deleted.\n- **h0iv must be argued again.** Runs can no longer be \"opened by the same header entry\" if the header is derived. The key becomes `(prefix up to L, level, group)`, which does separate `North/Q1` from `South/Q1`. But it is a new rule, and the h0iv evidence has to be re-pinned.\n- **Both front ends:** every `FrameEntry` match changes.\n\nB is the stronger invariant: an invariant made impossible to break, not one that is validated. It is also about three times the change, and its wire cost is real.\n\n### C. Membership computed before pruning (the ticket's example option)\n\nCompute `row_group_for` on the unpruned layout and carry it through pruning. This fixes the lossy flat answer, and makes it canonical: North/Mar is `None` while the user sees it under the `Upper` header row. It still needs either a second layout or an attribute carried per item. The second of those is B's cost with a worse answer, so I recommend rejecting C.\n\n### D. Keep flat membership and choose which property to lose\n\nFor example, go back to pre-#200 behaviour: `[Upper, GroupEnd, North/Mar]`, with membership stable and a header drawn over nothing. This is the cheapest option, and the ticket's enumeration shows flat membership cannot make both properties hold (274,584 cases against 45,806 at the two ends). Only acceptable as a documented leftover.\n\n## Recommendation\n\n**A.** It is the smallest type change that makes pruning on and off agree while also giving the answer that is correct at every level. It keeps h0iv's identity rule and the windowing payload bound. It also gives the browser what it would need to name a group's category for `z`.\n\nIf the user would rather pay for the invariant, B is the right shape. In that case I would take B in core first and put A's levelled markers on the wire: the host derives them, so the wire does not pay for O(items × levels).\n\n## Questions for the user\n\n1. **Which group does a row belong to?** Under A (and B), North/Mar is in `Upper`: `z` there collapses `Upper`, and the column band shows `Upper` over it. Today, unpruned, `z` there does nothing and the band is blank. Is the per-level answer the one you want?\n2. **What does `z` toggle when a row is in more than one open group?** I propose the innermost, which matches what `z` does today on North/Jan (`Q1`).\n3. **Is one column band enough?** A still paints one group per column (the innermost). One band per level would be a separate feature; I'd file it rather than fold it into this.\n\n## Proposed evidence for the fix\n\n- A property test over 2- and 3-level axes × collapse state × prune masks, the ticket's own enumeration, now as an assertion:\n - for every item that survives pruning, the membership stack of the pruned layout equals the unpruned layout's;\n - every header that is kept has at least one member.\n - Rival: today's flat `prune_axis_items`. It should fail on the ticket's 45,806-case shape. A pre-#200 rival should fail the no-empty-header half instead.\n- A frame-agreement test: `AxisFrame::groups()` on the projected frame equals `GridLayout`'s per-item membership, including a window that opens inside `North/Q1`. That is the per-level windowing rule, and a nearest-single-header rival must fail it.\n","created_at":"2026-09-23T16:01:42Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} -{"_type":"issue","id":"improvise-bf0q","title":"bd setup factory calls #201's minimal Beads block stale at once and swaps in profile:full; bd -C is ignored by bd setup","description":"Found reviewing PR #201 (head 31b5acdb), in a scratch copy of the tree with bd 1.3.0.\n\n1. `bd setup factory --check` reports the block #201 installs (`profile:minimal hash:46cd31e7`) as **stale** straight away, not only \"whenever bd's template has moved on\" as #201's new \"The Beads blocks are bd's\" section says. Running `bd setup factory` then replaces it with `profile:full hash:bacef91e`. That render is much longer, uses `--json` everywhere, and prescribes `git pull --rebase`. After the rewrite `--check` reports \"(current)\". So the first person to follow bd's own advice flips the profile. The repo-owned section outside the markers survives, verified byte for byte, and it already names the full profile's `git pull --rebase`. The policy therefore holds, but the sentence about when bd calls the block stale should say it happens at once for factory.\n\n2. **`bd -C \u003cdir\u003e setup factory` ignores `-C`** and writes `AGENTS.md` in the current working directory. Run from an agent's worktree with `-C /tmp/scratch`, it rewrote the worktree's `AGENTS.md` and left the scratch copy untouched, and `--check` read the worktree too. Anyone told to \"verify bd's regeneration on a scratch copy\" must `cd` into the copy and must not trust `-C`. This is an upstream bd bug, recorded here because the scratch-copy instruction will be given again.\n","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-23T05:53:27Z","created_by":"agent-review-201-202","updated_at":"2026-09-23T05:53:27Z","dependencies":[{"issue_id":"improvise-bf0q","depends_on_id":"improvise-x1uk","type":"discovered-from","created_at":"2026-09-23T05:53:27Z","created_by":"agent-review-201-202","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-e265","title":"prune_empty changes a surviving row's group on a two-level grouped axis: GroupEnd carries no level, so #200's pruned axis puts North/Mar in Upper where the unpruned one says no group","description":"Found reviewing PR #200 (improvise-b5zh) at 561cff47.\n\n`AxisEntry::GroupEnd` carries no level, and every consumer of membership is flat: `GridLayout::row_group_for`/`col_group_for` (what `z` / toggle-group-under-cursor collapses) and `AxisFrame::groups`/`group_runs` (what the TUI column band and the browser draw). On an axis with two grouped categories, pruning now changes a surviving row's group.\n\nRepro (Region outer: North in Upper; Month inner: Jan in Q1, Mar ungrouped; only North/Mar funded):\n- prune off: `[Upper, Q1, North/Jan, GroupEnd, North/Mar]`, so `row_group_for(North/Mar)` is `None`\n- prune on at #200: `[Upper, North/Mar]`, so it is `Some(Region, Upper)`\n- prune on at gh/main 98d10ca5: `[Upper, GroupEnd, North/Mar]`, so it is `None`. The layouts agree there, but Upper is a header drawn over nothing.\n\nEnumerated over 300,580 (axis x collapse x prune-mask) cases (2- and 3-level, collapsed and non-contiguous groups):\n- gh/main: 274,584 header-over-nothing, 0 membership changes.\n- #200: 0 header-over-nothing, 45,806 membership changes. Every one is this shape: the inner header was pruned, so its GroupEnd is dropped too, and the ungrouped inner item joins the outer group.\n\nUnder a flat membership model the two properties cannot both hold. In the unpruned layout the outer group has no flat members (the inner header takes over at once), so the unpruned answer is the lossy one. #200's pruned answer, that North/Mar is in Upper, is arguably the right one per level. The fix is a levelled GroupEnd, or per-level membership, so that prune on and prune off give the same answer. Choose which before fixing.\n\nA related cosmetic issue, on gh/main and #200 both: a pruned axis can end in a trailing GroupEnd (75k cases at #200, 57k on main), which `cross_product` strips from the unpruned axis.\n","notes":"USER RULING 2026-09-23: membership is per level (option A, levelled group markers), and z toggles the innermost group, the one closest to the cursor.","status":"closed","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T05:53:36Z","created_by":"agent-review-191-200-198","updated_at":"2026-09-24T21:45:09Z","started_at":"2026-09-23T18:00:32Z","closed_at":"2026-09-24T21:45:09Z","close_reason":"Landed in main via #250 (reviewed; merged 2026-09-24).","comments":[{"id":"b83ba7c3-248b-5435-a96f-a222009d197f","issue_id":"improvise-e265","author":"agent-docs-wave","text":"_Posted by Claude (Opus 5.5), an agent in Edward Langley's session. This is a design write-up; nothing is implemented._\n\n# improvise-e265: design options\n\n## Where things stand (checked at gh/main 5932ad74)\n\n#200 has merged, so the drift in membership is what `main` does now. The ticket's example, run through a scratch probe (not committed) against `GridLayout::with_frozen_records`:\n\n| prune | `row_items` | `row_group_for(North/Mar)` |\n|---|---|---|\n| off | `[Upper, Q1, North/Jan, GroupEnd, North/Mar]` | `None` |\n| on | `[Upper, North/Mar]` | `Some(Region, Upper)` |\n\nNeither answer is correct at every level. North/Mar is in `Upper` at level 0 and in no `Month` group at level 1.\n\nThe cause is not really pruning. It is the flat interpretation of the markers. `Month`'s `GroupEnd` is read as \"close everything\", so `Upper`'s run ends at an inner boundary. A flat reading can't reconcile the two layouts: whichever one it matches, the other disagrees.\n\n## Who reads membership\n\n- **Core:** `GridLayout::row_group_for` / `col_group_for`, which decide what `z` (`ToggleGroupAtCursor`) collapses.\n- **Protocol:** `AxisFrame::groups`, `group_of`, `group_runs`, `group_run_heads`.\n- **TUI:** the column group band (`src/ui/grid.rs` uses `groups()` and `group_run_heads()`). Rows draw headers inline from the entries and never ask about membership.\n- **Browser:** `col_groups` in `wasm-client/src/render.rs` (uses `group_runs()`). `z` is a no-op in the browser (improvise-uln5), so it reads no row membership.\n- **Also affected:** `app.rs`'s layout fingerprint hashes `GroupEnd`; `axis_frame` (projection windowing) and `prune_axis_items` both produce marker sequences.\n\n## Options\n\n### A. Levelled markers with stack membership (recommended)\n\nEach marker says which level it belongs to:\n- core: `AxisEntry::GroupHeader { cat_name, group_name }` is unchanged, since its level is `cats.position(cat_name)`, and `GroupEnd` becomes `GroupEnd { level }`;\n- wire: `FrameGroup { level, name, collapsed }` and `FrameEntry::GroupEnd { level }`.\n\nMembership becomes a per-level stack:\n- a header at level L sets `open[L]` and clears every deeper level;\n- `GroupEnd { level: L }` clears level L and every deeper level.\n\nAn item's membership is the whole stack. The single answer `row_group_for` and `groups()` return is the **innermost open level**. For North/Jan that is still `Q1`, as today. For North/Mar it becomes `Upper`, whether or not pruning is on.\n\nPruning keeps a `GroupEnd` only when the header it closes was kept. Matching is by level, so dropping an inner pair can't close an outer run. The same trailing-`GroupEnd` strip `cross_product` applies runs after pruning too, which fixes the cosmetic issue in the ticket.\n\nCosts:\n- **Core `layout.rs`:**\n - `expand_category` sets `level` on `GroupEnd`.\n - `prune_axis_items`: its `pending` becomes a per-level stack, and `close_runs_of` already truncates by category.\n - `row_group_for` / `col_group_for` walk a stack instead of finding the nearest marker.\n - The trailing-strip becomes one helper that both `cross_product` and prune call.\n - `run_start` is untouched.\n - The about 12 `GroupEnd` literals in tests gain a level.\n- **`app.rs` fingerprint:** hash the level as well. One line.\n- **`project.rs::axis_frame`:** a windowing rule changes. Before the window opens it now keeps the nearest header **per level** (at most `cats.len()` entries), not just the single nearest one. Otherwise a window opening inside `North/Q1` loses `Upper`. The payload stays O(levels), not O(groups), so the reason in `command.md` (\"only the nearest, or the payload grows\") still holds with \"per level\" added.\n- **Protocol `render.rs`:**\n - `groups()` and `group_of` keep a stack (one pass still).\n - `group_runs` / `group_run_heads` are unchanged: they coalesce whatever `groups()` returns, and h0iv's `std::ptr::eq` rule still applies.\n - About 13 `FrameGroup` literals and 6 `GroupEnd` literals gain a level.\n - `body_from` (0o6x) needs checking: at the top of a windowed axis it must drop every carried header, not only one.\n- **TUI:** no code change beyond constructing the new variants. The band still shows one group per column, now the innermost open one. In the example, the band over North/Mar reads `Upper` where it now reads nothing.\n- **Browser:** the same. `col_groups` is unchanged.\n- **Wire:** a serde shape change to `FrameGroup` and `FrameEntry::GroupEnd`. Both ends are one bundle, and the cache-bust stamp covers a stale page.\n- **A side effect worth having:** `level` indexes `AxisFrame::cats`, so a client can name the category of any group. That is the piece a browser `z` would need to compose `ToggleGroup` (improvise-uln5), and it does not bring back the dead field improvise-7xyx removed. `level` is read by `groups()`, so it is live, and the category *name* comes from `cats` rather than being carried a second time.\n\nWhy the level goes on `GroupEnd` and not only on the header: in principle a stack could pair an unlevelled `GroupEnd` with its header by position. That only works if every producer emits a balanced sequence, and there are three producers: `expand_category`, `prune_axis_items` and `axis_frame`'s windowing. The last two both cut sequences. A levelled marker can be read correctly on its own, even inside a pruned or windowed fragment. Every bug in this area (gd83, u6gc, lhi4, b5zh, and now e265) has come from a producer changing marker placement, so a marker that can be read without its neighbours is the property to buy.\n\n### B. Membership stored on the item; markers derived\n\n`DataItem` carries `groups: Vec\u003cOption\u003cString\u003e\u003e`, parallel to `tuple`, filled in `expand_category`, which is the one place it is known. Membership becomes an attribute of the item, so pruning can't change it. `prune_axis_items` becomes: filter the data items, then **re-derive** the markers from the survivors with the same function expansion uses. That removes the `pending`/`run_open`/`close_runs_of` machinery, which is where the bugs listed above lived. It also removes `GroupEnd` completely, since it exists only so that membership is not inherited. Both \"header over nothing\" and \"trailing `GroupEnd`\" become impossible to represent.\n\nCosts:\n- **Core:** the largest rewrite of the three. The `DataItem` shape, `expand_category`, `prune_axis_items`, `cross_product`, `run_start`, `row_group_for`, the fingerprint, and every `AxisEntry` literal in the tests.\n- **Wire:** `FrameEntry::Item` would carry per-level groups. That is O(items × levels) in the payload where A is O(markers). Otherwise the host still has to send levelled markers, and the client-side membership walk is option A anyway. `FrameEntry::GroupEnd` would be deleted.\n- **h0iv must be argued again.** Runs can no longer be \"opened by the same header entry\" if the header is derived. The key becomes `(prefix up to L, level, group)`, which does separate `North/Q1` from `South/Q1`. But it is a new rule, and the h0iv evidence has to be re-pinned.\n- **Both front ends:** every `FrameEntry` match changes.\n\nB is the stronger invariant: an invariant made impossible to break, not one that is validated. It is also about three times the change, and its wire cost is real.\n\n### C. Membership computed before pruning (the ticket's example option)\n\nCompute `row_group_for` on the unpruned layout and carry it through pruning. This fixes the lossy flat answer, and makes it canonical: North/Mar is `None` while the user sees it under the `Upper` header row. It still needs either a second layout or an attribute carried per item. The second of those is B's cost with a worse answer, so I recommend rejecting C.\n\n### D. Keep flat membership and choose which property to lose\n\nFor example, go back to pre-#200 behaviour: `[Upper, GroupEnd, North/Mar]`, with membership stable and a header drawn over nothing. This is the cheapest option, and the ticket's enumeration shows flat membership cannot make both properties hold (274,584 cases against 45,806 at the two ends). Only acceptable as a documented leftover.\n\n## Recommendation\n\n**A.** It is the smallest type change that makes pruning on and off agree while also giving the answer that is correct at every level. It keeps h0iv's identity rule and the windowing payload bound. It also gives the browser what it would need to name a group's category for `z`.\n\nIf the user would rather pay for the invariant, B is the right shape. In that case I would take B in core first and put A's levelled markers on the wire: the host derives them, so the wire does not pay for O(items × levels).\n\n## Questions for the user\n\n1. **Which group does a row belong to?** Under A (and B), North/Mar is in `Upper`: `z` there collapses `Upper`, and the column band shows `Upper` over it. Today, unpruned, `z` there does nothing and the band is blank. Is the per-level answer the one you want?\n2. **What does `z` toggle when a row is in more than one open group?** I propose the innermost, which matches what `z` does today on North/Jan (`Q1`).\n3. **Is one column band enough?** A still paints one group per column (the innermost). One band per level would be a separate feature; I'd file it rather than fold it into this.\n\n## Proposed evidence for the fix\n\n- A property test over 2- and 3-level axes × collapse state × prune masks, the ticket's own enumeration, now as an assertion:\n - for every item that survives pruning, the membership stack of the pruned layout equals the unpruned layout's;\n - every header that is kept has at least one member.\n - Rival: today's flat `prune_axis_items`. It should fail on the ticket's 45,806-case shape. A pre-#200 rival should fail the no-empty-header half instead.\n- A frame-agreement test: `AxisFrame::groups()` on the projected frame equals `GridLayout`'s per-item membership, including a window that opens inside `North/Q1`. That is the per-level windowing rule, and a nearest-single-header rival must fail it.\n","created_at":"2026-09-23T16:01:42Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} +{"_type":"issue","id":"improvise-bf0q","title":"bd setup factory calls #201's minimal Beads block stale at once and swaps in profile:full; bd -C is ignored by bd setup","description":"Found reviewing PR #201 (head 31b5acdb), in a scratch copy of the tree with bd 1.3.0.\n\n1. `bd setup factory --check` reports the block #201 installs (`profile:minimal hash:46cd31e7`) as **stale** straight away, not only \"whenever bd's template has moved on\" as #201's new \"The Beads blocks are bd's\" section says. Running `bd setup factory` then replaces it with `profile:full hash:bacef91e`. That render is much longer, uses `--json` everywhere, and prescribes `git pull --rebase`. After the rewrite `--check` reports \"(current)\". So the first person to follow bd's own advice flips the profile. The repo-owned section outside the markers survives, verified byte for byte, and it already names the full profile's `git pull --rebase`. The policy therefore holds, but the sentence about when bd calls the block stale should say it happens at once for factory.\n\n2. **`bd -C \u003cdir\u003e setup factory` ignores `-C`** and writes `AGENTS.md` in the current working directory. Run from an agent's worktree with `-C /tmp/scratch`, it rewrote the worktree's `AGENTS.md` and left the scratch copy untouched, and `--check` read the worktree too. Anyone told to \"verify bd's regeneration on a scratch copy\" must `cd` into the copy and must not trust `-C`. This is an upstream bd bug, recorded here because the scratch-copy instruction will be given again.\n","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-23T05:53:27Z","created_by":"agent-review-201-202","updated_at":"2026-09-23T05:53:27Z","dependencies":[{"issue_id":"improvise-bf0q","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T01:05:45Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-bf0q","depends_on_id":"improvise-x1uk","type":"discovered-from","created_at":"2026-09-23T05:53:27Z","created_by":"agent-review-201-202","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-qnpi","title":"pr-reviews-test 'an inline comment arriving mid-wait is seen' is flaky under load (failed 1 of 2 identical runs)","description":"`./scripts/checkers.sh` failed one case in pr-reviews-test on the first run and passed on the second, with the same tree and no changes in between:\n\n FAIL an inline comment arriving mid-wait is seen (exit 1, wanted 0)\n pr-reviews-test: 21 passed, 1 failed\n\nThe tree was PR #202 head feefe17d, which touches no script. The machine was Edward's Mac (darwin), running while several cargo builds were in flight in the same session. The case name says it depends on timing (\"mid-wait\"), so it looks load-sensitive.\n\nIt is in the `kb` CI job (`checkers.sh ... pr-reviews`) and in the agent gate list. A flaky gate either teaches agents to re-run until green or blocks a PR on noise. Next step: run the suite in a loop under load, find the sleep or poll interval the case races against, and make it wait on a condition rather than on time.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-docs-wave","owner":"el-github@elangley.org","created_at":"2026-09-23T05:53:24Z","created_by":"agent-review-201-202","updated_at":"2026-09-23T14:39:39Z","started_at":"2026-09-23T06:18:03Z","closed_at":"2026-09-23T14:39:39Z","close_reason":"Fixed in PR #212, merged after adversarial review at 1550d39. Follow-ups: improvise-7epn, improvise-lnsh.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-8haj","title":"Dead-ID audit scans gitignored build output: after harness/build.sh it reports improvise-design/eval/key from harness/serve","description":"The dead-ID audit in AGENTS.md (\"Cite a ticket ID you have read back\") runs `grep -r` over `web` and `harness`, so it also scans gitignored build output: `harness/serve/`, `web/pkg/`, `web/worker-pkg/`, `harness/opfs-contract/target/` and `harness/node_modules/`.\n\nReproduced at PR #201 head 31b5acdb. After `./harness/build.sh`, which #201's agent-rules Gates block now tells agents to run whenever they touch web/ or harness/, the recipe exactly as written printed:\n\n Issue improvise-design not found\n Issue improvise-eval not found\n Issue improvise-key not found\n\nAll three come only from `harness/serve/docs/{test-evidence/gp,code/clients,design-principles}.html`, which is rendered prose. `git grep` finds none of them in tracked files. After deleting `harness/serve` the audit read \"all resolve\", and it still caught an id injected into `.agents/skills/wave/SKILL.md`.\n\nThis is the hazard #201 already argues about for `.claude/worktrees` (\"named file by file, never whole\"). It applies equally to build output under the scanned roots. A false dead id reads as a real one, so it costs whoever sees it a hunt through bd.\n\nSuggested fix: `git grep -ohE 'improvise-[a-z0-9]{3,8}\\b' -- \u003csame paths\u003e`. This scans only tracked files, so it is independent of build state and of `.claude/worktrees/`. git grep does not follow tracked symlinks, so the skills are still scanned once, through `.agents`. Verify by injection before landing it.\n","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T05:53:21Z","created_by":"agent-review-201-202","updated_at":"2026-09-23T06:29:42Z","closed_at":"2026-09-23T06:29:42Z","close_reason":"Fixed in PR #201, merged after adversarial review (31b5acd, then the delta at 141be9f). Follow-ups: improvise-28ri, improvise-oga4, improvise-bf0q.","dependencies":[{"issue_id":"improvise-8haj","depends_on_id":"improvise-anmm","type":"discovered-from","created_at":"2026-09-23T05:53:21Z","created_by":"agent-review-201-202","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-qz9p","title":"A drill commit into a category removed mid-drill refuses and still writes an orphan cell","description":"Found by adversarial review of PR #204 at 92eefeec. This predates #204 and is not introduced by it. While a drill is open, another client's RemoveCategory can remove a category that the drill has a staged rename for. On commit, `effect::AddItem` refuses with \"Unknown category 'Type'\" (`last_batch_refused` is true). The rewrite's clears and sets still run afterwards, so the store gets a cell keyed `Type=Drink` for a category the model no longer has. That is an orphan that no grid shows. Probe: two_record_drill(); `workbook.remove_category(\"Type\")`; stage (1, Type, \"Drink\") and (1, Cost, \"999\"); ApplyAndClearDrill. The store is then `{Drink/Jan/Cost = 999}`. The base code wrote the same orphan at the old key (Clothing/Jan/Cost) and discarded add_item's error. #204 only makes the refusal visible. Decide whether a failed AddItem should drop the rewrite's writes for that row.\n","status":"in_progress","priority":3,"issue_type":"bug","assignee":"agent-uo2p","owner":"el-github@elangley.org","created_at":"2026-09-23T05:50:00Z","created_by":"agent-review-204","updated_at":"2026-09-23T06:35:47Z","started_at":"2026-09-23T06:35:47Z","lease_expires_at":"2026-09-23T06:40:47Z","heartbeat_at":"2026-09-23T06:35:47Z","labels":["adversarial-review","pr-204"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-qz9p","title":"A drill commit into a category removed mid-drill refuses and still writes an orphan cell","description":"Found by adversarial review of PR #204 at 92eefeec. This predates #204 and is not introduced by it. While a drill is open, another client's RemoveCategory can remove a category that the drill has a staged rename for. On commit, `effect::AddItem` refuses with \"Unknown category 'Type'\" (`last_batch_refused` is true). The rewrite's clears and sets still run afterwards, so the store gets a cell keyed `Type=Drink` for a category the model no longer has. That is an orphan that no grid shows. Probe: two_record_drill(); `workbook.remove_category(\"Type\")`; stage (1, Type, \"Drink\") and (1, Cost, \"999\"); ApplyAndClearDrill. The store is then `{Drink/Jan/Cost = 999}`. The base code wrote the same orphan at the old key (Clothing/Jan/Cost) and discarded add_item's error. #204 only makes the refusal visible. Decide whether a failed AddItem should drop the rewrite's writes for that row.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-uo2p","owner":"el-github@elangley.org","created_at":"2026-09-23T05:50:00Z","created_by":"agent-review-204","updated_at":"2026-09-24T21:45:27Z","started_at":"2026-09-23T06:35:47Z","closed_at":"2026-09-24T21:45:27Z","close_reason":"Landed in main via #222 (reviewed; merged 2026-09-24).","labels":["adversarial-review","pr-204"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-v8ab","title":"No test pins a fresh session's Snapshot slice equal to the host's startup ViewState; lockstep.rs seeds from app.view_state and bypasses fresh_view","description":"Found reviewing PR #206 (improvise-lxg0). The fresh slice is constructed twice: App::with_storage builds ViewState{active_view, ..default()} and project.rs::fresh_view builds it again. Nothing asserts they agree. tests/lockstep.rs seeds every client with app.view_state.clone(), never with a Snapshot from Sessions::connect, so it structurally could not see lxg0's phantom back-frame and cannot see the next drift between the two. Verified at b22f6e53: a scratch assert_eq!(sessions.connect(\u0026app,..).1.view, app.view_state) on a document opening on a non-Default view passes after the fix and fails with the old fold restored. Scope is connect only: a rebase deliberately resets the client's whole slice while install_workbook keeps the host's mode/panels, so equality is not the contract there. Proposal: one test asserting the connect equality on a fresh App, or have fresh_view and App::with_storage share one constructor so the equality holds by construction.","status":"closed","priority":3,"issue_type":"task","assignee":"agent-viewhist","owner":"el-github@elangley.org","created_at":"2026-09-23T05:49:45Z","created_by":"agent-review-206","updated_at":"2026-09-23T14:44:20Z","started_at":"2026-09-23T05:51:48Z","closed_at":"2026-09-23T14:44:20Z","close_reason":"Fixed in PR #207, merged after adversarial review at 13b998d; the later change was an evidence-only merge plus a wording fix. Follow-up: improvise-y5eo (#216).","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-4img","title":"PR #188's clear variant is refused as RecordMoved, not RecordGone as its evidence reasons; it has no test","description":"Found re-reviewing PR #188 at head a5fbd84f. context/test-evidence/clients.md, under an_edit_is_refused_when_the_cell_was_written_while_the_editor_was_open, says the clear variant (B clears the cell while A's editor is open) is RecordGone, and that this is reasoning, not a test. context/code/clients.md lists 'the record is gone: RecordGone' as one of the outcomes.\n\nRAN it with a scratch test against a real Host (not committed). The fixture is the one browser_round_trip uses, with A's editor on North/Revenue at version 0. B sends ClearCell. The record still resolves, because App::resolve_record falls back to the layout and North is still a row, and its counter is bumped to 1. A's commit is refused as Rejected(RecordMoved(North)), and North stays empty. So the property holds: the cell is not re-created. Only the stated reason was wrong. The test DISCRIMINATES: with the rival (claim at commit), the same scenario is accepted and the cell is re-created at 42. Proposed: add the test as an_edit_is_refused_when_the_cell_was_cleared_while_the_editor_was_open, asserting RecordMoved and an empty cell, and correct both context paragraphs. RecordGone is reached by RemoveItem or RemoveCategory while the editor is open, both also run and refused as RecordGone.","status":"closed","priority":3,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T05:42:13Z","created_by":"agent-review-188b","updated_at":"2026-09-23T06:35:13Z","started_at":"2026-09-23T05:50:58Z","closed_at":"2026-09-23T06:35:13Z","close_reason":"Fixed in PR #188, merged as de70373 after adversarial re-review at a5fbd84. au6c and 4img were the review-requested fixes; later merges touched evidence only. Follow-up: improvise-vndi.","labels":["adversarial-review","evidence","pr-188","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-b9f2","title":"Determinism law's width guard measures the argument list, not the drills the comparison built","description":"Found in adversarial re-review of PR #187 (improvise-hzjo / improvise-e1sm) at head 0825f998.\n\nThe width guard in `every_command_gives_equal_effects_in_independently_built_equal_contexts` (`widest_drill`) measures how many records the *argument lists* match through `matching_cells`. It does not measure how many records a *built command* actually ordered in the effects the comparison compared. So it is a parallel derivation. It can pass while the comparison has lost its wide drills.\n\nRival (run, not argued): with gotcha 18 re-injected (`positions.sort_unstable()` in `DataStore::matching_positions` commented out), change the `drill-into-cell` parser in `registry.rs` from `if args.is_empty()` to `if args.len() \u003c 2`. The one-coordinate drills are then refused by `parse`, and the registry's cursor fallback builds them as a one-record cursor drill. `widest_drill` still reports 4 and the guard is silent. The law then **passed 38 of 300 runs** (~13%), the same as the pre-e1sm rate. Without the parser rival it failed 300 of 300.\n\nSo two statements are false as written:\n- `context/test-evidence/command.md`: \"The width guard reads the same argument list the comparison uses, so it cannot pass while the comparison has lost its wide drills.\"\n- `context/code/command.md`: \"That guard reads the argument list the law uses, not a copy of it, so dropping the derived drills fails it\".\n\nBoth are true for the one rival that was tried (dropping the `extend` line). They are false for a rival that keeps the list but changes what gets built from it.\n\nSuggested fix: measure the width from what was compared. For example, have `compare` record the largest `StartDrill` record count among the drill-into-cell effects it actually compared, and assert on that. Or assert that `drill-into-cell` *built from* a one-coordinate argument yields at least 4 records. Also consider `drill_constraint`, which the guard skips and the command applies.\n\nMinor, same area: `command.md` names \"the analyzer's `HashSet`\" as an instance of the nondeterminism the law is about. `analyze_records` is reached only from `Effect::apply` (`ImportJsonHeadless`, effect.rs) and from the wizard, so the law cannot see it. Injected: a `HashSet`-iteration `distinct_vec` passed the law 100 of 100 runs. Only `the_distinct_values_are_in_first_seen_order` caught it. The execute/apply scope paragraph covers this implicitly, but that sentence should say the analyzer is out of scope.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-hzjo2","owner":"el-github@elangley.org","created_at":"2026-09-23T05:41:06Z","created_by":"agent-review-187b","updated_at":"2026-09-23T14:56:22Z","started_at":"2026-09-23T05:47:55Z","closed_at":"2026-09-23T14:56:22Z","close_reason":"Fixed in PR #208, merged as bf2de49. Reviewed at 7dfa90f; the btyw delta was the reviewer's verified one-line repair, re-measured by the author.","dependencies":[{"issue_id":"improvise-b9f2","depends_on_id":"improvise-e1sm","type":"discovered-from","created_at":"2026-09-23T05:41:12Z","created_by":"agent-review-187b","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-diho","title":"agent-rules: pin a resolved SHA, never the gh/main ref name, when resetting or diffing mid-task","description":"Remote refs are shared across worktrees, so gh/main moves under an agent whenever any other session fetches. agent-uo2p ran 'git reset --soft gh/main', which staged a reverse diff of #196. It noticed only because it read git status. Rule for .agents/skills/wave/agent-rules.md: at task start, resolve gh/main to a SHA and use that literal SHA for reset, merge-base and diff; re-resolve on purpose when you mean to merge newer main.","status":"closed","priority":3,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T05:35:46Z","created_by":"orchestrator","updated_at":"2026-09-23T15:15:50Z","started_at":"2026-09-23T06:30:19Z","closed_at":"2026-09-23T15:15:50Z","close_reason":"Fixed in PR #211, merged as 968f65a after adversarial review (delta at 41aa407).","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-kndq","title":"A records rename onto an existing record silently overwrites it (plain records mode and drill alike)","description":"Surfaced by improvise-uo2p (PR #204). When two drill rows are renamed onto the same coordinates, or a rename lands on a record another row is editing, the later snapshot row wins cell by cell and the earlier row's values are lost with no notice. Plain records mode already does this when a rename lands on an existing record, and #204 kept that behaviour deliberately so the two paths agree. Decide whether a rename that collides should be refused (in both paths, via RecordRewrite), or should merge, and say which.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T05:35:36Z","created_by":"orchestrator","updated_at":"2026-09-23T05:35:36Z","comments":[{"id":"fdf83794-0448-57bd-ad9f-13126012d8e6","issue_id":"improvise-kndq","author":"agent-review-204","text":"From the adversarial review of PR #204 (at 92eefeec): the rule that ships is \"later row wins **per cell, among rows that write**\". It is not \"the later row wins\". I measured it with probes on two_record_drill:\n- An unedited row does not write. So when row0 is renamed onto row1's coordinates, row0's values overwrite row1's, even though row1 is the later row. Plain mode does the same.\n- A no-op rename (row1 Type Clothing-\u003eClothing) makes row1 write, which flips the winner to row1. Pressing Enter on an unchanged cell changes which record survives.\n- The rows can carry different measures. With Food{Revenue=1, Cost=2} and Clothing{Revenue=42} both renamed to Drink, the result is Drink{Revenue=42, Cost=2}, a chimera of two records.\n- A blank edit on a measure the row HAS clears the source and leaves the destination's value. A blank edit on a measure the row LACKS clears the destination key, which can belong to a record outside the drill.\nAll of these agree with plain records mode as far as it can express them. Whatever policy this ticket picks has to answer these four cases.\n","created_at":"2026-09-23T05:50:08Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} -{"_type":"issue","id":"improvise-80vh","title":"Stale watchdog prose: 'the worker has no error that fires' / 'saying in-page worker forever' / setShellFailure","description":"Found reviewing PR #199 at d5854bc1. Three stale copies of the watchdog argument. (1) context/code/clients.md 'The watchdog, and the two close arms' (~line 1690) says the worker has no failure event that fires, so a browser with no OPFS 'would leave the page saying \"in-page worker\" over an empty grid forever'. Since improvise-1wft a start() rejection posts a BootFailed notice with its reason, and since #199 the pre-frame text is 'starting the worker · \u003cdoc\u003e…'. The watchdog now guards only a peer that never answers AND never says why (a hang), not a start() rejection. (2) context/repo-map.md gotcha 46 carries the same claim ('A worker that rejects in start() fires no error on the main thread — worker.js catches its own — so the guard is a first-frame watchdog'): the first half is still literally true, the 'so' is not, since 1wft's notice is the guard for that case. (3) clients.md ~1701 and ~1745, repo-map gotcha 48, and test-evidence/clients.md ~697-707 name setShellFailure, which no longer exists: the function is createShell().fail in web/shell.js. repo-map is orchestrator-only; clients.md and test-evidence are not.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-23T05:22:21Z","created_by":"agent-review-199","updated_at":"2026-09-23T05:22:21Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-a3dj","title":"A frame that arrives after the handshake watchdog fired leaves 'did not answer' over a working grid","description":"Found reviewing PR #199 (improvise-i0v9) at d5854bc1. createShell().sawFrame writes the success text only while the watchdog is still armed, which stands in for 'nothing has spoken since arming'. But the watchdog's OWN firing also disarms it, so a peer that answers after 8s (a slow cold wasm compile, a slow OPFS open) paints a working grid under 'the in-page worker did not answer (see console)' permanently. Probe: arm(msg,{answer}); fire the timer; sawFrame() -\u003e seen = [msg] only. Socket path is the same: 'never answered' outlives a late Snapshot. Pre-existing (before #199 sawFrame wrote nothing and the watchdog had already replaced 'in-page worker'), so not a regression of #199, but #199's rule 'success is claimed by the first frame, and only if nothing failed first' is violated by its proxy: the watchdog is a GUESS that a frame falsifies, whereas a loud failure (fail) is a fact a frame does not. Fix shape: a flag set by fail() (not by the timer) gates the claim, and a late first frame replaces the watchdog's text (with answer, or with the socket's connected text). Test: unit test in web/tests/shell.test.js arming, firing, then sawFrame, asserting the last write is the answer; plus the fail-then-frame case must still keep the failure.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T05:22:09Z","created_by":"agent-review-199","updated_at":"2026-09-23T05:22:09Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-kndq","title":"A records rename onto an existing record silently overwrites it (plain records mode and drill alike)","description":"Surfaced by improvise-uo2p (PR #204). When two drill rows are renamed onto the same coordinates, or a rename lands on a record another row is editing, the later snapshot row wins cell by cell and the earlier row's values are lost with no notice. Plain records mode already does this when a rename lands on an existing record, and #204 kept that behaviour deliberately so the two paths agree. Decide whether a rename that collides should be refused (in both paths, via RecordRewrite), or should merge, and say which.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T05:35:36Z","created_by":"orchestrator","updated_at":"2026-09-23T05:35:36Z","dependencies":[{"issue_id":"improvise-kndq","depends_on_id":"improvise-n28d","type":"parent-child","created_at":"2026-09-25T01:05:24Z","created_by":"Edward Langley","metadata":"{}"}],"comments":[{"id":"fdf83794-0448-57bd-ad9f-13126012d8e6","issue_id":"improvise-kndq","author":"agent-review-204","text":"From the adversarial review of PR #204 (at 92eefeec): the rule that ships is \"later row wins **per cell, among rows that write**\". It is not \"the later row wins\". I measured it with probes on two_record_drill:\n- An unedited row does not write. So when row0 is renamed onto row1's coordinates, row0's values overwrite row1's, even though row1 is the later row. Plain mode does the same.\n- A no-op rename (row1 Type Clothing-\u003eClothing) makes row1 write, which flips the winner to row1. Pressing Enter on an unchanged cell changes which record survives.\n- The rows can carry different measures. With Food{Revenue=1, Cost=2} and Clothing{Revenue=42} both renamed to Drink, the result is Drink{Revenue=42, Cost=2}, a chimera of two records.\n- A blank edit on a measure the row HAS clears the source and leaves the destination's value. A blank edit on a measure the row LACKS clears the destination key, which can belong to a record outside the drill.\nAll of these agree with plain records mode as far as it can express them. Whatever policy this ticket picks has to answer these four cases.\n","created_at":"2026-09-23T05:50:08Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} +{"_type":"issue","id":"improvise-80vh","title":"Stale watchdog prose: 'the worker has no error that fires' / 'saying in-page worker forever' / setShellFailure","description":"Found reviewing PR #199 at d5854bc1. Three stale copies of the watchdog argument. (1) context/code/clients.md 'The watchdog, and the two close arms' (~line 1690) says the worker has no failure event that fires, so a browser with no OPFS 'would leave the page saying \"in-page worker\" over an empty grid forever'. Since improvise-1wft a start() rejection posts a BootFailed notice with its reason, and since #199 the pre-frame text is 'starting the worker · \u003cdoc\u003e…'. The watchdog now guards only a peer that never answers AND never says why (a hang), not a start() rejection. (2) context/repo-map.md gotcha 46 carries the same claim ('A worker that rejects in start() fires no error on the main thread — worker.js catches its own — so the guard is a first-frame watchdog'): the first half is still literally true, the 'so' is not, since 1wft's notice is the guard for that case. (3) clients.md ~1701 and ~1745, repo-map gotcha 48, and test-evidence/clients.md ~697-707 name setShellFailure, which no longer exists: the function is createShell().fail in web/shell.js. repo-map is orchestrator-only; clients.md and test-evidence are not.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-23T05:22:21Z","created_by":"agent-review-199","updated_at":"2026-09-23T05:22:21Z","dependencies":[{"issue_id":"improvise-80vh","depends_on_id":"improvise-fhj0","type":"parent-child","created_at":"2026-09-25T01:05:42Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-a3dj","title":"A frame that arrives after the handshake watchdog fired leaves 'did not answer' over a working grid","description":"Found reviewing PR #199 (improvise-i0v9) at d5854bc1. createShell().sawFrame writes the success text only while the watchdog is still armed, which stands in for 'nothing has spoken since arming'. But the watchdog's OWN firing also disarms it, so a peer that answers after 8s (a slow cold wasm compile, a slow OPFS open) paints a working grid under 'the in-page worker did not answer (see console)' permanently. Probe: arm(msg,{answer}); fire the timer; sawFrame() -\u003e seen = [msg] only. Socket path is the same: 'never answered' outlives a late Snapshot. Pre-existing (before #199 sawFrame wrote nothing and the watchdog had already replaced 'in-page worker'), so not a regression of #199, but #199's rule 'success is claimed by the first frame, and only if nothing failed first' is violated by its proxy: the watchdog is a GUESS that a frame falsifies, whereas a loud failure (fail) is a fact a frame does not. Fix shape: a flag set by fail() (not by the timer) gates the claim, and a late first frame replaces the watchdog's text (with answer, or with the socket's connected text). Test: unit test in web/tests/shell.test.js arming, firing, then sawFrame, asserting the last write is the answer; plus the fail-then-frame case must still keep the failure.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T05:22:09Z","created_by":"agent-review-199","updated_at":"2026-09-23T05:22:09Z","dependencies":[{"issue_id":"improvise-a3dj","depends_on_id":"improvise-fhj0","type":"parent-child","created_at":"2026-09-25T01:05:36Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-aszt","title":"every_keys_row_drawn_is_one_the_builders_asked_for compares counts, so a self::keys bypass that also drops a real row passes the workspace","description":"Found in adversarial review of PR #198 at head 8772db2e (the improvise-ssoe fix).\n\nssoe made KeyRow::looked_up private and added help::tests::every_keys_row_drawn_is_one_the_builders_asked_for, which asserts COUNT(drawn HelpLine::Keys) == help_bindings().len(). The author named the residual: a builder can still call the module-level keys by path (self::keys) instead of its shadowing parameter, and argued the count catches it.\n\nIt does not when the bypass also drops one real row, so both sides move together. Injected in editing(), replacing the Editing-mode clear-buffer row with a block that (1) calls the parameter keys(e, \"clear-buffer\", Some(\u0026[\"edit\"]), \"Cancel edit, return to Normal\") and discards the result with let _ =, then (2) returns self::keys(n, \"clear-cell\", None, \"Edit the current cell\").\n\nThe parameter call is recorded and discarded; the bypass draws \"x Edit the current cell\" (the lie ssoe's own evidence names) unrecorded. cargo test --workspace --no-fail-fast: WHOLE WORKSPACE GREEN (improvise-protocol recompiled; the count test reported ok). The same injection on the edit-or-drill row is caught, but only by that row's dedicated test (the_edit_key_row_says_it_drills_into_an_aggregate).\n\nFix: compare CONTENT, not count. Walk the drawn Keys rows in order and assert they equal help_bindings() looked up in order (description and keys). A bypass that draws exactly what the builders asked for is then the only thing that passes, and that one is harmless.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-helpkeys2","owner":"el-github@elangley.org","created_at":"2026-09-23T05:21:51Z","created_by":"Edward Langley","updated_at":"2026-09-23T06:07:59Z","started_at":"2026-09-23T05:39:26Z","closed_at":"2026-09-23T06:07:59Z","close_reason":"Fixed in PR #198, merged after adversarial review (delta at f75b58a). Follow-up: improvise-5xbq.","labels":["help","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-1c5m","title":"Browser help overlay covers #hint once the app bar wraps, and Welcome no longer says how to page (PR #180 removed the tip)","description":"Found in adversarial review of PR #180 at head f2488276 (delta 7935b00, which removed the Welcome page's \"Tip: press Tab or l/n to go to the next page\" rather than deriving it, arguing Help mode's status hint already derives next-page).\n\nMEASURED (Playwright, chromium, harness tree built at f2488276, `?` from the grid, Welcome active):\n- 1280x800: #hint at y=9, text \"Tab/l/n/→:next-page S-Tab/h/p/←:prev-page ?/Esc/q:close\", 208px wide, elementFromPoint at the hint = #hint. Visible.\n- 800x800: the header has wrapped, #hint is at y=43 (363px wide), #help's top edge is 32px (inset: 2rem, z-index 30). elementFromPoint at the hint = #help. COVERED.\n- 600x800: #hint at y=43, 27px wide, elementFromPoint = .help-tab. COVERED.\n\nSo below the width at which the app bar wraps, the only paging instruction the browser shows on Welcome is under the overlay, and the .help-tab spans have no click handler. Before 7935b00 the Welcome body itself carried the instruction. The TUI is unaffected: its popup leaves the status row visible and its own footer spells l/Tab.\n\nRelated but distinct: improvise-rx8g (#hint illegible at phone width) is about the hint's width; this is the overlay covering it even when it has width.\n\nOptions: (a) a derived PROSE row on Welcome (keys off keys_for(ModeKey::Help, \"help-page-next\"), rendered as text, so help.spec.js's \"Welcome is prose\" still holds); (b) the browser overlay draws hint_line(Help) in its own footer, as the TUI popup does; (c) make .help-tab clickable. (a) or (b) keeps one derived table.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-docs-wave","owner":"el-github@elangley.org","created_at":"2026-09-23T05:21:23Z","created_by":"Edward Langley","updated_at":"2026-09-23T14:59:03Z","started_at":"2026-09-23T06:37:35Z","closed_at":"2026-09-23T14:59:03Z","close_reason":"Fixed in PR #215, merged after adversarial review at a8737f3. Follow-up: improvise-mv1d.","labels":["browser","help","ux"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-mkbp","title":"harness/build.sh rewrites harness/opfs-contract/Cargo.lock on every run (pest 2.9.1 -\u003e 2.9.2)","description":"Reported by agent-i0v9 and by the #180 author: every harness build dirties harness/opfs-contract/Cargo.lock, so each agent reverts it by hand, and autostash churns during merges. Either commit the resolved lock, or build with --locked so the drift fails loudly instead. Separately: re-running only web/assemble.sh wipes harness/serve/opfs, after which opfs.spec.js fails 8 times until build.sh's tail steps are run again. Document it or make assemble.sh preserve the directory.","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-docs-wave","owner":"el-github@elangley.org","created_at":"2026-09-23T05:15:58Z","created_by":"orchestrator","updated_at":"2026-09-23T14:39:39Z","started_at":"2026-09-23T06:18:01Z","closed_at":"2026-09-23T14:39:39Z","close_reason":"Fixed in PR #212, merged after adversarial review at 1550d39. Follow-ups: improvise-7epn, improvise-lnsh.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-z4up","title":"source_scan tests do not discriminate not(Unknown): rival not(Unknown)=No hides cfg(not(target_arch)) code with all 7 tests green","description":"Found by the adversarial review of PR #195 (improvise-59q8, head 43ba5e6).\n\n## The gap\n\n`source_scan::combine`'s `not` arm maps `Unknown` to `Unknown`. That is the direction which keeps code in production. Injecting the rival `not(Unknown) =\u003e No` (in the `_ =\u003e Truth::Unknown` arm under `\"not\"`) leaves **all 7** source_scan tests green after a real rebuild. Under that rival, `#[cfg(not(target_arch = \"wasm32\"))]` and `#[cfg(not(feature = \"x\"))]` items are classified test-only, and so hidden from both vocabulary guards.\n\nThe existing cases cannot see it:\n- `all(test, not(target_arch = ..))` is `all(No, _)`, which is `No` whatever `not` returns.\n- The controls are `not(test)`, `any(test, feature = \"serde\")` and a bare `target_arch`. None of them puts an unknown predicate under `not`.\n\nNo `cfg(not(\u003cnon-test\u003e))` exists in crates/*/src or src/ today, so it is latent.\n\n## Fix\n\nAdd a control to `a_test_only_cfg_is_recognised_whatever_its_spelling`: `#[cfg(not(target_arch = \"wasm32\"))]` must stay production. Then confirm that the rival above turns it red.\n\nChecked and held in the same review:\n- `feature = \"test-support\" =\u003e Unknown` is caught by `test_only_items_and_modules_are_not_production` (the `gated` assertion).\n- Dropping `c` from the raw-string arm is caught by `a_c_string_literal_is_blanked`.\n- Dropping `c` from the plain C-string arm passes every test, but is observationally equivalent: the `\"` is then lexed as an ordinary string with the same escapes.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T05:13:27Z","created_by":"agent-review-195","updated_at":"2026-09-23T05:13:27Z","labels":["adversarial-review","pr-195","protocol","testing"],"dependencies":[{"issue_id":"improvise-z4up","depends_on_id":"improvise-59q8","type":"discovered-from","created_at":"2026-09-23T05:13:27Z","created_by":"agent-review-195","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-yqnp","title":"source_scan hides later siblings after a comma-separated test-only element (match arm, vec!, struct field, enum variant) and after a block followed by an else-prefixed identifier","description":"Found by the adversarial review of PR #195 (improvise-59q8, head 43ba5e6). It is latent: the new scanner's output is token-identical to the old one on every .rs file in the workspace. But it contradicts the PR's stated property, that a test-only item hides nothing after it and that an item which cannot be bounded is a panic.\n\n## The shape\n\n`item_end` (crates/improvise-protocol/src/source_scan.rs) knows only two terminators: a `;` at depth 0, and the `}` closing a body. A test-only element that is separated from its siblings by a **comma** has neither. So the scan runs on to the bracket that closes the enclosing list, and the `')' | ']' | '}' if depth == 0 =\u003e return Some(k)` arm accepts that bracket as \"the item was the last expression\". Every later sibling is dropped, silently, with no panic.\n\nReproduced through `production_text`, with the output after `--- new ---`:\n\n1. **Match arm.**\n ```\n match k {\n #[cfg(test)]\n K::T =\u003e Command::TestArm(0),\n K::P =\u003e Command::ProdArm1(1),\n }\n ```\n Output: `match k { }`. So `Command::ProdArm1` is invisible to `vocabulary_is_composed`.\n2. **vec!/array element.** `vec![ #[cfg(test)] Command::TestElem(0), Command::ProdElem(1), ]` gives `vec![ ]`.\n3. **Struct-literal field.** `S { #[cfg(test)] t: .., p: Command::ProdField(1), }` gives `S { }`.\n4. **Enum variants and struct fields** behave the same: every later variant or field is dropped.\n\nSecond shape, same class. `continues()` tests `rest.starts_with(\"else\")` with no word boundary. So after a test-only block statement:\n```\n#[cfg(test)]\n{ setup(); }\nelsewhere(Command::ProdAfterBlock(1));\n```\nThe production statement `elsewhere(...)` is swallowed up to its `;`.\n\nThe old line scanner was worse on all of these: it hid to EOF. So this is not a regression; it is an unclosed part of 59q8's class.\n\n## Suggested fix\n\nRefuse rather than guess. If `item_end` reaches the enclosing close bracket having crossed a depth-0 `,`, panic with \"could not be bounded\". A plain `,` terminator would mis-bound generics such as `impl\u003cA, B\u003e` and `where A: X, B: Y`. Give `else` a word boundary.\n\nA test belongs in `a_test_only_item_hides_nothing_after_it` for each shape. Each fixture here was run under a scratch harness and then deleted.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T05:13:20Z","created_by":"agent-review-195","updated_at":"2026-09-23T05:13:20Z","labels":["adversarial-review","pr-195","protocol","testing"],"dependencies":[{"issue_id":"improvise-yqnp","depends_on_id":"improvise-59q8","type":"discovered-from","created_at":"2026-09-23T05:13:20Z","created_by":"agent-review-195","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-3m7b","title":"No test pins that yank and drill-into-cell with explicit coordinates act on them rather than the cursor","description":"Found in adversarial review of PR #182 (improvise-yf7p) at head 0a6e940, merged locally with gh/main da2e5ab.\n\n#171's review showed that a registry `contextualize` taking yank, paste and drill-into-cell's key from the cursor, over the parsed one, left the workspace green. #182 removes the registry as a place that bug can be written: `contextualize` is gone, and an `AtCursor` impl pointed at `key` is E0308, which I tried. But the same bug written in `execute` is still unguarded for two of the three commands.\n\n- `YankCell::execute` using `ctx.cell_key()` in place of `self.key`: the **full workspace is green**.\n- `DrillIntoCell::execute` using `ctx.cell_key()` in place of `self.key`: the **full workspace is green**.\n- `PasteCell` with the same injection fails `paste_with_an_explicit_full_key_writes_even_where_the_view_aggregates` and `paste_with_yanked_value_produces_set_cell`. Guarded.\n\nPre-existing, not introduced by #182. Wanted: for `yank` and `drill-into-cell`, a test that runs the command with explicit coordinates while the cursor sits on a different cell, both at nonzero positions, and asserts the effects name the explicit cell.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T05:10:00Z","created_by":"agent-review-182","updated_at":"2026-09-23T05:10:00Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-pakc","title":"The totals strip projects id: None, which the contract reserves for 'no cell' (exdg's sibling, needs its own decision)","description":"Found by agent-viewhist while fixing improvise-exdg (PR #196). The wasm client's render.rs::totals_strip projects every totals cell with id: None, which is the same conflation exdg fixed for records coordinate cells. exdg's fix can't be copied: a column total over a formula-owned column (e.g. Profit) is a SUM of that column, not the formula, so it is neither Aggregate in the ADR 002 sense nor Derived. Decide what identity a total carries, if any, and write it into ADR 002 alongside the fifth kind (Coordinate) that #196 added.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T05:06:15Z","created_by":"orchestrator","updated_at":"2026-09-23T05:06:15Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-qvpu","title":"pr-watch: a BLOCKED timeout should name the required checks that never registered","description":"Found re-reviewing PR #186 at 04230a7. Since improvise-zlj2, green + MERGEABLE/BLOCKED is waited on, so a required check that never registers (e.g. a CI job renamed so the ruleset's context no longer matches) now costs the default 1800s and exits 3 with only 'mergeability MERGEABLE/BLOCKED'; before zlj2 it exited 1 at once. Checked this repo's rules (gh api repos/fiddlerwoaroof/improvise/rules/branches/main, 2026-09-22): branch protection API 404s; one ruleset requires 3 checks (not strict), 0 approvals, no stale-review dismissal, no thread resolution. So review-required / stale-review BLOCKED cannot occur here, but the ruleset also carries require_extra_approval_for_unattributed_changes: true, whose trigger is unverified and which would present as exactly this timeout. Waiting is the right call; the gap is diagnosis. Suggested: on a BLOCKED timeout, diff the ruleset's required_status_checks contexts against the rollup names and print the missing ones (or print 'no required check missing' so an approval/rule block is distinguishable).","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-23T05:04:08Z","created_by":"agent-review-186b","updated_at":"2026-09-23T05:04:08Z","labels":["adversarial-review-pr-186"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-1qaa","title":"pr-watch waits the full timeout on a MERGED or CLOSED PR, which GitHub reports as UNKNOWN/UNKNOWN","description":"Found re-reviewing PR #186 at 04230a7. Live: gh pr view 189 --json mergeable,mergeStateStatus on the merged PR returns UNKNOWN/UNKNOWN with every check green. Since improvise-edpl UNKNOWN is waited on, so pr-watch on an already-merged (or closed) PR sleeps the default 1800s and exits 3 'mergeability UNKNOWN/UNKNOWN'. Fails closed (no false pass), but under merge-as-soon-as-green a watch on a PR that another actor just merged costs 30 minutes and names the wrong reason. The script never fetches .state. Fix: add state to the --json field list and end early on MERGED/CLOSED with a distinct message (exit code to be decided; not 0, since 0 means mergeable).","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T05:04:07Z","created_by":"agent-review-186b","updated_at":"2026-09-23T05:04:07Z","labels":["adversarial-review-pr-186"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-9pfs","title":"pr-watch-test has no case for a failed check under MERGEABLE/BLOCKED, the state a failed required check reports","description":"Found re-reviewing PR #186 at 04230a7. Every red fixture in scripts/pr-watch-test.sh wears UNSTABLE or UNKNOWN, but in this repo a failed REQUIRED check reports MERGEABLE/BLOCKED. Rival: gate the failed-check break with state != BLOCKED (if { [ \"$failed\" -gt 0 ] \u0026\u0026 [ \"$state\" != BLOCKED ]; } || ...). All 45 cases still pass; on a fixture of green build + FAILURE 'Test and lint (host)' + MERGEABLE/BLOCKED the head script exits 1 and the rival exits 3 after the full timeout. Not a false pass, but the commonest red in this repo is unpinned and a refactor of the wait could turn every red into a 30-minute timeout silently. Fix: add a case red rollup + MERGEABLE/BLOCKED expecting 1 at --timeout 0 (the table only enumerates green rollups).","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T05:03:54Z","created_by":"agent-review-186b","updated_at":"2026-09-23T05:03:54Z","labels":["adversarial-review-pr-186"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-z4up","title":"source_scan tests do not discriminate not(Unknown): rival not(Unknown)=No hides cfg(not(target_arch)) code with all 7 tests green","description":"Found by the adversarial review of PR #195 (improvise-59q8, head 43ba5e6).\n\n## The gap\n\n`source_scan::combine`'s `not` arm maps `Unknown` to `Unknown`. That is the direction which keeps code in production. Injecting the rival `not(Unknown) =\u003e No` (in the `_ =\u003e Truth::Unknown` arm under `\"not\"`) leaves **all 7** source_scan tests green after a real rebuild. Under that rival, `#[cfg(not(target_arch = \"wasm32\"))]` and `#[cfg(not(feature = \"x\"))]` items are classified test-only, and so hidden from both vocabulary guards.\n\nThe existing cases cannot see it:\n- `all(test, not(target_arch = ..))` is `all(No, _)`, which is `No` whatever `not` returns.\n- The controls are `not(test)`, `any(test, feature = \"serde\")` and a bare `target_arch`. None of them puts an unknown predicate under `not`.\n\nNo `cfg(not(\u003cnon-test\u003e))` exists in crates/*/src or src/ today, so it is latent.\n\n## Fix\n\nAdd a control to `a_test_only_cfg_is_recognised_whatever_its_spelling`: `#[cfg(not(target_arch = \"wasm32\"))]` must stay production. Then confirm that the rival above turns it red.\n\nChecked and held in the same review:\n- `feature = \"test-support\" =\u003e Unknown` is caught by `test_only_items_and_modules_are_not_production` (the `gated` assertion).\n- Dropping `c` from the raw-string arm is caught by `a_c_string_literal_is_blanked`.\n- Dropping `c` from the plain C-string arm passes every test, but is observationally equivalent: the `\"` is then lexed as an ordinary string with the same escapes.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T05:13:27Z","created_by":"agent-review-195","updated_at":"2026-09-23T05:13:27Z","labels":["adversarial-review","pr-195","protocol","testing"],"dependencies":[{"issue_id":"improvise-z4up","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T01:05:46Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-z4up","depends_on_id":"improvise-59q8","type":"discovered-from","created_at":"2026-09-23T05:13:27Z","created_by":"agent-review-195","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-yqnp","title":"source_scan hides later siblings after a comma-separated test-only element (match arm, vec!, struct field, enum variant) and after a block followed by an else-prefixed identifier","description":"Found by the adversarial review of PR #195 (improvise-59q8, head 43ba5e6). It is latent: the new scanner's output is token-identical to the old one on every .rs file in the workspace. But it contradicts the PR's stated property, that a test-only item hides nothing after it and that an item which cannot be bounded is a panic.\n\n## The shape\n\n`item_end` (crates/improvise-protocol/src/source_scan.rs) knows only two terminators: a `;` at depth 0, and the `}` closing a body. A test-only element that is separated from its siblings by a **comma** has neither. So the scan runs on to the bracket that closes the enclosing list, and the `')' | ']' | '}' if depth == 0 =\u003e return Some(k)` arm accepts that bracket as \"the item was the last expression\". Every later sibling is dropped, silently, with no panic.\n\nReproduced through `production_text`, with the output after `--- new ---`:\n\n1. **Match arm.**\n ```\n match k {\n #[cfg(test)]\n K::T =\u003e Command::TestArm(0),\n K::P =\u003e Command::ProdArm1(1),\n }\n ```\n Output: `match k { }`. So `Command::ProdArm1` is invisible to `vocabulary_is_composed`.\n2. **vec!/array element.** `vec![ #[cfg(test)] Command::TestElem(0), Command::ProdElem(1), ]` gives `vec![ ]`.\n3. **Struct-literal field.** `S { #[cfg(test)] t: .., p: Command::ProdField(1), }` gives `S { }`.\n4. **Enum variants and struct fields** behave the same: every later variant or field is dropped.\n\nSecond shape, same class. `continues()` tests `rest.starts_with(\"else\")` with no word boundary. So after a test-only block statement:\n```\n#[cfg(test)]\n{ setup(); }\nelsewhere(Command::ProdAfterBlock(1));\n```\nThe production statement `elsewhere(...)` is swallowed up to its `;`.\n\nThe old line scanner was worse on all of these: it hid to EOF. So this is not a regression; it is an unclosed part of 59q8's class.\n\n## Suggested fix\n\nRefuse rather than guess. If `item_end` reaches the enclosing close bracket having crossed a depth-0 `,`, panic with \"could not be bounded\". A plain `,` terminator would mis-bound generics such as `impl\u003cA, B\u003e` and `where A: X, B: Y`. Give `else` a word boundary.\n\nA test belongs in `a_test_only_item_hides_nothing_after_it` for each shape. Each fixture here was run under a scratch harness and then deleted.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T05:13:20Z","created_by":"agent-review-195","updated_at":"2026-09-23T05:13:20Z","labels":["adversarial-review","pr-195","protocol","testing"],"dependencies":[{"issue_id":"improvise-yqnp","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T01:05:46Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-yqnp","depends_on_id":"improvise-59q8","type":"discovered-from","created_at":"2026-09-23T05:13:20Z","created_by":"agent-review-195","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-3m7b","title":"No test pins that yank and drill-into-cell with explicit coordinates act on them rather than the cursor","description":"Found in adversarial review of PR #182 (improvise-yf7p) at head 0a6e940, merged locally with gh/main da2e5ab.\n\n#171's review showed that a registry `contextualize` taking yank, paste and drill-into-cell's key from the cursor, over the parsed one, left the workspace green. #182 removes the registry as a place that bug can be written: `contextualize` is gone, and an `AtCursor` impl pointed at `key` is E0308, which I tried. But the same bug written in `execute` is still unguarded for two of the three commands.\n\n- `YankCell::execute` using `ctx.cell_key()` in place of `self.key`: the **full workspace is green**.\n- `DrillIntoCell::execute` using `ctx.cell_key()` in place of `self.key`: the **full workspace is green**.\n- `PasteCell` with the same injection fails `paste_with_an_explicit_full_key_writes_even_where_the_view_aggregates` and `paste_with_yanked_value_produces_set_cell`. Guarded.\n\nPre-existing, not introduced by #182. Wanted: for `yank` and `drill-into-cell`, a test that runs the command with explicit coordinates while the cursor sits on a different cell, both at nonzero positions, and asserts the effects name the explicit cell.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T05:10:00Z","created_by":"agent-review-182","updated_at":"2026-09-23T05:10:00Z","dependencies":[{"issue_id":"improvise-3m7b","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T01:05:14Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-pakc","title":"The totals strip projects id: None, which the contract reserves for 'no cell' (exdg's sibling, needs its own decision)","description":"Found by agent-viewhist while fixing improvise-exdg (PR #196). The wasm client's render.rs::totals_strip projects every totals cell with id: None, which is the same conflation exdg fixed for records coordinate cells. exdg's fix can't be copied: a column total over a formula-owned column (e.g. Profit) is a SUM of that column, not the formula, so it is neither Aggregate in the ADR 002 sense nor Derived. Decide what identity a total carries, if any, and write it into ADR 002 alongside the fifth kind (Coordinate) that #196 added.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T05:06:15Z","created_by":"orchestrator","updated_at":"2026-09-23T05:06:15Z","dependencies":[{"issue_id":"improvise-pakc","depends_on_id":"improvise-6jk","type":"parent-child","created_at":"2026-09-25T01:04:44Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-qvpu","title":"pr-watch: a BLOCKED timeout should name the required checks that never registered","description":"Found re-reviewing PR #186 at 04230a7. Since improvise-zlj2, green + MERGEABLE/BLOCKED is waited on, so a required check that never registers (e.g. a CI job renamed so the ruleset's context no longer matches) now costs the default 1800s and exits 3 with only 'mergeability MERGEABLE/BLOCKED'; before zlj2 it exited 1 at once. Checked this repo's rules (gh api repos/fiddlerwoaroof/improvise/rules/branches/main, 2026-09-22): branch protection API 404s; one ruleset requires 3 checks (not strict), 0 approvals, no stale-review dismissal, no thread resolution. So review-required / stale-review BLOCKED cannot occur here, but the ruleset also carries require_extra_approval_for_unattributed_changes: true, whose trigger is unverified and which would present as exactly this timeout. Waiting is the right call; the gap is diagnosis. Suggested: on a BLOCKED timeout, diff the ruleset's required_status_checks contexts against the rollup names and print the missing ones (or print 'no required check missing' so an approval/rule block is distinguishable).","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-23T05:04:08Z","created_by":"agent-review-186b","updated_at":"2026-09-23T05:04:08Z","labels":["adversarial-review-pr-186"],"dependencies":[{"issue_id":"improvise-qvpu","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T01:05:48Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-1qaa","title":"pr-watch waits the full timeout on a MERGED or CLOSED PR, which GitHub reports as UNKNOWN/UNKNOWN","description":"Found re-reviewing PR #186 at 04230a7. Live: gh pr view 189 --json mergeable,mergeStateStatus on the merged PR returns UNKNOWN/UNKNOWN with every check green. Since improvise-edpl UNKNOWN is waited on, so pr-watch on an already-merged (or closed) PR sleeps the default 1800s and exits 3 'mergeability UNKNOWN/UNKNOWN'. Fails closed (no false pass), but under merge-as-soon-as-green a watch on a PR that another actor just merged costs 30 minutes and names the wrong reason. The script never fetches .state. Fix: add state to the --json field list and end early on MERGED/CLOSED with a distinct message (exit code to be decided; not 0, since 0 means mergeable).","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T05:04:07Z","created_by":"agent-review-186b","updated_at":"2026-09-24T21:45:58Z","closed_at":"2026-09-24T21:45:58Z","close_reason":"Landed in main via #253 (reviewed; merged 2026-09-24).","labels":["adversarial-review-pr-186"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-9pfs","title":"pr-watch-test has no case for a failed check under MERGEABLE/BLOCKED, the state a failed required check reports","description":"Found re-reviewing PR #186 at 04230a7. Every red fixture in scripts/pr-watch-test.sh wears UNSTABLE or UNKNOWN, but in this repo a failed REQUIRED check reports MERGEABLE/BLOCKED. Rival: gate the failed-check break with state != BLOCKED (if { [ \"$failed\" -gt 0 ] \u0026\u0026 [ \"$state\" != BLOCKED ]; } || ...). All 45 cases still pass; on a fixture of green build + FAILURE 'Test and lint (host)' + MERGEABLE/BLOCKED the head script exits 1 and the rival exits 3 after the full timeout. Not a false pass, but the commonest red in this repo is unpinned and a refactor of the wait could turn every red into a 30-minute timeout silently. Fix: add a case red rollup + MERGEABLE/BLOCKED expecting 1 at --timeout 0 (the table only enumerates green rollups).","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T05:03:54Z","created_by":"agent-review-186b","updated_at":"2026-09-24T21:45:58Z","closed_at":"2026-09-24T21:45:58Z","close_reason":"Landed in main via #253 (reviewed; merged 2026-09-24).","labels":["adversarial-review-pr-186"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-514o","title":"Five of ImportJsonHeadless's refuse_and_stop aborts fail no test when the abort is dropped","description":"Found by the adversarial review of PR #181 at head 12171fb.\n\n`refuse_and_stop` (refuse, plus `abort_effects`) is the per-site choice for LoadModel and the six ImportJsonHeadless arms. That abort is what keeps `import-json x . q` from quitting after an import that did nothing. I replaced `refuse_and_stop(app, …)` with `app.refuse(…)` one site at a time, which keeps the refusal and drops the abort, and ran `cargo test --workspace --no-fail-fast`:\n\n- LoadModel (effect.rs:971) fails `a_failed_load_or_import_does_not_run_the_quit_after_it`.\n- The JSON cannot-read arm (effect.rs:1047) fails the same test.\n- **Five arms fail nothing:** CSV cannot-read (1028), CSV parse error (1039), JSON parse error (1054), array-path error (1061), and import/build error (1100).\n\nThe wire table test `every_failing_model_command_refuses_its_whole_frame` cannot see the abort half, because the frame is rejected on `refused || aborted`, so the refusal alone suffices. Fix: extend the quit test's case list to the fixtures the table test already has (`absent.csv`, `bad.csv`, `bad.json`, `nested.json`+`nope`, `measures.json`).\n\nRelated, also untested: `Host::mutating_frame`'s `|| last_batch_aborted()` arm. Reading only `last_batch_refused()` passes every test. Today it is unreachable, because every wire-reachable abort also refuses and `AbortChain` is ViewOnly. It is harmless as a defensive arm, but it is unwitnessed.\n","status":"closed","priority":3,"issue_type":"task","assignee":"agent-refuse-fu","owner":"el-github@elangley.org","created_at":"2026-09-23T05:00:52Z","created_by":"agent-review-181","updated_at":"2026-09-23T14:56:21Z","started_at":"2026-09-23T05:16:54Z","closed_at":"2026-09-23T14:56:21Z","close_reason":"Fixed in PR #213, merged as 345a358 after adversarial review at 3929196. Follow-ups: improvise-we1a, improvise-rtud.","labels":["adversarial-review","pr-181"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-oplp","title":"Tile select opens on _Index, so T then Enter (or T then r) empties a pivot grid, and the browser persists it at once","description":"Found reviewing PR #192 (improvise-jgzw), head f44767c9. That PR keeps _Index/_Dim tileable, correctly, and states the residual cost in context/code/command.md: 'one cycle key on tile 0 puts _Index on Row alone ... If that turns out to matter, the fix is the order the tile bar lists categories in'. This ticket is that residual, measured.\n\nEnterTileSelect sets SetTileCatIdx(0), and tile 0 is _Index because Model::category_names() lists the virtual categories first. In TileSelect, Enter and Space are cycle-axis-for-tile and r is set-axis-for-tile row. So two keystrokes, T Enter (Enter reading as 'confirm') or T r, put _Index on Row.\n\nCONFIRMED headless at f44767c9 on a copy of web/demo.improv: 'set-axis _Index row' then save writes '_Index: row' into the real Plan view, and export-csv of the reopened file is the header row only ('_Index/Product (Quarter=Q1),Revenue,Cost,Profit'), against four product rows before. The TUI only persists on an explicit save (autosave writes a side file), but the browser worker persists every accepted model command to OPFS (gotcha 45), so there the empty grid is the saved document immediately. Recovery exists (the tile bar, status line says '_Index → Row'), which is why this is P3, not a reason to remove _Index/_Dim from the tile bar.\n\nThe full records layout is not a stray-key hazard: it needs T r l c at least.\n\nProposed fix, per the PR's own note: order the tile list so the cursor opens on the first regular category (virtuals last, or at least after regulars), rather than filtering. Guard: a test that EnterTileSelect then cycle-axis-for-tile moves a regular category, not _Index.","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-docs-wave","owner":"el-github@elangley.org","created_at":"2026-09-23T05:00:36Z","created_by":"agent-review-183","updated_at":"2026-09-23T15:48:57Z","started_at":"2026-09-23T14:55:55Z","closed_at":"2026-09-23T15:48:57Z","close_reason":"Fixed in PR #231, merged as 5932ad7 after adversarial review at 2383c37; the later changes were evidence-only merges of gh/main. Follow-ups: improvise-bqm0, improvise-q69m, improvise-52od.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-kkcy","title":"Browser p on a records coordinate or creating column is a silent Upstream; the TUI declines out loud since #184","description":"Found while fixing improvise-exdg. Since PR #184 the TUI's PasteCell declines on a records Region, +cat or + column with PASTE_ONTO_NO_VALUE ('Paste lands only on a measure column'). In the browser, resolve.rs's paste arm calls record_cell_at_cursor(state)?. That returns None for CellId::Coordinate and CellId::Create, so the resolver answers None, which is KeyOutcome::Upstream. Host::handle answers ClientFrame::Key with an empty vec, so the key does nothing and shows no status. #184's review (design call 2) called this 'matches the browser', but it matches only in not writing. The TUI tells the user; the browser is silent. improvise-exdg made the coordinate cell a named CellId::Coordinate, so the browser can now decline it deliberately with the TUI's text. The same applies to aggregates, where the TUI says PASTE_ONTO_AGGREGATE. Keep this separate from improvise-54g1 (Derived), which is the opposite divergence.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T04:58:25Z","created_by":"agent-viewhist","updated_at":"2026-09-23T04:58:25Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-kkcy","title":"Browser p on a records coordinate or creating column is a silent Upstream; the TUI declines out loud since #184","description":"Found while fixing improvise-exdg. Since PR #184 the TUI's PasteCell declines on a records Region, +cat or + column with PASTE_ONTO_NO_VALUE ('Paste lands only on a measure column'). In the browser, resolve.rs's paste arm calls record_cell_at_cursor(state)?. That returns None for CellId::Coordinate and CellId::Create, so the resolver answers None, which is KeyOutcome::Upstream. Host::handle answers ClientFrame::Key with an empty vec, so the key does nothing and shows no status. #184's review (design call 2) called this 'matches the browser', but it matches only in not writing. The TUI tells the user; the browser is silent. improvise-exdg made the coordinate cell a named CellId::Coordinate, so the browser can now decline it deliberately with the TUI's text. The same applies to aggregates, where the TUI says PASTE_ONTO_AGGREGATE. Keep this separate from improvise-54g1 (Derived), which is the opposite divergence.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T04:58:25Z","created_by":"agent-viewhist","updated_at":"2026-09-23T04:58:25Z","dependencies":[{"issue_id":"improvise-kkcy","depends_on_id":"improvise-6jk","type":"parent-child","created_at":"2026-09-25T01:04:43Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-ssoe","title":"A builder calling KeyRow::looked_up directly draws a row help_bindings never records: a lying 'edit' caption on x passes the whole workspace","description":"Found in re-review of PR #180 at head 544744cf, the follow-up to improvise-v29m.\n\n`KeyRow` now closes hand-typed keys: a struct literal outside `key_row` is E0451, which I verified from the `improvise` crate. But `KeyRow::looked_up` is `pub(super)`, so any page builder in `help.rs` can call it directly instead of through its `keys` parameter. `help_bindings()` records only calls made through the parameter, so such a row is drawn but never recorded.\n\nTwo guards read `help_bindings()` and so cannot see it:\n- jhdf's `only_the_edit_key_is_captioned_as_editing_the_cell`;\n- 3t5x's `every_advertised_command_answers_in_its_own_mode`, through `advertised_everywhere`.\n\nInjected into `editing()`:\n\n KeyRow::looked_up(n, \"clear-cell\", None, \"Edit the current cell\").map(HelpLine::Keys),\n\nThat captions `x` (clear-cell) as editing the cell, which is the exact lie jhdf exists to catch. `cargo test --workspace --no-fail-fast`: **0 failures.**\n\nThe keys on such a row are still real, so wk18's claim that no key is hand-typed holds. What fails is `help_bindings`' own doc: \"the same builders, so a guard over it cannot drift from what is drawn\".\n\nFix: assert that the number of `HelpLine::Keys` rows in `pages()` equals the number of rows `help_bindings()` recorded that resolve. Or narrow `looked_up` so that only `keys()` can reach it, e.g. by moving `keys` into `key_row`. Then re-run the injection.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-helpkeys2","owner":"el-github@elangley.org","created_at":"2026-09-23T04:51:47Z","created_by":"agent-review-180","updated_at":"2026-09-23T06:07:59Z","started_at":"2026-09-23T05:03:26Z","closed_at":"2026-09-23T06:07:59Z","close_reason":"Fixed in PR #198, merged after adversarial review (delta at f75b58a). Follow-up: improvise-5xbq.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-dz8c","title":"The float_roundtrip check names its three crates by hand, so a new frame-parsing crate is not caught","description":"Found by the adversarial review of PR #190 (head 3c6001a). browser_round_trip.rs::every_build_that_parses_a_frame_enables_float_roundtrip iterates a literal list: improvise-wasm-client, improvise-worker-server (both with --target wasm32-unknown-unknown) and improvise-ws-server. It catches the feature being removed from one of those, verified by injection. It cannot see a fourth crate that starts parsing frames, for example a SharedWorker host or a second socket transport. The doc (context/code/protocol.md) says 'A new crate that parses frames belongs in its list', which is a convention with nothing to enforce it. Proposed derivation: take every workspace package from cargo metadata --no-deps with a NORMAL dependency on both improvise-protocol and serde_json, and require float_roundtrip on each. That set today is {improvise-command, improvise-wasm-client, improvise-ws-server, improvise-worker-server}. improvise-command is the one that fails, and it fails for the reason improvise-6yvg names (its JSON reads in ui/effect.rs). So this likely lands with or after 6yvg. The alternative is to enable the feature structurally in the crate that owns the frame types. That removes the list altogether but adds a normal serde_json dep to improvise-protocol, whose repo-map entry says it depends only on improvise-core and serde.","acceptance_criteria":"The set of crates the check covers is derived (cargo metadata, or structurally by where the feature is enabled), not listed by hand. A throwaway crate added to the workspace with normal deps on improvise-protocol and serde_json and no float_roundtrip makes the check fail, naming it.","status":"closed","priority":3,"issue_type":"task","assignee":"agent-float2","owner":"el-github@elangley.org","created_at":"2026-09-23T04:51:19Z","created_by":"agent-review-190","updated_at":"2026-09-23T06:08:00Z","started_at":"2026-09-23T05:06:49Z","closed_at":"2026-09-23T06:08:00Z","close_reason":"Fixed in PR #202, merged after adversarial review at feefe17; later changes were an evidence-only merge plus dropping --locked, as the reviewer recommended.","labels":["adversarial-review","pr-190"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-h3ni","title":"pr-watch-test has no case for a head that moves during the UNKNOWN wait","description":"Found in adversarial review of PR #186 (head 128a2a0).\n\nThe script is correct here. The head-pin check runs on every poll, before any other logic, so a head that moves while the watch waits on UNKNOWN exits 2 (probe: UNKNOWN@aaaa -\u003e UNKNOWN@bbbb -\u003e CLEAN@bbbb exits 2, and with --expect aaaa it also exits 2). The suite does not pin it, though. The only head-move case, \"a head that moves mid-wait is not a pass\", moves while a check is IN_PROGRESS.\n\nRival that survives all 42 cases: re-pin the head while mergeable is UNKNOWN (`[ \"$mg\" = UNKNOWN ] \u0026\u0026 pinned=\"$head\"` ahead of the pin check). The probe exits 0 under it.\n\nFix: add a case where the head moves during the UNKNOWN wait (1.json UNKNOWN@aaaa, 2.json UNKNOWN@bbbb, last.json MERGEABLE/CLEAN@bbbb, want 2).\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-tooling3","owner":"el-github@elangley.org","created_at":"2026-09-23T04:48:00Z","created_by":"agent-review-186","updated_at":"2026-09-23T05:05:45Z","started_at":"2026-09-23T04:51:57Z","closed_at":"2026-09-23T05:05:45Z","close_reason":"Fixed in PR #186, merged as 5bcc3ca after adversarial re-review at 04230a7. Follow-ups: improvise-9pfs, improvise-1qaa, improvise-qvpu.","labels":["adversarial-review-pr-186"],"comments":[{"id":"4d780d65-51d5-5063-bf61-b8cdb7336ebc","issue_id":"improvise-h3ni","author":"agent-tooling3","text":"Case added on PR #186 at 04230a7","created_at":"2026-09-23T04:59:23Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} -{"_type":"issue","id":"improvise-gj5p","title":"R in a saved records layout says Pivot mode and changes nothing; the document opens in Normal over a records grid","description":"Found by improvise-jgzw (agent-registry). Reproduced in a scratch test at 1c184a5 on web/demo.improv.\n\nHand-assign _Index -\u003e Row and _Dim -\u003e Column in a real view (tile bar, or :set-axis). A real view persists every axis, so format_md writes both, and parse_md reopens the document in records layout. The doc on VIRTUAL_VIEW_NAMES says \"never-persisted _Index/_Dim on axes\", which is true only of _Records/_Drill.\n\nIn the reloaded App:\n\n1. The mode is Normal over a records grid (layout.is_records_mode() == true). This is the drifted state that bd9o's tests build by hand. App::new does not settle the mode off the layout.\n2. toggle-records-mode emits [CleanEmptyRecords, ViewBack, SetStatus(\"Pivot mode\")]. The back stack is empty, so ViewBack is a no-op and the layout stays records. The status line then says \"Pivot mode\", which is false, and R, the key the user knows, does nothing.\n\nThe only way out is to move _Index/_Dim with the tile bar or :set-axis. That is one of the reasons jgzw keeps them tileable (context/code/command.md, cmd/tile.rs).\n\nOptions, not decided here:\n- Make R's exit put _Index/_Dim to None on the active view when the back stack is empty.\n- Or have R say what it actually did, rather than claiming \"Pivot mode\".\n- Separately, settle the mode off the layout at load.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T04:45:13Z","created_by":"agent-registry","updated_at":"2026-09-23T04:45:13Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-48jd","title":"Paste inside a drill is declined, and the aggregate decline tells the user to drill in to paste","description":"From PR #184 review (0ed2e41). cell.rs: PASTE_ONTO_AGGREGATE reads 'Cannot paste onto an aggregate — drill in to paste into a record', but inside a drill paste_into_record declines with PASTE_INSIDE_DRILL ('Paste is not staged inside a drill — edit the cell instead'). So p on an aggregate sends the user to the one place p also refuses. Declining in the drill is a sound interim (it loses nothing: pending_edits holds strings and staging through Display would round, see improvise-umi4), but the gap is user-visible and unfiled. Close by (a) staging a typed CellValue in DrillState::pending_edits (the real fix context/code/command.md names), or at minimum (b) rewording PASTE_ONTO_AGGREGATE so it does not promise drill paste. Keep gotcha 19 in mind: the commit path's drill arm stages.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T04:39:07Z","created_by":"agent-review-184","updated_at":"2026-09-23T04:39:07Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-eucp","title":"yy inside a drill yanks the snapshot value, not the staged edit the grid draws","description":"From PR #184 review (0ed2e41). YankCell now answers a synthetic key with GridLayout::records_value, which reads the drill's frozen records. But src/ui/grid.rs draws DrillState::pending_edits over those cells. Probe: drill into East, stage (0, Measure(Revenue)) = \"77\" in pending_edits, cursor on that cell, yy -\u003e yanked Number(1.0) (the snapshot) while the grid shows 77. Before #184 yank gave None there, so this is strictly better, but it is two derivations of what the cell holds (drawn vs yanked). Browser never drills (declined), so no lockstep impact. Fix: consult App's staged_drill_edit first, as display_value_in does — which runs into the pending_edits-are-strings problem of improvise-umi4 and the drill-paste ticket.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T04:39:07Z","created_by":"agent-review-184","updated_at":"2026-09-23T04:39:07Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-gj5p","title":"R in a saved records layout says Pivot mode and changes nothing; the document opens in Normal over a records grid","description":"Found by improvise-jgzw (agent-registry). Reproduced in a scratch test at 1c184a5 on web/demo.improv.\n\nHand-assign _Index -\u003e Row and _Dim -\u003e Column in a real view (tile bar, or :set-axis). A real view persists every axis, so format_md writes both, and parse_md reopens the document in records layout. The doc on VIRTUAL_VIEW_NAMES says \"never-persisted _Index/_Dim on axes\", which is true only of _Records/_Drill.\n\nIn the reloaded App:\n\n1. The mode is Normal over a records grid (layout.is_records_mode() == true). This is the drifted state that bd9o's tests build by hand. App::new does not settle the mode off the layout.\n2. toggle-records-mode emits [CleanEmptyRecords, ViewBack, SetStatus(\"Pivot mode\")]. The back stack is empty, so ViewBack is a no-op and the layout stays records. The status line then says \"Pivot mode\", which is false, and R, the key the user knows, does nothing.\n\nThe only way out is to move _Index/_Dim with the tile bar or :set-axis. That is one of the reasons jgzw keeps them tileable (context/code/command.md, cmd/tile.rs).\n\nOptions, not decided here:\n- Make R's exit put _Index/_Dim to None on the active view when the back stack is empty.\n- Or have R say what it actually did, rather than claiming \"Pivot mode\".\n- Separately, settle the mode off the layout at load.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T04:45:13Z","created_by":"agent-registry","updated_at":"2026-09-23T04:45:13Z","dependencies":[{"issue_id":"improvise-gj5p","depends_on_id":"improvise-n28d","type":"parent-child","created_at":"2026-09-25T01:05:25Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-48jd","title":"Paste inside a drill is declined, and the aggregate decline tells the user to drill in to paste","description":"From PR #184 review (0ed2e41). cell.rs: PASTE_ONTO_AGGREGATE reads 'Cannot paste onto an aggregate — drill in to paste into a record', but inside a drill paste_into_record declines with PASTE_INSIDE_DRILL ('Paste is not staged inside a drill — edit the cell instead'). So p on an aggregate sends the user to the one place p also refuses. Declining in the drill is a sound interim (it loses nothing: pending_edits holds strings and staging through Display would round, see improvise-umi4), but the gap is user-visible and unfiled. Close by (a) staging a typed CellValue in DrillState::pending_edits (the real fix context/code/command.md names), or at minimum (b) rewording PASTE_ONTO_AGGREGATE so it does not promise drill paste. Keep gotcha 19 in mind: the commit path's drill arm stages.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T04:39:07Z","created_by":"agent-review-184","updated_at":"2026-09-23T04:39:07Z","dependencies":[{"issue_id":"improvise-48jd","depends_on_id":"improvise-n28d","type":"parent-child","created_at":"2026-09-25T01:05:21Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-eucp","title":"yy inside a drill yanks the snapshot value, not the staged edit the grid draws","description":"From PR #184 review (0ed2e41). YankCell now answers a synthetic key with GridLayout::records_value, which reads the drill's frozen records. But src/ui/grid.rs draws DrillState::pending_edits over those cells. Probe: drill into East, stage (0, Measure(Revenue)) = \"77\" in pending_edits, cursor on that cell, yy -\u003e yanked Number(1.0) (the snapshot) while the grid shows 77. Before #184 yank gave None there, so this is strictly better, but it is two derivations of what the cell holds (drawn vs yanked). Browser never drills (declined), so no lockstep impact. Fix: consult App's staged_drill_edit first, as display_value_in does — which runs into the pending_edits-are-strings problem of improvise-umi4 and the drill-paste ticket.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T04:39:07Z","created_by":"agent-review-184","updated_at":"2026-09-23T04:39:07Z","dependencies":[{"issue_id":"improvise-eucp","depends_on_id":"improvise-n28d","type":"parent-child","created_at":"2026-09-25T01:05:21Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-6yvg","title":"JSON import parses numbers 1 ULP off: the TUI build has no float_roundtrip","description":"Found while fixing improvise-6oy4, which enabled serde_json's float_roundtrip only on the three crates that parse FRAMES (ws-server, worker-server, wasm-client). The JSON import path, improvise-command ui/effect.rs (serde_json::from_slice::\u003cValue\u003e around lines 875 and 1008) and whatever improvise-io's import uses, parses numbers with the default best-effort parser. In the root improvise TUI binary's dependency graph no crate enables float_roundtrip, so importing a JSON file holding e.g. 0.24815714285714285 stores 0.24815714285714283. It was measured at roughly 1 in 7 ordinary decimals for the wire in 6oy4. The same parser here is unverified: this ticket has not been reproduced. The worker bundle happens to be exact, because worker-server enables the feature and unification reaches improvise-command in that build. So the same import gives different numbers in the TUI and the browser. Fix: float_roundtrip on improvise-io and improvise-command's serde_json deps (per crate, see context/code/protocol.md section 3), with a test importing that value and comparing f64 bits. Reproduce first.","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-float2","owner":"el-github@elangley.org","created_at":"2026-09-23T04:37:05Z","created_by":"agent-wirefloat","updated_at":"2026-09-23T06:08:00Z","started_at":"2026-09-23T05:06:42Z","closed_at":"2026-09-23T06:08:00Z","close_reason":"Fixed in PR #202, merged after adversarial review at feefe17; later changes were an evidence-only merge plus dropping --locked, as the reviewer recommended.","comments":[{"id":"fe35d006-2c89-55b7-bf0c-43f9a4ffdb34","issue_id":"improvise-6yvg","author":"agent-review-190","text":"Review of PR #190 (agent-review-190): one more parse path in the same family, not named above. improvise-io persistence/mod.rs::load deserializes a legacy-JSON workbook (a leading '{') with serde_json::from_str. The TUI build has no float_roundtrip anywhere (cargo tree -p improvise --invert serde_json -e normal,features shows none), so a legacy JSON file's numbers load through the lossy parser too. Unreproduced. It is the same fix and the same test shape.","created_at":"2026-09-23T04:51:29Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} {"_type":"issue","id":"improvise-54g1","title":"Paste onto a formula-owned (Derived) cell writes in the TUI and declines in the browser","description":"Found by the improvise-6p8q fixer (PR #184), out of that ticket's scope and not fixed. In the TUI, p on a cell whose value is owned by a formula (CellId::Derived per ADR 002) writes a stored cell under it; the formula's recompute then overrides the value, so the write is invisible but persists in ## Data. The browser declines, because Derived is not a settable CellId. Decide which is right. ADR 002 says Derived is the arm no write is constructible for, which argues the TUI should decline too, with a status that names the formula. Test through App::handle_key, save and reload, and assert no stored cell appears under the formula target. Lockstep: both ends agree.","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-collapse-fu","owner":"el-github@elangley.org","created_at":"2026-09-23T04:31:48Z","created_by":"Edward Langley","updated_at":"2026-09-23T15:19:01Z","started_at":"2026-09-23T04:52:46Z","closed_at":"2026-09-23T15:19:01Z","close_reason":"Fixed in PR #197, merged as 705c92d after adversarial re-review at 087aa85. Later changes were evidence-only merges of gh/main. Follow-ups: improvise-ywj2, improvise-ciw8 (#217).","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-s1lx","title":"Records-mode yy over a blank cell yanks Text(\"\") on both ends; p then writes an empty text cell","description":"Found fixing improvise-6p8q. GridLayout::records_value answers Text(\"\") for a creating column (+cat, +) and for a measure the record lacks. Since improvise-dsxq the browser yanks CellDisplay::value, filled from records_value, and since 6p8q the TUI yanks records_value too, so both ends agree: yy over a blank records cell yanks Some(Text(\"\")), where an empty pivot cell yanks None. p then writes SetCell(record, Text(\"\")), an invisible empty-text cell that persists into ## Data. Fix at the producer (records_value or the projection's value for a blank), not in one resolver, or the ends diverge again: tests/lockstep.rs::yy_then_p_in_records_mode_writes_the_record_the_host_writes asserts the two registers agree on the + column and currently expects Text(\"\"); update it with the fix.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T04:28:06Z","created_by":"agent-6p8q","updated_at":"2026-09-23T04:28:06Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-s1lx","title":"Records-mode yy over a blank cell yanks Text(\"\") on both ends; p then writes an empty text cell","description":"Found fixing improvise-6p8q. GridLayout::records_value answers Text(\"\") for a creating column (+cat, +) and for a measure the record lacks. Since improvise-dsxq the browser yanks CellDisplay::value, filled from records_value, and since 6p8q the TUI yanks records_value too, so both ends agree: yy over a blank records cell yanks Some(Text(\"\")), where an empty pivot cell yanks None. p then writes SetCell(record, Text(\"\")), an invisible empty-text cell that persists into ## Data. Fix at the producer (records_value or the projection's value for a blank), not in one resolver, or the ends diverge again: tests/lockstep.rs::yy_then_p_in_records_mode_writes_the_record_the_host_writes asserts the two registers agree on the + column and currently expects Text(\"\"); update it with the fix.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T04:28:06Z","created_by":"agent-6p8q","updated_at":"2026-09-23T04:28:06Z","dependencies":[{"issue_id":"improvise-s1lx","depends_on_id":"improvise-n28d","type":"parent-child","created_at":"2026-09-25T01:05:24Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-i3o5","title":"Refusals still written through set_status in panel.rs, keymap dispatch and draw.rs exit 0","description":"Left open by the udt2 change (App::refuse / effect::refuse, context/code/cli.md section 10). A refusal now sets a non-zero headless exit only where its writer calls App::refuse or emits effect::Refuse; a refusal still written through effect::set_status exits 0 as before.\n\nConverted: every refusal in ui/effect.rs, and in cmd/{commit,fit,grid,mode,search,text_buffer}.rs.\n\nNOT converted, because the files were outside the udt2 agent's lane:\n- cmd/panel.rs: \"Move cursor to a category header to change axis\", \"No category selected. Press n to add a category first.\", \"No category to delete\".\n- command/keymap.rs::dispatch: a bound command whose args fail to parse (set_status(e)). TUI-only, never reached headless.\n- src/draw.rs: \"no array found in that JSON\", \"category already exists\" (direct status_msg writes).\n- cmd/mode.rs Quit's \"Unsaved changes\" is deliberately left a report; see the sibling ticket about headless q.\n\nEach is a one-token change (set_status -\u003e refuse, or status_msg = -\u003e app.refuse). The guard to extend is tests/headless_cli.rs::a_refusal_that_only_writes_a_status_exits_non_zero.\n","status":"closed","priority":3,"issue_type":"task","assignee":"agent-refuse-fu","owner":"el-github@elangley.org","created_at":"2026-09-23T04:25:35Z","created_by":"agent-cli","updated_at":"2026-09-23T15:47:40Z","started_at":"2026-09-23T15:31:39Z","closed_at":"2026-09-23T15:47:40Z","close_reason":"Partly fixed in PR #236 (4 declines now refuse), merged as b4779ed after adversarial review at 7b04556. The rest (panel.rs x3 and the keymap dispatch arm) goes to the follow-up per the orchestrator's ruling; see the bead notes.","labels":["area:cli"],"comments":[{"id":"13862144-6c73-5b9c-87d9-2d7535d1411a","issue_id":"improvise-i3o5","author":"agent-review-213","text":"From the adversarial review of PR #213 at 3929196: two more refusals are written with set_status and so exit 0. This ticket does not list either.\n- cmd/mode.rs EditOrDrill, the collapsed-group arm (\"Collapsed group — press z to expand it...\"). Its two sibling arms in the same Cmd, \"No cell at cursor\" and \"computed by a formula\", both refuse. context/code/cli.md section 10 says every refusal in cmd/mode.rs goes through refuse, and this arm does not.\n- cmd/cell.rs paste_into / paste_into_record: PASTE_ONTO_AGGREGATE, PASTE_INSIDE_DRILL and PASTE_ONTO_NO_VALUE. The paste did not happen, so by section 10's rule they are refusals.\nBoth predate #185 (4f225e88f and 9d04b981/8df94311, dated 2026-09-22), so #185's sweep, and 0v8l's after it, never enumerated them. The sweeps grep for refuse( and cannot see a refusal written as set_status.\n","created_at":"2026-09-23T14:51:53Z"},{"id":"879f3777-a8fc-590f-a669-d34ea3e7db09","issue_id":"improvise-i3o5","author":"agent-refuse-fu","text":"agent-refuse-fu 2026-09-23, PR #236: converted the four declines the #213 review named. Those are EditOrDrill's collapsed-group arm and paste_into/paste_into_record's aggregate, coordinate-column and inside-a-drill declines. Each is pinned in the key-path refusal table with a succeeding twin, and a per-site revert fails 4 of 4.\n\nSTILL written with set_status on a decline path, found by reading every production set_status and status_msg write at bb5dfe5:\n- cmd/panel.rs:708 \"Move cursor to a category header to change axis\"\n- cmd/panel.rs:726 \"No category selected. Press n to add a category first.\"\n- cmd/panel.rs:818 \"No category to delete\"\n These three are reachable headless by their : names, so they exit 0.\n- command/keymap.rs:64, dispatch's set_status(e). Every interactive-constructor Err lands here, including the key-path forms of the four declines PR #236 converts (p/x/i onto an aggregate, a fold, a coordinate column or inside a drill). Only the TUI reaches it.\n\nNOT refusals, or stale:\n- mode.rs:313 Quit \"Unsaved changes\" is deliberately a report (improvise-ep5o).\n- The src/draw.rs strings listed in this bead's description now occur only in draw.rs's test module, so that item is stale.\n- improvise-protocol resolve.rs Command::SetStatus declines are browser-local; no frame is composed for them.\n","created_at":"2026-09-23T15:39:16Z"},{"id":"8da844ea-5c18-5df9-8cb7-3b7e9ff2ad7a","issue_id":"improvise-i3o5","author":"agent-review-236","text":"agent-review-236, review of PR #236 at 7b045567: the description's src/draw.rs bullet is not a production site. Both writes ('no array found in that JSON', 'category already exists', src/draw.rs:617/656) sit inside #[cfg(test)] mod bottom_bar_tests. The production set_status declines still unconverted are exactly: cmd/panel.rs:708, :726, :818 and command/keymap.rs::dispatch's arg-error arm (plus Quit, deliberate, ep5o). Every other production set_status/status_msg write was read and is a success report.","created_at":"2026-09-23T15:46:26Z"}],"dependency_count":0,"dependent_count":0,"comment_count":3} -{"_type":"issue","id":"improvise-ep5o","title":"Headless q on a dirty document says Unsaved changes and then saves anyway","description":"Found while closing improvise-udt2. `improvise cmd 'add-category X' 'q' -f doc.improv` prints \"Unsaved changes. Use :q! to force quit or :wq to save+quit.\" to stderr, exits 0, and the CLI then writes the document anyway, because run_headless_commands saves whenever a command changed it (context/code/cli.md section 4).\n\nSo this refusal is not merely unclassified: its text is false on this surface. Quit's status was deliberately left a report (not effect::refuse) in the udt2 change, because making it exit 1 would fail a script over a message that does not describe what happened. Decide what `q` means headless (probably nothing, since the process ends anyway) before classifying it.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T04:25:32Z","created_by":"agent-cli","updated_at":"2026-09-23T04:25:32Z","labels":["area:cli"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-ep5o","title":"Headless q on a dirty document says Unsaved changes and then saves anyway","description":"Found while closing improvise-udt2. `improvise cmd 'add-category X' 'q' -f doc.improv` prints \"Unsaved changes. Use :q! to force quit or :wq to save+quit.\" to stderr, exits 0, and the CLI then writes the document anyway, because run_headless_commands saves whenever a command changed it (context/code/cli.md section 4).\n\nSo this refusal is not merely unclassified: its text is false on this surface. Quit's status was deliberately left a report (not effect::refuse) in the udt2 change, because making it exit 1 would fail a script over a message that does not describe what happened. Decide what `q` means headless (probably nothing, since the process ends anyway) before classifying it.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T04:25:32Z","created_by":"agent-cli","updated_at":"2026-09-23T04:25:32Z","labels":["area:cli"],"dependencies":[{"issue_id":"improvise-ep5o","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T01:05:15Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-4w09","title":"A NaN cell never quiesces: CellDisplay's PartialEq makes the grid diff resend it every projection and breaks folded(seed)==cache","description":"Found by adversarial review of PR #178 (improvise-dsxq), head 5d1bf752.\n\n#178 put `value: Option\u003cCellValue\u003e` on CellDisplay and dropped `Eq` from CellDisplay/GridCache/GridUpdate. A cell holding Number(NaN) is legal (.improv carries `nan`, improvise-55v2), and it now compares unequal to itself. Measured with a one-cell document holding NaN:\n\n- grid_updates(\u0026g, \u0026g) is NOT empty. It returns Cells([((0,0), \u003cthe NaN cell\u003e)]).\n- GridCache::folded(\u0026g.seed()) == g is FALSE, so the compaction-equivalence property gotcha 32 says earns the seed does not hold for this document.\n- Sessions::project_all with Dirty::EVERYTHING three times in a row resends the NaN cell every time. The grid for this document never quiesces.\n\nBefore #178 the diff compared `text` (\"NaN\" == \"NaN\"), so this is new. The cost today is one redundant cell per grid-dirty projection per NaN cell, hence P3. The risk is any future code or test that relies on \"a projection of an unchanged grid is empty\" or on the seed round-trip, which is false exactly for documents that carry NaN.\n\nPossible answers: compare numbers by bits (to_bits / total_cmp) in the diff, or give CellDisplay a hand-written PartialEq that does. Do not re-add Eq by lying about f64.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-wirefloat","owner":"el-github@elangley.org","created_at":"2026-09-23T04:22:10Z","created_by":"agent-review-178","updated_at":"2026-09-23T05:03:58Z","started_at":"2026-09-23T04:24:24Z","closed_at":"2026-09-23T05:03:58Z","close_reason":"Fixed in PR #190, merged as 1158c23 (reviewed at 3c6001a; the only change after that was an evidence-only merge of gh/main at 44a183a).","labels":["adversarial-review","pr-178"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-yk2s","title":"H (hide-selected-row-item) on a collapsed group's row hides a nonexistent item named like the group: silent no-op, ghost hidden entry persisted","description":"Found by the ChatGPT Codex review of PR #176 (improvise-6qet) at head b014a97. Independently reproduced in the adversarial review.\n\n`HideSelectedRowItem` (`H`) reads `tuple[0]` of the data item under the cursor. On a collapsed group's row that is the group name, so it emits `HideItem { category: Month, item: \"Q1\" }`. `View::hide_item` accepts unknown names. The result is a ghost `Month/Q1` entry in `hidden_items` that gets persisted. The status line stays empty, and the rows are unchanged (`[\"Q1\", \"Apr\"]` before and after), so the key silently does nothing.\n\nFix: refuse on a fold, as `EditOrDrill` does (ask `layout.folds_a_group`), or define what hiding a group means. A regression test should press `H` on a fold that is not at row 0.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-collapse-fu","owner":"el-github@elangley.org","created_at":"2026-09-23T04:21:04Z","created_by":"agent-review-176","updated_at":"2026-09-23T15:19:01Z","started_at":"2026-09-23T04:38:24Z","closed_at":"2026-09-23T15:19:01Z","close_reason":"Fixed in PR #197, merged as 705c92d after adversarial re-review at 087aa85. Later changes were evidence-only merges of gh/main. Follow-ups: improvise-ywj2, improvise-ciw8 (#217).","labels":["adversarial-review","pr-176"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-59q8","title":"source_scan::strip_test_items hides all following production code after a multi-line test-only item; cfg(all(test,not(..))) and #![cfg(test)] read as production","description":"Found by the adversarial review of PR #172 (head 0b3fb05). None of these shapes occurs in the tree today (surveyed every `#[cfg(...test...)]` in crates/*/src and src/), so this is latent. But both vocabulary guards now depend on this scanner, and two of the misreads fail silently.\n\n## Steps to Reproduce\n\nFeed each text to `source_scan::production_text` (run as a scratch test and then reverted):\n\n1. **A test-only item that ends in `];`, `);` or `};`, or a `#[cfg(test)]` statement spanning lines.**\n impl X {\n #[cfg(test)]\n const NAMES: \u0026[\u0026str] = \u0026[\n \"a\",\n ];\n fn prod() { let c = Command::Hidden1(1); }\n }\n fn after() { Command::Visible(2) }\n Output: `impl X {` and nothing else. The first line does not end in `;`, so `strip_test_items` looks for ` }` at the attribute's indent, never finds it, and drops everything to EOF. The `#[cfg(test)] let x = foo(\\n 1,\\n);` statement form does the same.\n2. **A one-line `#[cfg(test)] use foo::bar;`.** It drops the whole rest of the file, because the attribute's own line is also the item line.\n3. **`#[cfg(all(test, not(target_arch = \"wasm32\")))]`** is kept as production, because `is_test_cfg` rejects any line containing `not(`.\n4. **Inner `#![cfg(test)]`** at the top of a module file is kept as production, because only `#[cfg(` is recognised.\n5. (Minor) C raw strings (`cr#\"…\"#`): the `r` has a word character before it, so the string is lexed as ordinary quotes and its contents leak.\n\nDirections: 1 and 2 **hide** production code. In `vocabulary_is_complete` a hidden effect is silently absent, so a new unlisted effect after such an item passes. 3 and 4 **count** test code, so in `vocabulary_is_composed` a variant composed only by such a test reads as composed.\n\nThe lexer half held against nested block comments, `r##\"…\"#…\"##`, byte strings containing `//` and `\\\"`, `'\"'`, `b'\"'`, `'\\u{27}'`, loop labels, and `'a` next to `'a'`.\n\n## Acceptance Criteria\n\n- Each shape above is a case in `source_scan`'s tests, and each fails against the current scanner.\n- `strip_test_items` either brace- or bracket-balances the item (on blanked text), or refuses (panics) on an item shape it cannot bound. It must not scan to EOF silently.\n- `is_test_cfg` evaluates the cfg predicate rather than using a substring test for `not(`, and handles `#![cfg(test)]`.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-wire2","owner":"el-github@elangley.org","created_at":"2026-09-23T04:16:04Z","created_by":"agent-review-172","updated_at":"2026-09-23T05:15:40Z","started_at":"2026-09-23T04:49:32Z","closed_at":"2026-09-23T05:15:40Z","close_reason":"Fixed in PR #195, merged after adversarial review at 43ba5e6. Follow-ups: improvise-yqnp, improvise-z4up, improvise-e958.","labels":["adversarial-review","pr-172","protocol","testing"],"comments":[{"id":"2d9f9c5e-3275-5cfa-b390-e5fd5cab66a4","issue_id":"improvise-59q8","author":"agent-review-172","text":"PR #172 re-review at 119f84f: another instance. A #[cfg(test)] attribute INSIDE simple_effect!( ... ) makes strip_test_items drop the rest of effect.rs, because the item line ends in neither ';' nor a closing brace at that indent. An unlisted production effect placed after it escaped every_effect_is_on_the_wire_or_has_a_verdict. It surfaced only because the_verdict_list_names_only_real_effects failed on 'no effect is named StartDrill', which is luck: it depends on a verdict-listed effect lying after the cut. The shape cannot compile in a non-test build, because the macro gates only the struct and not the impl, so this is not reachable from shipped code.","created_at":"2026-09-23T04:26:50Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} {"_type":"issue","id":"improvise-1o0y","title":"agent-rules.md hard-codes 'Posted by Claude' in a file SKILL.md says is agent-neutral for Codex","description":"Found reviewing PR #175 at b6d2c47.\n\nPR #175 makes one file serve both Claude and Codex (`.claude/skills/wave` symlinked to `.agents/skills/wave`), and its SKILL.md \"Shepherding\" says so: the attribution \"wording is agent-neutral because this one file serves Claude and Codex\". (That drift, \"a Claude agent\" in one copy and \"a Codex agent\" in the other, is part of what the PR fixes.)\n\nBut `agent-rules.md` \"GitHub\" hard-codes the line: `_Posted by Claude (\u003cmodel\u003e), an agent in Edward Langley's session._`. A Codex agent briefed from it is told to post as Claude. So the PR fixes the disagreement in SKILL.md and adds it again between SKILL.md and agent-rules.md.\n\nA tension to settle, not assume: AGENTS.md \"Shipping a wave\" and kb.pl `doctrine(read_the_reviews_before_merging_and_authorship_will_not_identify_them)` use \"ours OPEN with Posted by Claude\" as the tell that tells our posts apart from a ChatGPT Codex *review*. An agent-neutral line has to keep that tell working, e.g. the tell becomes \"Posted by\", not \"Posted by Claude\".\n\n## Acceptance Criteria\n- agent-rules.md's attribution template names the posting model without assuming Claude, and AGENTS.md's \"ours open with\" tell still separates our posts from a Codex review.\n","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T04:12:37Z","created_by":"agent-review-175","updated_at":"2026-09-23T04:22:21Z","closed_at":"2026-09-23T04:22:21Z","close_reason":"PR #175 MERGED (confirmed at 9f3f530), adversarially reviewed then re-reviewed after fixes (merge). The agent standing rules now live in the repo at .agents/skills/wave/agent-rules.md, replacing the non-durable /tmp/salvage/standing-rules.md. .claude/skills/wave is a symlink to .agents/skills/wave, so the two drifted copies are one. SKILL.md is the brief checklist's only home, and AGENTS.md points to it. The review confirmed every rule from the /tmp file landed, and the fix restored the two items the move had dropped: hand-back identifiers (branch, head SHA, PR number) and the base-commit check.","labels":["adversarial-review","docs","pr-175"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-ximf","title":"fmt-workspaces.sh help says exit 2 names NO file, but the new orphan arm names a Cargo.toml","description":"Raised by ChatGPT Codex on PR #170 (improvise-90ea), head 808cbb7d. Confirmed by the adversarial review of the same head.\n\nscripts/fmt-workspaces.sh usage() now lists \"a [package] that no root has as a member\" as an exit-2 cause, and the same paragraph still says \"NO verdict was produced and NO file is named.\" The new orphan arm prints each unreached Cargo.toml, and the fixture case \"and the unreached package is named\" requires that path in the output. So the help text gives exit 2 two incompatible meanings. The exit-code contract is the reason this script exists (improvise-b4ks).\n\nFix: narrow the sentence, e.g. \"no SOURCE file is named as a formatting failure; a preflight cause may name a manifest\". Pin the wording with a fixture case if the help text is meant to be a contract. Doc-only, no behaviour change.\n","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T04:07:41Z","created_by":"agent-review-169","updated_at":"2026-09-23T04:19:32Z","closed_at":"2026-09-23T04:19:32Z","close_reason":"PR #170 MERGED (confirmed), adversarially reviewed (merge after the ximf one-line fix, which was made); CI green at d11d2ac per pr-watch --expect. scripts/fmt-workspaces.sh: the fixture now misformats EVERY package it finds (discovered independently of the script) and requires each to be named exactly once, and the gate itself refuses (exit 2) if any [package] in the tree belongs to no workspace root. That structural check is what stops a fifth fixture gap; the reviewer added a fourth fixture package and it got its own case automatically. usage() now says exit 2 means NO VERDICT was produced.","labels":["adversarial-review","pr-170","tooling"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-ulhd","title":"ktlk's editor seed is unpinned by position: every absent-buffer test has the cursor at (0,0) with zero offsets","description":"Found by adversarial review of PR #169 (improvise-ktlk), head bdf9bff4.\n\nThe ktlk seed (ClientState::settle_editor -\u003e resolve::edit_seed) picks the cell under the cursor through cell_at_cursor, which subtracts the scroll offsets. The code is correct. But no test pins WHICH cell the seed comes from. Every test that reaches the absent-buffer path (resolve::tests::an_editor_entered_without_its_buffer_commits_and_extends_the_cell, render::tests::an_editing_mode_with_no_edit_buffer_shows_the_cell_rather_than_a_blank, render::tests::an_edit_buffer_the_user_has_emptied_is_not_refilled_from_the_cell) has the cursor at (0,0) with zero offsets. That is the standing \"cursor at index 0\" vacuity.\n\nINJECTED, run: edit_seed rewritten as\n let (r, c) = state.view.cursor().selected;\n state.grid.cell(r, c)?.edit_text.clone()\n(ignores the offsets). Full workspace: 1748 passed, 0 failed. Under a scrolled cursor that rival seeds from a different cell's edit_text, and Enter then commits that other value into this cell (silent data corruption, the same class as ktlk).\n\nDiscriminating fixture, verified: two record cells at viewport (0,0)=\"111\" and (1,0)=\"222\", SetSelected{1,0}, SetRowOffset(1), fold ChangeMode(editing) with no SetBuffer, assert read_buffer(\"edit\") == \"111\". Passes on bdf9bff4; fails under the rival with \"222\".\n\nFix: add that test (resolve.rs tests, next to the ktlk test) plus an evidence entry. Test-only.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T04:07:30Z","created_by":"agent-review-169","updated_at":"2026-09-23T04:07:30Z","labels":["adversarial-review","browser","pr-169","test-gap"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-ulhd","title":"ktlk's editor seed is unpinned by position: every absent-buffer test has the cursor at (0,0) with zero offsets","description":"Found by adversarial review of PR #169 (improvise-ktlk), head bdf9bff4.\n\nThe ktlk seed (ClientState::settle_editor -\u003e resolve::edit_seed) picks the cell under the cursor through cell_at_cursor, which subtracts the scroll offsets. The code is correct. But no test pins WHICH cell the seed comes from. Every test that reaches the absent-buffer path (resolve::tests::an_editor_entered_without_its_buffer_commits_and_extends_the_cell, render::tests::an_editing_mode_with_no_edit_buffer_shows_the_cell_rather_than_a_blank, render::tests::an_edit_buffer_the_user_has_emptied_is_not_refilled_from_the_cell) has the cursor at (0,0) with zero offsets. That is the standing \"cursor at index 0\" vacuity.\n\nINJECTED, run: edit_seed rewritten as\n let (r, c) = state.view.cursor().selected;\n state.grid.cell(r, c)?.edit_text.clone()\n(ignores the offsets). Full workspace: 1748 passed, 0 failed. Under a scrolled cursor that rival seeds from a different cell's edit_text, and Enter then commits that other value into this cell (silent data corruption, the same class as ktlk).\n\nDiscriminating fixture, verified: two record cells at viewport (0,0)=\"111\" and (1,0)=\"222\", SetSelected{1,0}, SetRowOffset(1), fold ChangeMode(editing) with no SetBuffer, assert read_buffer(\"edit\") == \"111\". Passes on bdf9bff4; fails under the rival with \"222\".\n\nFix: add that test (resolve.rs tests, next to the ktlk test) plus an evidence entry. Test-only.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T04:07:30Z","created_by":"agent-review-169","updated_at":"2026-09-23T04:07:30Z","labels":["adversarial-review","browser","pr-169","test-gap"],"dependencies":[{"issue_id":"improvise-ulhd","depends_on_id":"improvise-6jk","type":"parent-child","created_at":"2026-09-25T01:04:47Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-5fzx","title":"Three skills are byte-identical hand-kept copies under .claude/skills and .agents/skills","description":"kb, pr-watch and code-review each exist as two byte-identical directories, .claude/skills/\u003cname\u003e and .agents/skills/\u003cname\u003e (verified with diff -rq on 2026-09-23). That is the drift that improvise-1f8e found in the wave skill: its two copies had diverged (neither had PR #167's commit-frequently rule, and one said 'Claude agent' where the other said 'Codex agent'). PR #175 fixed wave the way beads-merge is already done: .claude/skills/wave became a symlink to .agents/skills/wave. Apply the same fix to the other three, after checking that the Skill loader follows the symlink (#175 left that unverified). A live hazard: improvise-ih3k/yea8/za97 are being fixed right now by an agent told to edit BOTH kb copies and keep them in sync by hand, which is exactly how they drift. Do this after that PR lands, to avoid a conflict.","status":"closed","priority":3,"issue_type":"task","assignee":"agent-tooling3","owner":"el-github@elangley.org","created_at":"2026-09-23T04:06:28Z","created_by":"Edward Langley","updated_at":"2026-09-23T04:51:03Z","started_at":"2026-09-23T04:30:39Z","closed_at":"2026-09-23T04:51:03Z","close_reason":"PR #189 MERGED (confirmed at 8641f9a), adversarially reviewed (merge, no findings). .claude/skills/{kb,pr-watch,code-review} are symlinks to .agents/skills/\u003cname\u003e (mode 120000), so every repo skill has one copy. The code-review skill's gate list now points at agent-rules.md 'Gates' rather than restating it, which removes the stale 'cargo fmt --all --check'.","comments":[{"id":"40346b4f-f104-53b7-a304-d1bc4fac5ad7","issue_id":"improvise-5fzx","author":"agent-tooling3","text":"PR #189 at 8641f9a (branch fix/5fzx-skill-symlinks)","created_at":"2026-09-23T04:41:57Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} {"_type":"issue","id":"improvise-b5zh","title":"prune_axis_items: close_pending_run's 'header whose run saw no item is collapsed' arm is unreachable since 6qet","description":"Since improvise-6qet a collapsed group emits one DataItem (its fold), so a header followed by no item at all can no longer be produced by expand_category. The doc comment on close_pending_run ('A header still pending saw either no item at all — it is collapsed, and survives') is now a stale claim in code, and the arm it describes is unreachable. Also: under prune_empty an EMPTY collapsed group now prunes its fold row and the header with it — a deliberate narrowing of improvise-lhi4 (context/code/core.md §14). Decide whether to delete the arm and simplify, and correct the comment and context/test-evidence/core.md's lhi4 entry, which still explains the arm.","status":"closed","priority":3,"issue_type":"task","assignee":"agent-collapse-fu","owner":"el-github@elangley.org","created_at":"2026-09-23T04:06:18Z","created_by":"agent-collapse3","updated_at":"2026-09-23T06:35:14Z","started_at":"2026-09-23T04:38:25Z","closed_at":"2026-09-23T06:35:14Z","close_reason":"Fixed in PR #200, merged as 7767cee after adversarial review at 561cff4. Follow-up: improvise-e265.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-j4i9","title":"TUI help overlay silently clips every page below ~50 rows: no scroll, no 'more' marker","description":"Pre-existing; found reviewing PR #168 (improvise-s8h7) at 334e218. src/ui/help.rs HelpWidget::render draws page_content rows until content_height (= height - 2*margin - 2 border - 4) and then breaks: no scrolling, no indicator that rows were dropped. Measured with a throwaway probe that renders each page and looks for its last row, width 100: minimum terminal height to see the last row is Welcome 45, Navigation 47 (46 on gh/main — #168 adds a row), Editing 44 (45 on gh/main), Panels \u0026 Views 50, Commands 48. So at 24 or 40 rows every page is truncated, silently: on Navigation at 24 rows the whole Search and View history sections are gone. #168 did not create this and does not raise the overall need (still 50, set by Panels \u0026 Views); it moves Navigation's own threshold by one. Fix direction is not obvious — scroll within a page (j/k are free in Help mode?) or split pages — so filed rather than prescribed. A test would render at a small height and assert either the last row or a truncation marker is drawn.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T04:03:22Z","created_by":"agent-review-s8h7","updated_at":"2026-09-23T04:03:22Z","labels":["adversarial-review","help","pr-168","tui"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-j4i9","title":"TUI help overlay silently clips every page below ~50 rows: no scroll, no 'more' marker","description":"Pre-existing; found reviewing PR #168 (improvise-s8h7) at 334e218. src/ui/help.rs HelpWidget::render draws page_content rows until content_height (= height - 2*margin - 2 border - 4) and then breaks: no scrolling, no indicator that rows were dropped. Measured with a throwaway probe that renders each page and looks for its last row, width 100: minimum terminal height to see the last row is Welcome 45, Navigation 47 (46 on gh/main — #168 adds a row), Editing 44 (45 on gh/main), Panels \u0026 Views 50, Commands 48. So at 24 or 40 rows every page is truncated, silently: on Navigation at 24 rows the whole Search and View history sections are gone. #168 did not create this and does not raise the overall need (still 50, set by Panels \u0026 Views); it moves Navigation's own threshold by one. Fix direction is not obvious — scroll within a page (j/k are free in Help mode?) or split pages — so filed rather than prescribed. A test would render at a small height and assert either the last row or a truncation marker is drawn.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T04:03:22Z","created_by":"agent-review-s8h7","updated_at":"2026-09-23T04:03:22Z","labels":["adversarial-review","help","pr-168","tui"],"dependencies":[{"issue_id":"improvise-j4i9","depends_on_id":"improvise-gtn7","type":"parent-child","created_at":"2026-09-25T01:05:30Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-1f8e","title":"wave skill's brief checklist not updated with PR #167's commit-frequently and push-own-branch rules","description":"Found reviewing PR #167 at ef16723.\n\nPR #167 renumbers AGENTS.md's \"Every wave agent's brief must include\" list: new item 4 \"Commit frequently, so the work is safe in git\", and item 5 changes \"Do not push, do not merge to main\" to \"Do not merge to main ... Pushing the agent's own branch (git push gh HEAD:refs/heads/\u003cbranch\u003e) is encouraged\".\n\nThe `wave` skill carries its own copy of the same checklist (\"What every brief must carry\", `.claude/skills/wave/SKILL.md` and the identical `.agents/skills/wave/SKILL.md`) and was not touched. Its item 6 still reads \"Do not push to `main`, do not merge.\" and it has no commit-frequently item. The skill is what an orchestrator loads to write a brief, so briefs built from it will omit the rule the PR exists to add. `git grep -n -iE 'do not push|commit' ef16723 -- .claude/skills/wave .agents/skills/wave` shows it.\n\nThis is the stale-sibling shape: one statement of a rule updated, its copy not.\n\n## Acceptance Criteria\n- both wave skill copies carry the commit-frequently item and the push-own-branch wording, or point at AGENTS.md instead of restating the list.\n","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-23T03:58:33Z","created_by":"agent-review-kb","updated_at":"2026-09-23T04:22:18Z","closed_at":"2026-09-23T04:22:18Z","close_reason":"PR #175 MERGED (confirmed at 9f3f530), adversarially reviewed then re-reviewed after fixes (merge). The agent standing rules now live in the repo at .agents/skills/wave/agent-rules.md, replacing the non-durable /tmp/salvage/standing-rules.md. .claude/skills/wave is a symlink to .agents/skills/wave, so the two drifted copies are one. SKILL.md is the brief checklist's only home, and AGENTS.md points to it. The review confirmed every rule from the /tmp file landed, and the fix restored the two items the move had dropped: hand-back identifiers (branch, head SHA, PR number) and the base-commit check.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-za97","title":"kb completeness guard docs: refusal suggests committing the loss, kb skill not updated, residue paragraph wrong","description":"Found reviewing PR #166 (improvise-usus) at a55b3c7. Three documentation defects around the new completeness guard; none is a code bug, all are where the next reader looks.\n\n1. **The refusal offers the committed-loss hole as a way out.** On an unexplained loss `scripts/kb` prints `kb: restore it from HEAD and re-apply your deltas, or commit the removal.` The second option, taken on a loss nobody intended, commits the loss, and `context/code/kb-tool.md` itself says \"a bad commit of kb.pl becomes the new floor\". An agent following the message literally makes the incident permanent and invisible. The message should say commit only a removal you intended and verified, and point at `kb rm` as the way to record one.\n\n2. **The `kb` skill (`.claude/skills/kb/SKILL.md`), which AGENTS.md says to read before the first write, was not updated.** It still says \"Every write verifies the result reads back as intended and runs `kb_consistent`\", with no mention of the HEAD comparison, the ledger, exit code 3, what the new refusal means, how to recover, or that a committed loss is the new floor. The argument is only in `context/code/kb-tool.md`.\n\n3. **`kb-tool.md` states a residue hazard that does not exist, and understates the residue.** It says a SIGKILL'd `kb.pl.cand.*` \"shows as untracked in prolog-sessions/, where a git add -A would pick it up\". Ran it: SIGKILL via `KB_TEST_KILL` on a copy inside `prolog-sessions/` left `rvcopy.pl.cand.XXXX`, `.cand.XXXX.base`, `.cand.XXXX.ledger`, `.cand.XXXX.removed` and `.lock`; `git status --untracked-files=all` and `git add -A --dry-run` both show nothing, because `.gitignore:40` ignores `prolog-sessions/*`. So the paragraph warns about a hazard that the tree already prevents and names one file where four are left.\n\n## Acceptance Criteria\n- the refusal message no longer offers committing an unexplained loss;\n- the kb skill describes the completeness refusal, the ledger and the recovery;\n- the residue paragraph matches what `git status` shows.\n","status":"closed","priority":3,"issue_type":"task","assignee":"agent-kbguard2","owner":"el-github@elangley.org","created_at":"2026-09-23T03:56:41Z","created_by":"agent-review-kb","updated_at":"2026-09-23T04:29:57Z","started_at":"2026-09-23T04:01:23Z","closed_at":"2026-09-23T04:29:57Z","close_reason":"PR #179 MERGED (confirmed at 9472989), adversarially reviewed (merge); CI 14/0 including the kb job on Linux with GNU stat. ih3k: the completeness guard counts clauses PER KEY, so losing one clause of a pair that shares a key is refused. yea8: the kb rm ledger carries a stamp (sha256 + inode + ns-mtime) of the file its write published, and any rewrite outside the tool voids it. A content hash alone would not work: kb.pl is canonical, so grep -v of a clause is byte-identical to kb rm output. In the reviewer's repro the in-place rewrite even keeps the inode, so the mtime is the part doing the work. za97: the refusal no longer offers committing the loss, and the kb skill documents the guard. Follow-up: improvise-4zei (P4, case 25 cannot see a content-only stamp; case 24 still guards it).","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-yea8","title":"A stale kb rm ledger entry excuses a later non-rm loss of that clause (restore or branch switch does not prune it)","description":"Found reviewing PR #166 (improvise-usus) at a55b3c7.\n\nThe `kb rm` ledger (`git rev-parse --git-path kb-removed.pl`, per worktree, not per branch) is pruned only when a later `scripts/kb` WRITE runs. `kb-tool.md` says an entry \"drops once the removal is committed, and once the key is re-added\", but a commit, a `git checkout -- prolog-sessions/kb.pl` and a branch switch are not writes. Until the next write, the entry excuses the loss of that exact clause by any path, which is the loss the guard exists to catch.\n\nSteps to reproduce (fixture copies, KB_FILE/KB_BASELINE, so the ledger is `$KB.removed`; the pruning logic is identical to the git-path one):\n1. copy = HEAD kb.pl. `printf \"location(cli_args,'src/main.rs',_).\\n\" \u003e rmX.pl; kb rm rmX.pl` -\u003e ledger holds `location(cli_args,'src/main.rs',A)`.\n2. Undo it the way the refusal message recommends: `cp HEAD-kb copy` (a restore; not a write, so no prune).\n3. Lose that clause by another path: `grep -v '^location(cli_args,' HEAD-kb \u003e copy`.\n4. `kb add \u003cnew doctrine\u003e` -\u003e exit 0, `complete -- baseline 809, result 809, 1 removal(s) recorded`. The loss was excused.\n- Control: the same step-3 copy with no ledger is refused, naming `location(cli_args,'src/main.rs',clap)`.\n\nThe branch-switch variant: `kb rm X` on branch A (committed or abandoned), switch to B whose HEAD has X; nothing prunes until a write, so a merge-resolution that drops X on B is excused by A's entry.\n\nThe window is narrow (one specific clause, until the next write). Pruning against the kb as read at the next write does NOT close it: by then the loss has already happened and the read kb lacks X too. One option is to record the HEAD commit and the kb stamp with each entry and discard entries whose recorded kb state is not the one being written onto (the rm's own output file is the only kb its entries may excuse); another is to key the ledger by HEAD commit so a commit or branch switch discards it. At minimum, correct the kb-tool.md sentence.\n\n## Acceptance Criteria\n- A kb-test case: rm X, restore kb.pl, lose X, add -\u003e refused.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-kbguard2","owner":"el-github@elangley.org","created_at":"2026-09-23T03:56:20Z","created_by":"agent-review-kb","updated_at":"2026-09-23T04:29:56Z","started_at":"2026-09-23T04:01:22Z","closed_at":"2026-09-23T04:29:56Z","close_reason":"PR #179 MERGED (confirmed at 9472989), adversarially reviewed (merge); CI 14/0 including the kb job on Linux with GNU stat. ih3k: the completeness guard counts clauses PER KEY, so losing one clause of a pair that shares a key is refused. yea8: the kb rm ledger carries a stamp (sha256 + inode + ns-mtime) of the file its write published, and any rewrite outside the tool voids it. A content hash alone would not work: kb.pl is canonical, so grep -v of a clause is byte-identical to kb rm output. In the reviewer's repro the in-place rewrite even keeps the inode, so the mtime is the part doing the work. za97: the refusal no longer offers committing the loss, and the kb skill documents the guard. Follow-up: improvise-4zei (P4, case 25 cannot see a content-only stamp; case 24 still guards it).","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -563,61 +691,60 @@ {"_type":"issue","id":"improvise-gho5","title":"Rolled-back-load tests pass against a host that never ran the load; the evidence's generation-check claim is false","description":"Found by post-merge adversarial review of PR #163 (improvise-k84e), head 5fa3fed1. Low severity: a claim in the evidence is false, and nothing is broken at runtime.\n\n`context/test-evidence/command.md` says of `a_rolled_back_load_tells_nobody_and_puts_every_mirror_back`: \"the `document_generation` equality is checked first, so 'the frame was rejected' cannot be confused with 'the frame never got that far'\". It can be confused, because generation equality is exactly what holds when the frame never got that far. Nothing in the rollback test witnesses that the LoadModel inside the frame actually ran before the rollback. The control (`the_load_this_fixture_performs_rebases_every_client`) runs the load in a separate, single-command frame, so it proves the fixture CAN load, not that this frame did.\n\nCONFIRMED at 40b8913. Injected at the top of `Host::commands`: reject up front, before CheckedBatch, any multi-command frame containing a LoadModel. Both `a_rolled_back_load_tells_nobody_and_puts_every_mirror_back` and its sibling `a_rolled_back_load_leaves_no_ghost_view_and_no_false_status` PASS against a host that never applied the load, so neither `undo_frame` nor the mirror clone is exercised. Reverted.\n\nToday the tests are not vacuous: the evidence's `let _ = mirrors` injection fails them, because the current CheckedBatch lets the frame through to apply. But if the record tier ever moves up front for this frame, both tests go silently vacuous. Cheap fix: assert the asker's reply is `ServerFrame::Rejected(\u003cthe apply-time reason\u003e)`, a reason CheckedBatch cannot produce up front, or otherwise observe that the load was applied (e.g. a counter or a probe) before concluding anything from the rollback.\n\nOn gotcha 39's fixture warning: `host_over_a_loadable_document` does RENAME the active view (\"Loaded\"). I checked whether that lets the mirror assertion pass by accident. It does not: an unrestored mirror, on a view that does not exist, still diffs non-empty against the restored document, so the rename does not hide the undo fault.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-wire2","owner":"el-github@elangley.org","created_at":"2026-09-23T03:47:12Z","created_by":"agent-review-wire","updated_at":"2026-09-23T04:18:09Z","started_at":"2026-09-23T04:01:00Z","closed_at":"2026-09-23T04:18:09Z","close_reason":"PR #174 MERGED (confirmed), adversarially reviewed at 912e25a (merge). Both clause-4 rollback tests now require Rejected(ChainBroken(North)) and assert North resolves before the frame, a reason only broken_chain produces after the load is applied. Before, they passed against a host that rejected up front and never ran the load. The reviewer injected three faults (up-front rejection; load applied but mirrors not restored; mirrors restored but app not) and each fails where it should. Residual, not filed: a host that rejects up front and INVENTS ChainBroken(first named record) would pass. Counting reads of the load path on the fixture's MemoryFs would close it.","labels":["adversarial-review","pr-163","protocol","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-8nd9","title":"ViewBack and ViewForward carry swzk's hazard and are safe only via forget_view","description":"effect::SwitchView was fixed by improvise-swzk (PR #154): it now pushes its history frame AFTER calling the guarded App::switch_to_view and only if active_view actually moved -- observation rather than prediction, the same answer cursor_epoch and document_generation give (gotchas 24/39/45).\n\nViewBack and ViewForward have THE SAME SHAPE and were not fixed there, deliberately, to keep #154 to its ticket. Both call switch_to_view unguardedly, pushing the current view onto the OPPOSITE stack before a switch they cannot be sure of.\n\nTHEY ARE CORRECT TODAY ONLY BY ACCIDENT: ViewState::forget_view purges both stacks when a view is deleted, so a popped frame always names a live view. That is A GUARD LIVING ONE LEVEL AWAY FROM THE HAZARD -- the exact shape that keeps returning in this repo (gotcha 18's empty `partial`, which was unreachable only because both callers guarded; gotcha 24's reset_cursor table). The class of bug returns the moment a second path can put a dead name on a stack.\n\nSo the question is not \"is this broken now\" (it is not) but \"what makes it stay correct\". Options: give ViewBack/ViewForward the same observe-after-the-fact shape as SwitchView, which costs three lines and removes the dependence entirely; or state the forget_view dependence where someone editing either side will see it.\n\nPrefer the first. A guard that is one level away is a guard that a refactor deletes without noticing, and the cheap fix here is the SAME fix already made next door -- so leaving two siblings with different shapes is itself the hazard.\n\nReachability note: a popped frame naming a deleted view needs forget_view to miss a path. Worth checking whether any code removes a view WITHOUT going through it before deciding how urgent this is. Reported by the improvise-htl6/swzk agent; recorded in context/code/command.md at the time.","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-viewhist","owner":"el-github@elangley.org","created_at":"2026-09-23T02:36:19Z","created_by":"Edward Langley","updated_at":"2026-09-23T05:38:01Z","started_at":"2026-09-23T04:38:08Z","closed_at":"2026-09-23T05:38:01Z","close_reason":"Fixed in PR #193, merged as a2f3c8d. Adversarial review passed at f8f4dd5; later changes were a gh/main merge touching evidence only, plus a docs correction pointing at improvise-lxg0.","dependencies":[{"issue_id":"improvise-8nd9","depends_on_id":"improvise-lrrx","type":"related","created_at":"2026-09-23T03:38:10Z","created_by":"agent-review-cmd","metadata":"{}"}],"comments":[{"id":"bad4d021-db16-5343-a024-1a0e13fb61b8","issue_id":"improvise-8nd9","author":"agent-review-cmd","text":"Post-merge review of #154: the 'second path that can put a dead name on a stack' already exists. install_workbook (LoadModel/AcceptWizardModel) clears cursors but not the view stacks. Confirmed in the headless CLI, and filed as improvise-lrrx. The 'it is not broken now' in this description is false.\n","created_at":"2026-09-23T03:38:10Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} {"_type":"issue","id":"improvise-jgzw","title":"The tile bar offers _Index and _Dim, because every call site passes category_names()","description":"All four tile call sites pass Model::category_names() -- which returns EVERY category, virtual ones included -- to View::tileable_cats:\n\n ui/project.rs:403, cmd/tile.rs:94, cmd/tile.rs:119, src/ui/tile_bar.rs:180\n\ntileable_cats filters only on axis (it drops the coordinates a drill fixed). In a default view _Index and _Dim sit on Axis::None, which IS user-assignable, so they pass the filter. Consequence: TILE CURSOR 0 IS _Index, not the first regular category.\n\nFound while writing improvise-lm5e's field-naming assertions (PR #148): no assertion in the suite had ever named a category, so nothing said so. Behaviour is UNCHANGED by that PR -- this ticket is the question it exposed, not a regression.\n\nTHE NUANCE THAT DECIDES THIS, and it is why the obvious fix is wrong: _Measure MUST stay tileable. Putting _Measure on an axis is not exotic, it is what every shipped document does (`_Measure: column` is in web/demo.improv and in the format example). So this is NOT \"use regular_category_names() for user-facing pickers\" as the repo-map advises for prompts -- that would remove _Measure and break the common case.\n\nThe categories actually in doubt are _Index and _Dim, which are records-mode machinery: GridLayout AUTO-DETECTS records mode from _Index-on-Row plus _Dim-on-Column, so a user assigning them by hand through the tile bar can construct that state by accident, without the mode change and the keymap that ToggleRecordsMode brings. Worth checking what that actually does before deciding it is bad -- it may be harmless, in which case the answer is to leave it and say why here.\n\nSo the options are: (a) leave it, recording the reason; (b) filter _Index/_Dim specifically, keeping _Measure -- which needs a predicate named for the distinction, since CategoryKind::is_index_or_dim() already exists and says exactly this; (c) decide records mode should be reachable that way and leave it deliberately.\n\nWhichever is chosen, ASSERT A CATEGORY NAME in the tile tests -- the gap that hid this is that none of them did. The rival to inject is a filter that also drops _Measure: it must fail a test that puts _Measure on a column.","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-registry","owner":"el-github@elangley.org","created_at":"2026-09-23T02:16:27Z","created_by":"Edward Langley","updated_at":"2026-09-23T05:41:35Z","started_at":"2026-09-23T04:12:57Z","closed_at":"2026-09-23T05:41:35Z","close_reason":"Resolved by PR #192 (merged after adversarial review at f44767c; later changes were evidence-only merges of gh/main). Decision: the tile bar keeps _Index/_Dim because it is the browser's only route into records mode. Follow-up: improvise-oplp (tile cursor opens on _Index).","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-t219","title":"_Drill is one shared view, so two clients drilling contend","description":"_Drill is a single view in Workbook::views, and Workbook::create_view REPLACES (deliberately -- see core.md and improvise-vw47). So two clients drilling at once contend: the second drill overwrites the first client's _Drill configuration, and since a View is shared document state the first client's grid silently becomes the second client's drill.\n\nPRE-EXISTING and NOT WORSENED by improvise-gd28's decomposition -- filed so it is not discovered as a regression of that work. Same is true of _Records.\n\nIt is currently unreachable because the browser cannot enter a drill at all (see the aggregate-addressing seam ticket). Closing that makes this live, so the two should be considered together -- the same masking relationship improvise-6uva has with improvise-up8g.\n\nNeeds a per-client view namespace, which is a real design question: a virtual view is document state by construction today (it lives in Workbook::views and is what GridLayout reads), but a drill is a UI act belonging to one client -- \"the model is shared, the UI is not\" says it should not be in the workbook at all. Note VIRTUAL_VIEW_NAMES are never persisted, so there is no format cost to moving them.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T01:36:28Z","created_by":"Edward Langley","updated_at":"2026-09-23T01:36:28Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-q36k","title":"jq is not in flake.nix, so a clean nix develop cannot run the pr-reviews suite","description":"Found by the improvise-f9qf agent while making scripts/checkers.sh run every\ncheck_the_checker by default.\n\nscripts/pr-reviews needs jq, and JQ IS NOT IN flake.nix. It resolves only via the\ndeveloper's own ~/.nix-profile, so A CLEAN `nix develop` WOULD FAIL\n`checkers.sh pr-reviews`. Nobody has hit it because every machine that has run it\nso far happened to have jq installed independently.\n\nThis is the same class as the bug improvise-f9qf just closed: an environment\ndifference that is invisible on the machine that has the thing, and fatal on the\none that does not. checkers.sh now scrubs the environment to an allowlist, which\nmakes the dev shell closer to CI -- and makes THIS gap reachable, because the\nallowlist keeps PATH but the flake does not put jq on it.\n\nTwo smaller siblings worth checking in the same pass, since they are the same\nquestion:\n - kb-test.sh fails SIXTEEN CASES CONFUSINGLY when tpl (trealla) is absent,\n rather than saying \"no trealla\". Pre-existing, and newly VISIBLE because\n checkers.sh runs everything by default rather than the suite you named.\n - What else does a scripts/ suite shell out to that the flake does not provide?\n The answer is a grep, and the fix is either a flake input or an explicit\n \"this suite needs X\" refusal with a distinct exit code -- checkers.sh already\n uses exit 3 for \"no usable bash\", so the precedent exists.\n\nPREFER THE EXPLICIT REFUSAL over silently skipping: a suite that quietly does not\nrun is how a green gate stops meaning anything, which is the failure improvise-f9qf\nwas filed about.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T23:16:24Z","created_by":"Edward Langley","updated_at":"2026-09-22T23:16:24Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-8mgz","title":"Row headers scroll out of the grid: not sticky, and the offsets cannot be computed in CSS","description":"Noticed by the improvise-tg27 agent while probing narrow widths, and correctly\nleft out of scope there.\n\n#grid is horizontally scrolled by scrollIntoView({inline:\"nearest\"}), and the\nrow-header columns are NOT sticky -- so at a narrow width the coordinate labels\nscroll off the screen and the user is looking at numbers with nothing naming the\nrows they belong to.\n\nPRE-EXISTING; improvise-tg27 did not introduce it and did not widen it. That fix\nstopped the PAGE scrolling horizontally (the app bar's reserves were the cause);\nthis is the GRID's own horizontal scroll, which is legitimate and remains.\n\nWHY IT IS NOT A CSS ONE-LINER: position:sticky on the row-header cells is the\nobvious move, and the grid's header structure is multi-level -- AxisFrame carries\n`cats` outermost-first with label TUPLES (gotcha 44), so there are N row-header\ncolumns, not one, and each needs its own `left` offset computed from the widths of\nthose to its left. The width rule has one home, improvise_protocol::header_level_widths,\nand CSS cannot read it.\n\nThat is the same shape as the sticky COLUMN headers improvise-tg27's agent\ndeclined for the same reason: getting the offsets wrong is SILENT -- the cell\nhides BEHIND the header while getBoundingClientRect still reports it in view, so\nthe existing scroll assertions stay green.\n\nSO THE TEST HAS TO ASSERT OCCLUSION, not position: that the row-header cell is the\ntopmost element at its own coordinates (elementFromPoint), not merely that its\nrect is inside the viewport. Without that, a wrong offset ships green.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T23:07:55Z","created_by":"Edward Langley","updated_at":"2026-09-22T23:07:55Z","labels":["browser"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-rx8g","title":"#hint is illegible at phone width: 1px at 520, and fixing it reopens n6t8's zero-basis argument","description":"Found and MEASURED by the improvise-tg27 agent, which first claimed its fix made\nthe hint legible, found its own spec had asserted that and PASSED, and corrected\nboth.\n\n#hint is flex:1 1 0 -- the only elastic child of the app bar (improvise-n6t8) --\nso after the header wraps it gets whatever the line leaves. Measured, settled\n(i.e. after #status composes the transport fact, not immediately after paint):\n\n 360px -\u003e 26px 520px -\u003e 1px 600px -\u003e 81px\n 700px -\u003e 317px 768px -\u003e 385px 900px -\u003e 67px 1024px -\u003e 6px\n\nNON-MONOTONIC, because it is the remainder after #view-name + #mode + #status take\ntheir reserves, and #status composes the DOCUMENT'S NAME, so the numbers are\ndocument-dependent too.\n\nWHY THIS IS NOT A ONE-LINE FIX, and the reason it is filed rather than patched:\ngiving #hint a min-width so it forces its own line DIRECTLY CONTRADICTS\nimprovise-n6t8's argument for flex:1 1 0 -- \"zero basis, absorbs the remainder,\ncontributes NOTHING TO OVERFLOW\". That basis is what stops the hint widening the\ndocument, which is exactly what improvise-tg27 then had to fix at the header\nlevel. So this reopens n6t8's reasoning and needs it argued again, not worked\naround.\n\nWORTH CONSIDERING INSTEAD OF A min-width: the hint is REFERENCE, not state (n6t8's\nown framing), so the honest answer at phone width may be to DROP it rather than\nsqueeze it -- a visible nothing beats an illegible something, and the affordance\nit carries is duplicated in the help overlay. That is a product call.\n\nMeasurements are in context/test-evidence/clients.md. Do not re-derive them\nimmediately after paint: the spec that did read 186px where the settled value is\n1px.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T23:07:55Z","created_by":"Edward Langley","updated_at":"2026-09-22T23:07:55Z","labels":["browser"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-t219","title":"_Drill is one shared view, so two clients drilling contend","description":"_Drill is a single view in Workbook::views, and Workbook::create_view REPLACES (deliberately -- see core.md and improvise-vw47). So two clients drilling at once contend: the second drill overwrites the first client's _Drill configuration, and since a View is shared document state the first client's grid silently becomes the second client's drill.\n\nPRE-EXISTING and NOT WORSENED by improvise-gd28's decomposition -- filed so it is not discovered as a regression of that work. Same is true of _Records.\n\nIt is currently unreachable because the browser cannot enter a drill at all (see the aggregate-addressing seam ticket). Closing that makes this live, so the two should be considered together -- the same masking relationship improvise-6uva has with improvise-up8g.\n\nNeeds a per-client view namespace, which is a real design question: a virtual view is document state by construction today (it lives in Workbook::views and is what GridLayout reads), but a drill is a UI act belonging to one client -- \"the model is shared, the UI is not\" says it should not be in the workbook at all. Note VIRTUAL_VIEW_NAMES are never persisted, so there is no format cost to moving them.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T01:36:28Z","created_by":"Edward Langley","updated_at":"2026-09-23T01:36:28Z","dependencies":[{"issue_id":"improvise-t219","depends_on_id":"improvise-n28d","type":"parent-child","created_at":"2026-09-25T01:05:26Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-q36k","title":"jq is not in flake.nix, so a clean nix develop cannot run the pr-reviews suite","description":"Found by the improvise-f9qf agent while making scripts/checkers.sh run every\ncheck_the_checker by default.\n\nscripts/pr-reviews needs jq, and JQ IS NOT IN flake.nix. It resolves only via the\ndeveloper's own ~/.nix-profile, so A CLEAN `nix develop` WOULD FAIL\n`checkers.sh pr-reviews`. Nobody has hit it because every machine that has run it\nso far happened to have jq installed independently.\n\nThis is the same class as the bug improvise-f9qf just closed: an environment\ndifference that is invisible on the machine that has the thing, and fatal on the\none that does not. checkers.sh now scrubs the environment to an allowlist, which\nmakes the dev shell closer to CI -- and makes THIS gap reachable, because the\nallowlist keeps PATH but the flake does not put jq on it.\n\nTwo smaller siblings worth checking in the same pass, since they are the same\nquestion:\n - kb-test.sh fails SIXTEEN CASES CONFUSINGLY when tpl (trealla) is absent,\n rather than saying \"no trealla\". Pre-existing, and newly VISIBLE because\n checkers.sh runs everything by default rather than the suite you named.\n - What else does a scripts/ suite shell out to that the flake does not provide?\n The answer is a grep, and the fix is either a flake input or an explicit\n \"this suite needs X\" refusal with a distinct exit code -- checkers.sh already\n uses exit 3 for \"no usable bash\", so the precedent exists.\n\nPREFER THE EXPLICIT REFUSAL over silently skipping: a suite that quietly does not\nrun is how a green gate stops meaning anything, which is the failure improvise-f9qf\nwas filed about.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T23:16:24Z","created_by":"Edward Langley","updated_at":"2026-09-22T23:16:24Z","dependencies":[{"issue_id":"improvise-q36k","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T01:05:47Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-8mgz","title":"Row headers scroll out of the grid: not sticky, and the offsets cannot be computed in CSS","description":"Noticed by the improvise-tg27 agent while probing narrow widths, and correctly\nleft out of scope there.\n\n#grid is horizontally scrolled by scrollIntoView({inline:\"nearest\"}), and the\nrow-header columns are NOT sticky -- so at a narrow width the coordinate labels\nscroll off the screen and the user is looking at numbers with nothing naming the\nrows they belong to.\n\nPRE-EXISTING; improvise-tg27 did not introduce it and did not widen it. That fix\nstopped the PAGE scrolling horizontally (the app bar's reserves were the cause);\nthis is the GRID's own horizontal scroll, which is legitimate and remains.\n\nWHY IT IS NOT A CSS ONE-LINER: position:sticky on the row-header cells is the\nobvious move, and the grid's header structure is multi-level -- AxisFrame carries\n`cats` outermost-first with label TUPLES (gotcha 44), so there are N row-header\ncolumns, not one, and each needs its own `left` offset computed from the widths of\nthose to its left. The width rule has one home, improvise_protocol::header_level_widths,\nand CSS cannot read it.\n\nThat is the same shape as the sticky COLUMN headers improvise-tg27's agent\ndeclined for the same reason: getting the offsets wrong is SILENT -- the cell\nhides BEHIND the header while getBoundingClientRect still reports it in view, so\nthe existing scroll assertions stay green.\n\nSO THE TEST HAS TO ASSERT OCCLUSION, not position: that the row-header cell is the\ntopmost element at its own coordinates (elementFromPoint), not merely that its\nrect is inside the viewport. Without that, a wrong offset ships green.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T23:07:55Z","created_by":"Edward Langley","updated_at":"2026-09-22T23:07:55Z","labels":["browser"],"dependencies":[{"issue_id":"improvise-8mgz","depends_on_id":"improvise-fhj0","type":"parent-child","created_at":"2026-09-25T01:05:36Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-rx8g","title":"#hint is illegible at phone width: 1px at 520, and fixing it reopens n6t8's zero-basis argument","description":"Found and MEASURED by the improvise-tg27 agent, which first claimed its fix made\nthe hint legible, found its own spec had asserted that and PASSED, and corrected\nboth.\n\n#hint is flex:1 1 0 -- the only elastic child of the app bar (improvise-n6t8) --\nso after the header wraps it gets whatever the line leaves. Measured, settled\n(i.e. after #status composes the transport fact, not immediately after paint):\n\n 360px -\u003e 26px 520px -\u003e 1px 600px -\u003e 81px\n 700px -\u003e 317px 768px -\u003e 385px 900px -\u003e 67px 1024px -\u003e 6px\n\nNON-MONOTONIC, because it is the remainder after #view-name + #mode + #status take\ntheir reserves, and #status composes the DOCUMENT'S NAME, so the numbers are\ndocument-dependent too.\n\nWHY THIS IS NOT A ONE-LINE FIX, and the reason it is filed rather than patched:\ngiving #hint a min-width so it forces its own line DIRECTLY CONTRADICTS\nimprovise-n6t8's argument for flex:1 1 0 -- \"zero basis, absorbs the remainder,\ncontributes NOTHING TO OVERFLOW\". That basis is what stops the hint widening the\ndocument, which is exactly what improvise-tg27 then had to fix at the header\nlevel. So this reopens n6t8's reasoning and needs it argued again, not worked\naround.\n\nWORTH CONSIDERING INSTEAD OF A min-width: the hint is REFERENCE, not state (n6t8's\nown framing), so the honest answer at phone width may be to DROP it rather than\nsqueeze it -- a visible nothing beats an illegible something, and the affordance\nit carries is duplicated in the help overlay. That is a product call.\n\nMeasurements are in context/test-evidence/clients.md. Do not re-derive them\nimmediately after paint: the spec that did read 186px where the settled value is\n1px.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T23:07:55Z","created_by":"Edward Langley","updated_at":"2026-09-22T23:07:55Z","labels":["browser"],"dependencies":[{"issue_id":"improvise-rx8g","depends_on_id":"improvise-fhj0","type":"parent-child","created_at":"2026-09-25T01:05:40Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-t14d","title":"The TUI title bar drops '?:help :q quit' when the document name is long: fill_line gives the left segment priority","description":"Found by the improvise-p8ls agent while proving that ticket invalid. The\naffordance it was filed about DOES exist -- and it is the first thing sacrificed\nwhen the title bar overflows, which happens at ordinary widths.\n\nsrc/draw.rs:172:\n fn fill_line(left: String, right: \u0026str, width: u16) -\u003e String {\n let pad = \" \".repeat((width as usize).saturating_sub(left.len() + right.len()));\n format!(\"{left}{pad}{right}\")\n }\nVerified by the orchestrator. On overflow it simply concatenates, and ratatui\nclips the RIGHT -- which is where \" ?:help :q quit \" lives.\n\nMEASURED against the real draw() through a TestBackend:\n w= 36 | improvise · Sales (sales.improv)| affordance GONE\n w= 44 ... ?:help| half gone\n w= 52 ... ?:help :q qui| truncated\n w= 60 intact\n\nBUT WIDTH IS NOT THE REAL VARIABLE -- TITLE LENGTH IS, and it is unbounded:\n w=100 | improvise · Quarterly Regional Sales Analysis\n (quarterly-regional-sales-2026.improv) ?:help :q|\nAn ordinary document name plus filename destroys the escape hatch AT 100 COLUMNS,\nin Normal, RecordsNormal and TileSelect.\n\nSO THE PRIORITY IS BACKWARDS: the left segment (decoration) has unconditional\npriority over the escape hatch (the thing a stuck user needs). That is the bug.\n\nTWO SMALLER FAULTS IN THE SAME THREE LINES:\n 1. left.len() is BYTES, and the title contains \"·\" (2 bytes in UTF-8), so the\n padding is always one column short and a non-ASCII document name shifts the\n right segment further left. Note src/ui/text.rs::truncate now exists as the\n one display-width-safe truncator (improvise-8l0c) -- fill_line predates it\n and does not use it.\n 2. There is no truncation strategy at all, so the failure is a MID-TOKEN clip\n (\"?:\") rather than a graceful drop of the whole segment.\n\nNOT A DUPLICATE of improvise-tg27, which is the WEB client's grid columns and\nweb/style.css flex/overflow. Different front end, different file, different\nmechanism. bd search \"narrow\" and \"title bar\" found nothing covering this.\n\nWHEN FIXING: give the right segment priority, truncate the left with\nsrc/ui/text.rs::truncate (display cells, never bytes), and test through\nTestBackend at the widths above -- a test at 80 columns with a short fixture name\ncannot see this.","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-tui","owner":"el-github@elangley.org","created_at":"2026-09-22T22:59:23Z","created_by":"Edward Langley","updated_at":"2026-09-23T02:00:33Z","started_at":"2026-09-23T01:18:16Z","closed_at":"2026-09-23T02:00:33Z","close_reason":"PR #145. draw.rs::fill_line takes a Keeps parameter, because the three rows sharing a line disagree about which side yields: the title bar keeps the RIGHT (the ?:help/:q escape hatch outranks an unbounded document name), while minibuffer_line and draw_status keep the LEFT (the user's own typing outranks a view badge). NOT PREDICTED BY THE TICKET: the byte-padding fault fires on a PURE-ASCII document name, because the separator dot is in draw_title's own format string -- two bytes, one cell -- so every document's right segment sat one cell in from the edge. The ticket framed it as a non-ASCII-name problem. Widths now go through ui::text::truncate, never str::len. (--force: agent-tui handed back.)","labels":["tui"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-56qu","title":"site/ is a tracked build artifact that silently lags context/, and nothing compares them","description":"Found by the improvise-xgra agent (PR #133) by accident: it ran\nscripts/build-doc-site.mjs with no argument, which defaults to the TRACKED\nsite/docs, and the regeneration produced 26 MODIFIED FILES plus THREE PAGES THAT\nDO NOT EXIST IN THE TREE AT ALL:\n\n adr/004-faces-and-themes\n code/cli\n test-evidence/cli\n\nAll three are documents added TODAY (ADR 004 in PR #122/#132, the CLI docs in PR\n#131). So the committed site/ lags context/ by at least a day, and NOTHING IN CI\nCOMPARES THEM. The build job assembles into its OWN output directory, so the\ntracked copy is never checked against the source it was generated from.\n\nTWO SEPARATE PROBLEMS, and they want different answers:\n\n1. site/ IS A TRACKED BUILD ARTIFACT THAT SILENTLY LAGS. Either CI regenerates\n and compares (failing when they differ), or site/ stops being tracked, or it is\n explicitly declared a periodic snapshot with the staleness accepted. The third\n is a real option -- improvise-oujb's agent noted site/docs has been touched by\n two commits ever -- but it should be a DECISION rather than the current\n accident, because a reader cannot tell a snapshot from a stale artifact.\n\n2. RUNNING THE DOC-SITE BUILD DIRTIES THE TREE, which is a trap for any agent\n that runs it to verify the SECTIONS gate. The xgra agent reverted its 26 files;\n the next one may not notice. Note the gate itself is worth keeping: running\n build-doc-site.mjs exits 0 and prints no \"neither published nor a named\n omission\", which is a cheap POSITIVE check that a touched context/ file is\n published -- not only a trap to avoid.\n\nRELATED, ALREADY KNOWN: improvise-oujb's agent reported that site/ is partially\ntracked (27 files under site/docs/) and is NOT in .gitignore, so following the\nREADME's own instructions dirties the tree. Same root, reported twice by two\nagents on different days, which is itself the argument for deciding it.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T21:04:54Z","created_by":"Edward Langley","updated_at":"2026-09-22T21:04:54Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-1jay","title":"context/code/protocol.md:470 says no client composes CellId::Create; gcvb closed that and bd9o closed the route","description":"Reported by the improvise-bd9o agent (PR #128) and deliberately not fixed there --\nnot its file, and stale independently of that branch.\n\ncontext/code/protocol.md:470 says \"No client composes it yet\" about\nCellId::Create, and cites improvise-gcvb as an open gap. That predates gcvb\nlanding: a client DOES compose it, in commit.\n\nAnd improvise-bd9o has since changed the picture again from the other side: it\ntook away the DOOR. edit_or_drill now declines on a records grid before reaching\nthe CellId arms at all, so CellId::Create is no longer reachable from a key --\nwhich is why improvise-x0xd exists (create_column is now explicitly dead rather\nthan accidentally so).\n\nSO ONE SENTENCE IS OWED, AND IT HAS TWO CLAUSES: gcvb closed the compose gap in\ncommit, and bd9o has since closed the key route. Writing only the first would\nleave a reader believing a key reaches it.\n\nThis is the same class as the two prose passages Codex caught on PR #128 -- a\nfact stated in more than one place, where the place that moved was not the place\nsomeone was editing. Cheap to fix, and worth doing while the two commits that\nfalsified it are still fresh enough to cite.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T08:16:51Z","created_by":"Edward Langley","updated_at":"2026-09-22T08:16:51Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-x0xd","title":"The browser records-mode creating columns are explicitly dead and could be live","description":"improvise-gcvb built `create_column` — the one records-mode commit a thin client\ncan compose, since a `CellId::Create` commits the typed NAME as one existing\nModelCommand (AddItem into _Measure, or AddCategory), reads no record_col and\nwrites no cell. It has never been reachable.\n\nThe only door into RecordsEditing is `edit-or-drill`, whose `CellId::Create` arm\nreturned None; `o` is declined at its `add-record-row` step. So `commit`'s\n`CellId::Create` branch was dead on arrival — an accident of two unrelated arms\nrather than a decision.\n\nimprovise-bd9o made that deadness EXPLICIT rather than closing it: the records\nguard now sits at the entrance of `edit_or_drill`, so every records column\ndeclines, and the `CellId::Create` arm declines too (\"creating a column is not\navailable in the browser yet\") instead of returning None. That was the ticket's\nown stated acceptance criterion and the right scope for a P1 about silence.\n\nWHAT THIS TICKET IS: open the editor on `CellId::Create` while still refusing\nthe coordinate and measure columns. That means the entrance guard becomes a\nper-column decision again, which is exactly the shape bd9o argued against for\nthe silence case — so it needs its own evidence rather than a revert.\n\nTwo things to answer first, both already argued in gcvb and clients.md:\n\n - The +cat half MUST NOT ADVANCE. An inserted column renumbers every index to\n its right and the advance was computed from the pre-commit layout (gotcha\n 23), so a client that advanced anyway lands one column off, silently.\n `create_column` already does this; the test does not exist end to end.\n - AddCategory can be refused by the 12-category limit and sets abort_effects,\n so the mode change goes FIRST, where an abort cannot reach it (gotcha 21).\n Also already in `create_column`.\n\nSo the code is written and argued; what is missing is a door and the evidence\nthat the door is safe. Worth doing because it is the ONE thing a browser could\ndo in records mode and currently cannot.\n\nACCEPTANCE CRITERIA\n\n - Pressing `i`/`a` on a records `+` or `+cat` column opens the editor in the\n browser, and Enter commits through the existing `create_column`.\n - Every OTHER records column still declines out loud (the bd9o property must\n not regress) — assert it by walking all columns, as\n `the_tile_bar_route_into_records_mode_does_not_strand_the_user` does.\n - An end-to-end test through a real Host proves +cat does not advance, by\n injecting the advancing rival and watching it land one column off.\n","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T07:57:32Z","created_by":"Edward Langley","updated_at":"2026-09-22T07:57:39Z","dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"improvise-56qu","title":"site/ is a tracked build artifact that silently lags context/, and nothing compares them","description":"Found by the improvise-xgra agent (PR #133) by accident: it ran\nscripts/build-doc-site.mjs with no argument, which defaults to the TRACKED\nsite/docs, and the regeneration produced 26 MODIFIED FILES plus THREE PAGES THAT\nDO NOT EXIST IN THE TREE AT ALL:\n\n adr/004-faces-and-themes\n code/cli\n test-evidence/cli\n\nAll three are documents added TODAY (ADR 004 in PR #122/#132, the CLI docs in PR\n#131). So the committed site/ lags context/ by at least a day, and NOTHING IN CI\nCOMPARES THEM. The build job assembles into its OWN output directory, so the\ntracked copy is never checked against the source it was generated from.\n\nTWO SEPARATE PROBLEMS, and they want different answers:\n\n1. site/ IS A TRACKED BUILD ARTIFACT THAT SILENTLY LAGS. Either CI regenerates\n and compares (failing when they differ), or site/ stops being tracked, or it is\n explicitly declared a periodic snapshot with the staleness accepted. The third\n is a real option -- improvise-oujb's agent noted site/docs has been touched by\n two commits ever -- but it should be a DECISION rather than the current\n accident, because a reader cannot tell a snapshot from a stale artifact.\n\n2. RUNNING THE DOC-SITE BUILD DIRTIES THE TREE, which is a trap for any agent\n that runs it to verify the SECTIONS gate. The xgra agent reverted its 26 files;\n the next one may not notice. Note the gate itself is worth keeping: running\n build-doc-site.mjs exits 0 and prints no \"neither published nor a named\n omission\", which is a cheap POSITIVE check that a touched context/ file is\n published -- not only a trap to avoid.\n\nRELATED, ALREADY KNOWN: improvise-oujb's agent reported that site/ is partially\ntracked (27 files under site/docs/) and is NOT in .gitignore, so following the\nREADME's own instructions dirties the tree. Same root, reported twice by two\nagents on different days, which is itself the argument for deciding it.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T21:04:54Z","created_by":"Edward Langley","updated_at":"2026-09-25T04:42:08Z","closed_at":"2026-09-25T04:42:08Z","close_reason":"Resolved by PR #312 (merged 3ad7d910): site/ is no longer tracked; every reader builds it from source (improvise-1xfb.4)","dependencies":[{"issue_id":"improvise-56qu","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T01:05:44Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-1jay","title":"context/code/protocol.md:470 says no client composes CellId::Create; gcvb closed that and bd9o closed the route","description":"Reported by the improvise-bd9o agent (PR #128) and deliberately not fixed there --\nnot its file, and stale independently of that branch.\n\ncontext/code/protocol.md:470 says \"No client composes it yet\" about\nCellId::Create, and cites improvise-gcvb as an open gap. That predates gcvb\nlanding: a client DOES compose it, in commit.\n\nAnd improvise-bd9o has since changed the picture again from the other side: it\ntook away the DOOR. edit_or_drill now declines on a records grid before reaching\nthe CellId arms at all, so CellId::Create is no longer reachable from a key --\nwhich is why improvise-x0xd exists (create_column is now explicitly dead rather\nthan accidentally so).\n\nSO ONE SENTENCE IS OWED, AND IT HAS TWO CLAUSES: gcvb closed the compose gap in\ncommit, and bd9o has since closed the key route. Writing only the first would\nleave a reader believing a key reaches it.\n\nThis is the same class as the two prose passages Codex caught on PR #128 -- a\nfact stated in more than one place, where the place that moved was not the place\nsomeone was editing. Cheap to fix, and worth doing while the two commits that\nfalsified it are still fresh enough to cite.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T08:16:51Z","created_by":"Edward Langley","updated_at":"2026-09-22T08:16:51Z","dependencies":[{"issue_id":"improvise-1jay","depends_on_id":"improvise-pmt0","type":"parent-child","created_at":"2026-09-25T01:05:02Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-x0xd","title":"The browser records-mode creating columns are explicitly dead and could be live","description":"improvise-gcvb built `create_column` — the one records-mode commit a thin client\ncan compose, since a `CellId::Create` commits the typed NAME as one existing\nModelCommand (AddItem into _Measure, or AddCategory), reads no record_col and\nwrites no cell. It has never been reachable.\n\nThe only door into RecordsEditing is `edit-or-drill`, whose `CellId::Create` arm\nreturned None; `o` is declined at its `add-record-row` step. So `commit`'s\n`CellId::Create` branch was dead on arrival — an accident of two unrelated arms\nrather than a decision.\n\nimprovise-bd9o made that deadness EXPLICIT rather than closing it: the records\nguard now sits at the entrance of `edit_or_drill`, so every records column\ndeclines, and the `CellId::Create` arm declines too (\"creating a column is not\navailable in the browser yet\") instead of returning None. That was the ticket's\nown stated acceptance criterion and the right scope for a P1 about silence.\n\nWHAT THIS TICKET IS: open the editor on `CellId::Create` while still refusing\nthe coordinate and measure columns. That means the entrance guard becomes a\nper-column decision again, which is exactly the shape bd9o argued against for\nthe silence case — so it needs its own evidence rather than a revert.\n\nTwo things to answer first, both already argued in gcvb and clients.md:\n\n - The +cat half MUST NOT ADVANCE. An inserted column renumbers every index to\n its right and the advance was computed from the pre-commit layout (gotcha\n 23), so a client that advanced anyway lands one column off, silently.\n `create_column` already does this; the test does not exist end to end.\n - AddCategory can be refused by the 12-category limit and sets abort_effects,\n so the mode change goes FIRST, where an abort cannot reach it (gotcha 21).\n Also already in `create_column`.\n\nSo the code is written and argued; what is missing is a door and the evidence\nthat the door is safe. Worth doing because it is the ONE thing a browser could\ndo in records mode and currently cannot.\n\nACCEPTANCE CRITERIA\n\n - Pressing `i`/`a` on a records `+` or `+cat` column opens the editor in the\n browser, and Enter commits through the existing `create_column`.\n - Every OTHER records column still declines out loud (the bd9o property must\n not regress) — assert it by walking all columns, as\n `the_tile_bar_route_into_records_mode_does_not_strand_the_user` does.\n - An end-to-end test through a real Host proves +cat does not advance, by\n injecting the advancing rival and watching it land one column off.\n","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T07:57:32Z","created_by":"Edward Langley","updated_at":"2026-09-22T07:57:39Z","dependencies":[{"issue_id":"improvise-x0xd","depends_on_id":"improvise-6jk","type":"parent-child","created_at":"2026-09-25T01:04:47Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"improvise-vgp4","title":"A refusal written while a minibuffer is open may never be drawn: draw_bottom_bar paints one or the other","description":"Found by the improvise-55l5 agent (PR #126), which wrote the message and stopped\nat the point where drawing it is a different question.\n\nimprovise-55l5 made a bound key that cannot construct its command SAY SO, via\nset_status. The class that reaches a user is \"no cell at cursor\" from six\nregistrations that read ctx.cell_key(), and the worst case is commit-cell-edit:\nthe user is in Editing, has typed a value, presses Enter, and the batch is\ndiscarded.\n\nTHE MESSAGE IS NOW WRITTEN AND MAY NOT BE DRAWN. gotcha 21 records that\ndraw_bottom_bar renders the MINIBUFFER INSTEAD OF the status line, which is\nexactly the rendering trap that made an aborted batch appear to do nothing at\nall. A refusal from commit-cell-edit leaves the user in Editing -- a minibuffer\nmode -- so the status it just wrote is plausibly invisible.\n\nTHIS IS THE SAME SHAPE AS improvise-j6x8 one layer over: a refusal that is\ncorrect, tested at the effect, and discarded by the surface that was supposed to\nshow it. Compare gotcha 21's own remedy -- tidy-up goes at the HEAD of a batch\nwhere an abort cannot reach it -- and note that remedy is about ORDERING within a\nbatch, not about which of two things draw_bottom_bar draws.\n\nTO ANSWER: does draw_bottom_bar draw a status while a minibuffer is open, and if\nnot, what should it do when the status is a REFUSAL of the very thing the\nminibuffer is for. A refusal the user cannot see is not a refusal\n(doctrine(a_refusal_the_headless_path_discards_is_not_a_refusal), the TUI twin).\n\nReproduce before fixing: the agent did not chase this and it may already hold --\ngotcha 21's own fix put mode restoration at the head of several batches.","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-tui","owner":"el-github@elangley.org","created_at":"2026-09-22T07:40:56Z","created_by":"Edward Langley","updated_at":"2026-09-23T02:00:31Z","started_at":"2026-09-23T01:34:40Z","closed_at":"2026-09-23T02:00:31Z","close_reason":"STALE-OPEN, nothing to fix. Fixed by 94a3d17 'fix: the minibuffer row carries a pending status message (improvise-mai9)', which landed 2026-09-20 -- TWO DAYS BEFORE vgp4 was filed. Verified with git merge-base --is-ancestor. Driven as the real user path (empty Region on Row, then i/4/2/Enter, giving Editing plus 'commit-cell-edit: no cell at cursor'), draw_bottom_bar renders the refusal at 30/40/50/60/80 columns; minibuffer_line composes BOTH segments and does not paint one or the other. WHY IT READ AS LIVE, and this is the transferable part: gotcha 21 still describes the PRE-mai9 behaviour in the past tense, because that is the stranding argument it is making. The trap is a correct historical narrative read as a current claim, not a stale gotcha. (Closed with --force: agent-tui handed back and stopped, so the claim is abandoned.)","labels":["tui"],"comments":[{"id":"f79e6ebc-7f67-5135-8f01-9c241e06704e","issue_id":"improvise-vgp4","author":"agent-tui","text":"DOES NOT REPRODUCE at a9cad15 (and not at any commit since 2026-09-20). The\nticket said it might already hold; it does, and the fixing commit is 94a3d17\n\"fix: the minibuffer row carries a pending status message (improvise-mai9)\",\nconfirmed an ancestor of a9cad15 by `git merge-base --is-ancestor` (exit 0).\nmai9 landed TWO DAYS BEFORE this ticket was filed.\n\n`draw_bottom_bar` does NOT paint \"one or the other\". It calls `minibuffer_line`,\nwhich composes BOTH: prompt + buffer + cursor on the left, the pending\n`status_msg` right-aligned. The premise in the ticket title is what gotcha 21\nrecords about the code BEFORE mai9, and gotcha 21 still describes it in the past\ntense for the stranding argument it is making -- which is how this read as live.\n\nDriven as the real user path rather than by inspection, on the worst case the\nticket names (commit-cell-edit, the user in Editing with a typed value):\n\n App with an empty `Region` on Row, so `layout.cell_key(0,0)` is None\n i, '4', '2', Enter\n -\u003e mode stays Editing { minibuf: \"edit\" }, status_msg\n \"commit-cell-edit: no cell at cursor\"\n\nand `draw_bottom_bar` rendered into a TestBackend at five widths:\n\n w=30 \"edit: 42▌ commit-cell-edit: …\"\n w=40 \"edit: 42▌ commit-cell-edit: no cell at…\"\n w=50 \"edit: 42▌ commit-cell-edit: no cell at cursor \"\n w=60 \"edit: 42▌ commit-cell-edit: no cell at cursor \"\n w=80 \"edit: 42▌ commit-cell-edit: no cell at cursor \"\n\nSo the refusal reaches the screen at every width. The ellipsis at 30 and 40 is\nimprovise-t14d's truncation (PR #145) landing in the same row; before it the\nnarrow cases were a hard clip at the same point, so the visible content is\nunchanged and only the mid-token cut is gone.\n\nThe ticket's second question -- \"what should it do when the status is a REFUSAL\nof the very thing the minibuffer is for\" -- is answered by the composition\nrather than by a special case: the refusal is never traded against the prompt,\nonly against the padding between them. At widths where even that runs out, the\nLEFT wins (improvise-t14d's `Keeps::Left` at this call site, which is what this\nrow already did), so a narrow terminal truncates the message rather than the\ntext the user is editing. That is a deliberate choice and is argued in\ncontext/code/tui.md; if it is the wrong one for a refusal specifically, that is\na new ticket about severity, not this one -- and improvise-udt2 is already open\non \"a status message carries no severity\".\n\nReported by Claude (Opus 5), an agent in Edward Langley's session. Closing is\nthe orchestrator's call; I have closed nothing.","created_at":"2026-09-23T01:36:34Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} -{"_type":"issue","id":"improvise-b8ng","title":"view_frame cannot tell an appended row from a renumbered one, so every append resets the cursor","description":"Found by the improvise-o9uq agent (PR #126) while fixing the cursor placement.\nFiled rather than fixed: it is the SECOND cause of o9uq and is not the one that\nmade o destructive.\n\nview_frame hashes entries.len() AND every entry, so it cannot distinguish \"rows\nwere appended\" from \"rows were renumbered\". Gotcha 24 invalidates the cursor by\nOBSERVING the frame, which is deliberately total over EffectKind::Model(_) and is\nthe reason there is no hand-kept table of geometry effects -- so every append\nthrows the cursor to the origin, although nothing above the new row moved.\n\nWHY IT IS NOT URGENT NOW: improvise-o9uq closed the destructive path by having\nadd-record-row place its own cursor AFTER the SetCell, so the invalidation is\nimmediately overwritten by a correct placement. The remaining cost is any OTHER\nappend-shaped change whose command does not place a cursor.\n\nWHY IT IS DELICATE, and the reason this is a ticket rather than a patch: the\nobservation's totality is the property gotcha 24 was rewritten to get. A frame\nhash that can tell append from renumber is a SMARTER observation, and a smarter\nobservation is one more thing that can be subtly wrong in a direction nothing\ntests -- gotcha 24 already records that doing only half of the invalidation is\nSTRICTLY WORSE THAN DOING NOTHING, because the diff then believes every client\nholds origin-positioned content. Do not optimise this without a test that\ndistinguishes the two cases by construction.\n\nNote the adjacent gap already filed: improvise-9c9o (a geometry effect\nimmediately after a mid-batch SwitchView skips the check entirely).","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T07:40:55Z","created_by":"Edward Langley","updated_at":"2026-09-22T07:40:55Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-n8vh","title":"The category and view panels never truncate, so a long name overwrites the panel border","description":"The category and view panels never truncate a row, so a name longer than the\npanel overwrites its right border.\n\nFound while fixing improvise-8l0c, which was the *wrong* truncation in\n`src/ui/formula_panel.rs`. These two are a different bug: no truncation at all.\n\n`src/ui/category_panel.rs::render_item` writes `format!(\"{indicator} {name} ({item_count})\")`\nstraight through `Buffer::set_string`, and `src/ui/view_panel.rs::render_item`\ndoes the same with `format!(\"{prefix}{}\", summary.name)`. `set_string` clips at\nthe *buffer* edge, not at the panel's `inner` rect, so a long category or view\nname draws over the block's right border character and, in a narrow terminal,\nruns to the screen edge. No panic.\n\n8l0c left the tool in place: `src/ui/text.rs::truncate` is the shared\ndisplay-width-safe truncator and is what these two want, with the axis tag /\naxis summary suppressed rather than pushed off. Both files already measure the\ntag's offset in cells since 8l0c, so the arithmetic is there.\n\nEvidence to write: `context/test-evidence/tui.md` already carries the\nobservation under the 8l0c section, keyed by\n`ui::category_panel::tests::the_axis_tag_sits_beside_a_multibyte_category_name`.\nA regression test should assert the border column still holds the border glyph.\n\nFiled by Claude (Opus 5), an agent in Edward Langley's session.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T06:28:47Z","created_by":"Edward Langley","updated_at":"2026-09-22T06:28:47Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-b8ng","title":"view_frame cannot tell an appended row from a renumbered one, so every append resets the cursor","description":"Found by the improvise-o9uq agent (PR #126) while fixing the cursor placement.\nFiled rather than fixed: it is the SECOND cause of o9uq and is not the one that\nmade o destructive.\n\nview_frame hashes entries.len() AND every entry, so it cannot distinguish \"rows\nwere appended\" from \"rows were renumbered\". Gotcha 24 invalidates the cursor by\nOBSERVING the frame, which is deliberately total over EffectKind::Model(_) and is\nthe reason there is no hand-kept table of geometry effects -- so every append\nthrows the cursor to the origin, although nothing above the new row moved.\n\nWHY IT IS NOT URGENT NOW: improvise-o9uq closed the destructive path by having\nadd-record-row place its own cursor AFTER the SetCell, so the invalidation is\nimmediately overwritten by a correct placement. The remaining cost is any OTHER\nappend-shaped change whose command does not place a cursor.\n\nWHY IT IS DELICATE, and the reason this is a ticket rather than a patch: the\nobservation's totality is the property gotcha 24 was rewritten to get. A frame\nhash that can tell append from renumber is a SMARTER observation, and a smarter\nobservation is one more thing that can be subtly wrong in a direction nothing\ntests -- gotcha 24 already records that doing only half of the invalidation is\nSTRICTLY WORSE THAN DOING NOTHING, because the diff then believes every client\nholds origin-positioned content. Do not optimise this without a test that\ndistinguishes the two cases by construction.\n\nNote the adjacent gap already filed: improvise-9c9o (a geometry effect\nimmediately after a mid-batch SwitchView skips the check entirely).","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T07:40:55Z","created_by":"Edward Langley","updated_at":"2026-09-22T07:40:55Z","dependencies":[{"issue_id":"improvise-b8ng","depends_on_id":"improvise-pmt0","type":"parent-child","created_at":"2026-09-25T01:05:07Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-n8vh","title":"The category and view panels never truncate, so a long name overwrites the panel border","description":"The category and view panels never truncate a row, so a name longer than the\npanel overwrites its right border.\n\nFound while fixing improvise-8l0c, which was the *wrong* truncation in\n`src/ui/formula_panel.rs`. These two are a different bug: no truncation at all.\n\n`src/ui/category_panel.rs::render_item` writes `format!(\"{indicator} {name} ({item_count})\")`\nstraight through `Buffer::set_string`, and `src/ui/view_panel.rs::render_item`\ndoes the same with `format!(\"{prefix}{}\", summary.name)`. `set_string` clips at\nthe *buffer* edge, not at the panel's `inner` rect, so a long category or view\nname draws over the block's right border character and, in a narrow terminal,\nruns to the screen edge. No panic.\n\n8l0c left the tool in place: `src/ui/text.rs::truncate` is the shared\ndisplay-width-safe truncator and is what these two want, with the axis tag /\naxis summary suppressed rather than pushed off. Both files already measure the\ntag's offset in cells since 8l0c, so the arithmetic is there.\n\nEvidence to write: `context/test-evidence/tui.md` already carries the\nobservation under the 8l0c section, keyed by\n`ui::category_panel::tests::the_axis_tag_sits_beside_a_multibyte_category_name`.\nA regression test should assert the border column still holds the border glyph.\n\nFiled by Claude (Opus 5), an agent in Edward Langley's session.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T06:28:47Z","created_by":"Edward Langley","updated_at":"2026-09-22T06:28:47Z","dependencies":[{"issue_id":"improvise-n8vh","depends_on_id":"improvise-gtn7","type":"parent-child","created_at":"2026-09-25T01:05:31Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-dw33","title":"Truncate the build stamp to 8 characters -- but only when it is a SHA, never a tag","description":"USER 2026-09-22: \"the build number in the header should be truncated to like 8\ndigits, `ee0f57d227e8d9601a7353b23704de78df9a75a8` is way too much text\"\n\nWHERE IT IS: web/index.html:43 carries\n`\u003cspan title=\"Build this page was assembled from\" id=\"build-version\"\u003eunstamped\u003c/span\u003e`,\nand web/assemble.sh:88 substitutes the token into it, gated by\nscripts/assemble-stamp.sh against the pattern `id=\"build-version\"\u003e${token}\u003c`.\n\n8 IS ALSO THE REPO'S OWN CONVENTION: scripts/pr-reviews prints `.commit.oid[0:8]`,\nand the orchestrator's prose uses 8 throughout. So this is consistency, not taste.\n\nTHE TRAP, AND IT IS THE WHOLE TICKET: THE TOKEN IS NOT ALWAYS A SHA. Pages passes\n$GITHUB_SHA; a RELEASE passes the tag (improvise-oujb). Truncating blindly turns\n`v0.1.0-rc2` into `v0.1.0-r`, which is not a shorter version -- it is a WRONG one,\nand it is wrong in the one build a user is most likely to quote in a bug report.\n\nSo truncate ONLY when the token is a 40-hex SHA. That test already exists in this\nrepo: scripts/build-doc-site.mjs:21 is\n`const ref = /^[0-9a-f]{40}$/.test(token) ? token : \"main\";`\n-- SHARE the rule rather than writing a third copy of it, or at minimum point at\nthat line so the two cannot drift. Two answers to \"is this token a SHA\" is the\ndisease design-principles section 5 names.\n\nKEEP THE FULL VALUE REACHABLE. The 40-char SHA renders today only because CSS\n`text-overflow` clamps it, and the #121 agent measured that selecting the text\nstill copies the WHOLE SHA. Truncating the text loses that unless the full value\ngoes somewhere -- the `title` attribute already exists and is the obvious home,\nor a `data-` attribute. A build id you cannot copy in full is a build id that\ncannot be pasted into an issue.\n\nTHE GATE MUST MATCH WHAT IS DISPLAYED. assemble.sh:88 passes `${token}` as the\nexpected pattern; if the displayed text is truncated, that argument becomes the\ntruncated form. Getting it wrong REFUSES THE ASSEMBLY rather than shipping an\nunstamped page, so it fails safe -- but it fails loudly during a release, so get\nit right rather than relying on that. scripts/assemble-stamp.sh verifies each of\nits five pairs independently (improvise-x4wv), so a wrong pattern names itself.\n\nTEST IT AT BOTH SHAPES. scripts/assemble-stamp-test.sh is the check_the_checker\nand already runs 9 checks; a tag token and a SHA token are two cases and only one\nof them is the interesting one. Inject a missed substitution at each shape.\n\nSEQUENCING: branch ui/page-menu is in flight. It should not touch\nweb/index.html:43 or web/assemble.sh, but confirm before starting.","notes":"CLOSED BY USER RULING 2026-09-22, as already answered by PR #121.\n\nThe user proposed the CSS form -- \"We could also just do a max-width there, plus\nellipsis text-truncation in css\" -- and on being shown that it is already live,\nruled: leave it.\n\nWHAT IS ALREADY ON MAIN (web/style.css:92, landed with improvise-oujb / PR #121):\n #build-version { flex: none; max-width: 14ch; overflow: hidden;\n text-overflow: ellipsis; white-space: nowrap; ... }\nwith a comment stating the property that makes it the right choice: A TAG FITS\nWHOLE, and a 40-char SHA truncates to a prefix long enough to name a build while\nselecting the text still copies the whole thing. Measured by the #121 agent in\nChromium: tag renders whole (scrollWidth 69 = clientWidth 69), SHA renders 13\nchars + ellipsis, header does not overflow at either.\n\nWHY THIS IS THE BETTER ANSWER AND NOT JUST THE CHEAPER ONE: clamping by WIDTH\ncannot mangle a tag, where truncating by CHARACTER COUNT turns v0.1.0-rc2 into\nv0.1.0-r -- a wrong version rather than a short one, in exactly the build a user\nwould quote. The CSS form sidesteps the whole SHA-vs-tag test this ticket was\nfiled to handle.\n\nTHE COST, ACCEPTED AND RECORDED so it is not rediscovered as a defect: the shown\nprefix is VIEWPORT-DEPENDENT, so two people may quote different prefixes of one\nSHA, and neither matches the 8-character form scripts/pr-reviews and the rest of\nthis repo's tooling uses. If that ever bites -- a bug report quoting a prefix\nnobody can match -- the fix is build-side truncation gated on\n/^[0-9a-f]{40}$/ (the test already exists at scripts/build-doc-site.mjs:21),\nwith the gate pattern in web/assemble.sh:88 changed to match the displayed form.\nReopen rather than re-derive.\n\nNOTE the user was most likely looking at a Pages build predating the #121 merge;\nthe deploy for it went out with the push of ee0f57d.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T06:15:12Z","created_by":"Edward Langley","updated_at":"2026-09-22T06:16:24Z","closed_at":"2026-09-22T06:16:24Z","close_reason":"Closed","labels":["browser"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-l2b8","title":"ADR 005: exact decimal arithmetic for formulas, on Scheme numeric-tower promotion rules","description":"USER 2026-09-22: \"Formulas should default to decimal math (ideally following\nScheme's numeric tower rules for promotion).\"\n\nSEPARATE FROM THE SYMPTOM THAT PROMPTED IT. The \"useless numbers\" the user saw\nare a FORMATTING defect (improvise-e0ex): number_format defaults to \",.0\", zero\ndecimal places, so a 0.66 ratio draws as \"1\". Exact arithmetic would not change\nthat by one character. This ticket is the numerics change on its own merits, and\nthose merits are real -- which is why it is filed rather than folded in.\n\nWHY IT IS WORTH DOING INDEPENDENTLY:\n - A spreadsheet that answers 0.1 + 0.2 with 0.30000000000000004 is wrong in the\n way users notice and never forgive. CellValue::Number(f64) does.\n - gotcha 18 records a REAL BUG this would have prevented: f64 addition is not\n associative, so SUM/AVG drifted in the last bits when DataStore iteration\n order changed. Min/Max/Count were unaffected because they are order-free.\n EXACT RATIONALS ARE ASSOCIATIVE -- the whole class disappears rather than\n being fixed by pinning iteration order.\n - gotcha 17's FLOAT_EQ_EPSILON (1e-10, shared by =, != and the div/0 guard in\n both evaluators) exists BECAUSE of binary floats. Exact arithmetic makes\n equality exact and the epsilon unnecessary -- six uses, one constant, gone.\n\nWHAT THE TOWER MEANS HERE. Scheme's rule: exact integer -\u003e exact rational -\u003e\ninexact real, with operations preserving exactness where they can, and `/` on two\nexacts yielding an exact rational rather than a float. So Profit / Revenue is an\nexact ratio, rounded only at display. Decide explicitly what makes a value\nINEXACT and whether improvise has any such operation today (POW with a\nnon-integer exponent is the obvious candidate; there are no transcendentals).\n\nSEVEN THINGS TO ANSWER BEFORE WRITING CODE:\n 1. WHAT IS THE STORED TYPE? CellValue::Number(f64) is in improvise-core and\n reaches everything. A new arm, a replaced arm, or a wrapper -- each has a\n different blast radius, and CellValue is Serialize/Deserialize and crosses\n the wire.\n 2. DENOMINATOR GROWTH. Summing thousands of exact rationals with unrelated\n denominators is how exact arithmetic becomes slow and memory-hungry. Say\n what bounds it. Measure at the 18,000-cell and 90,000-cell sizes gotcha 24\n already uses, so the numbers are comparable.\n 3. THE WASM BUNDLE. improvise-core is a browser-bundle crate. A bignum\n dependency is bundle weight on every page load; check with `cargo tree\n --edges normal` per crate (the default output includes DEV edges and reads\n like a leak -- see the Wasm section of repo-map).\n 4. .improv ROUND-TRIP. The format stores numbers as TEXT, so exact decimals\n round-trip BETTER than f64 does today. That is a point in favour, but the\n grammar and the round-trip tests must be re-read: \"1/3\" and \"0.333...\" are\n not the same value and the format has to say which it holds.\n 5. gp-tree IS NOT IN SCOPE. It fits over f32 feature matrices and its\n tests/scoring_known_answers.rs pins f32 BIT PATTERNS plus an RNG-call-order\n witness (gotcha 37c). It must keep binary floats; the seam is\n improvise-gp::dataset, and what crosses it needs stating.\n 6. WHAT DOES A THIN CLIENT SEE? CellDisplay carries text today, so a browser\n may be unaffected -- confirm rather than assume, because if a number crosses\n as a number, its type is a protocol change.\n 7. MIGRATION. Every existing .improv must load unchanged. Exactness is a\n property the current format does not record, so decide what an existing\n decimal literal BECOMES on load, and whether that is a behaviour change to\n existing documents.\n\nTHIS IS AN ADR, NOT A TICKET, and should be one before any code -- the type\nchoice reaches improvise-core, the wire, the grammar and the browser bundle at\nonce. Note ADR 004 is being drafted for the face system, so this is 005.\n\nDO NOT START BY REPLACING f64. Start by writing down what exactness means in a\npivot table, where most cells are user-entered decimals and most operations are\nSUM.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T06:11:47Z","created_by":"Edward Langley","updated_at":"2026-09-22T06:11:47Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-l2b8","title":"ADR 005: exact decimal arithmetic for formulas, on Scheme numeric-tower promotion rules","description":"USER 2026-09-22: \"Formulas should default to decimal math (ideally following\nScheme's numeric tower rules for promotion).\"\n\nSEPARATE FROM THE SYMPTOM THAT PROMPTED IT. The \"useless numbers\" the user saw\nare a FORMATTING defect (improvise-e0ex): number_format defaults to \",.0\", zero\ndecimal places, so a 0.66 ratio draws as \"1\". Exact arithmetic would not change\nthat by one character. This ticket is the numerics change on its own merits, and\nthose merits are real -- which is why it is filed rather than folded in.\n\nWHY IT IS WORTH DOING INDEPENDENTLY:\n - A spreadsheet that answers 0.1 + 0.2 with 0.30000000000000004 is wrong in the\n way users notice and never forgive. CellValue::Number(f64) does.\n - gotcha 18 records a REAL BUG this would have prevented: f64 addition is not\n associative, so SUM/AVG drifted in the last bits when DataStore iteration\n order changed. Min/Max/Count were unaffected because they are order-free.\n EXACT RATIONALS ARE ASSOCIATIVE -- the whole class disappears rather than\n being fixed by pinning iteration order.\n - gotcha 17's FLOAT_EQ_EPSILON (1e-10, shared by =, != and the div/0 guard in\n both evaluators) exists BECAUSE of binary floats. Exact arithmetic makes\n equality exact and the epsilon unnecessary -- six uses, one constant, gone.\n\nWHAT THE TOWER MEANS HERE. Scheme's rule: exact integer -\u003e exact rational -\u003e\ninexact real, with operations preserving exactness where they can, and `/` on two\nexacts yielding an exact rational rather than a float. So Profit / Revenue is an\nexact ratio, rounded only at display. Decide explicitly what makes a value\nINEXACT and whether improvise has any such operation today (POW with a\nnon-integer exponent is the obvious candidate; there are no transcendentals).\n\nSEVEN THINGS TO ANSWER BEFORE WRITING CODE:\n 1. WHAT IS THE STORED TYPE? CellValue::Number(f64) is in improvise-core and\n reaches everything. A new arm, a replaced arm, or a wrapper -- each has a\n different blast radius, and CellValue is Serialize/Deserialize and crosses\n the wire.\n 2. DENOMINATOR GROWTH. Summing thousands of exact rationals with unrelated\n denominators is how exact arithmetic becomes slow and memory-hungry. Say\n what bounds it. Measure at the 18,000-cell and 90,000-cell sizes gotcha 24\n already uses, so the numbers are comparable.\n 3. THE WASM BUNDLE. improvise-core is a browser-bundle crate. A bignum\n dependency is bundle weight on every page load; check with `cargo tree\n --edges normal` per crate (the default output includes DEV edges and reads\n like a leak -- see the Wasm section of repo-map).\n 4. .improv ROUND-TRIP. The format stores numbers as TEXT, so exact decimals\n round-trip BETTER than f64 does today. That is a point in favour, but the\n grammar and the round-trip tests must be re-read: \"1/3\" and \"0.333...\" are\n not the same value and the format has to say which it holds.\n 5. gp-tree IS NOT IN SCOPE. It fits over f32 feature matrices and its\n tests/scoring_known_answers.rs pins f32 BIT PATTERNS plus an RNG-call-order\n witness (gotcha 37c). It must keep binary floats; the seam is\n improvise-gp::dataset, and what crosses it needs stating.\n 6. WHAT DOES A THIN CLIENT SEE? CellDisplay carries text today, so a browser\n may be unaffected -- confirm rather than assume, because if a number crosses\n as a number, its type is a protocol change.\n 7. MIGRATION. Every existing .improv must load unchanged. Exactness is a\n property the current format does not record, so decide what an existing\n decimal literal BECOMES on load, and whether that is a behaviour change to\n existing documents.\n\nTHIS IS AN ADR, NOT A TICKET, and should be one before any code -- the type\nchoice reaches improvise-core, the wire, the grammar and the browser bundle at\nonce. Note ADR 004 is being drafted for the face system, so this is 005.\n\nDO NOT START BY REPLACING f64. Start by writing down what exactness means in a\npivot table, where most cells are user-entered decimals and most operations are\nSUM.","status":"in_progress","priority":3,"issue_type":"task","assignee":"agent-adr005","owner":"el-github@elangley.org","created_at":"2026-09-22T06:11:47Z","created_by":"Edward Langley","updated_at":"2026-09-25T04:45:31Z","started_at":"2026-09-25T04:45:31Z","lease_expires_at":"2026-09-25T04:50:31Z","heartbeat_at":"2026-09-25T04:45:31Z","dependencies":[{"issue_id":"improvise-l2b8","depends_on_id":"improvise-felo","type":"parent-child","created_at":"2026-09-25T01:05:18Z","created_by":"Edward Langley","metadata":"{}"}],"comments":[{"id":"da77dcfd-15a1-5256-9e46-bb12f61582d8","issue_id":"improvise-l2b8","author":"Edward Langley","text":"Evidence from the ADR 009 agent (PR #309, examples/retraction_drift.rs, M4 Max): TODAY's eager f64 SUM over 18k cells is up to 5.8e-10 from the exact decimal sum, above FLOAT_EQ_EPSILON (1e-10) at 7 of 8 checkpoints, so IF(SUM(..) = typed value) can already be wrong. And incrementally maintained SUM/AVG (ADR 009) cannot be made bit-equal to eager in f64 at all, so ADR 009 gates maintained Sum/Avg on this ADR.","created_at":"2026-09-25T03:19:21Z"},{"id":"68db5de9-d990-5a6a-8e9d-d49017a3bc8e","issue_id":"improvise-l2b8","author":"Edward Langley","text":"USER 2026-09-25 (asked 'whether division degrading into floats for 10/3 is ideal'), verbatim: 'I think (d): exact fractions with a size limit. should be added.' -\u003e ADR 005 question 2 gains option (d): bounded exact rationals (decimals are the power-of-10-denominator subset), demoting to f64 only on overflow; to be measured.","created_at":"2026-09-25T06:11:02Z"},{"id":"bd0edc40-db14-5aa1-9ffe-8d013e732958","issue_id":"improvise-l2b8","author":"Edward Langley","text":"USER 2026-09-25, verbatim: 'I think ADR 005 should implement the math operations in a separate crate'. The numeric tower (Num, arithmetic, contagion, overflow detection, conversion, exact comparison, the exact-sum accumulator, display rounding, exact parsing) becomes its own problem-agnostic workspace crate that improvise-core depends on.","created_at":"2026-09-25T06:39:37Z"}],"dependency_count":0,"dependent_count":0,"comment_count":3} {"_type":"issue","id":"improvise-wiew","title":"[ and ] should probably be in the global keymap: decide whether 'global' means every mode or every grid mode","description":"USER 2026-09-22: \"Also [/] probably should be in the global keymap.\"\n\nNote the \"probably\" -- judgement is invited, and there is a real question below.\n\nWHERE THEY ARE NOW, verified: keymap.rs:615-616 binds ']' -\u003e page-next and\n'[' -\u003e page-prev on NORMAL. global_keymap() (:392) currently holds '?', ':' and\nthe panel toggles.\n\nMECHANICALLY THIS IS SAFE AND CHEAP. Both are unmodified Char keys bound as\nBinding::Cmd, so they satisfy the_global_binds_only_unmodified_char_keys, the\nguard PR #112 added. And TEXT ENTRY IS NOT AFFECTED: the lookup order is\nexact(key,mods) -\u003e Char(NONE) -\u003e AnyChar -\u003e Any -\u003e parent, so a text-entry mode's\nAnyChar shadows the global BEFORE the fallthrough is reached. Typing '[' into a\ncategory name still types '['. That shadowing is already covered by #112's tests.\n\nNO EXACT CONFLICT EXISTS. The Help overlay pages with Right / l / n, not with\nbrackets, so nothing is shadowed by accident.\n\nTHE ACTUAL QUESTION, WHICH THE TICKET MUST ANSWER RATHER THAN ASSUME: is the\nglobal \"every mode\", or \"every mode whose subject is the grid\"? Falling through\nmeans [ and ] would change the DOCUMENT'S PAGE AXIS while the user is reading the\nHELP OVERLAY -- a real mutation, invisible because the overlay is covering the\ngrid it changed. The same question applies to TileSelect and the three panels,\nthough those are at least about the document.\n\nThree defensible answers:\n a. Global, unshadowed -- paging is always available, and the help overlay\n changing the page underneath is harmless because it is visible on exit.\n b. Global, shadowed to a no-op in Help -- costs a wart, but says plainly that\n an overlay is not a place to mutate the document.\n c. Not global; add it to the specific maps that want it (RecordsNormal already\n inherits Normal, so the real gain is the panels and TileSelect).\n\nPick one, say why in the PR body, and expect the user to have an opinion --\nthis is the kind of thing that is cheap now and annoying to reverse once muscle\nmemory forms.\n\nWHATEVER IS CHOSEN, the browser must follow: improvise-protocol/src/keymap.rs is\nthe shared table both front ends look up, so this is one change, not two -- but\ncheck resolve.rs still composes page-next/page-prev from whatever modes newly\nreach them, and that lockstep still agrees. improvise-p1ym closed the projection\ngap that used to make [ and ] inert in a browser, so they do work there now.","notes":"IMPLEMENTED in PR #123 (merge 7470b61), to the user's ruling of an INTERMEDIATE\nkeymap rather than any of the three options this ticket listed.\n\ndocument_keymap sits between global and Normal / the three panels / TileSelect.\nRecordsNormal reaches it THROUGH Normal. Help, ImportWizard and the text-entry\nmodes take global directly.\n\nTHE ARGUMENT IS NEW RATHER THAN THE GLOBAL'S AGAIN, and it is better than the\none I wrote on this ticket. I framed the question as \"is the document the subject\nright now\", which is a judgement. The agent found a STRUCTURAL line: every key\nthe global binds is a UI action, whereas page-next resolves to SetPageSelection,\nwhich is EffectKind::Model(LayoutGeometry) -- THE FIRST SHARED KEYS THAT MUTATE.\nUnder the help overlay that is an invisible change to the grid it is covering.\nSo the split is not taste; it is \"may a fallthrough key mutate the document\".\n\nThe three asks were met: the name is the guard (document_keymap, not\ncommon/shared, because nothing in the code stops a mode parenting it);\nRecordsNormal inherits ONCE, asserted on CHAIN IDENTITY (exactly four deep)\nrather than behaviour; and the layer gets its OWN Char-only guard, owed for the\nidentical fallthrough reason as the global's.\n\nTHE TEST-SHAPE LESSON: parenting RecordsNormal to the layer directly is a fault\nthe BEHAVIOURAL test does not catch -- it passes, because the key still works.\nOnly records_normal_inherits_the_document_layer_once_through_normal, which\nasserts the chain, sees it. A duplicated inheritance edge is invisible to any\ntest that only presses the key.\n\nprefix_keymaps_are_flat is about the sub-keymaps a PREFIX opens and is unaffected\nby depth. resolve.rs dispatches on command NAME, so the browser followed with no\nsecond table and no resolve.rs change was needed. lockstep.rs unchanged.\nIt did not grow: one binding moved, one layer added.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T06:07:52Z","created_by":"Edward Langley","updated_at":"2026-09-22T06:41:47Z","closed_at":"2026-09-22T06:41:47Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-771e","title":"The browser tile bar says 'Pag': short_label is a TUI column-width device, not a browser one","description":"Spotted by the orchestrator in PR #120's screenshots: the browser tile bar renders\n\"_Measure Col\", \"Product Row\", \"Quarter Pag\".\n\n\"Pag\" is not a CSS clip and not a bug in #120. Axis::short_label() (improvise-core\n/src/view/axis.rs) returns THREE CHARACTERS on purpose -- repo-map gotcha 26 says\nit is \"three columns for the tile bar\", so the TUI's tile bar aligns in a\nfixed-width terminal. Note `None` is \".\" there and is deliberately NOT a\ntruncation.\n\nIn a browser there is no column-alignment constraint, so the abbreviation buys\nnothing and reads to a user as a rendering defect. Axis::plain_label() already\nreturns \"Row\"/\"Col\"/\"Page\"/\"None\".\n\nTHE FIX IS NOT TO CHANGE short_label(). It is shared with the TUI, where the\nthree-column width is load-bearing. Pick the other EXISTING method on the browser\nside. That is not a second derivation -- both are already on Axis and the decision\nstays in the shared layer; only the choice of which vocabulary a front end uses is\nlocal, which is exactly the shape design-principles section 5 blesses for\nAxisColor -\u003e ratatui Color.\n\nCHECK BEFORE CHANGING: tests/grid_frame_agreement.rs asserts the two front ends\ndraw the same grid FRAME. Confirm it does not also pin the tile bar's label\nvocabulary, or this will fail for a reason that is not a bug.","notes":"FIXED in PR #123 (merge 7470b61). The browser tile bar asks plain_label();\nshort_label() now has exactly one caller, src/ui/tile_bar.rs.\n\nTHE FIND THAT MATTERS MORE THAN THE FIX, and it corrects this ticket's own\nguidance. I told the agent to check tests/grid_frame_agreement.rs before\nchanging the label, on the theory that it might pin the tile bar's vocabulary.\nIT DOES NOT -- it does not mention the tile bar at all.\n\nBut harness/tests/drill.spec.js DID: it asserted the tile text exactly, twice,\nwith \"Quarter Pag\" AS THE EXPECTED VALUE, plus \"Quarter ·\" after a cycle. A\nPlaywright spec in a directory this ticket never names, asserting the bug as its\nexpectation. Verified fixed on the merged branch: it now expects \"Quarter Page\".\n\nSO THE RULE IS: A RENDERED STRING IS PINNED WHEREVER ANYTHING READS THE SCREEN.\nThe search is for the STRING, not for the function that produces it -- I pointed\nat the function and named the wrong file. A now-false comment in\ncontract.spec.js naming short_label was fixed in the same PR.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T06:07:11Z","created_by":"Edward Langley","updated_at":"2026-09-22T06:41:46Z","closed_at":"2026-09-22T06:41:46Z","close_reason":"Closed","labels":["browser"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-ycxi","title":"The release preflight's dist plan arm is unchecked by its check_the_checker","description":"Proposed by the improvise-gx5d agent (PR #119) and DECLINED IN THAT PR on stated\ngrounds, which is why it is a bead rather than a fix.\n\nscripts/release-preflight.sh runs `dist plan --tag=\u003ctag\u003e` as a SECOND OPINION after\nits own textual tag-vs-version check, and matches on the refusal text. That arm can\nbe rewritten so it NEVER MATCHES and scripts/release-preflight-test.sh stays green,\nbecause every input the test feeds the script has ALREADY been refused by the\ntextual check -- so control never reaches the dist arm under test.\n\nWHY IT WAS DECLINED RATHER THAN FIXED, and the reasoning is sound: the arm is\nOPTIONAL BY DESIGN. An absent dist must not read as a pass, and a locally-installed\ndist whose version differs from dist-workspace.toml's cargo-dist-version stops\nbefore planning, which the script deliberately tolerates. Pinning the arm in a test\nwould mean installing a dist matching cargo-dist-version -- the exact mismatch the\nscript exists to tolerate.\n\nSO THE VALUE HERE IS NOT COVERAGE, IT IS THAT THE SILENCE IS RECORDED. It is stated\nin context/code/tooling.md so nobody reads the check_the_checker's green as evidence\nabout that arm. This bead exists so the statement does not become the permanent\nanswer by default.\n\nWHAT WOULD ACTUALLY CLOSE IT, if anyone takes it up: feed the dist arm an input the\ntextual check ACCEPTS but dist refuses. That requires a tree whose root version\nmatches the tag while dist still objects -- e.g. a dist-workspace.toml naming a\nmember that does not exist. That is a fixture, not an installed dist, and it may be\ncheap. Worth ten minutes before accepting the decline as permanent.\n\nDO NOT close this by deleting the dist arm. It is the thing that actually refused in\nCI, and running it is worth more than any restatement of its rules; the textual\ncheck is the one that catches the failure the script was written for, but it is not\nthe authority.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T05:40:52Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:40:52Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-ycxi","title":"The release preflight's dist plan arm is unchecked by its check_the_checker","description":"Proposed by the improvise-gx5d agent (PR #119) and DECLINED IN THAT PR on stated\ngrounds, which is why it is a bead rather than a fix.\n\nscripts/release-preflight.sh runs `dist plan --tag=\u003ctag\u003e` as a SECOND OPINION after\nits own textual tag-vs-version check, and matches on the refusal text. That arm can\nbe rewritten so it NEVER MATCHES and scripts/release-preflight-test.sh stays green,\nbecause every input the test feeds the script has ALREADY been refused by the\ntextual check -- so control never reaches the dist arm under test.\n\nWHY IT WAS DECLINED RATHER THAN FIXED, and the reasoning is sound: the arm is\nOPTIONAL BY DESIGN. An absent dist must not read as a pass, and a locally-installed\ndist whose version differs from dist-workspace.toml's cargo-dist-version stops\nbefore planning, which the script deliberately tolerates. Pinning the arm in a test\nwould mean installing a dist matching cargo-dist-version -- the exact mismatch the\nscript exists to tolerate.\n\nSO THE VALUE HERE IS NOT COVERAGE, IT IS THAT THE SILENCE IS RECORDED. It is stated\nin context/code/tooling.md so nobody reads the check_the_checker's green as evidence\nabout that arm. This bead exists so the statement does not become the permanent\nanswer by default.\n\nWHAT WOULD ACTUALLY CLOSE IT, if anyone takes it up: feed the dist arm an input the\ntextual check ACCEPTS but dist refuses. That requires a tree whose root version\nmatches the tag while dist still objects -- e.g. a dist-workspace.toml naming a\nmember that does not exist. That is a fixture, not an installed dist, and it may be\ncheap. Worth ten minutes before accepting the decline as permanent.\n\nDO NOT close this by deleting the dist arm. It is the thing that actually refused in\nCI, and running it is worth more than any restatement of its rules; the textual\ncheck is the one that catches the failure the script was written for, but it is not\nthe authority.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T05:40:52Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:40:52Z","dependencies":[{"issue_id":"improvise-ycxi","depends_on_id":"improvise-0s6","type":"parent-child","created_at":"2026-09-25T01:04:34Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-htl6","title":"view-forward is bound in no keymap, in either front end","description":"Found by the improvise-cfly agent while adding the browser's view-back arm, and\nNOT predicted by that ticket. Verified by the orchestrator: grep of\nKeymaps::default_keymaps() finds `\u003c` bound to view-back and NOTHING bound to\nview-forward.\n\nSo the FORWARD half of the view history has never had a key, in either front end:\n\n - TUI: reachable only as `:view-forward` through the command line.\n - BROWSER: not reachable at all -- declined() refuses to enter CommandMode at\n the door (improvise-y11g is the ruling that this must change).\n\nTHIS IS A TUI GAP FIRST, which is the surprising part. The browser was the thing\nunder investigation and the browser is the second victim, not the first.\n\nBoth ends COMPOSE the command correctly since improvise-cfly / PR #117; only the\nbinding is missing. So this is one keymap line plus its consequences, not a\nfeature.\n\nWHAT IT NEEDS:\n - a key. `\u003e` is taken by drill-into-cell, so this needs a choice rather than\n the obvious symmetry -- say what you picked and why.\n - a help.rs hint, or `every_hint_key_resolves_in_its_mode` and\n `only_quit_has_no_hint` will have something to say.\n - it WILL fail resolve::tests::the_forward_step_mirrors_its_cmd_and_no_key_reaches_it,\n which PR #117 added precisely so this gap cannot be closed silently. That\n test asserts the arm exists AND that no keymap binds view-forward, with\n view-back's presence as the control so it is not vacuous over a misspelling.\n Deleting the absence assertion is the correct move when adding the binding --\n coming there and saying so is the point.\n\nWHY IT SURVIVED: pressing `\u003c` twice is the only way to observe the forward stack,\nso nothing a user does by accident reaches it, and until PR #115 the stack was\nnot maintained for any client at all (gotcha 38's correction).","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-tui2","owner":"el-github@elangley.org","created_at":"2026-09-22T05:21:26Z","created_by":"Edward Langley","updated_at":"2026-09-23T02:36:32Z","started_at":"2026-09-23T02:17:59Z","closed_at":"2026-09-23T02:36:32Z","close_reason":"PR #155. view-forward was bound in NO keymap in either front end from the day it was written -- reachable only as ':view-forward'. Bound to ',' in the shared keymap. THE KEY ARGUMENT, since the ticket asked for one: '\u003c' is view-back and '\u003e' is drill-into-cell, so the pair that LOOKS symmetric is not one -- '\u003e' moves within the grid, '\u003c' moves between views -- and binding the forward half to anything reading as '\u003e's partner deepens exactly the confusion that hid the gap. ',' is the unshifted twin of '\u003c', so both halves of the history sit on one physical key with no grid-direction meaning. Deliberately NOT a Ctrl- key: Ctrl-R is browser reload and the keymap is SHARED, so a terminal choice is one the browser must live with -- improvise-clso is already open for that class and adding a second instance while closing another is not a trade worth making. Verified by the orchestrator: ',' was unbound elsewhere, and the change is one line in keymap.rs. THE ABSENCE ASSERTION WAS INVERTED, NOT DELETED -- the_forward_step_mirrors_its_cmd_and_no_key_reaches_it became ..._and_one_key_reaches_it, keeping view-back as a control that now guards the opposite failure (a table that binds everything), plus a third assertion pinning the key so the binding cannot drift while the presence check stays happy. resolve.rs needed NO code: it has had the arm since improvise-cfly. USER-FACING AND REVERSIBLE IN ONE LINE if a different key is preferred.","labels":["browser","tui"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-z24n","title":"Ctrl-Q should not be claimed by the browser at all: force-quit resolves Ignored, not Upstream","description":"USER RULING 2026-09-22: \"What should Ctrl-Q do in a browser tab? this shortcut\nonly really makes sense in an application context.\"\n\nSo: the browser must NOT claim it. `force-quit` should stop resolving to\nKeyOutcome::Upstream and resolve to Ignored -- no frame composed, nothing sent.\n\nWHAT IS ACTUALLY WRONG TODAY is narrower and stranger than \"a black hole\", and the\ndifference is the reason this is worth a ticket rather than a one-liner. web/app.js\n(~:599) reads:\n\n if (!e.ctrlKey \u0026\u0026 !e.altKey) e.preventDefault();\n\nwith the comment \"Ctrl/Alt combinations are left alone: they are the browser's.\"\nSo the page ALREADY declines to consume Ctrl chords. But `client.on_key_event` is\ncalled FIRST and force-quit resolves to Upstream, so the page composes and sends a\nClientFrame::Key that Host::handle answers with an empty vec (gotcha 47) -- AND the\nbrowser also acts on Ctrl-Q, which on Linux Firefox and Chrome QUITS THE BROWSER.\n\nSo the present behaviour is: send a useless frame, and let the browser quit. The\nfix aligns the resolver with the policy app.js already states rather than inventing\none -- which is why the answer is Ignored and NOT \"decline with a status message\":\na message would imply improvise had claimed the key, and it deliberately has not.\n\nCHECK BEFORE ASSUMING THE ABOVE: I read the handler and did not run it. Confirm by\ninjection that Ctrl-Q currently produces a frame, rather than trusting this note --\nthat is the whole discipline this repo runs on.\n\nDO IT IN THE SAME COMMIT: remove `force-quit` from\nthe_command_names_that_still_need_the_host_are_these, because gotcha 47 says closing\na gap has to come to that list and say so. This one closes by DECLINING rather than\nby composing, which the list should be able to express -- if it cannot, that is the\nfinding.\n\nRELATED: improvise-uln5 (help page 5 advertises keys a browser cannot resolve). If\nCtrl-Q is genuinely not a browser feature, the browser's help should not list it,\nand that is uln5's business rather than this ticket's -- but note it there.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T05:13:58Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:13:58Z","labels":["browser"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-dsn2","title":"Browser back/forward buttons and real URLs: deferred, not foreclosed","description":"USER RULING 2026-09-22: \"I want to ignore browser history for now, without\nforeclosing the question: GitHub Pages is going to make actual client-side\nnavigation annoying.\" Clarified on the same day: \"Only actual browser history,\n\u003c/\u003e is a core feature but I don't much care about how it interacts with the\nback/forward buttons at the moment.\"\n\nSO THE SPLIT IS EXPLICIT, and the two halves must not be confused again:\n\n IN SCOPE AND SHIPPING -- the IN-APP view history. `\u003c` and `\u003e` over\n ViewState::view_back_stack, a CORE FEATURE per the user. PR #115 landed the\n mechanism (Command::ViewBack/ViewForward/ForgetView, per-client stacks,\n ForgetView pruning a deleted view from every client's history); improvise-cfly\n / PR #117 gives the browser a key that reaches it. Nothing here is deferred.\n\n DEFERRED, NOT FORECLOSED -- binding any of that to the BROWSER's own history:\n history.pushState, real URLs per document or per view, and the back/forward\n buttons doing anything at all.\n\nWHY DEFERRED, in the user's own reasoning: GitHub Pages is the deployment target\nand has NO server-side rewrite. A client-side router therefore needs either hash\nrouting or the 404.html-copies-index.html trick, and a deep link to a document or\na view is awkward under both. The cost lands on the deployment, not on the code,\nwhich is exactly the kind of cost that is cheap to defer and expensive to\ndiscover late.\n\nWHAT IT WOULD TOUCH IF TAKEN UP, recorded so the shape is not re-derived:\nweb/app.js (the only file that knows about the page), web/assemble.sh and the\npages-deploy job in .github/workflows/ci.yml. It does NOT touch the protocol:\nCommand::SwitchView, ViewBack and ViewForward already exist, so this is a\nquestion about the SHELL, not the vocabulary -- which is the main reason it is\nsafe to leave open.\n\nONE THING TO CHECK BEFORE BUILDING IT, because it decides the design: a URL that\nnames a view is a URL that names ONE CLIENT'S UI state, and gotcha 29 says the\nmodel is shared and the UI is not. A shared link that moves the recipient's view\nis fine; one that moves the SENDER's is the bug. Decide which a URL means before\nwriting a router.\n\nDO NOT close this as done when \u003c/\u003e ships. They are different features and the\ntracker should not let the second stand in for the first.","status":"blocked","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T05:12:00Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:12:04Z","labels":["browser"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-z24n","title":"Ctrl-Q should not be claimed by the browser at all: force-quit resolves Ignored, not Upstream","description":"USER RULING 2026-09-22: \"What should Ctrl-Q do in a browser tab? this shortcut\nonly really makes sense in an application context.\"\n\nSo: the browser must NOT claim it. `force-quit` should stop resolving to\nKeyOutcome::Upstream and resolve to Ignored -- no frame composed, nothing sent.\n\nWHAT IS ACTUALLY WRONG TODAY is narrower and stranger than \"a black hole\", and the\ndifference is the reason this is worth a ticket rather than a one-liner. web/app.js\n(~:599) reads:\n\n if (!e.ctrlKey \u0026\u0026 !e.altKey) e.preventDefault();\n\nwith the comment \"Ctrl/Alt combinations are left alone: they are the browser's.\"\nSo the page ALREADY declines to consume Ctrl chords. But `client.on_key_event` is\ncalled FIRST and force-quit resolves to Upstream, so the page composes and sends a\nClientFrame::Key that Host::handle answers with an empty vec (gotcha 47) -- AND the\nbrowser also acts on Ctrl-Q, which on Linux Firefox and Chrome QUITS THE BROWSER.\n\nSo the present behaviour is: send a useless frame, and let the browser quit. The\nfix aligns the resolver with the policy app.js already states rather than inventing\none -- which is why the answer is Ignored and NOT \"decline with a status message\":\na message would imply improvise had claimed the key, and it deliberately has not.\n\nCHECK BEFORE ASSUMING THE ABOVE: I read the handler and did not run it. Confirm by\ninjection that Ctrl-Q currently produces a frame, rather than trusting this note --\nthat is the whole discipline this repo runs on.\n\nDO IT IN THE SAME COMMIT: remove `force-quit` from\nthe_command_names_that_still_need_the_host_are_these, because gotcha 47 says closing\na gap has to come to that list and say so. This one closes by DECLINING rather than\nby composing, which the list should be able to express -- if it cannot, that is the\nfinding.\n\nRELATED: improvise-uln5 (help page 5 advertises keys a browser cannot resolve). If\nCtrl-Q is genuinely not a browser feature, the browser's help should not list it,\nand that is uln5's business rather than this ticket's -- but note it there.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T05:13:58Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:13:58Z","labels":["browser"],"dependencies":[{"issue_id":"improvise-z24n","depends_on_id":"improvise-6jk","type":"parent-child","created_at":"2026-09-25T01:04:48Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-dsn2","title":"Browser back/forward buttons and real URLs: deferred, not foreclosed","description":"USER RULING 2026-09-22: \"I want to ignore browser history for now, without\nforeclosing the question: GitHub Pages is going to make actual client-side\nnavigation annoying.\" Clarified on the same day: \"Only actual browser history,\n\u003c/\u003e is a core feature but I don't much care about how it interacts with the\nback/forward buttons at the moment.\"\n\nSO THE SPLIT IS EXPLICIT, and the two halves must not be confused again:\n\n IN SCOPE AND SHIPPING -- the IN-APP view history. `\u003c` and `\u003e` over\n ViewState::view_back_stack, a CORE FEATURE per the user. PR #115 landed the\n mechanism (Command::ViewBack/ViewForward/ForgetView, per-client stacks,\n ForgetView pruning a deleted view from every client's history); improvise-cfly\n / PR #117 gives the browser a key that reaches it. Nothing here is deferred.\n\n DEFERRED, NOT FORECLOSED -- binding any of that to the BROWSER's own history:\n history.pushState, real URLs per document or per view, and the back/forward\n buttons doing anything at all.\n\nWHY DEFERRED, in the user's own reasoning: GitHub Pages is the deployment target\nand has NO server-side rewrite. A client-side router therefore needs either hash\nrouting or the 404.html-copies-index.html trick, and a deep link to a document or\na view is awkward under both. The cost lands on the deployment, not on the code,\nwhich is exactly the kind of cost that is cheap to defer and expensive to\ndiscover late.\n\nWHAT IT WOULD TOUCH IF TAKEN UP, recorded so the shape is not re-derived:\nweb/app.js (the only file that knows about the page), web/assemble.sh and the\npages-deploy job in .github/workflows/ci.yml. It does NOT touch the protocol:\nCommand::SwitchView, ViewBack and ViewForward already exist, so this is a\nquestion about the SHELL, not the vocabulary -- which is the main reason it is\nsafe to leave open.\n\nONE THING TO CHECK BEFORE BUILDING IT, because it decides the design: a URL that\nnames a view is a URL that names ONE CLIENT'S UI state, and gotcha 29 says the\nmodel is shared and the UI is not. A shared link that moves the recipient's view\nis fine; one that moves the SENDER's is the bug. Decide which a URL means before\nwriting a router.\n\nDO NOT close this as done when \u003c/\u003e ships. They are different features and the\ntracker should not let the second stand in for the first.","status":"blocked","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T05:12:00Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:12:04Z","labels":["browser"],"dependencies":[{"issue_id":"improvise-dsn2","depends_on_id":"improvise-fhj0","type":"parent-child","created_at":"2026-09-25T01:05:38Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-swzk","title":"A failed :view corrupts the TUI's own view history","description":"Found by the improvise-h3y7 completeness audit.\n\neffect::SwitchView (ui/effect.rs:336) pushes the current view onto view_back_stack\nBEFORE calling App::switch_to_view, which is guarded (ui/app.rs:565) and writes\nactive_view only if the view exists.\n\nSo `:view NoSuchView` leaves the user where they were WITH THEIR OWN CURRENT VIEW\nPUSHED ONTO THEIR OWN BACK STACK. `\u003c` then \"goes back\" to where they already are, and\none real history frame is buried under it.\n\nTHE WIRE PATH IS ALREADY CORRECT and this is not a regression of it: reduce_view's\n`switched` has no guard (the client holds no Workbook, so it cannot check), but\nSessions::apply_switch detects the refusal and relays the exact inverse\n([ViewBack, ForgetView], improvise-6c6q). Only the HOST'S OWN KEY PATH is unguarded.\nTUI-only, small, real.\n\nFIX: move the push after the switch, or make it conditional on the switch having\nhappened. Prefer whichever makes the ordering unrepresentable-otherwise rather than\nconventional.\n\nDEMONSTRATE WITH A FAILING TEST FIRST (AGENTS.md): switch to a real view, then to a\nnonexistent one, then `\u003c`, and assert you land on the real one. Note the test must use\na real second view -- a bystander sitting on the view it would land on anyway cannot\nsee this, which is the vacuity review demonstrated twice on PR #48.","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-tui2","owner":"el-github@elangley.org","created_at":"2026-09-22T04:47:55Z","created_by":"Edward Langley","updated_at":"2026-09-23T02:36:32Z","started_at":"2026-09-23T02:03:03Z","closed_at":"2026-09-23T02:36:32Z","close_reason":"PR #154. SwitchView::apply pushed the leaving frame onto view_back_stack BEFORE calling the guarded App::switch_to_view, under the predicate previous != self.0 -- which answers 'did the user ask for a different name' where the history means 'did this client go somewhere'. They differ on exactly one input: a name the document has not got. Fixed by observation rather than prediction (clone previous, switch, push only if active_view actually differs afterwards), which SUBSUMES the same-view case so the special case is gone rather than joined by a second one. The agent explicitly rejected re-testing workbook.views.contains_key as a second copy of switch_to_view's own condition. THE FIXTURE WAS MEASURED, NOT ASSUMED: the vacuous variant (refuse from Default, then '\u003c') PASSES AGAINST THE UNFIXED CODE, because the phantom frame it pops names Default and Default is where you already are. Rival 'if false' fails 11 tests, which is why the test asserts one frame SURVIVES and not only where the index lands. Sibling hazard filed as improvise-8nd9.","labels":["tui"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-4n4i","title":"effect::HelpPageSet is unclamped where its wire mirror clamps","description":"Found by the improvise-h3y7 completeness audit (direction B: vocabulary -\u003e effects).\n\neffect::HelpPageSet(n) (ui/effect.rs:1174) writes `help_page = self.0` with NO bound.\nreduce_view's arm clamps: `HelpPage::Set(n) =\u003e (*n).min(HELP_PAGE_COUNT - 1)`.\n\nreduce.rs's own comment above that arm says \"these mirror HelpPageNext/HelpPagePrev,\nwhich saturate. The two used to disagree\" -- Next/Prev were fixed and Set WAS LEFT\nBEHIND, so the comment is now false of the arm it sits above. A stale correction: the\nsame failure mode as gotcha 38's prose, and the reason this is filed rather than\nquietly patched.\n\nLATENT, NOT LIVE, and the ticket should say so rather than overstate it:\neffect::help_page_set has exactly one call site (cmd/effect_cmds.rs:404) passing the\nliteral 0, and no client composes HelpPage::Set (resolve.rs has only Next/Prev arms).\nSo it is an asymmetry waiting for its first real caller.\n\nFIX: clamp the effect, and correct the comment in the same commit -- the comment is\nthe thing that made this survive.\n\nTHE REGRESSION GUARD: a test that the two agree for n \u003e HELP_PAGE_COUNT. Nothing tests\nthis in EITHER direction today, which is why a one-sided fix would look complete.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T04:47:54Z","created_by":"Edward Langley","updated_at":"2026-09-22T04:47:54Z","labels":["protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-t3oh","title":"The cache-parity guard's whole-cache escape hatch proves consumption, not coverage","description":"Stated as a residual by the agent that WROTE the guard, in PR #113's evidence,\nrather than found later by someone else.\n\n`web/tests/render_surface.test.js` gained a second check one level up: every\n`pub` field of every widget cache reaches a front end, or is named as debt. It\nhas an escape hatch for the case where a whole cache is handed to a shared\nderivation, e.g. `flatten_cat_tree(\u0026state.categories, ..)` -- in which case\nevery field of that cache counts as reached.\n\nTHE HOLE: a second field added to `CategoryTreeCache` and ignored by\n`flatten_cat_tree` passes. The hand-off proves the cache is CONSUMED, never that\nevery field of it is.\n\nClosing it needs the CONSUMER checked rather than the call site -- i.e. asking\nwhat `flatten_cat_tree` actually reads, not that it was handed the struct.\n\nWHY THIS BEAD EXISTS RATHER THAN A TODO: the same guard's two OTHER escape\nhatches were both vacuous on first writing and both were found only by\ninjection -- `\u0026state.grid` is a substring of `\u0026state.grid.row_frame`, so every\nGridCache field read as covered (vacuous for precisely the cache p7lh was\nabout), and rustfmt splits `state.grid.totals` over three lines so a substring\nsearch calls a read field unread. Two of three hatches were wrong. The third\nbeing merely incomplete rather than wrong is not a reason to trust it.\n\nSee doctrine(an_escape_hatch_is_where_a_new_check_goes_vacuous).\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T02:28:50Z","created_by":"Edward Langley","updated_at":"2026-09-22T02:28:50Z","labels":["browser","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-ozxr","title":"The browser's page bar is display-only; clicking an entry cannot cycle it","description":"PR #113 (improvise-p7lh) made the page bar VISIBLE. It is display-only.\n\nIn the TUI, `[` and `]` cycle the page selection and the page bar is where a\npaged category is both shown and navigated. In the browser the bar now paints,\nbut clicking an entry does nothing -- cycling needs a `click_page` on the\nwasm-bindgen surface (`improvise-wasm-client/src/lib.rs`), which was outside\nthat agent's ground.\n\nWORTH KNOWING BEFORE SCOPING IT: `[` and `]` ALREADY WORKED in the browser\nbefore #113. The feature was never missing -- the FEEDBACK was. That is a worse\nfailure than a dead key, because the user sees the document change and cannot\nattribute it. So this bead is the mouse affordance for something the keyboard\nalready does, not new capability.\n\nPattern to follow: the other five surfaces got delegated handlers in\nimprovise-b8ot, with operands carried on the node as data attributes and read\nback by a pure `cellAction`/`indexAction` in `web/shell.js`. A page entry should\ndo the same -- `data-index` on the entry, a pure `pageAction`, one handler on\nthe `#pages` root. Do NOT derive the index from DOM position.\n\nNote gotcha 27: Page is the NAVIGABLE axis and Filter is not, and `PageAxis::cycle`\nis where that question is already answered -- ask it rather than respelling it.\n\nRelated: improvise-z336 (a page selection is never cleared, so cycling a\ncategory's axis hides a filter and cycling back restores it) is the other half\nof the page-axis story and is core-side, not browser-side.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T02:28:49Z","created_by":"Edward Langley","updated_at":"2026-09-22T02:28:49Z","labels":["browser","ux"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-nwgk","title":"Model::remove_item returns unit, so a wire RemoveItem naming nothing still reports success","description":"Left open by improvise-g3no, deliberately, with the reasoning in\ncontext/code/core.md section 9.\n\ndoctrine(a_status_line_composed_beside_a_mutation_lies_when_the_mutation_no_ops)\ngeneralises to: a mutation whose failure is reportable must RETURN whether it\nacted, because a unit-returning mutation makes the lie unavoidable rather than\naccidental. g3no removed the REACHABLE lie by restoring the formula sweep to\nModel::remove_item -- from either panel the item name comes off the tree, so it\nexists and the delete now really happens. What is left is narrow: a wire\nModelCommand::RemoveItem naming an item the document does not have is still\nanswered with an unconditional past-tense \"Deleted item ...\".\n\nWhy it was not done in that PR: closing it means threading an answer through\nWorkbook::remove_item and effect::RemoveItem, and ui/effect.rs was another\nagent's ground in that wave. It is a two-file change, not a design question.\n\nThe browser half is separately blocked and is the more interesting part:\nSetStatus is ViewOnly and never crosses the wire, so a host-side refusal cannot\nreach a client at all, and nothing in the projection stream says which _Measure\nrows are derived (FormulaCache is Vec\u003cString\u003e of Formula::raw, and recovering a\ntarget from that would be a second home for the unquoting gotcha 34 says has\nexactly one). A browser that is to tell the truth about a refused delete needs\neither a ServerFrame::Rejected arm or an enriched projection.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T02:27:40Z","created_by":"Edward Langley","updated_at":"2026-09-22T02:27:40Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-25bf","title":"The category panel's Space cycles the axis silently while the tile route announces it","description":"MEASURED at 523d571: cycle-axis-at-cursor from the category panel emits no\nSetStatus at all, where the tile-bar route for the same operation emits\n\"Product -\u003e None\".\n\nFour Space presses moved Product's axis through None/Row/Col/Page with #status\nfrozen the whole time on a stale \"Filter: Product = Espresso\" (see\nimprovise-z336 for the staleness itself).\n\nSo there are two derivations of \"cycle this category's axis\" and only one of\nthem tells the user. design-principles section 5 is about exactly this: a\nderivation done twice will diverge, and the divergence is invisible until\nsomeone compares them side by side.\n\nWorth noting Space IS advertised in the CATEGORIES hint as \"Space:cycle-axis\",\nso this is not a black hole -- the key works. It is the feedback that is missing,\nwhich makes it a smaller problem than improvise-koh6 and a different one.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T00:22:25Z","created_by":"Edward Langley","updated_at":"2026-09-22T00:22:25Z","labels":["browser","ux"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-4n4i","title":"effect::HelpPageSet is unclamped where its wire mirror clamps","description":"Found by the improvise-h3y7 completeness audit (direction B: vocabulary -\u003e effects).\n\neffect::HelpPageSet(n) (ui/effect.rs:1174) writes `help_page = self.0` with NO bound.\nreduce_view's arm clamps: `HelpPage::Set(n) =\u003e (*n).min(HELP_PAGE_COUNT - 1)`.\n\nreduce.rs's own comment above that arm says \"these mirror HelpPageNext/HelpPagePrev,\nwhich saturate. The two used to disagree\" -- Next/Prev were fixed and Set WAS LEFT\nBEHIND, so the comment is now false of the arm it sits above. A stale correction: the\nsame failure mode as gotcha 38's prose, and the reason this is filed rather than\nquietly patched.\n\nLATENT, NOT LIVE, and the ticket should say so rather than overstate it:\neffect::help_page_set has exactly one call site (cmd/effect_cmds.rs:404) passing the\nliteral 0, and no client composes HelpPage::Set (resolve.rs has only Next/Prev arms).\nSo it is an asymmetry waiting for its first real caller.\n\nFIX: clamp the effect, and correct the comment in the same commit -- the comment is\nthe thing that made this survive.\n\nTHE REGRESSION GUARD: a test that the two agree for n \u003e HELP_PAGE_COUNT. Nothing tests\nthis in EITHER direction today, which is why a one-sided fix would look complete.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T04:47:54Z","created_by":"Edward Langley","updated_at":"2026-09-22T04:47:54Z","labels":["protocol"],"dependencies":[{"issue_id":"improvise-4n4i","depends_on_id":"improvise-pmt0","type":"parent-child","created_at":"2026-09-25T01:05:00Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-t3oh","title":"The cache-parity guard's whole-cache escape hatch proves consumption, not coverage","description":"Stated as a residual by the agent that WROTE the guard, in PR #113's evidence,\nrather than found later by someone else.\n\n`web/tests/render_surface.test.js` gained a second check one level up: every\n`pub` field of every widget cache reaches a front end, or is named as debt. It\nhas an escape hatch for the case where a whole cache is handed to a shared\nderivation, e.g. `flatten_cat_tree(\u0026state.categories, ..)` -- in which case\nevery field of that cache counts as reached.\n\nTHE HOLE: a second field added to `CategoryTreeCache` and ignored by\n`flatten_cat_tree` passes. The hand-off proves the cache is CONSUMED, never that\nevery field of it is.\n\nClosing it needs the CONSUMER checked rather than the call site -- i.e. asking\nwhat `flatten_cat_tree` actually reads, not that it was handed the struct.\n\nWHY THIS BEAD EXISTS RATHER THAN A TODO: the same guard's two OTHER escape\nhatches were both vacuous on first writing and both were found only by\ninjection -- `\u0026state.grid` is a substring of `\u0026state.grid.row_frame`, so every\nGridCache field read as covered (vacuous for precisely the cache p7lh was\nabout), and rustfmt splits `state.grid.totals` over three lines so a substring\nsearch calls a read field unread. Two of three hatches were wrong. The third\nbeing merely incomplete rather than wrong is not a reason to trust it.\n\nSee doctrine(an_escape_hatch_is_where_a_new_check_goes_vacuous).\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T02:28:50Z","created_by":"Edward Langley","updated_at":"2026-09-22T02:28:50Z","labels":["browser","testing"],"dependencies":[{"issue_id":"improvise-t3oh","depends_on_id":"improvise-fhj0","type":"parent-child","created_at":"2026-09-25T01:05:41Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-ozxr","title":"The browser's page bar is display-only; clicking an entry cannot cycle it","description":"PR #113 (improvise-p7lh) made the page bar VISIBLE. It is display-only.\n\nIn the TUI, `[` and `]` cycle the page selection and the page bar is where a\npaged category is both shown and navigated. In the browser the bar now paints,\nbut clicking an entry does nothing -- cycling needs a `click_page` on the\nwasm-bindgen surface (`improvise-wasm-client/src/lib.rs`), which was outside\nthat agent's ground.\n\nWORTH KNOWING BEFORE SCOPING IT: `[` and `]` ALREADY WORKED in the browser\nbefore #113. The feature was never missing -- the FEEDBACK was. That is a worse\nfailure than a dead key, because the user sees the document change and cannot\nattribute it. So this bead is the mouse affordance for something the keyboard\nalready does, not new capability.\n\nPattern to follow: the other five surfaces got delegated handlers in\nimprovise-b8ot, with operands carried on the node as data attributes and read\nback by a pure `cellAction`/`indexAction` in `web/shell.js`. A page entry should\ndo the same -- `data-index` on the entry, a pure `pageAction`, one handler on\nthe `#pages` root. Do NOT derive the index from DOM position.\n\nNote gotcha 27: Page is the NAVIGABLE axis and Filter is not, and `PageAxis::cycle`\nis where that question is already answered -- ask it rather than respelling it.\n\nRelated: improvise-z336 (a page selection is never cleared, so cycling a\ncategory's axis hides a filter and cycling back restores it) is the other half\nof the page-axis story and is core-side, not browser-side.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T02:28:49Z","created_by":"Edward Langley","updated_at":"2026-09-22T02:28:49Z","labels":["browser","ux"],"dependencies":[{"issue_id":"improvise-ozxr","depends_on_id":"improvise-fhj0","type":"parent-child","created_at":"2026-09-25T01:05:40Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-nwgk","title":"Model::remove_item returns unit, so a wire RemoveItem naming nothing still reports success","description":"Left open by improvise-g3no, deliberately, with the reasoning in\ncontext/code/core.md section 9.\n\ndoctrine(a_status_line_composed_beside_a_mutation_lies_when_the_mutation_no_ops)\ngeneralises to: a mutation whose failure is reportable must RETURN whether it\nacted, because a unit-returning mutation makes the lie unavoidable rather than\naccidental. g3no removed the REACHABLE lie by restoring the formula sweep to\nModel::remove_item -- from either panel the item name comes off the tree, so it\nexists and the delete now really happens. What is left is narrow: a wire\nModelCommand::RemoveItem naming an item the document does not have is still\nanswered with an unconditional past-tense \"Deleted item ...\".\n\nWhy it was not done in that PR: closing it means threading an answer through\nWorkbook::remove_item and effect::RemoveItem, and ui/effect.rs was another\nagent's ground in that wave. It is a two-file change, not a design question.\n\nThe browser half is separately blocked and is the more interesting part:\nSetStatus is ViewOnly and never crosses the wire, so a host-side refusal cannot\nreach a client at all, and nothing in the projection stream says which _Measure\nrows are derived (FormulaCache is Vec\u003cString\u003e of Formula::raw, and recovering a\ntarget from that would be a second home for the unquoting gotcha 34 says has\nexactly one). A browser that is to tell the truth about a refused delete needs\neither a ServerFrame::Rejected arm or an enriched projection.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T02:27:40Z","created_by":"Edward Langley","updated_at":"2026-09-22T02:27:40Z","dependencies":[{"issue_id":"improvise-nwgk","depends_on_id":"improvise-felo","type":"parent-child","created_at":"2026-09-25T01:05:19Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-25bf","title":"The category panel's Space cycles the axis silently while the tile route announces it","description":"MEASURED at 523d571: cycle-axis-at-cursor from the category panel emits no\nSetStatus at all, where the tile-bar route for the same operation emits\n\"Product -\u003e None\".\n\nFour Space presses moved Product's axis through None/Row/Col/Page with #status\nfrozen the whole time on a stale \"Filter: Product = Espresso\" (see\nimprovise-z336 for the staleness itself).\n\nSo there are two derivations of \"cycle this category's axis\" and only one of\nthem tells the user. design-principles section 5 is about exactly this: a\nderivation done twice will diverge, and the divergence is invisible until\nsomeone compares them side by side.\n\nWorth noting Space IS advertised in the CATEGORIES hint as \"Space:cycle-axis\",\nso this is not a black hole -- the key works. It is the feedback that is missing,\nwhich makes it a smaller problem than improvise-koh6 and a different one.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T00:22:25Z","created_by":"Edward Langley","updated_at":"2026-09-22T00:22:25Z","labels":["browser","ux"],"dependencies":[{"issue_id":"improvise-25bf","depends_on_id":"improvise-6jk","type":"parent-child","created_at":"2026-09-25T01:04:39Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-ipqu","title":"The FitOutcome::warnings order stated in two places is checked in none","description":"Found by the adversarial reviewer of PR #111 at head 20f04c83.\n\ncrates/improvise-gp/src/fit.rs:317-318\n\n data.warnings.splice(0..0, table.warnings);\n data.warnings.splice(0..0, notices);\n\nyields notices -\u003e table -\u003e preprocess. BOTH the FitOutcome::warnings doc comment\n(\"the budget's notices, then the feature table's, then preprocess's\") AND\ngp.md section 14 assert that order, and FitOutcome's status line joins them\nverdict-first, so the order is USER-FACING.\n\nMEASURED: swapping the two statements passes all 73 improvise-gp tests.\n\nThe sharp part: PR #111's third commit (20f04c8) is a DOCS-ONLY commit whose\nentire subject is this ordering. So the repository now states the rule in two\nplaces and checks it in none.\n\nThis is the exact shape gotcha 43 records and closed -- \"held up only by the\norder of two statements\", fixed there by\na_published_basis_follows_the_grid_updates_it_describes, which was proved by\ninjecting the swap. Same repair applies here.\n\nWHAT IS NEEDED: one test asserting a notice precedes a table warning when BOTH\nare present. No current fixture produces both, which is why nothing caught it --\na budget that drops a column and a feature table that also warns. That fixture is\nthe work.\n\nSee also doctrine(fold_tests_must_test_order_not_the_axiom): asserting the\nconcatenation contains both is vacuous; the assertion has to be about ORDER.\n\nNOT a correctness bug, not a regression, and it touches neither improvise-pv3g's\nnor improvise-e8no's acceptance criteria -- hence P3 and not a merge blocker.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-core","owner":"el-github@elangley.org","created_at":"2026-09-21T23:29:51Z","created_by":"Edward Langley","updated_at":"2026-09-23T02:44:32Z","started_at":"2026-09-23T01:25:50Z","closed_at":"2026-09-23T02:44:32Z","close_reason":"PR #146. Pins the FitOutcome::warnings order that the doc comment and gp.md both state. THE FIXTURE WAS THE WHOLE WORK -- no existing fixture produced more than one tier. all_three_warning_tiers_at_once adds a _Measure item with no cell anywhere (preprocess warns) and a Cost cell at an unused coordinate (feature_table skips). Adversarial review PRINTED the actual warning vector rather than trusting the at() helper: exactly three, 1:1 with the needles, no collision. Both rivals confirmed failing only this test. The extend-instead-of-splice rival is what earns the third tier -- the splice swap alone never moves preprocess's warning, so a two-tier fixture would have pinned less than claimed.","labels":["gp","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-nzeu","title":"A deserialized TargetModel can still carry a non-finite affine map","description":"Left open deliberately by PR #111 (improvise-pv3g), which made a non-finite\naffine map unrepresentable at the point it is DERIVED but not at the point it is\nDESERIALIZED.\n\n`TargetModel::Regress { mean: f32, std: f32 }` in `crates/gp-tree/src/data.rs`\nstill carries a raw pair. So a hand-edited or corrupted JSON model can\nreintroduce a non-finite map on `fit_csv --predict`, which is exactly the\npoisoned state improvise-pv3g made unconstructible everywhere else.\n\nWhat pv3g DID close: `fit_csv --save` can no longer WRITE one, because\n`standardize` refuses before a model is produced. So the hole is narrower than\nit was and is inbound-only.\n\nTHE FIX SHAPE, and it matters which one: `#[serde(try_from = \"...\")]` on\n`Scaling`, so deserialization goes through the same one constructor that already\nrefuses a non-finite mean or std. NOT a check at the use site -- a use-site check\nis the validated form of an invariant that pv3g just spent its whole change\nmaking unrepresentable, and adding one back would leave two answers to \"is this\nmap usable\" with nothing keeping them in agreement.\n\nNote this reaches `Scaling`'s constructor by a path that IS meaningful for its\notherwise-unreachable `mean` check: `doctrine(an_unreachable_half_of_a_guard_must_say_so)`\nrecords that no `standardize` path can produce a non-finite mean, so the\nconstructor's mean arm is a contract of the type only. A deserializer is\nprecisely the caller that makes that contract load-bearing, so closing this bead\nalso gives that half a reachable path and a test.\n\nReachability today: requires a hand-edited or corrupted model file. Low, which is\nwhy it was not widened into pv3g's scope.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-core","owner":"el-github@elangley.org","created_at":"2026-09-21T23:05:32Z","created_by":"Edward Langley","updated_at":"2026-09-23T02:44:33Z","started_at":"2026-09-23T01:30:35Z","closed_at":"2026-09-23T02:44:33Z","close_reason":"PR #147. A deserialized Scaling goes through its one constructor, via serde try_from/into plus flatten on Regress. THE COMPATIBILITY CLAIM WAS MEASURED, NOT TRUSTED: adversarial review checked out the base commit, serialized an equivalent Model and compared -- BYTE-IDENTICAL, with flatten placing the two numbers where they were. scoring_known_answers.rs untouched, 6 bit-pattern tests pass. The 1e39 -\u003e f64 -\u003e as f32 -\u003e inf path is real (serde_json will not write inf, but 1e39 is ordinary JSON a hand editor types). A P3 found by review was fixed in-PR: the refusal format string was broken across source lines with no backslash continuation, baking EIGHTEEN SPACES into a user-facing message -- visible only by PRINTING it. NOTE FOR THE NEXT READER: the only thing protecting the saved format is one assertion inside a test named about FINITENESS; a_saved_model_round_trips_with_its_seed passes happily with flatten dropped, because it round-trips through itself.","labels":["gp"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-7k7v","title":"check-evidence cannot see a misfiled or body-severed evidence entry","description":"Found by a wave agent reviewing its own diff, NOT by the checker.\n\nscripts/check-comment-runs.py --check-evidence validates that every\ntest-name-shaped heading in context/test-evidence/ names a test that RUNS, and\nthat no test is named twice. It never asks whether the entry sits in the FILE it\nbelongs to, or whether a heading still has the body that was written for it.\n\nObserved: moving two new entries into protocol.md silently dragged the prune\nsection's fault table out of core.md along with them, and --check-evidence\nSTAYED GREEN. A misfiled or body-severed entry is invisible to it. Caught only\nby reading the diff -- which design-principles section 7 already records as\nhaving failed once, in the set_panel_open_and_cursor case.\n\nA second instance in the same session: wide_cell_clamps_without_overflow's\nevidence had been filed in core.md by hand, while improvise-yae4 touches\nviewport.rs and render.rs and does not touch improvise-core at all.\n\nSo the blind spot is structural rather than a slip, and it has two shapes:\n - an entry in the wrong crate's file\n - a heading whose body has been severed from it\n\nWHAT A FIX WOULD NEED TO DECIDE FIRST, because it is not obvious: there is no\nmechanical mapping from a test to the evidence file it belongs in. cargo's\n--list gives a module path, and the file naming is by AREA (core, protocol,\nclients, tooling, command, gp, formula-and-io, tui), which is a human judgement\nin the general case. A checker that guessed would produce false failures, which\nis worse than the current silence.\n\nA cheaper partial that avoids that problem entirely: assert every heading has a\nNON-EMPTY body before the next heading. That catches the severed-body shape\nwithout needing to know where an entry belongs, and it is the shape that lost\nreal content here.\n\nSee context/code/tooling.md for why the names come from cargo rather than from\nthe source (improvise-rm4s), which is the reasoning any change here must respect.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T22:47:55Z","created_by":"Edward Langley","updated_at":"2026-09-21T22:47:55Z","labels":["testing","tooling"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-9xsp","title":"The grid frame's prune composition is asserted at both ends and not across the seam","description":"PR #106 closed the narrower half: AxisFrame::group_of's GroupEnd rule now has\ntwo tests in improvise-protocol's render.rs, over HAND-BUILT frames. Injecting\n`FrameEntry::GroupEnd =\u003e None` fails both. Before that, the same injection left\nall 133 improvise-protocol lib tests passing.\n\nWHAT IS STILL NOT PINNED, and it is the seam rather than the contract: that\n`prune_axis_items -\u003e axis_frame -\u003e group_of` actually PRODUCES those frames. The\ntwo new tests assert group_of behaves correctly on frames someone hand-built;\nnothing asserts the real pipeline emits frames of that shape from a pruned\nlayout. So the property reaches the protocol by construction through project.rs\nand is asserted only at each end.\n\nThat is the shape gotcha 44 and design-principles section 5 exist to prevent --\na derivation living in two places with only one of them asserted.\n\nTHE FIXTURE, measured by hand and passing today (frame dump is in PR #106's\nbody), so this is a test to land rather than a bug to find:\n\n Month: Jan[Q1], Feb[Q1], Mar[Q2], Sep with Mar unfunded, prune_empty on\n assert grid_of(\u0026app, vp).row_frame.group_of(2) == None\n\n plus the collapsed fixture asserting the frame carries\n Group(FrameGroup { name: \"Q1\", collapsed: true })\n\nThe collapsed assertion matters on its own: a header arriving WITHOUT the flag\nis present and inert -- the user sees a group they cannot expand, which is\nimprovise-lhi4 one layer out.\n\nLives in improvise-command/src/ui/project.rs, which was outside the wave agent's\nground; that is the only reason it was not landed with #106.\n","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-21T22:47:53Z","created_by":"Edward Langley","updated_at":"2026-09-21T22:47:53Z","labels":["protocol","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-7k7v","title":"check-evidence cannot see a misfiled or body-severed evidence entry","description":"Found by a wave agent reviewing its own diff, NOT by the checker.\n\nscripts/check-comment-runs.py --check-evidence validates that every\ntest-name-shaped heading in context/test-evidence/ names a test that RUNS, and\nthat no test is named twice. It never asks whether the entry sits in the FILE it\nbelongs to, or whether a heading still has the body that was written for it.\n\nObserved: moving two new entries into protocol.md silently dragged the prune\nsection's fault table out of core.md along with them, and --check-evidence\nSTAYED GREEN. A misfiled or body-severed entry is invisible to it. Caught only\nby reading the diff -- which design-principles section 7 already records as\nhaving failed once, in the set_panel_open_and_cursor case.\n\nA second instance in the same session: wide_cell_clamps_without_overflow's\nevidence had been filed in core.md by hand, while improvise-yae4 touches\nviewport.rs and render.rs and does not touch improvise-core at all.\n\nSo the blind spot is structural rather than a slip, and it has two shapes:\n - an entry in the wrong crate's file\n - a heading whose body has been severed from it\n\nWHAT A FIX WOULD NEED TO DECIDE FIRST, because it is not obvious: there is no\nmechanical mapping from a test to the evidence file it belongs in. cargo's\n--list gives a module path, and the file naming is by AREA (core, protocol,\nclients, tooling, command, gp, formula-and-io, tui), which is a human judgement\nin the general case. A checker that guessed would produce false failures, which\nis worse than the current silence.\n\nA cheaper partial that avoids that problem entirely: assert every heading has a\nNON-EMPTY body before the next heading. That catches the severed-body shape\nwithout needing to know where an entry belongs, and it is the shape that lost\nreal content here.\n\nSee context/code/tooling.md for why the names come from cargo rather than from\nthe source (improvise-rm4s), which is the reasoning any change here must respect.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T22:47:55Z","created_by":"Edward Langley","updated_at":"2026-09-21T22:47:55Z","labels":["testing","tooling"],"dependencies":[{"issue_id":"improvise-7k7v","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T01:05:43Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-9xsp","title":"The grid frame's prune composition is asserted at both ends and not across the seam","description":"PR #106 closed the narrower half: AxisFrame::group_of's GroupEnd rule now has\ntwo tests in improvise-protocol's render.rs, over HAND-BUILT frames. Injecting\n`FrameEntry::GroupEnd =\u003e None` fails both. Before that, the same injection left\nall 133 improvise-protocol lib tests passing.\n\nWHAT IS STILL NOT PINNED, and it is the seam rather than the contract: that\n`prune_axis_items -\u003e axis_frame -\u003e group_of` actually PRODUCES those frames. The\ntwo new tests assert group_of behaves correctly on frames someone hand-built;\nnothing asserts the real pipeline emits frames of that shape from a pruned\nlayout. So the property reaches the protocol by construction through project.rs\nand is asserted only at each end.\n\nThat is the shape gotcha 44 and design-principles section 5 exist to prevent --\na derivation living in two places with only one of them asserted.\n\nTHE FIXTURE, measured by hand and passing today (frame dump is in PR #106's\nbody), so this is a test to land rather than a bug to find:\n\n Month: Jan[Q1], Feb[Q1], Mar[Q2], Sep with Mar unfunded, prune_empty on\n assert grid_of(\u0026app, vp).row_frame.group_of(2) == None\n\n plus the collapsed fixture asserting the frame carries\n Group(FrameGroup { name: \"Q1\", collapsed: true })\n\nThe collapsed assertion matters on its own: a header arriving WITHOUT the flag\nis present and inert -- the user sees a group they cannot expand, which is\nimprovise-lhi4 one layer out.\n\nLives in improvise-command/src/ui/project.rs, which was outside the wave agent's\nground; that is the only reason it was not landed with #106.\n","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-21T22:47:53Z","created_by":"Edward Langley","updated_at":"2026-09-21T22:47:53Z","labels":["protocol","testing"],"dependencies":[{"issue_id":"improvise-9xsp","depends_on_id":"improvise-pmt0","type":"parent-child","created_at":"2026-09-25T01:05:03Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-yss5","title":"extract_date_component silently drops a row whose date does not match the detected format","description":"Noticed while fixing improvise-jcfs, NOT reproduced -- so the first job is a\nfailing test, per AGENTS.md.\n\nextract_date_component silently returns None for a value that does not parse\nunder the detected format. The consequence: a row with one odd date vanishes\nfrom the derived _Year/_Month category with no warning at all.\n\nThis is the same shape as the three defects improvise-2cw9/jc95/5hfh closed --\na value the code cannot interpret becomes an absence rather than a report. The\nimport path now has warning machinery (csv_parser and wizard both emit), and the\nhouse wording is lowercase with single-quoted names and counts, e.g.\n`column 'E' missing from 2 row(s)`.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-core","owner":"el-github@elangley.org","created_at":"2026-09-21T21:59:44Z","created_by":"Edward Langley","updated_at":"2026-09-23T02:44:33Z","started_at":"2026-09-23T01:39:55Z","closed_at":"2026-09-23T02:44:33Z","close_reason":"PR #150. A date not matching the detected format is reported rather than dropped in silence. The ticket said NOT REPRODUCED; it is real but NARROWER than it reads -- detect_date_format accepts a format only when EVERY distinct non-empty sample parses and extract_date_component makes the identical call, so through the shipped CLI and TUI wizard it is unreachable; the door is ImportPipeline's pub proposals, and that reachability claim is GUARDED rather than left as prose. The rival worth naming: warning UNCONDITIONALLY passed all 250 improvise-io tests, because no existing test enabled date extraction AND asserted silence -- that silence guard is part of the PR. A ChatGPT Codex review found the tally was incremented while walking a record's categories BEFORE the record was known to survive the walk, making the count order-dependent; fixed, and the regression asserts the fixture's category order explicitly. Adversarial review confirmed all four rivals AND verified the order assertion non-vacuous by flipping the walk order for real -- its first attempt was a no-op, because serde_json::Map is a BTreeMap so first-seen order is alphabetical.","labels":["import"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-cgqt","title":"Import collision policy should be a wizard step, not a fixed default","description":"improvise-2cw9 decided that two source rows landing on one coordinate tuple are\nSUMMED and reported. That decision is recorded on the bead and is right as a\ndefault -- it is what a pivot table does and what measure_agg defaults to.\n\nIt is WRONG for a measure the user later sets to Avg: the value is already\npre-summed, so the average is taken over the wrong denominator. The warning is\nwhat currently keeps that from being invisible.\n\nThe user is the only one who knows which they want. The shape is a wizard step\nthat asks, on the field where the collision was detected -- not a change to the\ndefault, and not a silent guess from the field's inferred kind.\n\nSearch `duplicate`, `collision`, `aggregate` before filing anything adjacent.\n","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-21T21:59:43Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:59:43Z","labels":["import","ux"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-1uqj","title":"Long CSV rows lose their extra fields — now warned, still dropped","description":"improvise-2cw9's fix made a long CSV row REPORTED rather than silent -- a row\ncarrying more fields than the header now warns with counts and the widest row.\nThe extra values are still DROPPED.\n\nPreserving them means inventing column names, which changes the shape of the\nimported model. That is a wizard decision, not a parser one, which is why the\nimport agent stopped at \"no longer silent\" and flagged it.\n\nRelated and CLOSED: improvise-k8i covered the SHORT direction (a row with fewer\nfields than the header). Its own fix did not reach this half, because the bead\nreproduced only the short case. Read it before starting.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T21:59:42Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:59:42Z","labels":["import"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-is8c","title":"Move Basis from Command::Model to ClientFrame::Commands so one-basis-per-frame is not a convention","description":"Split out of improvise-yqpd, which delivered ADR-001 clause 4 but could not do\nthis half: it is a wire change in improvise-protocol/src/frame.rs.\n\nClause 1 says the FRAME states one basis. Today the Basis still sits inside\nCommand::Model, and CheckedBatch simply uses the one the first basis-carrying\ncommand stated, ignoring any later command's field. That works, and it is a\nCONVENTION rather than something the types enforce.\n\nMoving Basis from Command::Model to ClientFrame::Commands makes one-basis-per-\nframe unrepresentable-otherwise instead of conventional -- the same move the\nstruct variant made for \"the basis cannot be forgotten\" (gotcha 43).\n\nNote what this does NOT change, from gotcha 43: only two of the three tiers\nhoist. document_generation and layout_version describe the document and are\nanswered once per frame; the RECORD tier must still be answered per command,\nbecause which records are at stake is a property of the command (records_named).\nA first version that hoisted all three was a silent lost update.\n","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-21T21:59:42Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:59:42Z","labels":["adr-001","protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-1uqj","title":"Long CSV rows lose their extra fields — now warned, still dropped","description":"improvise-2cw9's fix made a long CSV row REPORTED rather than silent -- a row\ncarrying more fields than the header now warns with counts and the widest row.\nThe extra values are still DROPPED.\n\nPreserving them means inventing column names, which changes the shape of the\nimported model. That is a wizard decision, not a parser one, which is why the\nimport agent stopped at \"no longer silent\" and flagged it.\n\nRelated and CLOSED: improvise-k8i covered the SHORT direction (a row with fewer\nfields than the header). Its own fix did not reach this half, because the bead\nreproduced only the short case. Read it before starting.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T21:59:42Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:59:42Z","labels":["import"],"dependencies":[{"issue_id":"improvise-1uqj","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T01:05:26Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-is8c","title":"Move Basis from Command::Model to ClientFrame::Commands so one-basis-per-frame is not a convention","description":"Split out of improvise-yqpd, which delivered ADR-001 clause 4 but could not do\nthis half: it is a wire change in improvise-protocol/src/frame.rs.\n\nClause 1 says the FRAME states one basis. Today the Basis still sits inside\nCommand::Model, and CheckedBatch simply uses the one the first basis-carrying\ncommand stated, ignoring any later command's field. That works, and it is a\nCONVENTION rather than something the types enforce.\n\nMoving Basis from Command::Model to ClientFrame::Commands makes one-basis-per-\nframe unrepresentable-otherwise instead of conventional -- the same move the\nstruct variant made for \"the basis cannot be forgotten\" (gotcha 43).\n\nNote what this does NOT change, from gotcha 43: only two of the three tiers\nhoist. document_generation and layout_version describe the document and are\nanswered once per frame; the RECORD tier must still be answered per command,\nbecause which records are at stake is a property of the command (records_named).\nA first version that hoisted all three was a silent lost update.\n","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-21T21:59:42Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:59:42Z","labels":["adr-001","protocol"],"dependencies":[{"issue_id":"improvise-is8c","depends_on_id":"improvise-5krd","type":"parent-child","created_at":"2026-09-25T01:04:57Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-vep8","title":"Connection hang has no watchdog coverage (ready never resolves)","description":"## Summary\n\nHandshake watchdog in `web/app.js` arms **after** `await transport.ready`. If ready never resolves (connection hang), watchdog never arms — UI stuck on \"connecting...\" forever.\n\n## Steps to Reproduce\n\n1. Mock WebSocket that never fires `open`.\n2. Page shows connecting forever; no timeout.\n\n## Acceptance Criteria\n\n- Connection attempt has a timeout/watchdog that arms before or independently of ready; hang surfaces a loud failure.\n\n## Notes\n\nDistinct from improvise-9cma (invalid `?doc=` blank UI / generic watchdog text after peer silence).\n\n## Location\n\n`web/app.js` ~L489-494\n\n## PR / Attribution\n\nPR #100 P3 F25. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","status":"closed","priority":3,"issue_type":"bug","owner":"box@local","created_at":"2026-09-21T20:49:08Z","created_by":"external-grok-bot","updated_at":"2026-09-21T21:58:56Z","closed_at":"2026-09-21T21:58:56Z","close_reason":"Fixed in the 2026-09-21 wave, integrated into main at 1574 passed / 0 failed / 7 ignored. Playwright 42 passed / 0 failed / 4 skipped (WebKit, no OPFS) -- up from 37, so the count MOVED. All gates green.\n\nThe watchdog is armed before transport.ready, plus a new spec that hangs the socket forever.","external_ref":"gh-pr-100-P3-hang","labels":["adversarial-review","pr-100"],"dependencies":[{"issue_id":"improvise-vep8","depends_on_id":"improvise-1wft","type":"blocks","created_at":"2026-09-21T21:31:52Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-vep8","depends_on_id":"improvise-9cma","type":"relates-to","created_at":"2026-09-21T20:49:18Z","created_by":"external-grok-bot","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-nvq4","title":"Hidden/collapsed items with slash in name corrupt on persistence round-trip","description":"## Summary\n\n`hidden:` persistence line format is `hidden: Cat/Item`. If Item contains `/`, the parser splits wrongly; hidden state is lost or corrupted on round-trip. A test allegedly documents this.\n\n## Steps to Reproduce\n\n1. Create item named `A/B`, hide it, save, reload.\n2. Hidden state lost or corrupted.\n\n## Acceptance Criteria\n\n- Round-trip preserves hidden/collapsed for names containing `/` (escape or alternate encoding).\n\n## Location\n\n`crates/improvise-io/src/persistence/parse.rs` (hidden: line)\n\n## PR / Attribution\n\nPR #100 P3 F23. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","status":"closed","priority":3,"issue_type":"bug","owner":"box@local","created_at":"2026-09-21T20:49:07Z","created_by":"external-grok-bot","updated_at":"2026-09-21T21:58:17Z","closed_at":"2026-09-21T21:58:17Z","close_reason":"DOES NOT REPRODUCE -- closing invalid, on evidence.\n\nhidden_line reads two names; bare_name excludes '/', so quote_name already writes hidden: C/|A/B|. The grammar already forbade the hazard this bead describes.\n\nKept as a PASSING CONTROL rather than deleted: slash_in_hidden_name_survives_roundtrip now asserts it, so the bead cannot be re-filed without someone watching the property pass.\n\nSecond external-bot report in this area that did not reproduce (the first is the one AGENTS.md records at PR #21). Worth weighting bot-filed P3s accordingly.","external_ref":"gh-pr-100-P3-hidden","labels":["adversarial-review","pr-100"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-y8ym","title":"DeleteView effect discards error result","description":"## Summary\n\n`DeleteView` uses `let _ = app.model_state.workbook.delete_view(...)`, discarding `Result`. Failure (e.g. last view) produces no user error; code below assumes success.\n\n## Steps to Reproduce\n\n1. Attempt to delete the only view in a workbook.\n2. No error message; view may still exist while UI assumed deletion.\n\n## Acceptance Criteria\n\n- delete_view Err reaches status_msg (and abort if appropriate); success path only runs after Ok.\n\n## Location\n\n`crates/improvise-command/src/ui/effect.rs` ~L321\n\n## PR / Attribution\n\nPR #100 P3 F18. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","status":"open","priority":3,"issue_type":"bug","owner":"box@local","created_at":"2026-09-21T20:49:07Z","created_by":"external-grok-bot","updated_at":"2026-09-21T20:49:07Z","external_ref":"gh-pr-100-P3-deleteview","labels":["adversarial-review","pr-100"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-y8ym","title":"DeleteView effect discards error result","description":"## Summary\n\n`DeleteView` uses `let _ = app.model_state.workbook.delete_view(...)`, discarding `Result`. Failure (e.g. last view) produces no user error; code below assumes success.\n\n## Steps to Reproduce\n\n1. Attempt to delete the only view in a workbook.\n2. No error message; view may still exist while UI assumed deletion.\n\n## Acceptance Criteria\n\n- delete_view Err reaches status_msg (and abort if appropriate); success path only runs after Ok.\n\n## Location\n\n`crates/improvise-command/src/ui/effect.rs` ~L321\n\n## PR / Attribution\n\nPR #100 P3 F18. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","status":"open","priority":3,"issue_type":"bug","owner":"box@local","created_at":"2026-09-21T20:49:07Z","created_by":"external-grok-bot","updated_at":"2026-09-21T20:49:07Z","external_ref":"gh-pr-100-P3-deleteview","labels":["adversarial-review","pr-100"],"dependencies":[{"issue_id":"improvise-y8ym","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T01:05:13Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-v4hg","title":"AddItem/AddFormula errors don't abort the effect batch","description":"## Summary\n\n`AddItem` / `AddItemInGroup` / `AddFormula` (parse-error branch) set `status_msg` on failure but do not set `abort_effects = true`, unlike `AddCategory`. Downstream effects in the batch still run (e.g. SetCell after failed AddItem).\n\n## Steps to Reproduce\n\n1. Run a Sequence: AddItem to non-existent category, then an effect that assumes the item exists.\n2. Status shows error but second effect still applies.\n\n## Acceptance Criteria\n\n- Item/formula construction failures abort the batch consistently with AddCategory (or document intentional asymmetry).\n\n## Doctrine context\n\n`invariant(an_abort_stops_the_chain_not_the_bookkeeping)`\n\n## Location\n\n`crates/improvise-command/src/ui/effect.rs` ~L102-110 (contrast AddCategory ~L87)\n\n## PR / Attribution\n\nPR #100 P3 F17. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","notes":"Orchestrator 2026-09-23: #181 does NOT close v4hg. After #181's review, the one reachable key-path case left is AddFormula's parse-error arm. Enter in FormulaEdit on 'Total = (' drops the typed formula and lands in FormulaPanel. The proposed fix is refuse_and_stop on that arm, the way #181's earlier head did it. Route this after #181 merges.","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-refuse-fu","owner":"box@local","created_at":"2026-09-21T20:49:06Z","created_by":"external-grok-bot","updated_at":"2026-09-23T14:56:21Z","started_at":"2026-09-23T05:16:53Z","closed_at":"2026-09-23T14:56:21Z","close_reason":"Fixed in PR #213, merged as 345a358 after adversarial review at 3929196. Follow-ups: improvise-we1a, improvise-rtud.","external_ref":"gh-pr-100-P3-additem","labels":["adversarial-review","pr-100"],"comments":[{"id":"b97686ec-00ec-5927-9636-7863b45447bc","issue_id":"improvise-v4hg","author":"agent-review-181","text":"From the adversarial review of PR #181 at 12171fb: the key-path half of this ticket, after cz8d settled on \"AddItem/AddFormula refuse but do not abort\".\n\n**AddFormula: yes, one case exists.** Enter in `FormulaEdit` resolves to `[commit-buffer formula, clear-buffer formula]`. `CommitBuffer` emits `[SetStatus(\"Formula added\"), AddFormula, change_mode(FormulaPanel)]`, and `on_commit` is documented as \"where a *successful* commit lands the user\". Probed through `App::handle_key` with `Total = (` at 12171fb, the result is mode `FormulaPanel`, the formula buffer `\"\"`, the status \"Formula error: …\", and `last_batch_refused() == true`. The error is visible, but **the user's typed formula is thrown away** by effects that assume the commit worked. This is the same behaviour main has always had, so it is not a regression. The fix is for AddFormula's parse-error arm to use `refuse_and_stop`. With that change, the earlier head of #181 kept the user in `FormulaEdit` with the text intact, and the error was drawn to the right of the prompt by `minibuffer_line`. The abort costs nothing on the wire, because a frame is rejected on the refusal either way. `fit-measure` emits `AddFormula` last, so it is unaffected.\n\n**AddItem: no reachable case found.** `commit-buffer item` has no `on_commit`, so the user stays in `ItemAdd`, and the only thing after it is `clear-buffer`. `rename_record_coordinate` does `AddItem` followed by moving the cells, but its category is a live records column, so AddItem cannot fail there. `add-items` repeats the same failure. In a `:` line like `add-item Regoin X . set-cell … Regoin/X`, the orphan cell is `set-cell`'s own missing validation, and it happens without the `add-item` too, so an abort is the wrong fix for it.\n","created_at":"2026-09-23T05:01:05Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} -{"_type":"issue","id":"improvise-pcje","title":"Render parity: the browser drops col_widths, column ancestry, every AxisColor and the tile overflow","description":"DERIVED at 1f28868 by a mechanical grep of every cache field across src/ui/*.rs,\nsrc/draw.rs, wasm-client/src/render.rs, web/app.js and web/shell.js.\n\nGOOD NEWS FIRST: exposed-but-unpainted is EMPTY. All 22 RenderModel fields and\nevery nested field are read by app.js or shell.js. The four-widget gap that\ndesign-principles.md section 7 records as its motivating case is CLOSED. No\norphan DOM ids either.\n\nWhat differs, none of it silent-wrong, all of it cosmetic or structural:\n\n GridCache::col_widths TUI yes / browser NO -- never reaches RenderModel;\n the browser lets CSS table layout size columns\n GridCache::totals TUI yes / browser NO -- not on RenderModel at all\n GridCache::col_frame browser has no shares_ancestry equivalent, so the TUI's\n selected-column ancestry underline has no counterpart\n PageAxis::{cat,selected} TUI yes / browser NO\n CellDisplay::numeric TUI NO (it right-aligns every cell unconditionally,\n grid.rs:349) / browser yes\n CellDisplay::error read by NOBODY in src/, despite the field's doc saying\n \"rendered distinctly\"\n CellDisplay::id MIRROR-IMAGE PARTIALS: the TUI reads only Aggregate -\u003e\n italic; the browser collapses it to\n editable = matches!(id, Some(Record(_))) and so cannot\n tell Aggregate from Derived from None\n CategoryRow::axis TUI to_string() + axis_fg(axis.color());\n browser axis.initial(), NO COLOUR\n TileEntry::axis TUI short_label() + colour; browser label only\n TileBarCache::tiles TUI horizontal scroll with left/right overflow arrows;\n browser no scroll or overflow handling\n\nThe colour ones are one question, not four: AxisColor has no CSS counterpart at\neither site, unlike MinibufColor which crosses as a semantic enum. Answer it once\n(gotcha 26's shape: the decision is a semantic enum in the shared layer and only\nthe last translation is local) rather than per widget.\n\nCellDisplay::id's mirror-image partials are the one with teeth -- see the\nseparate ADR 002 / editable ticket.\n","notes":"GROOM 2026-09-21: SCOPE NARROWED -- this bead OVERLAPS improvise-p7lh and p7lh owns the intersection.\n\np7lh is the demonstrated one (probed against a real Host) and covers the two gaps that matter most: GridCache::totals (the column-totals strip) and page_coords (the page bar), both of which the server already projects and the browser's cache already holds correctly -- they are simply never painted. p7lh is also a blocker on improvise-bck, because the SHIPPED DEMO has a page axis and no page bar.\n\nWHAT REMAINS THIS BEAD'S, after removing totals and page_coords:\n - col_widths never reaches RenderModel; the browser lets CSS table layout size columns\n - col_frame has no shares_ancestry equivalent, so the TUI's selected-column\n ancestry underline has no browser counterpart\n - CategoryRow::axis and TileEntry::axis cross WITHOUT COLOUR -- AxisColor has\n no CSS counterpart at either site, unlike MinibufColor which does\n - TileBarCache::tiles has no scroll or overflow handling in the browser\n - CellDisplay::error is read by NOBODY in src/, despite the field's doc saying\n 'rendered distinctly'\n - CellDisplay::id: mirror-image partials -- the TUI reads only Aggregate, the\n browser collapses to editable = matches!(id, Some(Record(_)))\n\nThe colour items are ONE question, not four: answer it the way gotcha 26 already answers it for MinibufColor -- the decision stays a semantic enum in the shared layer and only the last translation is local -- rather than per widget.\n\nAlso worth carrying: the sweep found exposed-but-unpainted is EMPTY. All 22 RenderModel fields are read by app.js or shell.js, so the four-widget gap design-principles.md section 7 records as its motivating case is CLOSED.\nSCOPE NOTE 2026-09-21, and a correction to this bead's own framing.\n\nThis bead opens \"GOOD NEWS FIRST: exposed-but-unpainted is EMPTY. All 22 RenderModel fields ... are read\". That was TRUE and it was ALSO the wrong question, which PR #113 demonstrated rather than argued.\n\nThe sweep behind this bead was CLOSED UNDER RenderModel -- it asked whether every field RenderModel exposes is painted. GridCache::totals and page_coords were projected, folded by the browser's cache, and painted by nothing, for the entire period between improvise-n10 and improvise-p7lh, and the sweep truthfully reported an empty set the whole time. A completeness check closed over the artefact that can OMIT a thing cannot see the omission.\n\nPR #113 closed that by adding the question one level up to web/tests/render_surface.test.js: every pub field of every WIDGET CACHE reaches a front end, or is named as debt.\n\nWHAT THAT MEANS FOR THIS BEAD: `grid.col_widths` is now the single named debt entry in that guard, and it points here. So this bead is no longer \"a list someone derived by grepping\"; it is the one item a running check reports. The rest of its original list -- column ancestry, AxisColor, tile overflow -- should be re-derived against the guard rather than trusted from the grep, because that grep had the same blind spot.\n\nThe guard's own residual hole is improvise-t3oh (a whole-cache hand-off proves the cache is consumed, never that every field of it is).","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-21T20:39:57Z","created_by":"Edward Langley","updated_at":"2026-09-22T02:29:06Z","labels":["browser","parity","ui"],"dependencies":[{"issue_id":"improvise-pcje","depends_on_id":"improvise-p7lh","type":"discovered-from","created_at":"2026-09-21T21:31:56Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-pcje","title":"Render parity: the browser drops col_widths, column ancestry, every AxisColor and the tile overflow","description":"DERIVED at 1f28868 by a mechanical grep of every cache field across src/ui/*.rs,\nsrc/draw.rs, wasm-client/src/render.rs, web/app.js and web/shell.js.\n\nGOOD NEWS FIRST: exposed-but-unpainted is EMPTY. All 22 RenderModel fields and\nevery nested field are read by app.js or shell.js. The four-widget gap that\ndesign-principles.md section 7 records as its motivating case is CLOSED. No\norphan DOM ids either.\n\nWhat differs, none of it silent-wrong, all of it cosmetic or structural:\n\n GridCache::col_widths TUI yes / browser NO -- never reaches RenderModel;\n the browser lets CSS table layout size columns\n GridCache::totals TUI yes / browser NO -- not on RenderModel at all\n GridCache::col_frame browser has no shares_ancestry equivalent, so the TUI's\n selected-column ancestry underline has no counterpart\n PageAxis::{cat,selected} TUI yes / browser NO\n CellDisplay::numeric TUI NO (it right-aligns every cell unconditionally,\n grid.rs:349) / browser yes\n CellDisplay::error read by NOBODY in src/, despite the field's doc saying\n \"rendered distinctly\"\n CellDisplay::id MIRROR-IMAGE PARTIALS: the TUI reads only Aggregate -\u003e\n italic; the browser collapses it to\n editable = matches!(id, Some(Record(_))) and so cannot\n tell Aggregate from Derived from None\n CategoryRow::axis TUI to_string() + axis_fg(axis.color());\n browser axis.initial(), NO COLOUR\n TileEntry::axis TUI short_label() + colour; browser label only\n TileBarCache::tiles TUI horizontal scroll with left/right overflow arrows;\n browser no scroll or overflow handling\n\nThe colour ones are one question, not four: AxisColor has no CSS counterpart at\neither site, unlike MinibufColor which crosses as a semantic enum. Answer it once\n(gotcha 26's shape: the decision is a semantic enum in the shared layer and only\nthe last translation is local) rather than per widget.\n\nCellDisplay::id's mirror-image partials are the one with teeth -- see the\nseparate ADR 002 / editable ticket.\n","notes":"GROOM 2026-09-21: SCOPE NARROWED -- this bead OVERLAPS improvise-p7lh and p7lh owns the intersection.\n\np7lh is the demonstrated one (probed against a real Host) and covers the two gaps that matter most: GridCache::totals (the column-totals strip) and page_coords (the page bar), both of which the server already projects and the browser's cache already holds correctly -- they are simply never painted. p7lh is also a blocker on improvise-bck, because the SHIPPED DEMO has a page axis and no page bar.\n\nWHAT REMAINS THIS BEAD'S, after removing totals and page_coords:\n - col_widths never reaches RenderModel; the browser lets CSS table layout size columns\n - col_frame has no shares_ancestry equivalent, so the TUI's selected-column\n ancestry underline has no browser counterpart\n - CategoryRow::axis and TileEntry::axis cross WITHOUT COLOUR -- AxisColor has\n no CSS counterpart at either site, unlike MinibufColor which does\n - TileBarCache::tiles has no scroll or overflow handling in the browser\n - CellDisplay::error is read by NOBODY in src/, despite the field's doc saying\n 'rendered distinctly'\n - CellDisplay::id: mirror-image partials -- the TUI reads only Aggregate, the\n browser collapses to editable = matches!(id, Some(Record(_)))\n\nThe colour items are ONE question, not four: answer it the way gotcha 26 already answers it for MinibufColor -- the decision stays a semantic enum in the shared layer and only the last translation is local -- rather than per widget.\n\nAlso worth carrying: the sweep found exposed-but-unpainted is EMPTY. All 22 RenderModel fields are read by app.js or shell.js, so the four-widget gap design-principles.md section 7 records as its motivating case is CLOSED.\nSCOPE NOTE 2026-09-21, and a correction to this bead's own framing.\n\nThis bead opens \"GOOD NEWS FIRST: exposed-but-unpainted is EMPTY. All 22 RenderModel fields ... are read\". That was TRUE and it was ALSO the wrong question, which PR #113 demonstrated rather than argued.\n\nThe sweep behind this bead was CLOSED UNDER RenderModel -- it asked whether every field RenderModel exposes is painted. GridCache::totals and page_coords were projected, folded by the browser's cache, and painted by nothing, for the entire period between improvise-n10 and improvise-p7lh, and the sweep truthfully reported an empty set the whole time. A completeness check closed over the artefact that can OMIT a thing cannot see the omission.\n\nPR #113 closed that by adding the question one level up to web/tests/render_surface.test.js: every pub field of every WIDGET CACHE reaches a front end, or is named as debt.\n\nWHAT THAT MEANS FOR THIS BEAD: `grid.col_widths` is now the single named debt entry in that guard, and it points here. So this bead is no longer \"a list someone derived by grepping\"; it is the one item a running check reports. The rest of its original list -- column ancestry, AxisColor, tile overflow -- should be re-derived against the guard rather than trusted from the grep, because that grep had the same blind spot.\n\nThe guard's own residual hole is improvise-t3oh (a whole-cache hand-off proves the cache is consumed, never that every field of it is).","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-21T20:39:57Z","created_by":"Edward Langley","updated_at":"2026-09-22T02:29:06Z","labels":["browser","parity","ui"],"dependencies":[{"issue_id":"improvise-pcje","depends_on_id":"improvise-fhj0","type":"parent-child","created_at":"2026-09-25T01:05:41Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-pcje","depends_on_id":"improvise-p7lh","type":"discovered-from","created_at":"2026-09-21T21:31:56Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-yae4","title":"Column width overflows before clamping a 65535-column text value","description":"Report-only hunt at 70025e2. No implementation changes.\n\nLocation: crates/improvise-command/src/ui/viewport.rs:66,85\n\nA stored 65535-character ASCII text value causes compute_col_widths/grid_body to panic at w+1 in debug builds. Width is narrowed to u16 before adding the cell gap and clamping to MAX_COL_WIDTH. In release the sum wraps and chooses a minimum-width column instead of the configured maximum. Longer widths can truncate during the earlier as u16 cast even when addition does not overflow. Clamp while still usize or use bounded/saturating conversion before arithmetic. Test widths around 65535 and 65536 as well as ordinary text. Duplicate checks: width, overflow, full existing descriptions.\n\n## Steps to Reproduce\nStandalone Rust 2024 package with improvise and improvise-io path dependencies:\n```rust\nuse improvise_io::workbook::Workbook;\nfn revenue_book()-\u003eWorkbook {\n use improvise_io::model::cell::{CellKey,CellValue};\n let mut w=Workbook::new(\"T\");w.add_category(\"Region\").unwrap();w.add_item(\"Region\",\"East\").unwrap();w.add_item(\"_Measure\",\"Revenue\").unwrap();\n w.model.set_cell(CellKey::new(vec![(\"Region\".into(),\"East\".into()),(\"_Measure\".into(),\"Revenue\".into())]),CellValue::Number(10.0));w\n}\n#[test] fn wide_cell_clamps_without_overflow() {\n use improvise::{ui::app::App,model::cell::{CellKey,CellValue}};\n let mut w=revenue_book();\n w.model.set_cell(CellKey::new(vec![(\"Region\".into(),\"East\".into()),(\"_Measure\".into(),\"Revenue\".into())]),CellValue::Text(\"x\".repeat(65535)));\n let app=App::new(w,None);\n let widths=improvise::ui::viewport::compute_col_widths(\u0026app.model_state.workbook.model,\u0026app.layout,false,0);\n assert!(widths.iter().all(|w| *w\u003c=32));\n}\n```\nRun cargo test wide_cell_clamps_without_overflow -- --nocapture. Current source failed this test.\n\n## Acceptance Criteria\nThe focused regression passes without weakening the expected behavior; ordinary cases still agree, with both debug and release arithmetic considered.\n","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:37:41Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:58:44Z","closed_at":"2026-09-21T21:58:44Z","close_reason":"Fixed in the 2026-09-21 wave, integrated into main at 1574 passed / 0 failed / 7 ignored, all gates green, every guard fault-injected.\n\nCapped while still usize in the one place every measurement enters (widen). Behaviour identical for every non-overflowing input.\n\nTHE SECOND INSTANCE, which the bead did not name and which was the more dangerous: improvise_protocol::header_level_widths had byte-for-byte the same defect, on the axis gotcha 44 calls the one home for the width rule -- and it is in the PROTOCOL, so it is shared by both front ends. Fixing only the site the bead named would have left the row header and the column widths with two different answers to the same arithmetic.\n\nThe release symptom is the one to lead with, not the panic: the WIDEST value in the document silently rendering at the NARROWEST column width, with no failing test anywhere. Faults injected: debug -\u003e 'attempt to add with overflow'; release -\u003e left [5], right [32], the wrap reported as the minimum width.\n\n'as u16 followed by + 1 and .clamp' is a greppable shape and the grep finds both.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-l3hm","title":"[bug] i on CellId::Create is a silent black hole; Aggregate/Derived decline out loud","description":"## Summary\n`i` / `a` on a `CellId::Create` (records-mode `+` column) is a silent black hole (`KeyOutcome` upstream/none), while Aggregate and Derived decline out loud with `SetStatus`. Same doctrine (`an_upstream_key_is_a_black_hole`) that drove loud declines for the other non-editable ids.\n\n## Repro (reasoning / unit-shaped)\n1. Records mode with a creating column under the cursor (`CellId::Create`).\n2. Press `i`.\n3. Observe: no status, no mode change — key appears dead.\n\n## Expected\nA local `SetStatus` naming that creating/naming a column is not available in the browser yet (same family as Aggregate’s drill message and Derived’s formula message), per clients.md loud-decline doctrine.\n\n## Actual\n`edit_or_drill` match arm `CellId::Create(_) =\u003e None` in `crates/improvise-protocol/src/resolve.rs`, which becomes Upstream/Ignored rather than Local SetStatus. Comment cites improvise-poia (projection identity; closed) — the loud-decline gap remains.\n\n## Evidence\n- Code hunt by external Grok Bot agent for Ed Langley, 2026-09-21 PT.\n- Contrast: `i_on_an_aggregate_declines_out_loud` / `i_on_a_derived_cell_names_the_formula` in the same file.\n- Closed improvise-poia / improvise-gcvb covered identity/commit, not this decline wording.\n\n## Notes\nFound by an external Grok Bot agent smoke/code hunt for Ed Langley (not Claude). Not an upstream black-hole key that is documented as intentionally silent for host features like `[`/`]`/drill wire — this is the client’s own edit-or-drill door answering None.\n","status":"open","priority":3,"issue_type":"bug","created_at":"2026-09-21T20:32:39Z","created_by":"box","updated_at":"2026-09-21T20:32:39Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-pj8a","title":"The browser paints the host's hints and help over a smaller capability set, and ten keys are silent","description":"Found by the UX audit. ASSERTED (read: keymap + declined() + Upstream list + help pages).\n\nThe browser paints the HOST'S hint line and the HOST'S help pages over a smaller capability set. Three tiers.\n\n## (a) Loud — honest, but reads as broken\nSix commands are declined() with \"... is not available in the browser yet\": drill-into-cell, toggle-prune-empty, toggle-records-mode, add-record-row, delete-formula-at-cursor, view-back.\n\nFOUR OF THEM ARE ADVERTISED BY THE HINT LINE THE BROWSER ITSELF PAINTS: R:records, P:prune, \u003e:drill, and Del/d:delete in the FORMULAS line. The footer offers a key and the key answers no. Note you can ADD a formula in the browser but not DELETE one.\n\n## (b) Silent — the worse tier\nThe Upstream list resolves to ClientFrame::Key, which Host::handle answers with Vec::new() (ui/host.rs:82). Nothing happens, nothing is said: / n N, Enter (enter-advance), Ctrl-S, Ctrl-Q, z, H, and the g/y/Z prefixes.\n\nThe browser paints the SHARED help overlay, so HELP PAGE 2'S ENTIRE \"SEARCH\" SECTION, and page 3's \"Enter — Edit current cell (same as i)\", ARE TAUGHT TO A BROWSER USER AND DO NOTHING. Enter is the sharpest: on the same help page, `i` works and its documented synonym is inert.\n\nThere is also no which-key popup in the browser, since prefixes go Upstream — so g, y, Z are dead AND unexplained, where the TUI pops a menu.\n\n## (c) Dropped entirely\nCovered by the sibling ticket on page_coords and totals.\n\n## Acceptance Criteria\n- Extend declined() to cover the Upstream names with a status, exactly as (a) does. That turns \"broken\" into \"not here yet\" for ten keys and costs one match arm each. THIS IS THE HIGH-VALUE HALF.\n- Decide what the browser's help overlay should show: either mark the unavailable sections, or derive the pages per surface. The pages are already derived from the keymap (help.rs), so a capability filter is the natural shape.\n- The hint line should not advertise a key the surface declines.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:30:20Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:30:20Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-l3hm","title":"[bug] i on CellId::Create is a silent black hole; Aggregate/Derived decline out loud","description":"## Summary\n`i` / `a` on a `CellId::Create` (records-mode `+` column) is a silent black hole (`KeyOutcome` upstream/none), while Aggregate and Derived decline out loud with `SetStatus`. Same doctrine (`an_upstream_key_is_a_black_hole`) that drove loud declines for the other non-editable ids.\n\n## Repro (reasoning / unit-shaped)\n1. Records mode with a creating column under the cursor (`CellId::Create`).\n2. Press `i`.\n3. Observe: no status, no mode change — key appears dead.\n\n## Expected\nA local `SetStatus` naming that creating/naming a column is not available in the browser yet (same family as Aggregate’s drill message and Derived’s formula message), per clients.md loud-decline doctrine.\n\n## Actual\n`edit_or_drill` match arm `CellId::Create(_) =\u003e None` in `crates/improvise-protocol/src/resolve.rs`, which becomes Upstream/Ignored rather than Local SetStatus. Comment cites improvise-poia (projection identity; closed) — the loud-decline gap remains.\n\n## Evidence\n- Code hunt by external Grok Bot agent for Ed Langley, 2026-09-21 PT.\n- Contrast: `i_on_an_aggregate_declines_out_loud` / `i_on_a_derived_cell_names_the_formula` in the same file.\n- Closed improvise-poia / improvise-gcvb covered identity/commit, not this decline wording.\n\n## Notes\nFound by an external Grok Bot agent smoke/code hunt for Ed Langley (not Claude). Not an upstream black-hole key that is documented as intentionally silent for host features like `[`/`]`/drill wire — this is the client’s own edit-or-drill door answering None.\n","status":"open","priority":3,"issue_type":"bug","created_at":"2026-09-21T20:32:39Z","created_by":"box","updated_at":"2026-09-21T20:32:39Z","dependencies":[{"issue_id":"improvise-l3hm","depends_on_id":"improvise-6jk","type":"parent-child","created_at":"2026-09-25T01:04:43Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-pj8a","title":"The browser paints the host's hints and help over a smaller capability set, and ten keys are silent","description":"Found by the UX audit. ASSERTED (read: keymap + declined() + Upstream list + help pages).\n\nThe browser paints the HOST'S hint line and the HOST'S help pages over a smaller capability set. Three tiers.\n\n## (a) Loud — honest, but reads as broken\nSix commands are declined() with \"... is not available in the browser yet\": drill-into-cell, toggle-prune-empty, toggle-records-mode, add-record-row, delete-formula-at-cursor, view-back.\n\nFOUR OF THEM ARE ADVERTISED BY THE HINT LINE THE BROWSER ITSELF PAINTS: R:records, P:prune, \u003e:drill, and Del/d:delete in the FORMULAS line. The footer offers a key and the key answers no. Note you can ADD a formula in the browser but not DELETE one.\n\n## (b) Silent — the worse tier\nThe Upstream list resolves to ClientFrame::Key, which Host::handle answers with Vec::new() (ui/host.rs:82). Nothing happens, nothing is said: / n N, Enter (enter-advance), Ctrl-S, Ctrl-Q, z, H, and the g/y/Z prefixes.\n\nThe browser paints the SHARED help overlay, so HELP PAGE 2'S ENTIRE \"SEARCH\" SECTION, and page 3's \"Enter — Edit current cell (same as i)\", ARE TAUGHT TO A BROWSER USER AND DO NOTHING. Enter is the sharpest: on the same help page, `i` works and its documented synonym is inert.\n\nThere is also no which-key popup in the browser, since prefixes go Upstream — so g, y, Z are dead AND unexplained, where the TUI pops a menu.\n\n## (c) Dropped entirely\nCovered by the sibling ticket on page_coords and totals.\n\n## Acceptance Criteria\n- Extend declined() to cover the Upstream names with a status, exactly as (a) does. That turns \"broken\" into \"not here yet\" for ten keys and costs one match arm each. THIS IS THE HIGH-VALUE HALF.\n- Decide what the browser's help overlay should show: either mark the unavailable sections, or derive the pages per surface. The pages are already derived from the keymap (help.rs), so a capability filter is the natural shape.\n- The hint line should not advertise a key the surface declines.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:30:20Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:30:20Z","dependencies":[{"issue_id":"improvise-pj8a","depends_on_id":"improvise-6jk","type":"parent-child","created_at":"2026-09-25T01:04:45Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-rr8n","title":"The tile bar's permanent hint is false, and the status bar contradicts the wizard on four of eight steps","description":"Found by the UX audit. Two findings about on-screen text that is wrong or contradictory. Both ASSERTED (read against the keymap), both cheap to confirm in a terminal.\n\n## 1 — the tile bar's permanent hint is FALSE\nsrc/ui/tile_bar.rs:140: let hint = \" Ctrl+^v\u003c-\u003e to move tiles\";\n\nThose four keys are bound to enter-tile-select, not to moving anything (keymap.rs:632):\n normal.bind(KeyCode::Left, ctrl, \"enter-tile-select\"); // and Right/Up/Down\n\nPressing Ctrl-Right ENTERS TILES MODE; nothing moves. This is the only persistent on-screen documentation of those keys and it describes a different command. It also spells `Ctrl+` where keymap::key_label — and therefore every derived hint and help row — spells `Ctrl-`.\n\nFix: \" Ctrl-^v\u003c-\u003e or T to pick a tile\".\n\n## 2 — four hint dialects, two on screen at once, disagreeing\ndraw.rs::draw() stacks the tile bar (row 3) above the status bar (row 4), so in tile-select mode the user sees on adjacent lines:\n\n tile bar: [Enter] cycle axis [r/c/p] set axis [\u003c-\u003e] select [Esc] cancel\n status bar: hl\u003c-\u003e:select Enter/Space:cycle r:row c:col p:page Esc/Tab:back\n\nDifferent notation, different content — the tile bar omits Space, h/l and Tab.\n\nThe import wizard has the same overlap, and there the status bar is WRONG ON FOUR OF EIGHT STEPS:\n\n NameModel overlay: Enter build, \u003c- back, Esc cancel | status: Space:toggle c:cycle | TRUTH: Space and c TYPE INTO THE NAME\n DefineFormulas overlay: n new, d delete, Enter next | status: Space:toggle c:cycle | TRUTH: n/d; Space and c do nothing\n Done overlay: Enter accept, \u003c-/Esc back | status: Enter:next Esc:cancel| TRUTH: Esc goes BACK, does not cancel\n Preview overlay: Press Enter to continue | status: Space:toggle c:cycle | TRUTH: Enter/Space advance\n\nThe `Done` row is an outright contradiction: the status bar tells the user Esc cancels when it goes back.\n\n## Acceptance Criteria\n- Fix the tile-bar string and its Ctrl+ / Ctrl- spelling.\n- SUPPRESS the status-bar hint when an overlay that draws its own is up — make hint_tail(ImportWizard) empty and skip draw_status's hint fallback in TileSelect. The tile bar and wizard already draw correct, local, per-state hints, so the cheap repair is to stop drawing the second table rather than to maintain it.\n- Longer term the wizard's per-step keys belong in advertised() keyed by step, which makes them derived like everything else.\n\nNote the wizard's back path itself is SOUND: Left steps back from every step (every_step_binds_left_as_back proves it), Done's Esc goes back rather than discarding. The only hole is Left being dead while a formula is typed, survivable because Esc cancels the edit first.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-tui","owner":"el-github@elangley.org","created_at":"2026-09-21T20:30:19Z","created_by":"Edward Langley","updated_at":"2026-09-23T02:00:33Z","started_at":"2026-09-23T01:37:05Z","closed_at":"2026-09-23T02:00:33Z","close_reason":"PR #149, with a DELIBERATE INVERSION of the acceptance criteria for tile select, accepted by the orchestrator. The ticket asked to rewrite the tile-bar literal and suppress the derived status hint; the agent deleted BOTH tile-bar literals instead, on the ticket's own finding 2 -- the derived status line is a strict superset (Space, h/l, Tab appear only there) and cannot go stale, so suppressing it would delete information and keep the table that had just rotted. Verified before accepting: the literal read 'Ctrl+arrows to move tiles' while those keys are bound to enter-tile-select, so it was already false. This follows design-principles section 5 directly (a derivation done twice is the defect; sharing the table beats testing two copies). The import wizard keeps the ticket's ORIGINAL answer, because its derived line comes from the mode keymap while the real keys are per-step inside HandleWizardKey. NOT PREDICTED: the shipped characterization test renders at 80 columns, where the hint does not fit and is never drawn -- the one test pinning that widget could not see either hint. (--force: agent-tui handed back.)","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-ppru","title":"Nondefault measure aggregation is lost by the .improv roundtrip","description":"Report-only bug hunt at HEAD 70025e2, 2026-09-21. No implementation changes.\n\nLocation: crates/improvise-io/src/persistence/format.rs; crates/improvise-core/src/model/types.rs:234,473\n\nSet model.measure_agg[Price] to AggFunc::Avg; after parse_md(format_md(wb)) measure_agg is empty and lookup falls back to Sum. This is API/legacy-JSON reachable: no current production setter for measure_agg was found, so priority is lower than ordinary UI persistence defects. Add a grammar-backed representation or explicitly resolve this unsupported state. Duplicate checks: measure_agg, aggregation, average, persist.\n\n## Steps to Reproduce\nA standalone Rust 2024 test package depends on improvise-io by path. Imports:\n```rust\nuse improvise_io::{workbook::Workbook,persistence::{format_md,parse_md},formula::AggFunc};\nuse improvise_io::import::csv_parser::parse_csv_bytes_with_warnings;\n#[test] fn aggregation_survives_save() {\n let mut w=Workbook::new(\"test\"); w.add_item(\"_Measure\",\"Price\").unwrap();\n w.model.measure_agg.insert(\"Price\".into(),AggFunc::Avg);\n let r=parse_md(\u0026format_md(\u0026w)).unwrap(); assert!(matches!(r.model.measure_agg.get(\"Price\"),Some(AggFunc::Avg)), \"loaded aggregation: {:?}\",r.model.measure_agg);\n}\n```\nRun cargo test aggregation_survives_save. This test failed against the unchanged source.\n\n## Acceptance Criteria\nThe focused test passes after correction, with a control covering the unchanged ordinary case. Preserve the behavior described above without silent data loss.\n","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:27:45Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:58:16Z","closed_at":"2026-09-21T21:58:16Z","close_reason":"Fixed in the 2026-09-21 wave, integrated into main at 1574 passed / 0 failed / 7 ignored, all gates green, every guard fault-injected.\n\nNew ## Aggregation section, written SORTED because measure_agg is a HashMap (gotcha 18). The ticket's 'no current production setter' hid a second defect: writing it naively is a gotcha-18 bug the moment anything sets more than one entry. The low priority was about reachability, not about the fix being trivial.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-el9h","title":"The server's mirror settles its mode once and never again, so it diverges from the client it mirrors","description":"Found by the command-layer bug hunt. ASSERTED, with the settling test specified.\n\nClientSession::fold (project.rs:711) settles self.view.mode against self.grid.records — the grid LAST PROJECTED to that client. project_diff (project.rs:758) updates self.grid AFTER the fold and never re-settles.\n\nThe client side does not have this shape: ClientState::fold calls settle_mode() on EVERY fold including Command::Project, so when the new view's grid projection arrives the client re-settles and is correct.\n\nSo for [SwitchView(records-shaped view), ChangeMode(Normal)] — precisely the case PR #98 exists to fix — THE MIRROR KEEPS Normal WHILE THE CLIENT ENDS ON RecordsNormal. That commit's own message says the mirror was given the settle \"so the two cannot diverge in a field nobody currently reads\"; the mechanism as written does not achieve it.\n\nSecond, smaller instance of the same shape: apply_switch (project.rs:930), adopt_deleted_views and adopt_cursor_epoch all write the mirror with bare reduce_view, bypassing fold and therefore the settle — three doors past the one documented as \"the same function the client runs\".\n\n## Why P3\nClientSession::view() has no production caller (grepped across improvise-command, improvise-ws-server, improvise-worker-server), and reseed/connect rebuild the view from fresh_view(app) rather than from the folded mirror, so no Snapshot currently carries the wrong value. It is a correctness hole waiting for its first reader — which is exactly the shape improvise-e5pf was before someone read it.\n\n## Acceptance Criteria\n- The settling test, which is specified and cheap: drive a Host with a pivot-shaped default view and a second records-shaped view, Sessions::connect, send ClientFrame::Commands([SwitchView(\"records view\"), ChangeMode(Normal)]), and compare session.view().mode against a ClientState fed the resulting ServerFrames in arrival order. ClientSession::view is pub so this is reachable from a test in project.rs.\n- Then the one-line fix: re-settle at the end of project_diff after self.grid = desired. Or make fold the only writer of the mirror's ViewState and settle there plus after each projection — which also closes the three bypass doors.\n- Do NOT fix before the test exists: the current behaviour is unobservable, so a fix without a test is unfalsifiable.\n","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:25:15Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:58:44Z","closed_at":"2026-09-21T21:58:44Z","close_reason":"Fixed in the 2026-09-21 wave, integrated into main at 1574 passed / 0 failed / 7 ignored, all gates green, every guard fault-injected.\n\nsettle_mode now called from fold AND at the end of project_diff, which also covers the three bare-reduce_view doors. The test was written first and failed exactly as the bead predicted (client RecordsNormal, mirror Normal).\n\nNote the fixture trap found on the way: a fresh View::new carries no axis entry for _Index/_Dim, so is_records_mode can never fire on one. Without the precondition assertion the test would have compared two Normals and PASSED against the unfixed code.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-i1wi","title":"scrolled_to drops viewport_effects' max(1) guard, so click_cell underflows on a zero window","description":"Found by the protocol bug hunt. DEMONSTRATED (debug build).\n\nresolve.rs:525 and :531 —\n\n row_offset = nr.saturating_sub(window_rows - 1);\n col_offset = nc.saturating_sub(window_cols - 1);\n\nIts original, improvise-command/src/command/cmd/navigation.rs:48, has\n\n let vr = visible_rows.max(1);\n let vc = visible_cols.max(1);\n\nTHE MIRROR DROPPED THE GUARD. grid_move (resolve.rs:497) checks window_rows == 0 || window_cols == 0 and returns early — but click_cell (:167) does NOT, and it calls scrolled_to directly.\n\n ClientState::default() with grid.extent = {rows:5, cols:5}, window left at (0,0)\n click(\u0026state, Click::Cell{row:1, col:1})\n -\u003e panicked at panic_const_sub_overflow\n resolve::scrolled_to resolve.rs:525\n resolve::click_cell resolve.rs:176\n\nIn a release wasm build (overflow-checks off) it does not panic: 0usize - 1 wraps and both offsets silently collapse to 0.\n\n## Reachability\nLow from the shipped page. shell.js::extentOf clamps to Math.max(1, ..) and app.js sends set_viewport before any grid arrives, so window == (0,0) needs a click before the handshake. The frame-only Client::viewport twin is deleted (improvise-seik). So this is a latent panic in a pub API and a real mirror divergence, not a live user bug.\n\n## Why it matters anyway\ndoctrine(mirror_the_cmd_line_for_line) is the rule these resolvers live under, and the whole point is that a resolver reproduces its Cmd INCLUDING where siblings disagree. A dropped .max(1) is the mirror silently disagreeing, and tests/lockstep.rs did not catch it because no fixture has a zero window.\n\n## Acceptance Criteria\n- One line at resolve.rs:517: let (window_rows, window_cols) = (state.window.0.max(1), state.window.1.max(1));\n- A lockstep case with a zero window, or an explicit note in the evidence that no fixture has one and why.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:24:49Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:24:49Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-j79v","title":"Four click surfaces still use click, so a gesture from an open editor is swallowed","description":"Found by the browser bug hunt. ASSERTED (read, not run) — what would settle it is stated below.\n\nimprovise-0x9u fixed the grid's cell listener: web/app.js:321 is on MOUSEDOWN with event.preventDefault(), and the comment at :318 gives the reason — from an editing state the focused \u003cinput\u003e's blur repaints first and the element is detached before click can fire.\n\nTHE OTHER FOUR CLICK SURFACES KEPT `click`:\n web/app.js:396 paintPanel (view and formula panel rows)\n web/app.js:436 paintTiles (the tile bar)\n web/app.js:453 categoryList (category panel rows)\n\n## Steps to Reproduce\nOpen the demo. Click an editable cell — the inline \u003cinput\u003e opens and takes focus. Now click a tile in the tile bar (or a category/view/formula panel row).\n\nMousedown's default action moves focus off the \u003cinput\u003e -\u003e blur -\u003e dismiss(false) -\u003e close() -\u003e painted = -1 -\u003e repaint() -\u003e replaceChildren(). The element the user pressed is now detached, mouseup lands elsewhere, and NO click event reaches the listener.\n\nThe gesture is swallowed: the first click only closes the editor. The user clicks twice for one action, or concludes the tile bar is dead.\n\n## Acceptance Criteria\n- The four listeners become mousedown + preventDefault, matching the td arm. A shared helper would be better; the four are identical in shape.\n- A case in harness/tests/shell.spec.js (the right home per gotcha 48): open the inline editor on #grid td.editable, click #tiles .tile, assert the tile's axis changed after ONE click.\n- Prove the mechanism is the one named: revert the td listener to click and confirm the existing commit test fails.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:24:14Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:24:14Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-i1wi","title":"scrolled_to drops viewport_effects' max(1) guard, so click_cell underflows on a zero window","description":"Found by the protocol bug hunt. DEMONSTRATED (debug build).\n\nresolve.rs:525 and :531 —\n\n row_offset = nr.saturating_sub(window_rows - 1);\n col_offset = nc.saturating_sub(window_cols - 1);\n\nIts original, improvise-command/src/command/cmd/navigation.rs:48, has\n\n let vr = visible_rows.max(1);\n let vc = visible_cols.max(1);\n\nTHE MIRROR DROPPED THE GUARD. grid_move (resolve.rs:497) checks window_rows == 0 || window_cols == 0 and returns early — but click_cell (:167) does NOT, and it calls scrolled_to directly.\n\n ClientState::default() with grid.extent = {rows:5, cols:5}, window left at (0,0)\n click(\u0026state, Click::Cell{row:1, col:1})\n -\u003e panicked at panic_const_sub_overflow\n resolve::scrolled_to resolve.rs:525\n resolve::click_cell resolve.rs:176\n\nIn a release wasm build (overflow-checks off) it does not panic: 0usize - 1 wraps and both offsets silently collapse to 0.\n\n## Reachability\nLow from the shipped page. shell.js::extentOf clamps to Math.max(1, ..) and app.js sends set_viewport before any grid arrives, so window == (0,0) needs a click before the handshake. The frame-only Client::viewport twin is deleted (improvise-seik). So this is a latent panic in a pub API and a real mirror divergence, not a live user bug.\n\n## Why it matters anyway\ndoctrine(mirror_the_cmd_line_for_line) is the rule these resolvers live under, and the whole point is that a resolver reproduces its Cmd INCLUDING where siblings disagree. A dropped .max(1) is the mirror silently disagreeing, and tests/lockstep.rs did not catch it because no fixture has a zero window.\n\n## Acceptance Criteria\n- One line at resolve.rs:517: let (window_rows, window_cols) = (state.window.0.max(1), state.window.1.max(1));\n- A lockstep case with a zero window, or an explicit note in the evidence that no fixture has one and why.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:24:49Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:24:49Z","dependencies":[{"issue_id":"improvise-i1wi","depends_on_id":"improvise-6jk","type":"parent-child","created_at":"2026-09-25T01:04:42Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-j79v","title":"Four click surfaces still use click, so a gesture from an open editor is swallowed","description":"Found by the browser bug hunt. ASSERTED (read, not run) — what would settle it is stated below.\n\nimprovise-0x9u fixed the grid's cell listener: web/app.js:321 is on MOUSEDOWN with event.preventDefault(), and the comment at :318 gives the reason — from an editing state the focused \u003cinput\u003e's blur repaints first and the element is detached before click can fire.\n\nTHE OTHER FOUR CLICK SURFACES KEPT `click`:\n web/app.js:396 paintPanel (view and formula panel rows)\n web/app.js:436 paintTiles (the tile bar)\n web/app.js:453 categoryList (category panel rows)\n\n## Steps to Reproduce\nOpen the demo. Click an editable cell — the inline \u003cinput\u003e opens and takes focus. Now click a tile in the tile bar (or a category/view/formula panel row).\n\nMousedown's default action moves focus off the \u003cinput\u003e -\u003e blur -\u003e dismiss(false) -\u003e close() -\u003e painted = -1 -\u003e repaint() -\u003e replaceChildren(). The element the user pressed is now detached, mouseup lands elsewhere, and NO click event reaches the listener.\n\nThe gesture is swallowed: the first click only closes the editor. The user clicks twice for one action, or concludes the tile bar is dead.\n\n## Acceptance Criteria\n- The four listeners become mousedown + preventDefault, matching the td arm. A shared helper would be better; the four are identical in shape.\n- A case in harness/tests/shell.spec.js (the right home per gotcha 48): open the inline editor on #grid td.editable, click #tiles .tile, assert the tile's axis changed after ONE click.\n- Prove the mechanism is the one named: revert the td listener to click and confirm the existing commit test fails.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:24:14Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:24:14Z","dependencies":[{"issue_id":"improvise-j79v","depends_on_id":"improvise-fhj0","type":"parent-child","created_at":"2026-09-25T01:05:39Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-qx1z","title":"hidden_items and collapsed_groups outlive the item or category they name","description":"Found by the core bug hunt, DEMONSTRATED, two arms of one asymmetry.\n\nA view's hidden_items and collapsed_groups outlive the item or category they name, so re-adding it brings it back in the hidden/collapsed state nobody asked for.\n\n## Arm 1 — hidden_items survives an item removal\nWorkbook::remove_item (workbook.rs:92) calls Model::remove_item (model/types.rs:331), which removes the item and its cells and TOUCHES NO VIEW AT ALL. hidden_items[\"Region\"] keeps \"East\".\n\n hide Region/East -\u003e remove item East -\u003e re-add East\n =\u003e view.is_hidden(\"Region\",\"East\") == true\n\nThe item is in the model, visible in the category panel, and ABSENT FROM THE GRID with no indication why.\n\n## Arm 2 — collapsed_groups survives a category removal\nView::on_category_removed (view/types.rs:92) clears category_axes, page_selections and hidden_items — but NOT collapsed_groups. The other three lines are right there, which is what makes this an omission rather than a decision.\n\n collapse Month/Q1 -\u003e remove category Month -\u003e re-add Month with Jan[Q1]\n =\u003e view.is_group_collapsed(\"Month\",\"Q1\") == true\n\nThe group comes back pre-collapsed.\n\nBoth reachable from shipped keys: hide an item, delete the item or category from the category panel, re-add it.\n\n## Acceptance Criteria\n- Arm 2 is one line: `self.collapsed_groups.remove(cat_name);` in View::on_category_removed.\n- Arm 1 wants a View::on_item_removed(cat, item) dropping the hidden_items entry, called from Workbook::remove_item over every view — Workbook is already the stated home of cross-view ops (gotcha 6, the item facade).\n- A test per arm. Neither is covered today.\n- While there, check the same question of page_selections: does a page selection naming a removed item survive?\n","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:23:04Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:58:16Z","closed_at":"2026-09-21T21:58:16Z","close_reason":"Fixed in the 2026-09-21 wave, integrated into main at 1574 passed / 0 failed / 7 ignored, all gates green, every guard fault-injected.\n\nView::on_item_removed added and called from Workbook::remove_item over every view; on_category_removed now clears collapsed_groups. Both arms plus the third question the bead asked (page selection).","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-hsoe","title":"check-evidence cannot see a duplicate heading, and three exist","description":"Found by the Wave I agent while merging PR #92, which had accumulated TWO entries headed `### a_window_narrows_the_cells_and_neither_the_totals_nor_the_widths` — the original, describing a fixture that no longer existed and repeating a claim the adversarial review had measured false, plus the superseding one. A reader hitting the first would have taken it as current.\n\n--check-evidence CANNOT SEE THIS. It verifies that a heading names a test that runs, and a duplicate heading names one just as well. Both copies pass.\n\n## Three already on main, verified 2026-09-21\n ### a_layout_change_moves_the_layout_version_and_no_record (test-evidence/command.md)\n ### the_two_front_ends_draw_the_same_column_header (test-evidence/tui.md)\n ### the_whole_frame_is_unchanged (test-evidence/tui.md)\n\nReproduce:\n grep -rhoE '^### [A-Za-z_:][A-Za-z_0-9:]*$' context/test-evidence/*.md | sort | uniq -d\n\n## Why it matters beyond tidiness\ncontext/test-evidence records WHY a test can fail. Two entries under one heading means one of them is stale by construction — they cannot both describe the current test — and nothing indicates which. That is the same class as an evidence entry whose mechanism measurement contradicts (doctrine an_evidence_entry_that_measurement_contradicts_is_worse_than_none): a reader gets a confident wrong answer rather than no answer.\n\n## Acceptance Criteria\n- --check-evidence refuses a duplicate heading, naming the file and both line numbers. It should refuse rather than warn, per the precedent set for ambiguous leaf names in improvise-rm4s.\n- Prove it by injection: add a second copy of an existing heading and watch the checker fail by name.\n- Resolve the three above. Each needs a read to decide which copy is current, or whether they describe genuinely different aspects and want distinct names.\n- Consider whether the same check should apply ACROSS files, not only within one: a heading is keyed to a test name, and test names are unique workspace-wide once module-qualified (improvise-rm4s), so two files claiming the same test is also a defect.\n","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T12:39:28Z","created_by":"Edward Langley","updated_at":"2026-09-21T18:04:26Z","closed_at":"2026-09-21T18:04:26Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-swre","title":"grid_of recomputes an unchanged grid every frame; memoise on the tiers that already exist","description":"Split out of improvise-jmjz on the adversarial review of PR #92, which closed jmjz on its own acceptance criterion (fuse the passes) and argued this is the separate, larger win.\n\ngrid_of recomputes the whole grid from scratch EVERY FRAME, even when nothing moved. After jmjz that is exactly one pass rather than three — 95ms at 18,000 cells, 1.32s at 90,000 — but the common case during scrolling, panel toggling and cursor movement is that the grid has not changed at all.\n\n## Why this is cheap to do here\nThe invalidation signal ALREADY EXISTS and is maintained for other reasons:\n- document_generation, layout_version, record_versions (gotcha 40, ADR 001's three tiers)\n- improvise-mcq4's view_shapes / view_frame observation\n\nA GridBody memo keyed on those takes an unchanged grid to roughly zero.\n\n## Why the remaining pass cannot be narrowed instead\nRuled out twice over, and the review measured it: totals sum EVERY row including ones a viewport scrolled past (gotcha 44), and widths are content-adaptive over every row. The benchmark's windowing ratio stayed 1.2-1.4x precisely because the surviving pass is the one a viewport cannot narrow. improvise-i5tk's decline stands.\n\n## Acceptance Criteria\n- A memo on the projected grid body keyed on the existing invalidation tiers; no NEW tier invented, because a second description of \"has the document changed\" is the disease (see the reasoning behind gotcha 39 and 45, where the trigger is the counter moving rather than a table of command variants).\n- Benchmarked: an unchanged grid across frames should approach zero, and the first frame must not regress.\n- The negative arm matters as much as the positive: a test that a grid which DID change is recomputed. A memo that never invalidates is fast and wrong, and it is the failure mode nothing will notice.\n\n## Second-order, much bigger, not this ticket\n~5us per cell at 18k is dominated by evaluate_aggregated doing a matching_values set intersection per cell (gotcha 18). An incremental aggregate index is the deeper fix. 90,000 cells at 1.32s remains unusable; that case is labelled pathological and memoisation helps it most.\n","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-21T11:30:15Z","created_by":"Edward Langley","updated_at":"2026-09-21T11:30:15Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-swre","title":"grid_of recomputes an unchanged grid every frame; memoise on the tiers that already exist","description":"Split out of improvise-jmjz on the adversarial review of PR #92, which closed jmjz on its own acceptance criterion (fuse the passes) and argued this is the separate, larger win.\n\ngrid_of recomputes the whole grid from scratch EVERY FRAME, even when nothing moved. After jmjz that is exactly one pass rather than three — 95ms at 18,000 cells, 1.32s at 90,000 — but the common case during scrolling, panel toggling and cursor movement is that the grid has not changed at all.\n\n## Why this is cheap to do here\nThe invalidation signal ALREADY EXISTS and is maintained for other reasons:\n- document_generation, layout_version, record_versions (gotcha 40, ADR 001's three tiers)\n- improvise-mcq4's view_shapes / view_frame observation\n\nA GridBody memo keyed on those takes an unchanged grid to roughly zero.\n\n## Why the remaining pass cannot be narrowed instead\nRuled out twice over, and the review measured it: totals sum EVERY row including ones a viewport scrolled past (gotcha 44), and widths are content-adaptive over every row. The benchmark's windowing ratio stayed 1.2-1.4x precisely because the surviving pass is the one a viewport cannot narrow. improvise-i5tk's decline stands.\n\n## Acceptance Criteria\n- A memo on the projected grid body keyed on the existing invalidation tiers; no NEW tier invented, because a second description of \"has the document changed\" is the disease (see the reasoning behind gotcha 39 and 45, where the trigger is the counter moving rather than a table of command variants).\n- Benchmarked: an unchanged grid across frames should approach zero, and the first frame must not regress.\n- The negative arm matters as much as the positive: a test that a grid which DID change is recomputed. A memo that never invalidates is fast and wrong, and it is the failure mode nothing will notice.\n\n## Second-order, much bigger, not this ticket\n~5us per cell at 18k is dominated by evaluate_aggregated doing a matching_values set intersection per cell (gotcha 18). An incremental aggregate index is the deeper fix. 90,000 cells at 1.32s remains unusable; that case is labelled pathological and memoisation helps it most.\n","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-21T11:30:15Z","created_by":"Edward Langley","updated_at":"2026-09-21T11:30:15Z","dependencies":[{"issue_id":"improvise-swre","depends_on_id":"improvise-pmt0","type":"parent-child","created_at":"2026-09-25T01:05:06Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-4dpj","title":"The RecordsNormal-to-Normal arm of settle_normal_mode has zero coverage, and it is live","description":"Two gaps in settle_normal_mode's coverage, both live code with zero tests. The second was raised by ChatGPT Codex on PR #91 and is recorded here because the behaviour turned out CORRECT and only the guard is missing.\n\n## Gap 1 — the RecordsNormal-to-Normal arm (found by injection)\nReplacing settle_normal_mode's guard with a one-way version — matching only AppMode::Normal, dropping the RecordsNormal arm — leaves THE ENTIRE WORKSPACE SUITE PASSING.\n\nThe existing crossing test drives the LAYOUT both ways, but both branches end with `enter-mode normal`, so the mode handed to the settle is AppMode::Normal in BOTH directions. It discriminates \"settles at all\" and \"settles against the ending layout\", but not \"settles from either mode\".\n\nThe arm is live, proved reachable in production:\n\n records mode -\u003e apply_effects([SetAxis { \"_Index\", Axis::None }])\n layout.is_records_mode() == false, mode was RecordsNormal\n -\u003e settle yields AppMode::Normal\n\nOne extra case in the existing loop — seed the mode as RecordsNormal, cross OUT — closes it, and the one-way injection must then fail by name.\n\n## Gap 2 — the SwitchView path, raised by Codex and VERIFIED CORRECT\nCodex reviewed PR #91 at c186909 (round 1, the per-effect resolution) and pointed out that SwitchViewAtCursor emits [SwitchView(target), ChangeMode(Normal)]: open the view panel over a records view, select a pivot view, and round 1 would leave RecordsNormal over the pivot grid, with the inverse leaving Normal over records.\n\nRound 2 (a10e95e) moved the settle to ONE call after rebuild_layout at the end of apply_effects, so it resolves against the POST-batch layout. Verified on main at ce895ff with a throwaway probe driving a real `:` line that crosses records-ness within one batch:\n\n start_records=true -\u003e ended_records=false mode=Normal\n start_records=false -\u003e ended_records=true mode=RecordsNormal\n\nCorrect in both directions. So the behaviour Codex named is fixed.\n\nTHE GUARD IS STILL MISSING. Codex asked specifically for \"a regression that switches between records and pivot views through the view panel\", and no such test exists. The path it names is different from the one the crossing test drives (toggle-records-mode, not SwitchView), and records-ness is auto-detected from the axes (gotcha 1), so a view switch can change it.\n\n## Acceptance Criteria\n- The gap-1 case added to the existing loop; the one-way-guard injection fails by name.\n- A regression driving SwitchViewAtCursor over the view panel, records view to pivot view AND back, asserting the mode matches the grid the batch ends on.\n- Both must fail if the settle is moved back before rebuild_layout.\n","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T11:29:18Z","created_by":"Edward Langley","updated_at":"2026-09-21T18:04:27Z","closed_at":"2026-09-21T18:04:27Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-26dm","title":"The fourth length-not-completeness list: cases.len() == 13 in ui/effect.rs","description":"improvise-5ubs is closed for the two CYCLIC all() functions, by reading serde's\nderive-generated variant list out of its \"unknown variant ... expected one of\"\nmessage and diffing it against the names the cycle reached. That closes the\nAppMode and Rejection halves and the mechanism generalises to any serde enum.\n\nIt does NOT reach the FOURTH instance 5ubs records in its notes:\nlayout_geometry_effects_actually_change_document_config in\ncrates/improvise-command/src/ui/effect.rs, whose assert_eq!(cases.len(), 13) is\nover a hand-built list of EFFECTS, not of enum variants. serde has nothing to\nsay about it: an effect is a struct implementing a trait, so there is no derived\nenumeration to diff against.\n\nThe general statement stands and is the reason this is worth its own ticket:\ndoctrine(a_list_checked_for_length_reads_like_a_list_checked_for_completeness).\nA count catches a case being DELETED from the list and never one being ABSENT\nfrom it, and the two are written identically as an assert_eq over a number. The\nadversarial review of PR #78 added a fourteenth pub struct declaring\nEffectKind::Model(ModelChange::LayoutGeometry), pointed at by nothing, and the\nwhole suite stayed green.\n\nWhat would close it: an enumeration of the effects the compiler maintains. The\ncandidates are a proc-macro over the impl blocks, or inventory-style\nregistration, or reading the Debug names off a derived list the way\nevery_model_command_names_the_effect_of_the_same_name already does for the\nModelCommand mapping -- that last one is the closest existing precedent in this\nrepo and is worth looking at first.\n\nNot fixed in wave I because ui/effect.rs was another wave's ground that round,\nand because a fourth hand-rolled attempt in a fourth place is the disease 5ubs\nnames.\n","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T11:09:56Z","created_by":"Edward Langley","updated_at":"2026-09-21T18:04:28Z","closed_at":"2026-09-21T18:04:28Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-e8no","title":"A wide numeric category now refuses a fit that used to ignore it","description":"improvise-zv8t forces every document category to `Forced::Categorical`, which is\nright (a `Year` category with items 2024/2025 is a coordinate, not a number).\nThe cost: a WIDE numeric category one-hots past `MAX_FEATURES` and refuses the\nwhole fit, where before it encoded as one numeric feature and a fit that never\nsplit on it succeeded.\n\nMeasured on 100 records with a 100-item numeric `Year` beside a `Cost` that\npredicts `Score` on its own:\n- pre-zv8t: fit lands on `IF(Cost \u003c 48.015...)`; `Year` never appears.\n- post-zv8t: preflight refuses at 101 features.\n\nPinned by `a_wide_numeric_category_now_refuses_a_fit_that_used_to_ignore_it`\nin crates/improvise-gp/src/fit.rs, and argued in context/code/gp.md section 11.\n\nThe workaround the refusal names works: mark the category a Label and the\npre-zv8t feature set is restored exactly. What is lost is the case where the\nuser wants neither -- a wide numeric category left alone while the fit uses the\nmeasures.\n\n## Steps to Reproduce\n\nBuild a model with a Regular category `Year` holding 100 numeric items\n(1900..1999), a Label category for record identity, and two measures `Cost` and\n`Score` where Score = 4000 if Cost \u003e 50 else 1000. Put `Year` on Axis::Row and\n_Measure on Axis::Page. Run `fit_measure(.., \"Score\", ..)`.\n\nPre-zv8t it lands on `IF(Cost \u003c 48.015..)`. Post-zv8t it is refused with\n\"would encode 101 features from 100 records\". The fixture is\n`wide_category_beside_a_good_measure()` in crates/improvise-gp/src/fit.rs.\n\n## Acceptance Criteria\n\nThe honest fix is a budget that can decline A COLUMN rather than the whole\ntable: `encoded_width` (and the exact check beside it) would drop the widest\ncategory and report it as a `Dataset::warnings` notice -- gotcha 37(a), it comes\nback as DATA and nothing prints. That keeps the zv8t decision intact (a category\nis still never inferred numeric) and changes only what the budget does when it\ncannot afford one.\n\nDo NOT close this by making the forcing conditional on cardinality: that is\ninference by another name, and makes the ENCODING depend on a budget rather than\non what the document says.\n","notes":"STALE-OPEN. Its acceptance -- a budget that DROPS a column rather than refusing the table -- is implemented and passing. Verified by the orchestrator by running them: improvise-gp fit::tests::a_wide_numeric_category_is_dropped_rather_than_refusing_the_fit (crates/improvise-gp/src/fit.rs:735) and the_budget_drops_the_widest_category_and_keeps_the_narrow_one both pass at 1ae2d2e, 1 passed each.\n\nFound by the improvise-k844 agent, which noticed because THIS TICKET'S BODY HAD BEEN COPIED INTO k844's DESCRIPTION -- so it read e8no's text while working k844's title, and checked whether e8no was done. The tracker defect is what surfaced the stale-open.\n\nEighth stale-open found today. See doctrine(a_ready_ticket_may_already_be_fixed).","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T10:55:32Z","created_by":"Edward Langley","updated_at":"2026-09-22T23:11:20Z","closed_at":"2026-09-22T23:11:20Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-sxsf","title":"cargo test -- --list cannot name the binary, so two crates could share a full test path","description":"MEASURED 2026-09-21 (PR #88). Re-verified latent: 1444 names, zero duplicate full paths, zero duplicate (crate, path) pairs.\n\nTHE PROPOSED FIX IS DECLINED ON TWO INDEPENDENT GROUNDS, and the second is the interesting one:\n\n1. COST: 45.2s against 2.4s for the one workspace call (11 members, warm) — 18x. And --check-evidence needs the --ignored twin, so the honest figure is 22 cargo invocations.\n\n2. IT DOES NOT CLOSE THE CLASS IT NAMES. A package is not a binary. The improvise crate alone has FIVE integration test binaries beside its lib, and an integration test's top-level fn lists with NO MODULE PATH AT ALL — 60 of 1444 names are bare. So '-p improvise' prefixes all six identically and tests/a.rs::foo still cannot be told from tests/b.rs::foo.\n\nAny real fix is per-TARGET (--lib, --test \u003cname\u003e), not per-package. Whoever takes this should start there.\n\nWorth noting how this was found: the agent set out to time the per-package fix and discovered it does not work while reading the target list. The cost measurement was the excuse for reading it; the correctness finding was the payoff — an argument for measuring declines rather than asserting them.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T04:49:16Z","created_by":"Edward Langley","updated_at":"2026-09-21T05:23:48Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-sxsf","title":"cargo test -- --list cannot name the binary, so two crates could share a full test path","description":"MEASURED 2026-09-21 (PR #88). Re-verified latent: 1444 names, zero duplicate full paths, zero duplicate (crate, path) pairs.\n\nTHE PROPOSED FIX IS DECLINED ON TWO INDEPENDENT GROUNDS, and the second is the interesting one:\n\n1. COST: 45.2s against 2.4s for the one workspace call (11 members, warm) — 18x. And --check-evidence needs the --ignored twin, so the honest figure is 22 cargo invocations.\n\n2. IT DOES NOT CLOSE THE CLASS IT NAMES. A package is not a binary. The improvise crate alone has FIVE integration test binaries beside its lib, and an integration test's top-level fn lists with NO MODULE PATH AT ALL — 60 of 1444 names are bare. So '-p improvise' prefixes all six identically and tests/a.rs::foo still cannot be told from tests/b.rs::foo.\n\nAny real fix is per-TARGET (--lib, --test \u003cname\u003e), not per-package. Whoever takes this should start there.\n\nWorth noting how this was found: the agent set out to time the per-package fix and discovered it does not work while reading the target list. The cost measurement was the excuse for reading it; the correctness finding was the payoff — an argument for measuring declines rather than asserting them.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T04:49:16Z","created_by":"Edward Langley","updated_at":"2026-09-21T05:23:48Z","dependencies":[{"issue_id":"improvise-sxsf","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T01:05:49Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-8shu","title":"Constrain the .improv grammar generator's strategy; improvise-io's lib binary is the run's outlier","description":"Split out of improvise-8ec3, whose second half (stop pinning one machine's number) is done. This is the first half, left open.\n\nimprovise-io's lib binary is an order of magnitude above every other binary in the run on every machine it has been measured on: 1.40-1.45s over three runs here (vs a ~0.06s median), 3.70-5.24s on the machine that filed 8ec3. The ratio is the portable signal; the seconds are not.\n\nIts slow half is the grammar-walking generator in persistence/parse.rs plus the .improv roundtrip proptests at the default 256 cases. A grammar walk with no size bound generates deeply nested documents whose cost is not uniform across cases, so a few cases dominate.\n\ndoctrine(proptest_cases) rules out the cheap fix: constrain the strategy, do NOT lower the count below 256.\n\nAcceptance: improvise-io's lib binary is no longer an order of magnitude above the run's median, with the case count still 256 and the properties still covering groups, formulas, views, hidden items and pipe-quoting edges. Report the before/after ratio, not seconds.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-21T04:14:01Z","created_by":"Edward Langley","updated_at":"2026-09-21T05:15:42Z","closed_at":"2026-09-21T05:15:42Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-9v0j","title":"view_frame and view_shape are separated by example, not by property","description":"RE-SCOPED 2026-09-21. The ticket proposed a property test asserting that view_frame and view_shape agree — roughly \"every reframe is also a reshape\". THAT PROPERTY IS FALSE and the test would have failed. Measured by the Wave I agent and confirmed by the adversarial review of PR #92, with a prune_empty view of two rows and a ClearCell on North's only cell, driven through sessions.apply_caught_up:\n\n rows 2 -\u003e 1, layout_version 0 -\u003e 0, cursor_epoch 0 -\u003e 1\n\nA genuine reframe with no reshape. That is improvise-mcq4 working as designed: a cell write changes DATA, not the view's CONFIGURATION, and view_shape hashes configuration. Writing the proposed test would have been wrong.\n\nSo the original acceptance criterion is withdrawn. What remains is the real question the ticket was reaching for:\n\n## Acceptance Criteria (replaced)\n- State, in context/code/command.md, the PROPERTY that actually separates the two: view_shape answers \"has this view's configuration changed\" and view_frame answers \"does this view draw differently\". A data write moves the second and not the first, by design.\n- If a property test is wanted, it must be the true one — e.g. a configuration change moves BOTH, and a pure data write moves only the frame — with the direction asserted rather than the conjunction.\n- The consequence for ADR 001's middle tier is filed separately as improvise-6nsd (P4), with the severity note: a client names a cell by RecordHash, a coordinate surrogate, so a renumbered row does not misaddress a write, and cursor_epoch did move so every client is told.\n\nNote the file ownership that blocked the original attempt: view_frame, view_shape, view_frames and view_shapes are all private in crates/improvise-command/src/ui/app.rs.\n","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-21T04:01:34Z","created_by":"claude-agent-protocol","updated_at":"2026-09-21T17:09:51Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-9v0j","title":"view_frame and view_shape are separated by example, not by property","description":"RE-SCOPED 2026-09-21. The ticket proposed a property test asserting that view_frame and view_shape agree — roughly \"every reframe is also a reshape\". THAT PROPERTY IS FALSE and the test would have failed. Measured by the Wave I agent and confirmed by the adversarial review of PR #92, with a prune_empty view of two rows and a ClearCell on North's only cell, driven through sessions.apply_caught_up:\n\n rows 2 -\u003e 1, layout_version 0 -\u003e 0, cursor_epoch 0 -\u003e 1\n\nA genuine reframe with no reshape. That is improvise-mcq4 working as designed: a cell write changes DATA, not the view's CONFIGURATION, and view_shape hashes configuration. Writing the proposed test would have been wrong.\n\nSo the original acceptance criterion is withdrawn. What remains is the real question the ticket was reaching for:\n\n## Acceptance Criteria (replaced)\n- State, in context/code/command.md, the PROPERTY that actually separates the two: view_shape answers \"has this view's configuration changed\" and view_frame answers \"does this view draw differently\". A data write moves the second and not the first, by design.\n- If a property test is wanted, it must be the true one — e.g. a configuration change moves BOTH, and a pure data write moves only the frame — with the direction asserted rather than the conjunction.\n- The consequence for ADR 001's middle tier is filed separately as improvise-6nsd (P4), with the severity note: a client names a cell by RecordHash, a coordinate surrogate, so a renumbered row does not misaddress a write, and cursor_epoch did move so every client is told.\n\nNote the file ownership that blocked the original attempt: view_frame, view_shape, view_frames and view_shapes are all private in crates/improvise-command/src/ui/app.rs.\n","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-21T04:01:34Z","created_by":"claude-agent-protocol","updated_at":"2026-09-21T17:09:51Z","dependencies":[{"issue_id":"improvise-9v0j","depends_on_id":"improvise-pmt0","type":"parent-child","created_at":"2026-09-25T01:05:07Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-h0iv","title":"group_runs coalesces by name alone, so two runs under different headers merge","description":"Found by the adversarial review of PR #77, which landed `AxisFrame::group_runs`\nas the one home of column-group run coalescing (improvise-8qlr).\n\n`group_runs` coalesces adjacent positions by group **name**:\n\n Some((prev, span)) if prev.map(|p| p.name.as_str()) == name =\u003e *span += 1,\n\nSo two runs under two DIFFERENT headers that happen to share a name read as one\nrun, and the browser draws one `\u003cth colspan\u003e` across both. `FrameGroup` carries\n`cat` as well as `name` — precisely so a client never has to work out which\ncategory a label came from — and comparing the pair would fix it.\n\nWHY PR #77 WAS RIGHT NOT TO FIX IT, so this is a decision and not a\nrediscovery: that PR's job was to make one rule out of two. Both prior copies\n— `src/ui/grid.rs`'s `group_name != prev_group` and\n`improvise-wasm-client/src/render.rs::col_groups`' `run.label == label` —\ncoalesced by name alone, so name-only is what the consolidation had to preserve\nto stay a consolidation. Changing the rule in the same commit that moves it\nwould have meant the agreement test could no longer tell \"the two ends agree\"\nfrom \"the two ends changed together\", which is the whole evidence of the move.\n\nTHE RISK OF LEAVING IT IS SPECIFIC AND NEW: a single home reads as decided.\nThe next person to look at `group_runs` finds one rule, one call site per front\nend, and a test pinning both — and has no reason to suspect the comparison is\nnarrower than the data it is given. That is the cost of a good consolidation\nand the reason to file rather than to note.\n\nReachability today: two categories on one axis, where the inner category has\ngroups of the same name under two different outer items — e.g. `Month` grouped\ninto `Q1` nested under `Region`, giving `North/Q1` and `South/Q1` adjacent. The\nexisting fixture in `tests/grid_frame_agreement.rs` has exactly this shape but\nis saved by the ungrouped `Sep` sitting between the two `Q1` runs, which is\nalso what makes improvise-gd83's spans come out as\n`[(Q1,2), (None,1), (Q1,2), (None,1)]` rather than merged. Remove `Sep` and the\ntwo runs merge.\n\nNot obviously a bug in every case: two adjacent runs of the same group name may\nwell be one visual span a user WANTS spanned. That is the question to answer\nfirst, and it is a question about what a group header means across a nesting\nboundary — which is why this is filed rather than fixed.\n\n## Steps to Reproduce\n\n1. Build a two-level column axis whose inner category is grouped and whose\n groups repeat under each outer item, with NO ungrouped item between them.\n2. `AxisFrame::group_runs` returns one run spanning both.\n3. Both front ends draw one group header across the boundary, because both now\n ask the same (name-only) rule.\n\n## Acceptance Criteria\n\nDecide whether two adjacent same-named runs under different headers are one\nspan or two, and say why in the commit. If two, `group_runs` compares\n`(cat, name)` and the agreement-test fixture gains a case with no separating\nungrouped item — the current one cannot see this.\n","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T04:00:18Z","created_by":"agent-render-frame","updated_at":"2026-09-21T12:39:37Z","closed_at":"2026-09-21T12:39:37Z","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-1wl0","title":"AxisFrame::first is serde(default), so a frame with no origin claims it leads the axis","description":"Found by the adversarial review of PR #77, which landed `AxisFrame::first`.\n\n`#[serde(default)]` on `first` forges the PERMISSIVE claim. `first: 0` is not\nan absence — it is the assertion \"this frame LEADS the axis\", and that is\nexactly what `body_from` acts on:\n\n match self.first + idx == 0 { true =\u003e \u0026self.entries[..], ... }\n\nSo an older peer's frame, or any frame whose `first` is dropped in transit,\ndeserializes as leading the axis. A scrolled browser then draws the carried\nheader ABOVE its first row — the header `body_from` exists to discard there,\nand the mirror image of the improvise-0o6x bug the field was added to fix.\n\nThis is the same argument that gave `Basis` no `Default` and no public\nconstructor (gotcha 43): a zero-valued claim that happens to be TRUE in the\ncommon case is a forgeable bypass rather than an absence. `first` is worse in\none respect — a `Basis` at least has to be constructed deliberately, while a\nserde default is applied silently by a missing field.\n\nNO SKEW SHIPS TODAY. Both ends are built from one workspace at one commit, so\nthere is no older peer. This is about the day there is one, and about the\nattribute reading as a considered compatibility decision when it was not.\n\nOptions, not yet chosen:\n\n * Drop the attribute, so a frame missing `first` fails to deserialize. Loud,\n and correct if a frame is never partially constructed. Note gotcha 37's\n observation that `#[serde(default)]` on an Option is documentation and not\n mechanism — here it is the opposite, it IS the mechanism.\n * Make the leading-ness unrepresentable-otherwise: `Option\u003cusize\u003e`, or a\n two-variant enum (`LeadsAxis` / `StartsAt(usize)`), so \"unknown\" cannot\n silently mean \"leads\".\n\nWhichever, say which in the commit, because the reason to prefer one is about\nwire compatibility and not about the type.\n\n## Steps to Reproduce\n\n1. Serialize an `AxisFrame` built for a scrolled window (`first \u003e 0`) and\n remove the `first` key from the JSON — the shape an older peer sends.\n2. Deserialize it. `first` is 0.\n3. `body_from(0)` now takes the `first + idx == 0` arm and returns the whole\n entry list, including the carried group header.\n4. The browser draws a group header above its first row, at every scroll\n position. Nothing reports anything.\n","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T04:00:14Z","created_by":"agent-render-frame","updated_at":"2026-09-21T12:39:36Z","closed_at":"2026-09-21T12:39:36Z","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-gfvj","title":"A pure record reorder invalidates no cursor, and I could not build a fixture that reorders","description":"Left open by improvise-ni6k, which closed the rest of its class by observing the grid frame.\n\nni6k names SortData as its SHARPEST case: it changes neither the row count nor any label, and every position still names a different record. The frame fingerprint ni6k landed (App::view_frame, hashing GridLayout row_items/col_items) CANNOT see it, because records-mode row_items are positional (gotcha 1) -- they are 0,1,2... whatever order the store is in.\n\nI wrote the mechanism -- hashing layout.records[].coords alongside the labels -- and then REMOVED it before committing, because I could not demonstrate it catches anything. Every fixture I built came back in key order whatever order the cells were inserted in:\n\n wb.add_item(Region, North); wb.add_item(Region, East);\n set_cell(North/Revenue); set_cell(East/Revenue);\n -\u003e records mode row order is [East, North] BEFORE any SortData\n\nSo either SortData is a no-op for that shape, or something between DataStore::live_records / matching_cells / RecordRow::group is ordering records that gotcha 18 says arrive in insertion order. I did not chase which, and I am not asserting a bug in gotcha 18 on this evidence -- matching_positions sorts POSITIONS (which preserves insertion order), so the explanation is probably elsewhere and probably boring.\n\nTWO THINGS TO DO, in order:\n1. Find out what actually orders records-mode rows, and whether SortData can reorder them at all. If it cannot, this ticket closes as 'not a case' and ni6k's note should be corrected -- its sharpest example would be hypothetical.\n2. Only if it can: put the coords back into view_frame and pin it with the fixture that reproduces.\n\nWHY IT WAS NOT SHIPPED UNVERIFIED: a confident comment over a mechanism nobody has seen fire is this repo's named defect. See doctrine(check_the_checker) and the wave brief's 'a green suite is not evidence'.","notes":"REPRODUCED AND FIXED. My original conclusion was WRONG, and the way it was wrong is the useful part.\n\nI claimed I could not build a fixture where SortData reorders records. The reviewer of PR #78 reproduced it in thirty lines. I re-derived it: insert West/North/East, call sort_by_key, and both live_records() and the records-mode GridLayout reorder to East/North/West.\n\nWHY MY FIXTURE HID IT -- not the reviewer's guess (insertion already sorted), and better than that: MY FIXTURE ENTERED RECORDS MODE BY PRESSING R, and ToggleRecordsMode (command/cmd/grid.rs:476) pushes effect::SortData AS ITS FIRST EFFECT. So the store was sorted by the act of entering records mode, and the SortData I then applied was a genuine no-op. My two-record store did reorder at the DataStore level -- I checked after the review -- so the fixture, not the store, was the problem.\n\nTHE GENERAL TRAP, worth carrying: a records-mode fixture built through the key path has an already-sorted store by construction, so it cannot demonstrate anything order-sensitive. Build one by setting _Index/_Dim axes directly. That is now written into context/test-evidence/command.md under sorting_the_store_in_records_mode_invalidates_the_cursor.\n\nTHE FIX: the layout.records coords hash I removed is restored in App::view_frame, and sorting_the_store_in_records_mode_invalidates_the_cursor pins it -- asserting the row COUNT and the LABELS are both unchanged, so it is the sharp case and not a shape change wearing its name. Proved by removing the coords hash again: that test fails and NOTHING ELSE IN THE CRATE does.\n\nMY PROCESS ERROR, for the record: I concluded 'cannot reproduce' from ONE fixture without varying it, and then filed that conclusion as a finding. Removing the unverified mechanism was right; concluding the hole was not real was not. 'I could not reproduce it' and 'it does not reproduce' are different claims and I filed the second having only earned the first.\n\nClosing.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T03:47:15Z","created_by":"claude-agent-protocol","updated_at":"2026-09-21T04:01:44Z","closed_at":"2026-09-21T04:01:44Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -634,10 +761,10 @@ {"_type":"issue","id":"improvise-4ktt","title":"The js job's bundle-freedom is a convention, not a gate","description":"Found by adversarial review of PR #61, 2026-09-20 (finding 6).\n\nPR #61 states a constraint as load-bearing: the `js` CI job must never need a\nbuilt wasm bundle, because the moment it does it inherits the browser job's\nthree wasm-pack builds and its whole value — seconds instead of ~4m30s —\nevaporates. context/code/clients.md says so in prose.\n\nThe MECHANISM is a shell glob and a directory convention:\n\n js job: node --test web/tests/*.test.js\n browser job: node --test web/tests/bundle/*.test.js\n\nNothing stops someone adding `readFileSync('../pkg/…')` to a file directly in\nweb/tests/. It would pass locally (the bundle is usually built) and fail in the\n`js` job with a confusing ENOENT — or, worse, be \"fixed\" by adding a build step\nto the job, which silently spends the four minutes the split exists to avoid.\n\nThis is the shape the repo already dislikes: a rule stated in prose with no\ngate, where the failure direction looks like ordinary breakage.\n\nPOSSIBLE FIXES, cheapest first:\n1. A node test in web/tests/ that reads its own sibling files and asserts none\n of them mentions `pkg/` or `worker-pkg/` — the same static shape as\n render_surface.test.js, which reads app.js and index.html.\n2. Run the `js` job in a container/step with web/pkg absent, so the dependency\n is impossible rather than discouraged. Costs a clean checkout, which the job\n already has: `web/pkg` is gitignored, so in CI it is ALREADY absent — which\n means (2) is really \"assert it stays that way\", i.e. (1).\n\nNote the constraint is currently satisfied and is checked by nothing.\n","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T20:53:55Z","created_by":"wave3-jstests","updated_at":"2026-09-21T04:49:30Z","closed_at":"2026-09-21T04:49:30Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-r0qa","title":"The evidence checker validates only heading-to-test, never test-to-evidence","description":"Found by adversarial review of PR #59, 2026-09-20, as the first case to walk into the gap.\n\nscripts/check-comment-runs.py --check-evidence validates ONE direction: every '### name' heading in context/test-evidence/ must name a test that exists. design-principles §7 states that choice deliberately — 'a test may have no evidence (most do not), but evidence naming a test nobody can find is a dangling explanation.'\n\nThe blind spot is the reverse. PR #59 added a Rust test whose doc comment says 'see context/test-evidence/clients.md', and that file has NO entry for the_drill_key_and_an_aggregate_edit_refuse_for_different_reasons. The checker passed (503 headings, all resolving). So the new assertion's proof lived only in the PR body — which is not in the repo, and which design-principles §6 now says is exactly where an argument must not live, because nothing can audit it.\n\nWHY THE ORIGINAL ASYMMETRY IS STILL RIGHT, and why this is a narrower fix than 'check both ways': most tests have no evidence and should not. The check is not 'every test has evidence' — it is 'a test that CITES evidence cites evidence that exists'. That is a different predicate and it has no false positives: a doc comment naming context/test-evidence/\u003cfile\u003e.md (and optionally a heading) is a claim, and the claim is checkable.\n\nNote the failure mode it closes is the one this repo keeps finding: a pointer that reads as a promise the thing exists. Same argument as a dead ticket ID, in another key.\n\n## Steps to Reproduce\n\n1. Add a Rust test whose doc comment cites context/test-evidence/clients.md for a section that does not exist.\n2. python3 scripts/check-comment-runs.py --check-evidence\n3. It passes.","acceptance_criteria":"A test whose doc comment cites context/test-evidence/ for a section that does not exist fails the checker. Demonstrated by pointing a test's doc comment at an absent heading and watching --check-evidence fail.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T20:39:27Z","created_by":"orchestrator","updated_at":"2026-09-21T04:49:28Z","closed_at":"2026-09-21T04:49:28Z","comments":[{"id":"503ea75f-8d90-5bdd-bb0e-fa3aa18b0e61","issue_id":"improvise-r0qa","author":"wave3-jstests","text":"A second, sharper instance of the same blind spot, found by adversarial review of PR #61 (2026-09-20). --check-evidence resolves only `### ` headings. context/test-evidence/clients.md uses `#### \"quoted test name\"` for the Playwright specs, which is one `#` deeper — so the checker cannot see them at all. PR #61 deleted the test named at clients.md:547 and the gate stayed green while the heading became exactly the dangling explanation design-principles §7 describes: it read as a promise that a guard existed, for a property that had in fact been lost (the PR #21 close-routing half, lost for the third time). The heading is accurate again only because the follow-up restored the test under its exact original name. Whatever fix this ticket takes should normalise the heading depth or accept both levels; the `####` form is used deliberately for JS test names, which cannot be `### ` because the checker would then demand a Rust `fn` of that name.\n","created_at":"2026-09-20T20:54:02Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} {"_type":"issue","id":"improvise-0x9u","title":"A click cannot move the grid selection while an editor is open","description":"Found by adversarial review of the unreviewed pointer work (959f249), demonstrated in Chromium.\n\nweb/app.js:333-339 (cell click) plus edit()'s 'if (editing) return;' (~:354) and its blur -\u003e close(false) -\u003e repaint().\n\nDemonstrated: click cell 0 (editor opens, input present) -\u003e click cell 4 -\u003e 0 inputs, selection STILL AT INDEX 0. The mousedown on cell 4 blurs the input, close(false) calls repaint(), el.grid.replaceChildren(..) destroys the \u003ctd\u003e that received the mousedown, so no click event ever fires and click_cell is never called. The pointer is dead for that interaction; the user must Escape or click outside the grid first.\n\nSo the property the new spec names — 'a click moves the selection, not only the editor' — DOES NOT HOLD from an editing state, and pointer.spec.js:112 cannot see it because it presses Escape before asserting. That is the repo's characteristic find: a test that passes while the thing it names is broken in a neighbouring state.\n\nPre-existing in the edit()/repaint interaction rather than introduced by 959f249 — but now LOAD-BEARING, because the click is advertised as a selection move.\n\nRepair: defer the teardown (queueMicrotask/setTimeout(0) around repaint() in close), or use mousedown rather than click for the cell handler so it fires before the blur tears the table down. The regression test belongs in harness/tests/pointer.spec.js per gotcha 48.\n\n## Steps to Reproduce\n\n1. Load /app/index.html, click an editable cell — an editor opens.\n2. Without pressing Escape, click a different editable cell.\n3. The editor closes and the selection does not move.","acceptance_criteria":"Clicking a second cell while an editor is open moves the selection to it. A harness assertion in pointer.spec.js covers the editing state specifically, not only the post-Escape state.","status":"closed","priority":3,"issue_type":"bug","assignee":"wave-browser-agent","owner":"el-github@elangley.org","created_at":"2026-09-20T20:18:11Z","created_by":"orchestrator","updated_at":"2026-09-21T04:43:14Z","started_at":"2026-09-21T03:28:12Z","closed_at":"2026-09-21T04:43:14Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-h04z","title":"Decide whether UI glyphs move to the protocol, as short_label() did","description":"web/app.js's indicator() carries the comment 'only this spelling is the page's (gotcha 26)', so 'the fact is shared, the glyph is the page's' is a DOCUMENTED decision, not an oversight. This ticket asks whether that decision is still right. It is filed rather than answered because overturning something written down deliberately is a product call.\n\nEVIDENCE, which is observed rather than argued. The convention has already failed three times inside the code that states it:\n\n1. web/app.js categoryList inlined U+25B8/U+25BE (SMALL triangles) and did not call indicator(), which returns U+25B6/U+25BC. So the browser's category panel disagreed with src/ui/category_panel.rs:78 AND with the browser's own grid group headers. Fixed on PR #55 (323677b) by calling indicator().\n2. harness/tests/widgets.spec.js asserted the divergent pair, so the obvious cure would have failed a test. Fixed in the same commit.\n3. harness/tests/pointer.spec.js had INDEPENDENTLY grown the same wrong assertions on a different branch. Three files converging on a spelling nothing compares is the failure design-principles §5 predicts, and this is it observed with data rather than argued from doctrine.\n\nStill outstanding after those fixes: web/app.js viewRow spells the active-view marker \"▶ \" as a literal, duplicating src/ui/view_panel.rs:62. The two AGREE today (both U+25B6), so it is the hazard rather than the divergence — which is exactly the state categoryList was in before it drifted.\n\nTHE DECISION. Either (a) glyphs move to the shared layer the way Axis::short_label()/initial()/glyph() already did, making the TUI and the browser draw one spelling by construction; or (b) the per-page convention is kept and the comment is corrected to say 'one spelling PER PAGE, which may differ from the TUI', which is what it actually means today. What must not persist is the current state, where the comment says 'only this spelling is the page's' (singular) while nothing enforces it.\n\nNote (a) is not free: a glyph is presentation, and design-principles §5 explicitly lists 'the mapping from a semantic value to a framework type' as what legitimately stays per-framework (AxisColor -\u003e ratatui::Color). The counter-argument is that a terminal and a DOM can both render U+25B6, so unlike a Color there is nothing to translate — which is why this is a decision and not an obvious cleanup.\n\nFound by the review agent on PR #55.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T19:50:18Z","created_by":"wave3-webui","updated_at":"2026-09-20T19:50:18Z","comments":[{"id":"246755fe-5911-5924-96d1-9f3403529bf8","issue_id":"improvise-h04z","author":"wave3-webui","text":"COUNTER-ARGUMENT, recorded before anyone decides — because the evidence in the\ndescription supports two readings and the incident makes only one of them salient.\n\nTHE READING THE DESCRIPTION IMPLIES: the convention is wrong, so glyphs should\nmove to the shared layer as Axis::short_label()/initial()/glyph() did.\n\nTHE OTHER READING, which fits the same evidence: the convention is RIGHT and was\nsimply not followed, because nothing enforced it. Gotcha 26's pattern is that the\n*fact* is shared and the *rendering* is local — exactly what AxisColor -\u003e\nratatui::Color does — and a third front end would rightly pick its own glyph. On\nthis reading the fix is to enforce one-home-per-front-end and to correct\nindicator()'s comment, which claims a singular spelling that nothing checks.\n\nWHAT DECIDES BETWEEN THEM, and it is a fact rather than a preference:\n`web/app.js indicator()` and `src/ui/category_panel.rs:78` ALREADY AGREED —\nU+25B6/U+25BC in both. Fixing `categoryList` to call `indicator()` made the two\nfront ends agree with NO shared-layer change at all. That is direct evidence the\nconvention was sufficient and merely unenforced.\n\nI OVERSTATED THE EVIDENCE AND WANT THAT ON THE RECORD. I wrote that three files\nconverging \"answers 'is one duplicated literal worth a protocol change' with data\ninstead of doctrine.\" It does not. The three are not three independent\nderivations:\n\n 1. categoryList BYPASSED indicator(), the one home the page already had.\n 2. widgets.spec.js asserted the page's resulting (wrong) output.\n 3. pointer.spec.js asserted the same output, on a later branch.\n\nOnly (1) chose a glyph. (2) and (3) are downstream of it — tests pinning an\nobserved rendering, which is the \"a test cements the wrong state\" failure and is\nserious, but is NOT evidence about where glyphs should live. So the three answer\n\"was the page's own one-home rule enforced?\" (no), not \"is a protocol change\nwarranted?\" — a different question from the one I claimed.\n\nTHE ONE PIECE OF EVIDENCE THAT DOES SURVIVE FOR THE PROTOCOL READING:\n`web/app.js viewRow` spells the active-view marker \"▶ \" as a literal duplicating\nsrc/ui/view_panel.rs:62. That one is NOT a bypass — there is no browser-side\nmarker helper to bypass — so it is a genuine second spelling of a fact the TUI\nalso spells, and it is the only true cross-front-end duplication left after the\ncategoryList fix. A decision should turn on that case, not on the three.\n\nWHAT MUST NOT PERSIST EITHER WAY: indicator()'s comment says \"only this spelling\nis the page's\" (singular) while nothing enforces it — which is how the bypass\nwent unnoticed. Whichever reading wins, that comment is currently false.\n\nRecorded at the orchestrator's request so the decision is made against both\nreadings. Not resolved here.\n","created_at":"2026-09-20T20:10:52Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} +{"_type":"issue","id":"improvise-h04z","title":"Decide whether UI glyphs move to the protocol, as short_label() did","description":"web/app.js's indicator() carries the comment 'only this spelling is the page's (gotcha 26)', so 'the fact is shared, the glyph is the page's' is a DOCUMENTED decision, not an oversight. This ticket asks whether that decision is still right. It is filed rather than answered because overturning something written down deliberately is a product call.\n\nEVIDENCE, which is observed rather than argued. The convention has already failed three times inside the code that states it:\n\n1. web/app.js categoryList inlined U+25B8/U+25BE (SMALL triangles) and did not call indicator(), which returns U+25B6/U+25BC. So the browser's category panel disagreed with src/ui/category_panel.rs:78 AND with the browser's own grid group headers. Fixed on PR #55 (323677b) by calling indicator().\n2. harness/tests/widgets.spec.js asserted the divergent pair, so the obvious cure would have failed a test. Fixed in the same commit.\n3. harness/tests/pointer.spec.js had INDEPENDENTLY grown the same wrong assertions on a different branch. Three files converging on a spelling nothing compares is the failure design-principles §5 predicts, and this is it observed with data rather than argued from doctrine.\n\nStill outstanding after those fixes: web/app.js viewRow spells the active-view marker \"▶ \" as a literal, duplicating src/ui/view_panel.rs:62. The two AGREE today (both U+25B6), so it is the hazard rather than the divergence — which is exactly the state categoryList was in before it drifted.\n\nTHE DECISION. Either (a) glyphs move to the shared layer the way Axis::short_label()/initial()/glyph() already did, making the TUI and the browser draw one spelling by construction; or (b) the per-page convention is kept and the comment is corrected to say 'one spelling PER PAGE, which may differ from the TUI', which is what it actually means today. What must not persist is the current state, where the comment says 'only this spelling is the page's' (singular) while nothing enforces it.\n\nNote (a) is not free: a glyph is presentation, and design-principles §5 explicitly lists 'the mapping from a semantic value to a framework type' as what legitimately stays per-framework (AxisColor -\u003e ratatui::Color). The counter-argument is that a terminal and a DOM can both render U+25B6, so unlike a Color there is nothing to translate — which is why this is a decision and not an obvious cleanup.\n\nFound by the review agent on PR #55.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T19:50:18Z","created_by":"wave3-webui","updated_at":"2026-09-20T19:50:18Z","dependencies":[{"issue_id":"improvise-h04z","depends_on_id":"improvise-gtn7","type":"parent-child","created_at":"2026-09-25T01:05:33Z","created_by":"Edward Langley","metadata":"{}"}],"comments":[{"id":"246755fe-5911-5924-96d1-9f3403529bf8","issue_id":"improvise-h04z","author":"wave3-webui","text":"COUNTER-ARGUMENT, recorded before anyone decides — because the evidence in the\ndescription supports two readings and the incident makes only one of them salient.\n\nTHE READING THE DESCRIPTION IMPLIES: the convention is wrong, so glyphs should\nmove to the shared layer as Axis::short_label()/initial()/glyph() did.\n\nTHE OTHER READING, which fits the same evidence: the convention is RIGHT and was\nsimply not followed, because nothing enforced it. Gotcha 26's pattern is that the\n*fact* is shared and the *rendering* is local — exactly what AxisColor -\u003e\nratatui::Color does — and a third front end would rightly pick its own glyph. On\nthis reading the fix is to enforce one-home-per-front-end and to correct\nindicator()'s comment, which claims a singular spelling that nothing checks.\n\nWHAT DECIDES BETWEEN THEM, and it is a fact rather than a preference:\n`web/app.js indicator()` and `src/ui/category_panel.rs:78` ALREADY AGREED —\nU+25B6/U+25BC in both. Fixing `categoryList` to call `indicator()` made the two\nfront ends agree with NO shared-layer change at all. That is direct evidence the\nconvention was sufficient and merely unenforced.\n\nI OVERSTATED THE EVIDENCE AND WANT THAT ON THE RECORD. I wrote that three files\nconverging \"answers 'is one duplicated literal worth a protocol change' with data\ninstead of doctrine.\" It does not. The three are not three independent\nderivations:\n\n 1. categoryList BYPASSED indicator(), the one home the page already had.\n 2. widgets.spec.js asserted the page's resulting (wrong) output.\n 3. pointer.spec.js asserted the same output, on a later branch.\n\nOnly (1) chose a glyph. (2) and (3) are downstream of it — tests pinning an\nobserved rendering, which is the \"a test cements the wrong state\" failure and is\nserious, but is NOT evidence about where glyphs should live. So the three answer\n\"was the page's own one-home rule enforced?\" (no), not \"is a protocol change\nwarranted?\" — a different question from the one I claimed.\n\nTHE ONE PIECE OF EVIDENCE THAT DOES SURVIVE FOR THE PROTOCOL READING:\n`web/app.js viewRow` spells the active-view marker \"▶ \" as a literal duplicating\nsrc/ui/view_panel.rs:62. That one is NOT a bypass — there is no browser-side\nmarker helper to bypass — so it is a genuine second spelling of a fact the TUI\nalso spells, and it is the only true cross-front-end duplication left after the\ncategoryList fix. A decision should turn on that case, not on the three.\n\nWHAT MUST NOT PERSIST EITHER WAY: indicator()'s comment says \"only this spelling\nis the page's\" (singular) while nothing enforces it — which is how the bypass\nwent unnoticed. Whichever reading wins, that comment is currently false.\n\nRecorded at the orchestrator's request so the decision is made against both\nreadings. Not resolved here.\n","created_at":"2026-09-20T20:10:52Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} {"_type":"issue","id":"improvise-wsaw","title":"The browser ignores category_panel_open, so C toggles nothing visible","description":"RenderModel::category_panel_open has existed since the panel was added and web/app.js has never read it: repaint() calls categoryList(model) unconditionally, so #categories is permanently visible. Verified in Chromium: on a fresh load the client's own view slice says category_panel_open:false while the sidebar is drawn.\n\nConsequences. The TUI draws the category panel only when the flag is set (src/draw.rs:322), so the two front ends disagree about what the panel toggle does. In the browser, Shift-C and Ctrl-C change the mode label and nothing else - the user gets no feedback that the panel opened, which is part of why 'I can't expand categories' reads as broken.\n\nThe DECISION: honour the flag (matching the TUI, but the sidebar then disappears on load and the user must press C to see any category), or keep the always-visible sidebar and treat the flag as not applying to this front end (in which case app.js should stop carrying a field it ignores, and the browser's C should do something else visible - focus styling on the panel, say).\n\nNote fix/web-ui-correctness added #views and #formulas and DOES honour their flags, so the three panels are currently inconsistent with each other. That inconsistency is the concrete cost of leaving this open.","status":"closed","priority":3,"issue_type":"bug","assignee":"wave3-webui","owner":"el-github@elangley.org","created_at":"2026-09-20T19:17:16Z","created_by":"wave3-webui","updated_at":"2026-09-21T04:43:23Z","started_at":"2026-09-20T19:30:59Z","closed_at":"2026-09-21T04:43:23Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-8ec3","title":"improvise-io's test binary breaches the 2s per-binary rule on some machines, and the pinned figure is not reproducible","description":"`doctrine(suite_speed)` and design-principles §7 both state the rule that bites\nas **no single test binary over 2 seconds**, and pin it at `22d43b2` with\n\"longest single binary 1.42s\".\n\nThat number is not reproducible across machines. Measured on PR #51's branch:\n\n- This agent's machine: `improvise_io` lib, 1.42-1.68s over four runs.\n- The reviewing agent's machine, same branch: **3.70-5.24s**, and **4.82s on\n `main`** — so the breach is pre-existing, not introduced by #51.\n\nTwo things follow, and the second is the one worth doing.\n\n**The stated number is a measurement of one machine presented as a property of\nthe suite.** `doctrine(suite_speed)` already says \"re-MEASURE rather than\nre-assert\", and this is what that costs when the measurement is 3x apart on two\nmachines: a PR body claiming \"longest single binary 1.47s\" is checkable and\nfalse for the reader who checks it, which is worse than no claim.\n\n**`improvise-io` is the binary at issue, and it is proptest-dominated.** Its\nslow half is the grammar-walking generator in `persistence/parse.rs` plus the\n`.improv` roundtrip proptests, at the default 256 cases.\n`doctrine(proptest_cases)` says constrain the strategy rather than lower the\ncount, so the work is to look at what the generator actually produces — a\ngrammar walk with no size bound generates deeply nested documents whose cost is\nnot uniform across cases.\n\n## Steps to Reproduce\n\n1. `cargo test -p improvise-io --lib` and read the `finished in` it prints.\n2. Compare against the \"1.42s\" pinned in `context/design-principles.md` §7 and\n in `doctrine(suite_speed)`.\n3. On a loaded or slower machine the figure exceeds the stated 2-second\n per-binary limit, on `main` as well as on any branch.\n","acceptance_criteria":"Either improvise-io's lib binary is brought under 2s on the slow measurement (by constraining the .improv grammar generator's strategy, NOT by lowering proptest cases below 256 — doctrine(proptest_cases)), or design-principles §7 and doctrine(suite_speed) stop pinning a single machine's number and say what the rule is measured against. A figure that is 3x apart on two machines is not checkable, and doctrine(suite_speed) already says re-measure rather than re-assert.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:53:08Z","created_by":"wave2-b-projection","updated_at":"2026-09-21T04:49:32Z","closed_at":"2026-09-21T04:49:32Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-9iyv","title":"The route-then-aggregate fold's cost, measured","description":"RESOLVED on the PR that raised it (#54). Kept open only to record the numbers\nand the one remaining, declined, optimisation.\n\n## What it was\n\nThe stall was **not** the fold itself. It was the fold running through\n`eval_expr_cached`: at a member-record key, `Expr::Ref` looks the value up in\n`formula_cache`, which is keyed by stems with `none_cats` STRIPPED, so it could\nnever hit — and fell through to `aggregate_raw`, one poorly-narrowed scan PER\nMEMBER. An O(n^2) shape, which is why it was superlinear.\n\nThat is the same defect as the review's finding 1 (the two evaluators\ndisagreeing). Fixing the divergence — both evaluators hand the fold the same\nrecord-level `eval_expr` — fixed the cost. One defect, two symptoms.\n\n## Measured\n\n`crates/improvise-core/examples/fold_bench.rs`, five runs per point, best-worst,\none routing formula `Pred = SUM(IF(Cost \u003c 50, 1, 10))` with Tag on Axis::None.\n\n cells routing (before) routing (after) linear Rev - Cost\n 400 0.6-0.9 ms 0.4-0.6 ms 0.2-0.2 ms\n 2000 3.1-4.3 ms 2.0-2.1 ms 0.8-0.9 ms\n 18050 51.1-53.3 ms 17.9-18.7 ms 7.6-8.3 ms\n\nDebug at 18,050 cells: ~133 ms after, against ~72 ms linear.\n\nBefore: 45x the cells cost 85x the time (superlinear).\nAfter: 45x the cells cost 45x the time (linear), at 2.3x a linear formula.\n\n## \"18,050 cells\" was not a sufficient description of the old workload\n\nThe strongest evidence for the diagnosis, measured by the PR's reviewer on the\nmerged tree and recorded here because it is the part that actually pins the\ncause. At the SAME 18,050 cells, the old code cost 51 ms at a 25x361 shape and\n244 ms at 95x95 — a 4.8x spread from group COUNT alone, which is what a\nper-member `aggregate_raw` scan predicts and a constant factor does not. After\nthe fix those two shapes are 16.9-17.2 ms and 16.4-17.5 ms: indistinguishable.\n\n**The shape-dependence itself is gone**, and that is the confirmation. Scaling\nre-checked linear out to 72,200 cells (4x the cells, 4x the time, 17 -\u003e 68 ms).\nIndependently: a `SUM(Rev) - SUM(Cost)` baseline measures the same 7.1-7.8 ms as\nthe `Rev - Cost` one, so the 2.3x ratio is not an artefact of comparing against\na cheaper formula.\n\nAnyone re-measuring this must state the region/tag shape, not just the cell\ncount.\n\n## The remaining optimisation, and why it is declined\n\n`recompute_formulas` runs exactly TWO passes (measured, by counting, not\nassumed): one to compute and one to confirm stability. Raw data does not change\nwithin a recompute, so the member set per `partial` is stable across those two\npasses and could be computed once. That caps the win at ~2x on the fold's share\nof an already-linear 18 ms.\n\nDeclined as not worth it: a memo is state, and a memo keyed by anything less\nthan the whole `partial` is a second answer to \"what does this cell depend on\",\nwhich is the disease this repo names. Reopen with a document that makes 18 ms\nhurt.\n\n## What was considered and is wrong, so nobody re-proposes it\n\nRaised during the wave as a possible direction — NOT by the code review, which\ndid not propose it — and rejected on inspection:\n\n\"Fold only where folding changes the answer — if `inner` has no If and no Is,\naggregate-then-route equals route-then-aggregate\" does NOT hold. It fails for\nMIN/MAX over any non-trivial expression, and for any non-linear BinOp:\n`SUM(Rev * Cost)` is not `SUM(Rev) * SUM(Cost)`. Worse, the cheap path it would\nfall back to never computed aggregate-then-route at all — it summed the raw\nmatching cells and DISCARDED the expression. The only provably-equal case is the\nbare `Expr::Ref` fast path, which is already special-cased.\n\n## Termination, checked rather than assumed\n\nThe cached path's hand-off to `eval_expr` resets depth to MAX_EVAL_DEPTH at each\n`Agg`, while the uncached `Agg` threads its own `depth` through. That asymmetry\nis what keeps a chain bounded and must not be \"tidied\" into one rule. Verified\nterminating and cached/uncached-agreeing on: mutual reference, self-reference, a\n24-deep chain of folded formulas, nested SUM(SUM(..)), and a derived measure\ninside the fold.\n\n## Steps to Reproduce (the original stall)\n\n1. `direnv exec . cargo run --release -p improvise-core --example fold_bench`\n2. Before the fix, route the cached fold through `eval_expr_cached` rather than\n `eval_expr` (one line in `eval_formula_with_cache`'s `Expr::Agg` arm).\n3. The 18,050-cell row goes from ~18 ms to ~51 ms at a 25x361 shape, ~244 ms at\n 95x95, and the scaling goes superlinear.\n","acceptance_criteria":"A measured number for recompute_formulas over a routed formula at a realistic record count, beside the same document with a linear formula, written into context/code/core.md 13 next to the claim it replaces. If it is slow enough to matter, a decision recorded about what to do; if it is not, the measurement recorded so the next reader does not re-open it.","notes":"ATTRIBUTION CORRECTION 2026-09-20: the rejected 'no If and no Is =\u003e aggregate-then-route equals route-then-aggregate' optimisation was proposed by the ORCHESTRATOR, not by the adversarial reviewer. The reviewer asked for it to be on the record that it never proposed it; what it offered was 'either teach the fold's Ref to reach the cache, or document the restriction'. The rejection stands and the reasoning is the implementing agent's: the shortcut fails for MIN/MAX and for any non-linear BinOp (SUM(Rev*Cost) != SUM(Rev)*SUM(Cost)), and the cheap path it would fall back to never computed aggregate-then-route at all — it discarded the expression. The only provably-equal case is the bare Expr::Ref already special-cased.\n\nALSO from the re-review, and it is better evidence than the single-point improvement: the two benchmark fixtures did NOT measure the same thing before the fix and DO now. At 18,050 cells the author's 25x361 shape and the reviewer's 95x95 shape gave 51 ms and 244 ms pre-fix — a 4.8x spread AT EQUAL CELL COUNT, which is exactly what a per-member aggregate_raw scan predicts, since its cost grew with GROUP count rather than cell count. Post-fix the spread is gone (16.9-17.2 vs 16.4-17.5). The diagnosis is confirmed by the DISAPPEARANCE OF THE SHAPE-DEPENDENCE, not merely by a smaller number. So '18,050 cells' was never a sufficient description of the old workload; it is now. Scaling verified linear to 72,200 cells (4x cells, 4x time).","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:51:21Z","created_by":"wave3-4zl7","updated_at":"2026-09-20T19:31:00Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-9iyv","title":"The route-then-aggregate fold's cost, measured","description":"RESOLVED on the PR that raised it (#54). Kept open only to record the numbers\nand the one remaining, declined, optimisation.\n\n## What it was\n\nThe stall was **not** the fold itself. It was the fold running through\n`eval_expr_cached`: at a member-record key, `Expr::Ref` looks the value up in\n`formula_cache`, which is keyed by stems with `none_cats` STRIPPED, so it could\nnever hit — and fell through to `aggregate_raw`, one poorly-narrowed scan PER\nMEMBER. An O(n^2) shape, which is why it was superlinear.\n\nThat is the same defect as the review's finding 1 (the two evaluators\ndisagreeing). Fixing the divergence — both evaluators hand the fold the same\nrecord-level `eval_expr` — fixed the cost. One defect, two symptoms.\n\n## Measured\n\n`crates/improvise-core/examples/fold_bench.rs`, five runs per point, best-worst,\none routing formula `Pred = SUM(IF(Cost \u003c 50, 1, 10))` with Tag on Axis::None.\n\n cells routing (before) routing (after) linear Rev - Cost\n 400 0.6-0.9 ms 0.4-0.6 ms 0.2-0.2 ms\n 2000 3.1-4.3 ms 2.0-2.1 ms 0.8-0.9 ms\n 18050 51.1-53.3 ms 17.9-18.7 ms 7.6-8.3 ms\n\nDebug at 18,050 cells: ~133 ms after, against ~72 ms linear.\n\nBefore: 45x the cells cost 85x the time (superlinear).\nAfter: 45x the cells cost 45x the time (linear), at 2.3x a linear formula.\n\n## \"18,050 cells\" was not a sufficient description of the old workload\n\nThe strongest evidence for the diagnosis, measured by the PR's reviewer on the\nmerged tree and recorded here because it is the part that actually pins the\ncause. At the SAME 18,050 cells, the old code cost 51 ms at a 25x361 shape and\n244 ms at 95x95 — a 4.8x spread from group COUNT alone, which is what a\nper-member `aggregate_raw` scan predicts and a constant factor does not. After\nthe fix those two shapes are 16.9-17.2 ms and 16.4-17.5 ms: indistinguishable.\n\n**The shape-dependence itself is gone**, and that is the confirmation. Scaling\nre-checked linear out to 72,200 cells (4x the cells, 4x the time, 17 -\u003e 68 ms).\nIndependently: a `SUM(Rev) - SUM(Cost)` baseline measures the same 7.1-7.8 ms as\nthe `Rev - Cost` one, so the 2.3x ratio is not an artefact of comparing against\na cheaper formula.\n\nAnyone re-measuring this must state the region/tag shape, not just the cell\ncount.\n\n## The remaining optimisation, and why it is declined\n\n`recompute_formulas` runs exactly TWO passes (measured, by counting, not\nassumed): one to compute and one to confirm stability. Raw data does not change\nwithin a recompute, so the member set per `partial` is stable across those two\npasses and could be computed once. That caps the win at ~2x on the fold's share\nof an already-linear 18 ms.\n\nDeclined as not worth it: a memo is state, and a memo keyed by anything less\nthan the whole `partial` is a second answer to \"what does this cell depend on\",\nwhich is the disease this repo names. Reopen with a document that makes 18 ms\nhurt.\n\n## What was considered and is wrong, so nobody re-proposes it\n\nRaised during the wave as a possible direction — NOT by the code review, which\ndid not propose it — and rejected on inspection:\n\n\"Fold only where folding changes the answer — if `inner` has no If and no Is,\naggregate-then-route equals route-then-aggregate\" does NOT hold. It fails for\nMIN/MAX over any non-trivial expression, and for any non-linear BinOp:\n`SUM(Rev * Cost)` is not `SUM(Rev) * SUM(Cost)`. Worse, the cheap path it would\nfall back to never computed aggregate-then-route at all — it summed the raw\nmatching cells and DISCARDED the expression. The only provably-equal case is the\nbare `Expr::Ref` fast path, which is already special-cased.\n\n## Termination, checked rather than assumed\n\nThe cached path's hand-off to `eval_expr` resets depth to MAX_EVAL_DEPTH at each\n`Agg`, while the uncached `Agg` threads its own `depth` through. That asymmetry\nis what keeps a chain bounded and must not be \"tidied\" into one rule. Verified\nterminating and cached/uncached-agreeing on: mutual reference, self-reference, a\n24-deep chain of folded formulas, nested SUM(SUM(..)), and a derived measure\ninside the fold.\n\n## Steps to Reproduce (the original stall)\n\n1. `direnv exec . cargo run --release -p improvise-core --example fold_bench`\n2. Before the fix, route the cached fold through `eval_expr_cached` rather than\n `eval_expr` (one line in `eval_formula_with_cache`'s `Expr::Agg` arm).\n3. The 18,050-cell row goes from ~18 ms to ~51 ms at a 25x361 shape, ~244 ms at\n 95x95, and the scaling goes superlinear.\n","acceptance_criteria":"A measured number for recompute_formulas over a routed formula at a realistic record count, beside the same document with a linear formula, written into context/code/core.md 13 next to the claim it replaces. If it is slow enough to matter, a decision recorded about what to do; if it is not, the measurement recorded so the next reader does not re-open it.","notes":"ATTRIBUTION CORRECTION 2026-09-20: the rejected 'no If and no Is =\u003e aggregate-then-route equals route-then-aggregate' optimisation was proposed by the ORCHESTRATOR, not by the adversarial reviewer. The reviewer asked for it to be on the record that it never proposed it; what it offered was 'either teach the fold's Ref to reach the cache, or document the restriction'. The rejection stands and the reasoning is the implementing agent's: the shortcut fails for MIN/MAX and for any non-linear BinOp (SUM(Rev*Cost) != SUM(Rev)*SUM(Cost)), and the cheap path it would fall back to never computed aggregate-then-route at all — it discarded the expression. The only provably-equal case is the bare Expr::Ref already special-cased.\n\nALSO from the re-review, and it is better evidence than the single-point improvement: the two benchmark fixtures did NOT measure the same thing before the fix and DO now. At 18,050 cells the author's 25x361 shape and the reviewer's 95x95 shape gave 51 ms and 244 ms pre-fix — a 4.8x spread AT EQUAL CELL COUNT, which is exactly what a per-member aggregate_raw scan predicts, since its cost grew with GROUP count rather than cell count. Post-fix the spread is gone (16.9-17.2 vs 16.4-17.5). The diagnosis is confirmed by the DISAPPEARANCE OF THE SHAPE-DEPENDENCE, not merely by a smaller number. So '18,050 cells' was never a sufficient description of the old workload; it is now. Scaling verified linear to 72,200 cells (4x cells, 4x time).","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:51:21Z","created_by":"wave3-4zl7","updated_at":"2026-09-20T19:31:00Z","dependencies":[{"issue_id":"improvise-9iyv","depends_on_id":"improvise-felo","type":"parent-child","created_at":"2026-09-25T01:05:17Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-wd6n","title":"A fit may still not split on col.missing or a date sub-feature","description":"improvise-4zl7 closed the categorical third of improvise-8xhy: a one-hot over a\nreal category is now emitted as `Cat IS Item`. Two artefact families remain\n`Spelling::Artefact` and are still refused by `fitted_formula`:\n\n- `col.missing`, the 0/1 indicator `preprocess` adds for a column with any\n missing cell. It means \"this record has no value for measure X\" — a question\n the formula language has no word for at all. `Expr::Ref` over an absent cell\n is an error, not a zero, so there is no existing spelling to reuse.\n- the date sub-features `when.epoch_day`, `when.year`, `when.month`,\n `when.day`, `when.dow`, and with a time component `when.hour`, `when.minute`.\n These need date arithmetic improvise does not have anywhere: a `.improv` cell\n holding a date is `CellValue::Text`, and nothing in `eval_expr` reads one.\n\nBoth are visible to a user only as a refusal naming the feature, which is the\nright interim. Closing either is a formula-language change, not an emitter\nchange, and they are independent of each other.\n\n## Steps to Reproduce\n\n1. Build a document whose only usable feature is a measure with some cells\n cleared, so `preprocess` emits `\u003cmeasure\u003e.missing`, and whose target is best\n explained by which records have that measure at all.\n2. `fit-measure \u003ctarget\u003e`.\n3. The champion splits on `\u003cmeasure\u003e.missing` and `fit_measure` refuses with\n \"the fitted tree splits on [\\\"\u003cmeasure\u003e.missing\\\"], which the formula\n language cannot spell at all\".\n\nThe date half reproduces the same way with a column of ISO dates: see\n`crates/gp-tree/src/data.rs::build_date` for the sub-features it emits, and\n`the_missing_flag_and_the_date_sub_features_are_refused_too` in\n`crates/improvise-gp/src/lib.rs` for the refusal asserted.\n","acceptance_criteria":"A fitted tree that splits on a col.missing flag or a date sub-feature is emitted as a formula that evaluates, or the family is declined in writing with the reason. Whichever way it goes, the two families are answered separately: they need different vocabulary. improvise-4zl7's route-then-aggregate rule (context/code/core.md 13) must keep holding for whatever is emitted.","status":"closed","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-20T18:50:51Z","created_by":"wave3-4zl7","updated_at":"2026-09-21T05:15:42Z","closed_at":"2026-09-21T05:15:42Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-cl7y","title":"A browser reports \"Formula added\" for a formula only the host can parse","description":"Found by the lockstep test improvise-e24v added for commit-buffer, once it\nwas widened to cover the Formula kind (the review's Finding 2).\n\nCommitBuffer emits its optimistic status FIRST and the add effect LAST, so on\nthe host a parse failure overwrites \"Formula added\" with \"Formula error: ...\"\n(improvise-6t3). The client mirrors the command line for line, which is\ncorrect -- but it cannot parse, so it folds \"Formula added\" and stops there.\nThe two ends then disagree on the status line, and the host's correction is a\nViewOnly effect that reaches nobody.\n\nThe user sees a browser confidently report a formula it did not add. This is\nthe same room improvise-fdt6 describes from the other side.\n\nNot a defect in the mirror: doctrine(mirror_the_cmd_line_for_line) says the\nclient reproduces its Cmd, and it does. The gap is that AddFormula's success\nis not observable to the composer. Pinned, not fixed, by\na_formula_the_client_cannot_parse_diverges_on_the_status_line, which asserts\nBOTH sides of the divergence so a fix has to come there and change it.\n\n## Steps to Reproduce\n1. Open the browser client and press F, then n, to open the formula prompt.\n2. Type an unparseable formula -- \"P=\" -- and press Enter.\n3. The status line reads \"Formula added\". No formula was added, and the host's\n \"Formula error\" never arrives.","acceptance_criteria":"A client that cannot parse does not claim success. Either the status\nfor a fallible add is withheld until the host answers (which needs the host to\naddress a status back to the composer, as ClientFrame::Save's rejection does),\nor the formula text is validated where both ends can do it. Whichever, the\ntest named above must change, because it currently asserts the divergence.","notes":"Checked during the adversarial review of PR #50, so nobody re-litigates it:\n\"the client cannot parse\" is true of the DEPENDENCY GRAPH, not merely of the\ncode as written. The repo map lists improvise-formula as a browser-bundle\ncrate, which reads as though parse_formula were a free call away -- it is not.\nimprovise-formula is a dependency of neither improvise-protocol nor\nimprovise-wasm-client; it reaches the bundle only through improvise-io and\nimprovise-command, neither of which a thin client has.\n\nSo closing this is a dependency decision (put improvise-formula on the thin\nclient's graph, and pay whatever that costs the bundle) rather than a call\nsomebody forgot to make. The other route -- have the host address a status\nback to the composer, the way ClientFrame::Save's rejection already does --\ncosts no dependency and is probably the cheaper answer.","status":"closed","priority":3,"issue_type":"bug","assignee":"wave-browser-agent","owner":"el-github@elangley.org","created_at":"2026-09-20T18:45:39Z","created_by":"wave2-h-frontend","updated_at":"2026-09-21T04:43:21Z","started_at":"2026-09-21T03:28:13Z","closed_at":"2026-09-21T04:43:21Z","labels":["area:shared-frontend"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-pubt","title":"[bug] display_value_in reads the host's drill edits against a foreign layout","description":"Found by the adversarial review of PR #48 (improvise-jytm).\n\n`project::cell_display` fills `edit_text` from `App::display_value_in(layout, key)`,\nand that function consults `App::drill_state` — the host's staged, uncommitted drill\nedits, which are session-local residue belonging to whoever opened the drill\n(gotcha 30, improvise-h0b).\n\nBefore improvise-jytm every client shared one layout, so a client seeing the host's\npending drill text was at least seeing it against the coordinates it was staged\nagainst: shared, arguably wrong, but coherent. That is improvise-tom1's territory.\n\nAfter the split a client can be on a DIFFERENT view, and `grid_in` derives a separate\n`GridLayout` for it. `display_value_in` is then handed that foreign layout while\n`drill_state.pending_edits` are keyed by `(row index, RecordCol)` against the HOST's\ndrill layout (gotcha 1). Row indices are positions, and positions in one layout mean\nnothing in another — so if that path is reachable, a client is shown another client's\nuncommitted text against coordinates it was never staged for.\n\nWhy it is probably not reachable today, and why \"probably\" is the reason to file it\nrather than to fix it blind: the `synthetic_record_info(key)` arm only fires for the\nsynthetic `(_Index, _Dim)` keys that records/drill mode produces, and a foreign\nlayout built by `GridLayout::new` carries no frozen records, so it should not produce\none. That argument is a read, not a test, and it is the kind of read this repo has\nbeen wrong about before.\n\nTwo things to do, in order: write the test that decides whether it is reachable, and\nif it is, stop `display_value_in` consulting `drill_state` for a layout that is not\nthe host's — the drill snapshot is the host's session-local residue and a foreign\nlayout is by definition not its addressee.\n\n## Steps to Reproduce\n\nNot confirmed reachable; establishing that is the first half of the work.\n\n1. Host opens a drill and types into a cell without committing, so\n `App::drill_state.pending_edits` is non-empty.\n2. A connected client switches to a different view (`Command::SwitchView`), so\n `grid_in` derives a layout that is not `App::layout`.\n3. Project that client's grid and read `CellDisplay::edit_text`.\n\nIf any cell's `edit_text` comes from `pending_edits`, that is the bug: the row index\nthose edits are keyed by addresses a row in the host's drill, not in this client's\nlayout.\n","acceptance_criteria":"A test establishes whether the drill arm of display_value_in is reachable with a layout that is not App::layout. If it is, drill_state is not consulted for a foreign layout. If it is not, the test stays as the guard that says so, because the argument for unreachability is currently a read rather than a check.","status":"closed","priority":3,"issue_type":"bug","assignee":"claude-agent-cmd","owner":"el-github@elangley.org","created_at":"2026-09-20T18:33:24Z","created_by":"wave2-e-active-view","updated_at":"2026-09-21T04:43:49Z","started_at":"2026-09-21T03:27:55Z","closed_at":"2026-09-21T04:43:49Z","labels":["area:active-view"],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -645,13 +772,13 @@ {"_type":"issue","id":"improvise-ni6k","title":"Invalidate the cursor by observing the grid frame, not by a per-effect table","description":"Successor to improvise-9mo9, proposed by the agent that implemented 9mo9 against its own work.\n\n9mo9 made ten effects call app.reset_cursor(). That is a PER-EFFECT TABLE, and this repo names that shape as the disease: 'a table of variants would be a second description of it, free to disagree' (gotcha 24, which says exactly this about why cursor_epoch is watched instead of matching on command variants).\n\nThe disagreement arrived the same day it was written: improvise-7kvw is SetPageSelection renumbering the grid under prune_empty without resetting — a gap in the new table, found immediately.\n\nThe repo's own preferred mechanism was available and not taken:\n * cursor_epoch beats matching on CycleAxis (gotcha 24)\n * document_generation beats matching on LoadModel (gotcha 39)\n * model_state.dirty beats a table of command variants (gotcha 45)\n\nAll three are observations. The equivalent here is to fingerprint the grid's FRAME (row/col identity, not contents) before and after a model batch in App::apply_effects, and reset the cursor when it moves. Then no effect declares anything, no new geometry effect can forget, and 7kvw closes as a consequence.\n\nThe agent's reason for not doing it is legitimate and recorded: the ticket and its acceptance criteria both asked for per-effect, and the change touches apply_effects, which WU-B and WU-E were editing concurrently. So this is a deliberate follow-up, not an oversight.\n\nTwo constraints any implementation must keep, both learned the hard way in 9mo9:\n 1. Reset only on SUCCESS. The epoch is document-wide, so a REFUSED add otherwise sends every client a ResetCursor for a change that never happened. The first version of 9mo9 had this bug with 1312 tests green; it was caught by reading the diff, not by a test.\n 2. The negative arm must stay tested, and there are now two arms that cannot see each other's bug: SetNumberFormat (succeeds, reshapes nothing) and a refused AddItem (reshapes nothing because it was refused).\n\n## Steps to Reproduce\n\nNot a defect report — 7kvw is the concrete instance. This ticket is the structural fix that makes instances like it unrepresentable.","acceptance_criteria":"reset_cursor is driven by an observation — the grid's frame fingerprinted before and after a model batch, reset when it moves — rather than by each effect declaring it. improvise-7kvw and any sibling gap close as a consequence rather than needing their own fix. The negative arm still holds: a batch that reshapes nothing invalidates nothing, including a REFUSED one.","notes":"THE GAP IS SIX COMMANDS WIDE, NOT ONE (enumerated 2026-09-20, at a6258f7, after\nthe adversarial review of PR #49). improvise-9mo9 closed the ten it named and\nfiled improvise-7kvw as \"the residual\". That framing undersells this ticket:\n7kvw is one instance of a class, and here is the whole class.\n\nEffects that call app.reset_cursor() -- twelve: AddCategory, AddItem,\nAddItemInGroup, CycleAxis, HideItem, RemoveCategory, RemoveItem, SetAxis,\nShowItem, ToggleGroup, TogglePruneEmpty, TransposeAxes.\n\nModelCommand variants that CAN renumber the grid and do NOT reset:\n - SetPageSelection -- under prune_empty, a different page prunes different\n rows (improvise-7kvw, already filed)\n - AddFormula / RemoveFormula -- a formula target is a _Measure item, so it is\n a column whenever _Measure is on Row or Column\n - SetCell / ClearCell -- under prune_empty, writing into or clearing the last\n value in a row makes that row appear or disappear\n - CleanEmptyRecords -- drops records, so rows, in records mode\n - SortData -- reorders records, which renumbers EVERY row in records mode\n while changing neither the row count nor any label\n\nSortData is the sharpest of these and the best argument for observing rather\nthan declaring: it is the one case where row_count() and the label set are both\nunchanged and every position still names a different record. A guard written as\n\"did the shape change\" would miss it; the frame fingerprint would not, because\nthe fingerprint is over row_items/col_items, which carry the labels IN ORDER.\n\nWHY THIS WAS NOT FIXED PER-EFFECT TOO: the F1 round of the review showed the\ncost. Six accepted-but-no-op commands each needed their own change-detector\n(SetAxis comparing try_axis_of, HideItem/ShowItem comparing is_hidden,\nAddItem/RemoveItem via a shared has_item, RemoveCategory via category().is_some())\n-- six chances to get it wrong, three of which had already been taken across two\nrounds. Adding six more effects to that table doubles the surface without\nchanging the shape of the mistake.\n\nTHE COST THAT STILL HAS TO BE MEASURED BEFORE DOING THIS: a per-batch frame\nfingerprint sits on the keypress path, and benches/grid_projection.rs (landed in\nPR #49) measures a whole-grid pass at ~150ms for 18,000 cells. A fingerprint over\nrow_items/col_items is much cheaper than grid_of -- it hashes labels, it does not\nevaluate cells -- but \"much cheaper\" is the kind of claim that ticket's own\nacceptance criterion exists to refuse. Benchmark it against the same fixture\nsizes before landing.\nTWO MORE INSTANCES, from the adversarial review of PR #49 (2026-09-20).\n\n(1) TogglePruneEmpty on a grid with nothing empty resets the cursor and bumps the\nepoch. It is the only residual no-op that is ORDINARY USE rather than a bad name,\nand it is ONE KEYSTROKE in the TUI. It is not guarded per-effect because guarding\nit means computing the pruned layout to find out whether pruning would change\nanything -- i.e. building the very frame this ticket proposes fingerprinting. It\nis the cleanest single argument for doing this the observed way.\n\n(2) HideItem / RemoveItem on a category parked on Axis::None: the data changes,\nrow_items/col_items do not. A frame fingerprint is right by construction here,\nwhere a per-effect predicate would need its own notion of \"visible axis\" -- and\nPage/Filter make that a judgement call (gotcha 27), not a lookup.\n\nWHAT PR #49 DID FIX PER-EFFECT, so this ticket is not inheriting it: SetAxis on an\nunregistered category, HideItem/ShowItem on a name the grid never drew,\nToggleGroup on a nonexistent group, and AddItem/AddItemInGroup/RemoveItem via an\neffective-items comparison across the mutation (which is itself an observation,\nand the reason it is correct in both directions at once).\n\nThe AddItem case is worth carrying into this ticket's design: the predicate that\nfailed read `category.items` while _Measure's effective items are explicit UNION\nformula targets (gotcha 7). Any fingerprint must be taken over what the GRID\ndraws, never over what the model stores.\nDONE except SortData, which is improvise-gfvj.\n\nClosed as a consequence: improvise-7kvw (SetPageSelection under prune_empty), pinned by paging_across_a_prune_that_drops_a_row_invalidates_the_cursor. Note 7kvw is claimed by claude-agent-cmd with an expired lease -- if that agent also patched it per-effect, the two changes overlap and the per-effect guard should go, since the observation covers it.\n\nAlso covered, with no arm of their own: AddFormula/RemoveFormula (added to the shared reshaping_commands table), TogglePruneEmpty on a grid with nothing empty, and HideItem/RemoveItem on a category parked on Axis::None -- all three now answer by observation.\n\nCost measured as the AC required, benches/reshape_observation.rs: +13% to +20% on one SetCell batch at 400 / 9.6k / 18k / 90k cells. The batch is dominated by rebuild_layout and recompute_formulas.\n\nTHE DESIGN POINT WORTH CARRYING: the cursor's question and layout_version's question are NOT the same, so this is a second observation beside view_shape and not a replacement for it. AddCategory of an empty category moves no row or column but does change the coordinates a client's CellKey hangs off. Unifying them broke tests in both directions -- that is how the distinction was found, not by reasoning.","status":"closed","priority":3,"issue_type":"task","assignee":"claude-agent-protocol","owner":"el-github@elangley.org","created_at":"2026-09-20T18:23:51Z","created_by":"orchestrator","updated_at":"2026-09-21T03:47:21Z","started_at":"2026-09-21T03:28:18Z","closed_at":"2026-09-21T03:47:21Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-5jna","title":"The browser page does not paint the title bar it is now told about","description":"improvise-z754 put the document's name, its dirty marker and what its host\ncalls it on `RenderModel` (`doc_name`, `doc_dirty`, `doc_stored_as`), fed by\n`Projection::Document` and `DocumentCache`. The TUI's `draw_title` now draws\nfrom that same derivation. `web/app.js` does NOT paint them: the header has\n`#view-name`, `#mode`, `#hint` and `#status` and no element for the document.\n\nThat omission is deliberate rather than forgotten. Gotcha 48 says a change to\n`web/*.js` belongs in `harness/tests/shell.spec.js`, and every fixture there\ntoday mocks a peer that FAILS — a refused upgrade, a bad frame, a socket that\ndrops. None drives a real document through the page, so a title-bar element\nadded now would be unrun JavaScript, which is exactly what that rule exists to\nstop.\n\nSo the work is two halves, and the first is the expensive one:\n\n1. A harness fixture that serves a real `Snapshot` to `web/app.js` — either a\n built wasm bundle against a mocked `ws` peer replaying recorded frames, or\n the worker path against a seeded OPFS origin. That fixture is worth more\n than this ticket: it is the first Playwright test that exercises the page's\n PAINT rather than its failure reporting.\n2. A `#doc-name` span, set from `model.doc_name` / `doc_dirty` / `doc_stored_as`,\n with the `[+]` marker spelled the way the TUI spells it.\n\nDo not do 2 without 1.\n","acceptance_criteria":"web/app.js paints the document name, its dirty marker and what the host calls it, from RenderModel's doc_name/doc_dirty/doc_stored_as — and a harness/tests/shell.spec.js fixture that serves a REAL Snapshot asserts it. The fixture comes first: gotcha 48 forbids unrun web/*.js, and every existing harness fixture mocks a FAILING peer, so none of them can see a painted title. Do not add the element without the fixture.","status":"closed","priority":3,"issue_type":"task","assignee":"wave-browser-agent","owner":"el-github@elangley.org","created_at":"2026-09-20T18:11:01Z","created_by":"wave2-b-projection","updated_at":"2026-09-21T04:43:20Z","started_at":"2026-09-21T03:28:12Z","closed_at":"2026-09-21T04:43:20Z","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-rm4s","title":"The evidence check accepts headings naming tests that never run","description":"Found by adversarial review of PR #44 (finding F9), demonstrated with a built fixture that returned 'test-evidence: 3 entries, all name a test that exists', exit 0.\n\nscripts/check-comment-runs.py --check-evidence scrapes source text line by line for 'fn NAME' preceded by a test attribute. Three shapes defeat it:\n\n1. A test behind a cfg feature nobody enables — compiled out, never runs.\n2. A test attribute and fn inside a STRING LITERAL (an r-hash fixture). The scanner reads inside string literals, so this names a test that does not exist at all.\n3. A #[cfg(target_arch = wasm32)] test. This is the realistic one for THIS repo: it has real wasm32-only code (improvise-io/src/opfs.rs, improvise-worker-server) and a wasm CI job that only runs cargo check. Evidence keyed to such a test is accepted while cargo test --workspace never executes it.\n\nAll three are the same defect one layer up from the one the gate exists to prevent: evidence for a check that never runs.\n\nCheap fix the reviewer proposed and I verified is currently free: diff the heading set against the test names cargo actually reports (cargo test -- --list, or the run log) instead of scraping source. Cross-checking all 451 current headings against the executed test names from a real cargo test run came back EMPTY — every heading names a test that really ran — so adopting the stricter source costs nothing today.\n\n## Steps to Reproduce\n\n1. Create context/test-evidence/x.md with a heading naming a_test_that_is_only_text.\n2. Put a #[test] fn a_test_that_is_only_text() {} inside an r-hash string constant in any scanned .rs file.\n3. python3 scripts/check-comment-runs.py --check-evidence -\u003e passes, exit 0.","acceptance_criteria":"A heading naming a cfg-gated, wasm32-only, or string-literal 'test' is rejected; the check is diffed against the test names cargo actually reports rather than scraped from source text. Demonstrated by adding each of the three shapes and watching the check fail.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:06:03Z","created_by":"orchestrator","updated_at":"2026-09-21T04:49:28Z","closed_at":"2026-09-21T04:49:28Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-azhb","title":"Decide who sets a document's default view now that a switch does not","description":"A product question opened by improvise-jytm, which made `Workbook::active_view` the\ndocument's *default* — what a freshly connected client opens — and stopped a view\nswitch from moving it.\n\n`.improv` writes `Initial View` from the workbook (jytm's AC says it must), so the\ndefault is now frozen at load. For the multi-client story that is exactly right. For\nthe single-user TUI, which is the primary use today, it changes a behaviour people\nrely on: \"arrange the views, switch to the one I want this file to open on, save\".\nAfter jytm the file keeps whatever `Initial View` it was loaded with.\n\nThree shapes, none obviously right, which is why this is a ticket and not a fix:\n\n1. A host's own switch also sets the document default. Defensible — the host owns\n the document, there is exactly one of it, and the TUI is where `Initial View` is\n authored — but it re-couples the two copies in `App::switch_to_view`, the exact\n place jytm split them, and a headless ws-server would then have its phantom\n view_state authoring the default.\n2. An explicit command (`:set-default-view`, or a `w` variant). Unambiguous, costs\n the user a keystroke they did not used to need, and needs a keybinding argued.\n3. Leave it. The default is a document property you edit by editing the file.\n\nNothing is broken today in the sense of a wrong answer; the file is written\ncorrectly against the new meaning. What it is, is a silent change in what saving\nrecords, which is worth a decision rather than a discovery.\n\n## Steps to Reproduce\n\n1. `improvise model.improv` where the file says `Initial View: Default`.\n2. Switch to another view (`gv` / the view panel / `:switch-view Second`).\n3. Edit a cell so the document is dirty, then `:w`.\n4. Read the file: `Initial View: Default`. Before improvise-jytm it said `Second`.\n","acceptance_criteria":"A decision is recorded in context/code/command.md and, if it changes behaviour, implemented with a test asserting what a save writes as Initial View. Leaving it frozen at load is an acceptable outcome, but must be the stated answer rather than the default one.","notes":"ANSWERED BY THE GOVERNING PRINCIPLE, user 2026-09-21: \"The whole point of the view / server split is that multiple clients can have different views of the model.\"\n\nThis bead asked who sets a document's default view now that a switch does not. The principle answers it: the DEFAULT belongs to the DOCUMENT and a SWITCH belongs to the CLIENT, and they are different things that happen to name the same kind of value.\n\n - Workbook::active_view is the document's default -- what a freshly connected client opens, and what .improv writes as `Initial View`. It is shared state and changing it is a model change.\n - ViewState::active_view is authoritative FOR THAT CLIENT. Changing it is that client's business and reaches nobody else.\n\nSo \"who sets the default\" is not the same question as \"who switches\", and the bead conflated them because before improvise-jytm they were one field. The remaining REAL question is narrower: what user gesture, if any, should write the document default -- because today nothing does except loading a file, and improvise-azhb's original complaint was that `Initial View` no longer follows the TUI user.\n\nThat is now a small product question rather than an architectural one: should there be an explicit \"make this the document's default view\" action, or does the default only ever come from the file? Note that an implicit answer -- the TUI user's current view silently becoming the default -- is the one option the principle FORBIDS, because it would let one client's UI decide what every other client opens.\n\nFollow mode is deliberately not foreclosed (gotcha 29), but it would have to be opt-in and per-client, never a side effect of navigating.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:06:00Z","created_by":"wave2-e-active-view","updated_at":"2026-09-22T04:17:52Z","labels":["area:active-view"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-azhb","title":"Decide who sets a document's default view now that a switch does not","description":"A product question opened by improvise-jytm, which made `Workbook::active_view` the\ndocument's *default* — what a freshly connected client opens — and stopped a view\nswitch from moving it.\n\n`.improv` writes `Initial View` from the workbook (jytm's AC says it must), so the\ndefault is now frozen at load. For the multi-client story that is exactly right. For\nthe single-user TUI, which is the primary use today, it changes a behaviour people\nrely on: \"arrange the views, switch to the one I want this file to open on, save\".\nAfter jytm the file keeps whatever `Initial View` it was loaded with.\n\nThree shapes, none obviously right, which is why this is a ticket and not a fix:\n\n1. A host's own switch also sets the document default. Defensible — the host owns\n the document, there is exactly one of it, and the TUI is where `Initial View` is\n authored — but it re-couples the two copies in `App::switch_to_view`, the exact\n place jytm split them, and a headless ws-server would then have its phantom\n view_state authoring the default.\n2. An explicit command (`:set-default-view`, or a `w` variant). Unambiguous, costs\n the user a keystroke they did not used to need, and needs a keybinding argued.\n3. Leave it. The default is a document property you edit by editing the file.\n\nNothing is broken today in the sense of a wrong answer; the file is written\ncorrectly against the new meaning. What it is, is a silent change in what saving\nrecords, which is worth a decision rather than a discovery.\n\n## Steps to Reproduce\n\n1. `improvise model.improv` where the file says `Initial View: Default`.\n2. Switch to another view (`gv` / the view panel / `:switch-view Second`).\n3. Edit a cell so the document is dirty, then `:w`.\n4. Read the file: `Initial View: Default`. Before improvise-jytm it said `Second`.\n","acceptance_criteria":"A decision is recorded in context/code/command.md and, if it changes behaviour, implemented with a test asserting what a save writes as Initial View. Leaving it frozen at load is an acceptable outcome, but must be the stated answer rather than the default one.","notes":"ANSWERED BY THE GOVERNING PRINCIPLE, user 2026-09-21: \"The whole point of the view / server split is that multiple clients can have different views of the model.\"\n\nThis bead asked who sets a document's default view now that a switch does not. The principle answers it: the DEFAULT belongs to the DOCUMENT and a SWITCH belongs to the CLIENT, and they are different things that happen to name the same kind of value.\n\n - Workbook::active_view is the document's default -- what a freshly connected client opens, and what .improv writes as `Initial View`. It is shared state and changing it is a model change.\n - ViewState::active_view is authoritative FOR THAT CLIENT. Changing it is that client's business and reaches nobody else.\n\nSo \"who sets the default\" is not the same question as \"who switches\", and the bead conflated them because before improvise-jytm they were one field. The remaining REAL question is narrower: what user gesture, if any, should write the document default -- because today nothing does except loading a file, and improvise-azhb's original complaint was that `Initial View` no longer follows the TUI user.\n\nThat is now a small product question rather than an architectural one: should there be an explicit \"make this the document's default view\" action, or does the default only ever come from the file? Note that an implicit answer -- the TUI user's current view silently becoming the default -- is the one option the principle FORBIDS, because it would let one client's UI decide what every other client opens.\n\nFollow mode is deliberately not foreclosed (gotcha 29), but it would have to be opt-in and per-client, never a side effect of navigating.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:06:00Z","created_by":"wave2-e-active-view","updated_at":"2026-09-22T04:17:52Z","labels":["area:active-view"],"dependencies":[{"issue_id":"improvise-azhb","depends_on_id":"improvise-pmt0","type":"parent-child","created_at":"2026-09-25T01:05:03Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-ahdf","title":"repo-map gotchas 36 and 43 still name Sessions::apply as the production door","description":"Found by adversarial review of PR #45 (finding F11).\n\nVerified: Sessions::apply has NO production caller. Host calls apply_checked (crates/improvise-command/src/ui/host.rs:144); apply (project.rs:774) delegates to apply_checked (:783) after Checked::against, and its only call sites are tests. context/code/command.md already records this.\n\nTwo repo-map gotchas still say otherwise:\n * gotcha 36: 'Sessions::apply(app, id, cmd) is the only way a connected client's Command reaches the server'\n * gotcha 43: 'Sessions::apply answers once, before the dispatch'\n\nBoth are PRE-EXISTING rather than introduced by #45 — but they are the siblings of the sentence #45 fixed in design-principles §5, and gotcha 48 is the rule they break: 'when a fact is stated in more than one place, changing it is a grep, not an edit.' #45 fixed §5 and the now-false note in command.md; these two were left because they sit inside long paragraphs whose surrounding argument needs reading, not a sed.\n\n## Steps to Reproduce\n\n1. grep -n 'Sessions::apply' context/repo-map.md\n2. Compare against crates/improvise-command/src/ui/host.rs:144 and the note in context/code/command.md.\n\nNote the claims are not merely imprecise: a future agent told that apply is 'the only way' may add a caller to it, bypassing the CheckedBatch frame-level answer that host.rs actually performs.","acceptance_criteria":"Gotchas 36 and 43 name apply_checked where they mean the door production uses, or say explicitly that apply is the single-command convenience its tests drive; a grep for 'Sessions::apply' across context/ finds no claim that contradicts context/code/command.md.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:05:51Z","created_by":"orchestrator","updated_at":"2026-09-21T04:44:18Z","closed_at":"2026-09-21T04:44:18Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-4bwa","title":"Three bindings improvise-e24v listed as needing no enrichment do need it","description":"improvise-e24v listed these under \"the operands are already in ViewState or a\nwidget cache\". Closing the tranche established that each of the three is short\nexactly one fact, and each wants a different answer.\n\ntoggle-prune-empty. TogglePruneEmpty::execute reads ctx.view.prune_empty to word\nits status (\"Showing all rows/columns\" vs \"Hiding empty rows/columns\").\nView::prune_empty is on the View inside the Workbook; it is in no widget cache\nand not in ViewState. A client can compose ModelCommand::TogglePruneEmpty but\ncannot compose the sentence beside it, so mirroring it would diverge on the\nstatus line. Cheapest fix: the flag is one bool on a per-view projection.\n\nview-back. ViewNavigate { forward: false } emits ApplyAndClearDrill *before*\nViewBack, and ApplyAndClearDrill is deliberately off the wire (gotcha 38,\nimprovise-3x3u) because its operand is another session's uncommitted drill\nedits. So this is not an enrichment at all: it is blocked on ADR 001 layer 2's\naddressed form. Leave it upstream and say so.\n\ndelete-formula-at-cursor. The effect is RemoveFormula { target,\ntarget_category }. FormulaCache carries the formula's *raw text*\n(\"Profit = Revenue - Cost\"), from which neither field is recoverable without\nreimplementing parse_formula in the client. Cheapest fix: FormulaCache carries\nthe target and its category beside the text.","acceptance_criteria":"Each of the three is either closed with the enrichment named in its\nparagraph, or declined in writing with the reason. toggle-prune-empty and\ndelete-formula-at-cursor are ordinary projection additions; view-back is NOT\nand must not be mirrored by reconstructing ApplyAndClearDrill on the client,\nwhich is the exact thing improvise-3x3u removed. Whatever lands, the named\nlist the_command_names_that_still_need_the_host_are_these shrinks by exactly\nwhat closed.","status":"closed","priority":3,"issue_type":"task","assignee":"wave-browser-agent","owner":"el-github@elangley.org","created_at":"2026-09-20T18:04:14Z","created_by":"wave2-h-frontend","updated_at":"2026-09-21T04:43:22Z","started_at":"2026-09-21T03:28:13Z","closed_at":"2026-09-21T04:43:22Z","labels":["area:shared-frontend"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-2oxt","title":"The landing page's casts predate the records columns and show the recorder's shell prompt","description":"The four casts under `docs/casts/` were recorded 2026-04-09 (improvise-d4w) and\nare now embedded on the landing page `docs/index.html` (improvise-e61), served\nat `/landing/`. improvise-e61's acceptance criteria asked for them to be checked\nagainst the current UI before embedding. They were, and three things came back.\n\n1. THEY PREDATE THE RECORDS COLUMNS. `improvise-2zq`'s `+cat` column landed in\n `6a929c1` on 2026-09-19 and `improvise-c5v`'s `+` measure column with it —\n five months after the recording. `drill.cast` shows records/drill mode as it\n was BEFORE either column existed, so the drill cast is the one that is\n materially wrong rather than merely old. What still matches: the tile bar's\n `short_label()` vocabulary (`Row`/`Col`/`Pag`/`·`) and the mode line, both\n verified against `crates/improvise-core/src/view/axis.rs`.\n\n2. THEY CARRY THE RECORDER'S SHELL PROMPT. Every cast opens and closes on\n `(0) Mac:edwlan--s059 ~gf/u/edwlan/improvise git-\u003emain \u003cdate\u003e`, which is a\n local hostname and home-directory path on a public page. `formulas.cast` and\n `import.cast` are the worst: the visible opening frame is the prompt, not\n improvise.\n\n3. THE GEOMETRY DISAGREES WITH ITS OWN TICKET. improvise-d4w's description says\n \"Terminal: 100x30\" and improvise-e61 repeats it as the player config; the\n cast headers say `\"width\": 120, \"height\": 37`. The landing page therefore\n sets `cols: 120, rows: 37`, because a 30-row player clips the bottom seven\n rows of a 37-row cast — which is exactly where the tile bar and status line\n are. The recordings also use only about twenty of the thirty-seven rows, so\n each player is roughly half empty.\n\nRE-RECORDING FIXES ALL THREE AT ONCE and needs nothing built: record at the\ngeometry the page declares, from a clean prompt (`env -i` or a bare `PS1`), and\nmake the drill cast show the `+cat`/`+` columns that now exist. Then update the\n`cols`/`rows` in `docs/index.html` if the new geometry differs.\n\n## Steps to Reproduce\n\n1. `python3 -m http.server 8123 --directory docs` and open\n `http://127.0.0.1:8123/index.html`.\n2. Press play on \"Formulas over dimensions\": the first frame is a zsh prompt\n reading `Mac:edwlan--s059 ~gf/u/edwlan/improvise`.\n3. Press play on \"Drill into an aggregate\" and watch the records grid: there is\n no `+cat` column and no `+` measure column, both of which the current build\n draws (`crates/improvise-core/src/view/layout.rs::records_columns`).\n4. `head -c 200 docs/casts/pivot.cast` — the header says `\"width\": 120,\n \"height\": 37`, not the 100x30 improvise-d4w's description claims.","acceptance_criteria":"All four casts under docs/casts/ are re-recorded from a clean prompt (no hostname or home-directory path in any frame), at a geometry matching docs/index.html's cols/rows, with drill.cast showing the current records grid including the +cat and + measure columns. docs/index.html's player config is updated if the new geometry differs. Verified by playing each of the four on the served page and reading the first frame.","status":"closed","priority":3,"issue_type":"bug","assignee":"wave-browser-agent","owner":"el-github@elangley.org","created_at":"2026-09-20T18:01:04Z","created_by":"wave2-p-milestones","updated_at":"2026-09-21T04:43:20Z","started_at":"2026-09-21T03:28:15Z","closed_at":"2026-09-21T04:43:20Z","labels":["area:release"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-7kvw","title":"SetPageSelection renumbers the grid under prune_empty and does not reset the cursor","description":"Left open by improvise-9mo9, which decided the other nine geometry effects and\nstopped here on purpose.\n\n`SetPageSelection` changes which records are visible, not which categories frame\nthem, so normally a grid position keeps naming the same cell. Under\n`View::prune_empty` that is false: pruning drops rows and columns whose cells are\nall empty, and which rows those are is a function of the page selection. Flipping\na page therefore renumbers the grid while `SetPageSelection` calls neither\n`app.reset_cursor()` nor anything that bumps `cursor_epoch`, so the host's cursor\nis stale and no client is told (gotcha 24's both-directions failure).\n\nWHY 9mo9 DID NOT JUST PATCH IT: `[` / `]` page cycling is navigation the user does\nconstantly, and sending the cursor to the origin on every page flip is a worse\nanswer than the hole when `prune_empty` is off — which is the default. A reset\nconditional on `view.prune_empty` is the obvious shape and is a decision about\nintended UX, not a patch.\n\n## Steps to Reproduce\n\n1. Open a view with a Page category and `prune_empty` on, where two pages have\n different sets of non-empty rows.\n2. Put the cursor somewhere other than the origin, on a row that exists on this\n page but not the next.\n3. Page across. The grid renumbers and the cursor is not reset, so it addresses a\n position that has moved; no `Command::ResetCursor` reaches any other client\n either, because `cursor_epoch` never moved.","acceptance_criteria":"SetPageSelection either resets the cursor when the active view has prune_empty on, or carries the reason it does not — decided the same way improvise-9mo9 decided the other nine. If it resets, every_reshaping_model_command_invalidates_every_cursor gains a case built on a prune_empty view where two pages really do prune different rows; the case must fail the fixture's non-vacuity assertion if the pages ever stop differing.","status":"closed","priority":3,"issue_type":"bug","assignee":"claude-agent-cmd","owner":"el-github@elangley.org","created_at":"2026-09-20T17:57:25Z","created_by":"wave2-n-cleanups","updated_at":"2026-09-21T04:43:48Z","started_at":"2026-09-21T03:27:54Z","closed_at":"2026-09-21T04:43:48Z","labels":["area:command"],"dependencies":[{"issue_id":"improvise-7kvw","depends_on_id":"improvise-ni6k","type":"related","created_at":"2026-09-20T18:24:00Z","created_by":"orchestrator","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-mbmf","title":"An assertion message in gp-tree states the opposite of what it checks","description":"crates/gp-tree/src/fit.rs, in the_ranking_is_ordered_shares_with_the_unused_features_dropped:\n\n assert_eq!(ranked.len(), 2, \"the zero-importance feature is listed\");\n\nimportance_ranking is given three features, one of zero importance, and the\nassertion requires TWO entries back — i.e. the zero one was dropped. The\nmessage says it is listed.\n\nThe message is only ever read on failure, which is exactly when someone is\ntrying to work out what the intended behaviour was; here it would tell them\nthe opposite. The test name and the doc comment both say 'dropped', so the\nmessage is the only wrong copy of three.\n\n## Steps to Reproduce\n\n1. Change importance_ranking to list zero-importance features at 0%.\n2. cargo test -p gp-tree the_ranking_is_ordered\n3. The failure reads 'the zero-importance feature is listed' — describing the\n new (wrong) behaviour as though it were the requirement.\n\nFound during the 2026-09-20 comment-cleanup wave review.","acceptance_criteria":"The message names what the assertion actually requires (the zero-importance feature is dropped); a deliberate injection of a 3-entry ranking makes the test fail with a message that reads correctly.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T17:49:30Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:12:59Z","closed_at":"2026-09-20T18:12:59Z","close_reason":"Fixed in PR #45 (commit 56ff51e): the assertion message in the_ranking_is_ordered_shares_with_the_unused_features_dropped now says 'the zero-importance feature is dropped', matching what the assertion checks and what the test name and doc comment already said. The Closes: trailer was written before the bead was actually closed — flagged by adversarial review as a dead promise of the same class as a dead ticket ID.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-a1ec","title":"Eight pub doors have no production caller — decide seam or dead code for each","description":"Found by the post-wave code consistency review. A grooming sweep rather than a defect: none of these is urgent alone, and filing nine tickets would be worse than one.\n\n`pub` items with NO PRODUCTION CALLER (test modules stripped before counting):\n\n * DataStore::live_records — cell.rs:368. improvise-2yc2 decided its ORDER and closed; the door itself stayed. Its intended caller is the ADR 002 surrogate path.\n * App::resolve_aggregate — app.rs:410. An ADR 002 door; the resolver exists and is tested, the command that would use it is improvise-gd28 (blocked on ADR 001 layer 2).\n * AxisFrame::leaf_labels — render.rs:268. NEW THIS WAVE, and deliberately not \"the labels\" (see gotcha 44); worth checking whether anything was meant to call it.\n * CellDisplay::with_id — render.rs:171\n * App::cursor_in — app.rs:292\n * CmdContext::synthetic_record_at_cursor — cmd/core.rs:73\n * CmdContext::cat_at_cursor — cmd/core.rs:99\n * gp_tree::retain_valid — lib.rs:455\n\nAlready filed separately and NOT part of this sweep: Sessions::apply (project.rs:1083, now documented as having no production caller) and reduce_full (improvise-s17o).\n\nWHY IT IS WORTH A PASS RATHER THAN A SHRUG: doctrine(a_documented_door_with_no_caller_is_the_dangerous_kind). The risk is not the dead code, it is that a `pub` door with no caller has no user keeping its behaviour honest, and the next caller inherits whatever it happens to do. Several of these are deliberate seams waiting for a specific ticket — those should SAY SO on the item, naming the ticket, which is cheap and turns a smell into a plan.\n","acceptance_criteria":"Each of the eight is either deleted, made pub(crate) or private, or carries a one-line doc naming the ticket it is a seam for. No item is left pub, uncalled and unexplained. Where an item is kept as a seam, bd show the cited ticket to confirm it is real and open — a dead ID here is worse than the dead code.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T07:50:12Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:50:12Z","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-a1ec","title":"Eight pub doors have no production caller — decide seam or dead code for each","description":"Found by the post-wave code consistency review. A grooming sweep rather than a defect: none of these is urgent alone, and filing nine tickets would be worse than one.\n\n`pub` items with NO PRODUCTION CALLER (test modules stripped before counting):\n\n * DataStore::live_records — cell.rs:368. improvise-2yc2 decided its ORDER and closed; the door itself stayed. Its intended caller is the ADR 002 surrogate path.\n * App::resolve_aggregate — app.rs:410. An ADR 002 door; the resolver exists and is tested, the command that would use it is improvise-gd28 (blocked on ADR 001 layer 2).\n * AxisFrame::leaf_labels — render.rs:268. NEW THIS WAVE, and deliberately not \"the labels\" (see gotcha 44); worth checking whether anything was meant to call it.\n * CellDisplay::with_id — render.rs:171\n * App::cursor_in — app.rs:292\n * CmdContext::synthetic_record_at_cursor — cmd/core.rs:73\n * CmdContext::cat_at_cursor — cmd/core.rs:99\n * gp_tree::retain_valid — lib.rs:455\n\nAlready filed separately and NOT part of this sweep: Sessions::apply (project.rs:1083, now documented as having no production caller) and reduce_full (improvise-s17o).\n\nWHY IT IS WORTH A PASS RATHER THAN A SHRUG: doctrine(a_documented_door_with_no_caller_is_the_dangerous_kind). The risk is not the dead code, it is that a `pub` door with no caller has no user keeping its behaviour honest, and the next caller inherits whatever it happens to do. Several of these are deliberate seams waiting for a specific ticket — those should SAY SO on the item, naming the ticket, which is cheap and turns a smell into a plan.\n","acceptance_criteria":"Each of the eight is either deleted, made pub(crate) or private, or carries a one-line doc naming the ticket it is a seam for. No item is left pub, uncalled and unexplained. Where an item is kept as a seam, bd show the cited ticket to confirm it is real and open — a dead ID here is worse than the dead code.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T07:50:12Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:50:12Z","labels":["area:command"],"dependencies":[{"issue_id":"improvise-a1ec","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T01:05:11Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-23qd","title":"The Projection::Basis record set is derived twice, and the second copy is the tests' yardstick","description":"Found by the post-wave code consistency review.\n\nThe set of records a client's Projection::Basis should carry is derived TWICE:\n * crates/improvise-command/src/ui/project.rs:883-892 — ClientSession::basis_update, the producer\n * crates/improvise-command/src/ui/project.rs:1350-1364 — `server_truth`, a #[cfg(test)] helper\n\nand `server_truth` IS THE GROUND TRUTH the tests measure against (used at :1632, :1660, :2511). It re-implements \"which records does this client's grid name\" rather than asking the producer.\n\nSo a change to WHICH records get published would move both together and be invisible to every test measured against it. That is doctrine(a_helper_that_builds_the_world_the_code_wants_tests_nothing) in its most dangerous form — not a helper that fabricates an input, but a helper that fabricates the EXPECTED OUTPUT.\n\nThe fix is small: have server_truth call the same filter, or extract it as a pub(crate) fn both use. What must not happen is the two staying separate with a comment saying they agree.\n\n## Steps to Reproduce\n\nNot a runtime failure — a test-integrity one, which is why it is filed rather than patched.\n\n1. Change which records `ClientSession::basis_update` publishes (`project.rs:883-892`).\n2. Make the matching change in `server_truth` (`project.rs:1350-1364`), the `#[cfg(test)]` helper the basis tests measure against at `:1632`, `:1660` and `:2511`.\n3. Every test passes, whatever the change was — including one that publishes the wrong set — because the yardstick moved with the thing it measures.\n","acceptance_criteria":"server_truth calls the same filter the producer uses, or the filter is extracted as a pub(crate) fn with both calling it. Changing which records are published then moves the producer and the yardstick together by construction, and a test proves it: alter the filter and watch the basis tests fail rather than silently track it.","status":"closed","priority":3,"issue_type":"bug","assignee":"claude-agent-protocol","owner":"el-github@elangley.org","created_at":"2026-09-20T07:49:56Z","created_by":"Edward Langley","updated_at":"2026-09-21T03:47:00Z","started_at":"2026-09-21T03:28:17Z","closed_at":"2026-09-21T03:47:00Z","close_reason":"Closed","labels":["area:protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-e1z5","title":"AppMode::minibuffer and strands_the_user opt out of exhaustiveness, and both fail silently","description":"Found by the post-wave code consistency review. Two instances of one shape, filed together because the fix is identical and trivial.\n\n1. `AppMode::minibuffer()` (crates/improvise-protocol/src/lib.rs:322-332) ends in `_ =\u003e None`, where its sibling `panel()` (:303-320) is exhaustive. SavePrompt — added this wave — had to be listed BY HAND, and the compiler could not have caught its omission. The failure mode is silent: a text-entry mode whose minibuffer is invisible to EnterMode's clear, to keys.rs::entered, and to the TextEntry builder.\n\n2. `strands_the_user` (crates/improvise-wasm-client/src/keys.rs:498-503) is a `matches!` over AppMode. Its own doc says \"a third such prompt belongs here\" — and a fourth would answer false in silence, which is precisely the dead-room bug found against SavePrompt in the first place (doctrine(a_doorway_to_a_black_hole_is_worse_than_the_key)).\n\nBoth are one or-pattern arm away from being exhaustive, at no cost. The repo's compiler-exhaustiveness theme says business rules go through the enum so the compiler names every site; these two opted out.\n","acceptance_criteria":"Both are exhaustive matches over AppMode with no wildcard, so adding a variant fails the build until someone answers for it. Verified by adding a throwaway AppMode variant and watching both sites fail to compile.","status":"closed","priority":3,"issue_type":"task","assignee":"claude-agent-protocol","owner":"el-github@elangley.org","created_at":"2026-09-20T07:49:56Z","created_by":"Edward Langley","updated_at":"2026-09-21T03:47:02Z","started_at":"2026-09-21T03:28:19Z","closed_at":"2026-09-21T03:47:02Z","close_reason":"Closed","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-seik","title":"Client::viewport is kept for a reason web/app.js falsified in the same wave","description":"Found by the post-wave code consistency review.\n\ncrates/improvise-wasm-client/src/lib.rs:141-158. `Client::viewport` is a #[wasm_bindgen]-exported door with NO PRODUCTION CALLER. Its doc says it is \"kept because web/app.js already spells it and retiring it is a co-ordinated change\" — and web/app.js:137-144 now says the OPPOSITE in its own comment and calls `set_viewport`.\n\nSo the justification for keeping it was falsified IN THE SAME WAVE that wrote it.\n\nThis matters more than an ordinary unused function because its documented failure mode is SILENT: calling `viewport` instead of `set_viewport` does not error, it quietly turns a whole feature off — the window is not recorded, so cursor keys stop resolving locally and every one of them becomes an upstream black hole (doctrine(an_upstream_key_is_a_black_hole)). Gotcha 47 is explicit that the announced window is NOT derivable from row_labels.len(), which is the window clipped to the grid.\n\ndoctrine(a_documented_door_with_no_caller_is_the_dangerous_kind), with the justification removed.\n\nSIBLING, same file, lower severity: `Client::render_state` (lib.rs:120-124) is exported to JS with ZERO callers anywhere, while the crate's module doc (lib.rs:8) still names it as what the DOM renderer consumes. The page consumes `render_model`. `revision()` is likewise uncalled by the page, but `on_frame` returns the revision anyway, so that one is harmless.\n","acceptance_criteria":"Client::viewport is deleted and its tests move onto set_viewport, or its doc states a reason that is true at HEAD. Same for render_state: deleted, or the crate module doc stops naming it as what the DOM renderer consumes. Check web/app.js and the generated .d.ts before deleting — the page is not compiled against the crate, so a removed export is a runtime failure there, not a build error.","status":"closed","priority":3,"issue_type":"task","assignee":"wave-browser-agent","owner":"el-github@elangley.org","created_at":"2026-09-20T07:49:55Z","created_by":"Edward Langley","updated_at":"2026-09-21T04:43:21Z","started_at":"2026-09-21T03:28:14Z","closed_at":"2026-09-21T04:43:21Z","labels":["area:browser"],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -660,7 +787,7 @@ {"_type":"issue","id":"improvise-gd83","title":"AxisEntry carries no group-end boundary, so group_of gives an ungrouped item the preceding group","description":"Found by Copilot on PR #23 (improvise-anr9), DECLINED there with reasons on the thread, and filed so it is a decision rather than a rediscovery. COPILOT IS RIGHT AND THIS IS A REAL BUG.\n\nAxisFrame::group_of answers with the NEAREST PRECEDING GROUP HEADER, which is not the same as the item's group membership. GridLayout::expand_category emits a header on ENTERING a group and NO BOUNDARY ON LEAVING ONE, so an ungrouped item appearing after a grouped run inherits that group -- in the frame, and in the span the TUI paints.\n\nIT IS GridLayout's BUG, NOT THE FRAME'S. The TUI has painted that span forever; improvise-anr9 faithfully reproduced the existing rule when it moved the frame onto the wire, which is what a migration should do (doctrine(characterization_survives_the_move)). What changed is that the rule is now WRITTEN DOWN and therefore visible.\n\nWHY IT WAS RIGHT TO DECLINE IT THERE: fixing it means changing AxisEntry in improvise-core to carry a group-end boundary, and changing what the TUI DRAWS -- which would destroy `the_whole_frame_is_unchanged`, the exact-buffer characterization that was PR #23's only evidence the migration changed nothing. Same shape as improvise-q0vb's decline on PR #12: a finding that is correct and still wrong to fix in that PR, because of what it costs in evidence.\n\nTHE EVIDENCE IS ALREADY WRITTEN AND IT IS THE GOOD KIND. `the_two_front_ends_draw_the_same_column_header` asserts the WRONG answer as the current one -- Q1 spanning all six columns -- with THE CORRECTED EXPECTATION `[(Some(\"Q1\"), 4), (None, 2)]` WRITTEN BESIDE IT. So the day someone fixes AxisEntry, that test fails and the fix is one line of expectation away, with the intended answer already argued. The doc on group_of states the rule and names the defect.\n\nWHAT THE FIX COSTS, so nobody starts it casually: AxisEntry is in improvise-core; a group-end boundary changes the shape every consumer of row_items/col_items walks; and the characterization buffers in src/ui/grid.rs move, which means re-pinning them deliberately and saying so in the commit, exactly as improvise-q0vb's acceptance clause required.\n\n## Steps to Reproduce\n\n1. Build an axis with a grouped run followed by an UNGROUPED item — `expand_category` emits a header on entering the group and no boundary on leaving it.\n2. Ask `AxisFrame::group_of(idx)` for the ungrouped item's index.\n3. It answers with the nearest preceding header, so the ungrouped item is reported as a member of the group that ended before it.\n4. The TUI paints the same span, so this is `GridLayout`'s rule faithfully reproduced rather than a frame defect.\n\n`the_two_front_ends_draw_the_same_column_header` in `tests/grid_frame_agreement.rs` asserts the wrong answer as the current one, with `THE_MEMBERSHIP_CORRECT_SPANS` and an `assert_ne!` beside it that starts failing the day this is fixed.\n","acceptance_criteria":"AxisEntry carries a group-end boundary, so group_of answers membership rather than nearest-preceding. THE_MEMBERSHIP_CORRECT_SPANS in tests/grid_frame_agreement.rs becomes the asserted value and its assert_ne! is removed. The TUI's painted spans change, so the characterization buffers move deliberately and the commit says so.","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-render-frame","owner":"el-github@elangley.org","created_at":"2026-09-20T07:16:26Z","created_by":"Edward Langley","updated_at":"2026-09-21T03:46:31Z","started_at":"2026-09-21T03:39:55Z","closed_at":"2026-09-21T03:46:31Z","close_reason":"Fixed on PR #77 (fix/render-frame-wave, head 8f3a976). Each guard proved by injecting the fault it names; GROUPED_FRAME and harness/tests/grouped.spec.js re-pinned deliberately.","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-xps8","title":"The handshake watchdog overwrites a genuine mid-session disconnect eight seconds later","description":"Found by improvise-vvl3's harness (PR #24) -- the first time web/app.js had ever been executed -- and it is a THIRD bug in the same family, distinct from both of the ones already dealt with.\n\nTHE SEQUENCE, measured rather than argued:\n\n open-then-close: connecting... -\u003e connected -\u003e disconnected\n -\u003e \"accepted the connection but sent no document\" \u003c- EIGHT SECONDS LATER\n\nOn the open-then-close arm the handshake watchdog IS armed (armWatchdog runs after transport.ready, which resolves on `open`), and nothing disarms it when the socket later closes. So a genuine mid-session disconnect is correctly reported, and then silently replaced eight seconds later by a message that is FALSE -- the peer did not fail to answer a handshake, it answered and then went away.\n\nIT SURVIVES improvise-taxx'S FIX, confirmed rather than assumed: the harness simulated the `opened` flag and the assertion still fails. taxx distinguishes never-connected from was-connected-and-dropped in the CLOSE listener; this is the TIMER overwriting whatever that listener concluded. Different mechanism, same surface.\n\nIT IS THE SAME CLASS AS THE BUG setShellFailure ALREADY FIXED -- a timer firing later and replacing a more precise message (gotcha 48, \"a watchdog must be disarmed, not pre-empted\"). The fix for that one cleared the timer on a LOUD failure. This arm is not loud: a close after a successful open is an ordinary event, not an error, so it never reaches setShellFailure. The likely answer is that the close listener disarms the watchdog on the opened path, since once a socket has opened the handshake question is settled either way -- but check whether any path legitimately wants the watchdog to survive a close before assuming it.\n\nWHY IT IS NOT A CI GUARD, and this is the part worth reading before trying to test it: the harness shipped an assertion for this and HAD TO REMOVE IT. It reproduces on macOS/Chromium and NOT on the GitHub runner, because the trigger is the ORDER of error/close/timer on the host's network stack. A Playwright test.fail is green when the bug is present and red when it is absent, so an expected-failure whose trigger is platform-dependent does not merely flake -- IT INVERTS CI'S MEANING, and it failed the browser job twice reporting \"expected to fail, but passed\". See doctrine(an_expected_failure_must_not_be_platform_dependent). The measurement and the reproduction recipe are recorded in harness/tests/shell.spec.js.\n\nNOTE THE SAME CAVEAT NOW APPLIES TO improvise-taxx: its bug is also only locally reproducible, which is why the two assertions the fix owed could not be shipped as guards either. The fix is still right; it is the pinning that is unavailable.\n\n## Steps to Reproduce\n\n1. Serve the page and point it at a reachable `improvise-ws-server` so the socket actually OPENS (status reads `connected`).\n2. Stop the server, or otherwise drop the connection mid-session.\n3. The status line reads `disconnected` — correct.\n4. Wait eight seconds. The handshake watchdog, armed on open and never disarmed, replaces it with `accepted the connection but sent no document`.\n\nReproduces on macOS/Chromium. Does NOT reproduce on the GitHub runner, where the order of `error`/`close`/timer on the host's network stack differs — which is why this cannot be pinned as a CI guard. See `harness/tests/shell.spec.js` for the measurement.\n","acceptance_criteria":"The watchdog is disarmed when a socket that had OPENED subsequently closes, so a mid-session disconnect keeps its own message. The never-opened path is unchanged (the watchdog is the only thing that speaks there). Stated in the code why this is not a CI guard: the trigger is the host network stack's ordering of error/close/timer, so an expected-failure would invert CI's meaning rather than flake.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T06:44:26Z","created_by":"Edward Langley","updated_at":"2026-09-20T08:05:26Z","closed_at":"2026-09-20T08:05:26Z","close_reason":"CLOSED AS INVALID 2026-09-20. THE BUG DOES NOT EXIST, and I filed it without checking the merged code.\n\nTHE CLAIM WAS: on the open-then-close path the handshake watchdog stays armed, so eight seconds after a genuine mid-session disconnect the status line replaces \"disconnected\" with \"accepted the connection but sent no document\".\n\nTHE CODE SAYS OTHERWISE, verified by reading at main 22d43b2:\n * web/app.js:248-250 routes BOTH arms of the close listener through setShellFailure.\n * setShellFailure (app.js:165-169) calls clearTimeout(watchdog) synchronously and nulls it.\n * The comment at app.js:241-247 states the property explicitly and ends \"Neither arm leaves one armed.\"\n\nHOW A REAL MEASUREMENT PRODUCED A FALSE BEAD, which is the part worth keeping:\n\nThe harness agent (improvise-vvl3) MEASURED the 8s overwrite for real. But its worktree was based at 97ea489 -- BEFORE PR #21 landed improvise-taxx's fix. It then reported the bug \"survives improvise-taxx's fix, confirmed by simulating PR #21's opened flag\". THAT SIMULATION WAS INCOMPLETE. PR #21's fix is not only the `opened` flag; it is routing BOTH ARMS THROUGH setShellFailure. Simulating the flag alone leaves the timer armed and faithfully reproduces a bug the real fix does not have.\n\nSo the measurement was honest, the simulation was not the fix it named, and I filed the bead on the report without checking the merged tree. doctrine(an_injected_fault_must_be_the_fault_you_named) one level up: A SIMULATED FIX MUST BE THE FIX YOU NAMED. A partial simulation of a repair reproduces the original bug by construction and reads as evidence that the repair failed.\n\nFOUND BY: a reviewer (ChatGPT) on PR #30, which had propagated the false claim into context/repo-map.md's gotcha 48 and into harness/tests/shell.spec.js's comment block. Both are being corrected.\n\nNOT WASTED: PR #30 adds the test the claim should always have had -- the open-then-close arm now runs past HANDSHAKE_TIMEOUT_MS and asserts \"sent no document\" never appears, proved discriminating by making setShellFailure not clear the timer. So the property is executed rather than argued, and the next person cannot re-derive this conclusion from a partial simulation.\n\nimprovise-taxx (the close-listener fix) remains correctly closed and is unaffected.","dependencies":[{"issue_id":"improvise-xps8","depends_on_id":"improvise-taxx","type":"relates-to","created_at":"2026-09-20T06:44:28Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-l8rh","title":"The ambiguous-date heuristic reads American-first, so a European CSV imports with days and months swapped","description":"Named and pinned by WU-L on PR #22 (improvise-7yx item 2), and left UNCHANGED by the user's ruling 2026-09-19: leave it pinned, file it as a known limit.\n\nTHE BEHAVIOUR: import/analyzer.rs::detect_date_format is a FIRST-MATCH heuristic, so for a column of values that fit both %m/%d/%Y and %d/%m/%Y -- \"01/02/2025\", \"03/04/2025\" -- the AMERICAN reading wins. A European CSV therefore imports with days and months SWAPPED, silently, and every date in the column is wrong in a way that still looks like a date.\n\nWHY IT IS TRACKED RATHER THAN FIXED, and this is the argument that carried:\n * The import wizard SHOWS the detected format and lets the user override it, so the mis-read is visible at the point of import to a user who looks. That is the same argument detect_short_year_format already recorded for its own guess.\n * Changing the default to day-first only moves which half of the world imports silently wrong.\n * The honest fix is larger than the heuristic: an AMBIGUOUS column should surface its ambiguity rather than resolve it, which changes the analyzer's return shape (FieldProposal) and the wizard step that displays it. Worth doing deliberately, not as a patch.\n\nWHAT PR #22 DID CHANGE, and it is why this is now a stable known limit rather than a lottery: analyze_records used to iterate a HashSet, so the ten samples detect_date_format looks at were a DIFFERENT TEN EVERY RUN -- meaning a column whose eleventh value rules out %m/%d/%Y detected a different format run to run. Same CSV, same import, different dates. That is fixed (first-seen order, invariant(analyzer_first_seen_order)), so the heuristic is now WRONG DETERMINISTICALLY, which is the precondition for anyone being able to reason about it at all.\n\nPINNED BY: an_ambiguous_date_is_read_american_first, whose doc-comment states plainly that this silently mis-reads a European CSV, with the day-first reading asserted to parse so the test is a statement about AMBIGUITY rather than about parsing. a_day_first_only_value_settles_the_ambiguity is the discriminating other arm.\n\nSTILL TRUE AND UNCHANGED: the ten-sample window itself. A column whose first ten values are ambiguous and whose eleventh is not will still be read american-first. Now deterministic, still a limit.\n\n## Decision\n\nLeave the first-match heuristic as it is, and track it as a known limit. Ruled by the user 2026-09-20.\n\n## Rationale\n\nThe import wizard SHOWS the detected format and lets the user override it, so the mis-read is visible at the point of import to a user who looks — the same argument `detect_short_year_format` already recorded for its own guess.\n\nWhat made it worth tracking rather than ignoring is that PR #22 changed its character: `analyze_records` used to iterate a `HashSet`, so the ten samples `detect_date_format` inspects were a DIFFERENT ten every run, and a column whose eleventh value ruled out `%m/%d/%Y` detected a different format run to run. It is now wrong DETERMINISTICALLY, which is the precondition for anyone being able to reason about it at all.\n\n## Alternatives Considered\n\n**Change the default to day-first.** Rejected: it only moves which half of the world imports silently wrong.\n\n**Surface the ambiguity instead of resolving it.** The honest fix and larger than the heuristic — it changes `FieldProposal`'s shape and the wizard step that displays it. Worth doing deliberately rather than as a patch, which is why it is not folded in here.\n\n**Widen the ten-sample window.** Does not address ambiguity at all; a fully ambiguous column stays fully ambiguous.\n","status":"closed","priority":3,"issue_type":"decision","owner":"el-github@elangley.org","created_at":"2026-09-20T06:28:43Z","created_by":"Edward Langley","updated_at":"2026-09-21T04:42:56Z","closed_at":"2026-09-21T04:42:56Z","close_reason":"Closed","labels":["area:core-io"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-rw3a","title":"Decide whether ZZ routes into the save prompt automatically","description":"The half of improvise-lae that was deliberately left open. PR #27 built the prompt (AppMode::SavePrompt, TextEntry builder, committing to SaveAs, bound Ctrl-W and reachable as `:enter-mode save-prompt`) and stopped short of making ZZ route into it.\n\nTODAY, after PR #27: ZZ on a pathless document refuses, stays in the app, and the status line names the prompt. That is already better than improvise-8tk left it -- the user is told where to go. The open question is whether being told is enough, or whether the vim reflex should just land somewhere useful.\n\nTHE OBSTACLE THE ORIGINAL TICKET NAMED, still true: CmdContext does not expose file_path, so a Cmd cannot currently decide \"is there a path\" at execute time. Adding the field is small; note improvise-2ud is separately proposing that CmdContext borrow the ModelState/ViewState slices rather than copying ~20 individual fields, which would make this free. Coordinate rather than adding a 21st copied field ahead of it.\n\nTHE CONSTRAINT PR #27 ESTABLISHED, and it must be respected by whatever lands here: the prompt is SESSION-LOCAL and was deliberately kept in the COMMAND layer rather than routed through effect::Save. Save is becoming a wire command (improvise-368h), and a prompt is addressed to precisely one session -- routing it through the effect would open a prompt on a host nobody asked, which is gotcha 38's argument for keeping AcceptWizardModel off the wire. So: a Cmd that inspects the path and emits a mode change, never an effect that decides to prompt.\n\nDECIDE ALSO what ZZ means once the prompt is open. ZZ is \"save and quit\"; if the prompt commits to SaveAs and the batch was aborted, the quit is gone. Should committing the path then quit? That is a second composition and the answer is not obviously yes -- a user who hit ZZ, got a prompt, and typed a path plausibly wants to leave, but making the prompt's Enter mode-dependent is the kind of hidden state improvise-ct5 just declined to add for `i`.\n\n## Decision\n\nOpen. This is the half of improvise-lae that was deliberately left undecided when the save prompt landed.\n\n## Rationale\n\nAfter PR #27, `ZZ` on a pathless document refuses, stays in the app, and the status line names the prompt. That is already better than improvise-8tk left it — the user is told where to go. The question is whether being told is enough, or whether the vim reflex should land somewhere useful by itself.\n\n## Alternatives Considered\n\n**Route `ZZ` into the prompt automatically.** Needs `CmdContext` to expose `file_path`, which it does not. improvise-2ud separately proposes that `CmdContext` borrow the ModelState/ViewState slices rather than copying ~20 fields, which would make this free — coordinate rather than adding a 21st copied field ahead of it.\n\n**Leave `ZZ` as it is.** The status line names `Ctrl-W`; the cost is one keystroke of discovery.\n\n**Constraint on any answer:** the prompt is SESSION-LOCAL and must stay in the command layer, never routed through `effect::Save`. Save is now a wire command (improvise-368h), and an effect that decides to prompt would open a prompt on a host nobody asked — gotcha 38's argument for keeping AcceptWizardModel off the wire.\n\n**Unanswered second question:** what `ZZ` means once the prompt is open. ZZ is save-AND-QUIT; if committing the path does not also quit, the reflex is only half honoured — but making the prompt's Enter mode-dependent is the hidden state improvise-ct5 just declined to add for `i`.\n","status":"open","priority":3,"issue_type":"decision","owner":"el-github@elangley.org","created_at":"2026-09-20T06:04:32Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:45:42Z","labels":["area:tui"],"dependencies":[{"issue_id":"improvise-rw3a","depends_on_id":"improvise-2ud","type":"blocks","created_at":"2026-09-20T16:20:29Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-rw3a","title":"Decide whether ZZ routes into the save prompt automatically","description":"The half of improvise-lae that was deliberately left open. PR #27 built the prompt (AppMode::SavePrompt, TextEntry builder, committing to SaveAs, bound Ctrl-W and reachable as `:enter-mode save-prompt`) and stopped short of making ZZ route into it.\n\nTODAY, after PR #27: ZZ on a pathless document refuses, stays in the app, and the status line names the prompt. That is already better than improvise-8tk left it -- the user is told where to go. The open question is whether being told is enough, or whether the vim reflex should just land somewhere useful.\n\nTHE OBSTACLE THE ORIGINAL TICKET NAMED, still true: CmdContext does not expose file_path, so a Cmd cannot currently decide \"is there a path\" at execute time. Adding the field is small; note improvise-2ud is separately proposing that CmdContext borrow the ModelState/ViewState slices rather than copying ~20 individual fields, which would make this free. Coordinate rather than adding a 21st copied field ahead of it.\n\nTHE CONSTRAINT PR #27 ESTABLISHED, and it must be respected by whatever lands here: the prompt is SESSION-LOCAL and was deliberately kept in the COMMAND layer rather than routed through effect::Save. Save is becoming a wire command (improvise-368h), and a prompt is addressed to precisely one session -- routing it through the effect would open a prompt on a host nobody asked, which is gotcha 38's argument for keeping AcceptWizardModel off the wire. So: a Cmd that inspects the path and emits a mode change, never an effect that decides to prompt.\n\nDECIDE ALSO what ZZ means once the prompt is open. ZZ is \"save and quit\"; if the prompt commits to SaveAs and the batch was aborted, the quit is gone. Should committing the path then quit? That is a second composition and the answer is not obviously yes -- a user who hit ZZ, got a prompt, and typed a path plausibly wants to leave, but making the prompt's Enter mode-dependent is the kind of hidden state improvise-ct5 just declined to add for `i`.\n\n## Decision\n\nOpen. This is the half of improvise-lae that was deliberately left undecided when the save prompt landed.\n\n## Rationale\n\nAfter PR #27, `ZZ` on a pathless document refuses, stays in the app, and the status line names the prompt. That is already better than improvise-8tk left it — the user is told where to go. The question is whether being told is enough, or whether the vim reflex should land somewhere useful by itself.\n\n## Alternatives Considered\n\n**Route `ZZ` into the prompt automatically.** Needs `CmdContext` to expose `file_path`, which it does not. improvise-2ud separately proposes that `CmdContext` borrow the ModelState/ViewState slices rather than copying ~20 fields, which would make this free — coordinate rather than adding a 21st copied field ahead of it.\n\n**Leave `ZZ` as it is.** The status line names `Ctrl-W`; the cost is one keystroke of discovery.\n\n**Constraint on any answer:** the prompt is SESSION-LOCAL and must stay in the command layer, never routed through `effect::Save`. Save is now a wire command (improvise-368h), and an effect that decides to prompt would open a prompt on a host nobody asked — gotcha 38's argument for keeping AcceptWizardModel off the wire.\n\n**Unanswered second question:** what `ZZ` means once the prompt is open. ZZ is save-AND-QUIT; if committing the path does not also quit, the reflex is only half honoured — but making the prompt's Enter mode-dependent is the hidden state improvise-ct5 just declined to add for `i`.\n","status":"open","priority":3,"issue_type":"decision","owner":"el-github@elangley.org","created_at":"2026-09-20T06:04:32Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:45:42Z","labels":["area:tui"],"dependencies":[{"issue_id":"improvise-rw3a","depends_on_id":"improvise-2ud","type":"blocks","created_at":"2026-09-20T16:20:29Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-rw3a","depends_on_id":"improvise-gtn7","type":"parent-child","created_at":"2026-09-25T01:05:31Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-g9jb","title":"The text-entry mode list is hand-written in two crates and checked against AppMode in neither","description":"Found by WU-G on PR #27 while adding AppMode::SavePrompt.\n\nThe set of text-entry modes is written down BY HAND IN TWO PLACES, in two crates:\n * improvise-command::keymap::tests::text_entry_modes_with_exits\n * improvise-wasm-client::keys::tests::text_entry_modes\n\nand NOTHING CHECKS EITHER AGAINST AppMode. So adding a text-entry variant compiles, passes, and is silently absent from both lists -- which matters more since PR #27, because the browser now refuses to ENTER a prompt it cannot commit (keys.rs::strands_the_user), and that refusal is driven off one of these hand-written lists.\n\nTHE FAILURE MODE IS THE QUIET ONE: a new prompt mode omitted from the wasm list is one a browser will happily open and then swallow the user's typing in -- doctrine(a_doorway_to_a_black_hole_is_worse_than_the_key), which PR #27 established against SavePrompt and found ExportPrompt had already.\n\nTHE FIX: derive the list once, from AppMode itself. `MinibufferConfig` is already the thing that makes a variant text-entry -- AppMode::minibuffer() is an exhaustive match returning Option\u003cMinibufferConfig\u003e (gotcha 11), so \"the text-entry modes\" is derivable rather than enumerable. A const or a function in improvise-protocol beside AppMode, with both crates reading it, replaces both lists.\n\nCHECK ONE THING BEFORE ASSUMING THE DERIVATION IS FREE: the wasm list and the command list may not be the same set for a reason -- SearchMode is hand-written on purpose and has no commit key (gotcha 11), and the browser's question (\"can this client commit here\") is not identical to the keymap's (\"does this mode take text\"). If they genuinely differ, the answer is two derived predicates over one enum, not two hand-written lists.\n\ndoctrine(a_hand_enumerated_mirror_needs_an_exhaustive_match) already covers this; this is an instance with no match behind it at all.\n","acceptance_criteria":"The set of text-entry modes is DERIVED from AppMode rather than hand-written twice. AppMode::minibuffer() is already an exhaustive match returning Option\u003cMinibufferConfig\u003e, so the set is derivable. Before assuming one derivation serves both: check whether the keymap's question ('does this mode take text') and the browser's question ('can this client commit here') are actually the same set — SearchMode is hand-written on purpose and has no commit key. If they differ, the answer is two derived predicates over one enum, never two hand-written lists.","status":"closed","priority":3,"issue_type":"task","assignee":"claude-agent-protocol","owner":"el-github@elangley.org","created_at":"2026-09-20T06:04:22Z","created_by":"Edward Langley","updated_at":"2026-09-21T03:47:03Z","started_at":"2026-09-21T03:28:20Z","closed_at":"2026-09-21T03:47:03Z","close_reason":"Closed","labels":["area:shared-frontend"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-z05r","title":"CommitExport does not bail on an empty path, and ExportCsv does not abort the batch","description":"Found by WU-G on PR #27 while fixing the save arms, and left deliberately: these are the same two defects one file over, at lower severity, and fixing them in that PR would have widened a save-focused change into an export-focused one.\n\nTWO ARMS, both mirroring what was just fixed for save:\n 1. CommitExport does not bail on an empty path, where CommitBuffer's three kinds all do (gotcha 20: buffer -\u003e trim -\u003e bail-on-empty -\u003e dirty -\u003e status is the shared spine).\n 2. ExportCsv does not set abort_effects on failure, where Save and (as of PR #27) SaveAs both do. So a `Binding::Sequence` or a `:` line composing an export with anything after it continues past a failed write.\n\nWHY IT IS LOWER SEVERITY THAN THE SAVE ARMS, and this is the honest reason it was deferred rather than a claim that it does not matter: an export failure discards nothing. improvise-8tk was a P1 because ZZ quit and the model was gone. Here the model is untouched and the user has simply not got the CSV they asked for -- so the cost is a missing file and a message, not lost work.\n\nNOTE THE SHAPE, because it is the third instance in this area: improvise-8tk fixed Save and not SaveAs; PR #27 found SaveAs; and this is the export arm of the same rule. doctrine(one_arm_of_a_symmetric_rule_is_evidence_about_that_arm_only) exists for exactly this, and the general fix may be better than the third patch -- consider whether \"an effect that writes a file and fails aborts the batch\" can be made structural rather than remembered three times.\n\nALSO CHECK, while in there: whether the export error is visible at all, given that ExportPrompt is a minibuffer mode and a status message written while a minibuffer is open is never drawn (see the sibling bead on draw_bottom_bar).\n\n## Steps to Reproduce\n\n1. `CommitExport` with an empty path: it does not bail, where `CommitBuffer`'s three kinds all do (gotcha 20's shared spine is buffer → trim → bail-on-empty → dirty → status).\n2. Compose an export with anything after it — a `Binding::Sequence`, or a `:` line on ` . ` — and make the write fail. `ExportCsv` does not set `abort_effects`, so the chain continues past the failure, where `Save` and (since PR #27) `SaveAs` both stop it.\n\nLower severity than the save arms because an export failure discards nothing: the model is untouched and the user simply has not got their CSV.\n","acceptance_criteria":"CommitExport bails on an empty path like the other three commit kinds, and ExportCsv aborts the batch on a failed write like Save and SaveAs. Prefer the structural fix over the third patch: this is the third instance of the same asymmetry, so consider whether 'an effect that writes a file and fails aborts the batch' can be made impossible to forget rather than remembered three times.","status":"closed","priority":3,"issue_type":"bug","assignee":"claude-agent-cmd","owner":"el-github@elangley.org","created_at":"2026-09-20T06:04:21Z","created_by":"Edward Langley","updated_at":"2026-09-21T04:43:49Z","started_at":"2026-09-21T03:27:54Z","closed_at":"2026-09-21T04:43:49Z","labels":["area:tui"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-44ad","title":"ColWidths is redundant under the label fold, and the test that would change is gotcha 33(c)'s evidence","description":"Found while closing improvise-hkt6 (PR #20, WU-K), tried, and DELIBERATELY REVERTED rather than landed -- filed so it is a decision rather than a rediscovery.\n\nhkt6(b) obliged grid_updates to diff `totals` against the LABEL-FOLDED cache rather than against the mirror (see gotcha 33). The symmetric move for `col_widths` looks obviously right and is not free: it BREAKS `a_client_on_a_socket_is_seeded_and_gets_its_edit_back`.\n\nWHY, and it is a real property rather than a brittle assertion: folding the labels RE-FITS the widths (gotcha 33(c) -- every arm that touches labels or widths re-fits, because a renderer zips widths with labels and a length mismatch indexes off the end). So under a fold-relative diff the ColWidths that a reframe currently carries becomes REDUNDANT and is suppressed. The client is correct either way; what changes is whether the reframe still SENDS the widths.\n\nTHE REASON IT WAS NOT SPENT IN PR #20: that assertion is gotcha 33(c)'s standing evidence -- it is the test that catches \"a late ColWidths put the mismatch straight back\". Rewriting it inside a PR whose subject was three totality gaps would have destroyed the evidence for a rule while fixing something else, which is exactly the shape improvise-q0vb was declined for on PR #12. Two right changes, different costs.\n\nTHE DECISION TO MAKE:\n * Leave widths diffed against the mirror. Costs one redundant ColWidths per reframe -- the same direction Dirty::of is deliberately sloppy in (gotcha 35: over-report pays one redundant Projection, under-report leaves a client wrong forever). Defensible and arguably correct on that principle alone.\n * Or move widths to the fold and REPLACE the socket assertion with one that still pins 33(c)'s property -- that a client never holds widths of a different length from its labels -- rather than one that pins the message count. That is the better test either way, and writing it is most of the work.\n\nPREFER THE SECOND ONLY IF the replacement test is written first and shown to fail against a deliberately late ColWidths. Otherwise prefer the first and say so in gotcha 33, so the asymmetry between totals and widths is STATED rather than discovered by the next person who notices it.\n\n## Rationale\n\nimprovise-hkt6 obliged `grid_updates` to diff `totals` against the LABEL-FOLDED cache rather than the mirror, or a rename that leaves the number equal sends no `Totals` and the client blanks its own. The symmetric move for `col_widths` looks obviously right and is not free: it breaks `a_client_on_a_socket_is_seeded_and_gets_its_edit_back`.\n\nThat is a real property, not a brittle assertion. Folding the labels RE-FITS the widths (gotcha 33(c)), so under a fold-relative diff the `ColWidths` a reframe currently carries becomes redundant and is suppressed. The client is correct either way; what changes is whether the reframe still SENDS them.\n\n## Alternatives Considered\n\n**Leave widths diffed against the mirror.** Costs one redundant `ColWidths` per reframe — the same direction `Dirty::of` is deliberately sloppy in (gotcha 35: over-report pays one redundant Projection, under-report leaves a client wrong forever). Defensible on that principle alone, and then the asymmetry between totals and widths should be STATED in gotcha 33 rather than rediscovered.\n\n**Move widths to the fold and replace the socket assertion** with one that still pins 33(c)'s property — that a client never holds widths of a different length from its labels — rather than one that pins the message count. That is the better test either way, and writing it is most of the work.\n\n**Not acceptable: move widths to the fold and simply update the failing assertion.** It is gotcha 33(c)'s standing evidence, and rewriting it inside a change it is meant to guard destroys the evidence for that change — the improvise-q0vb / PR #12 shape.\n","status":"closed","priority":3,"issue_type":"decision","owner":"el-github@elangley.org","created_at":"2026-09-20T05:54:56Z","created_by":"Edward Langley","updated_at":"2026-09-21T12:39:38Z","closed_at":"2026-09-21T12:39:38Z","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -676,12 +803,12 @@ {"_type":"issue","id":"improvise-x7sw","title":"Which-key hints have no protocol home (the open half of improvise-jb3)","description":"improvise-jb3 closed with its WIDGET half already done -- WhichKeyWidget::new(\u0026[(String, \u0026static str)]) was never \u0026App-coupled. This is the half that was never that ticket.\n\nThe derivation happens in draw.rs: App::armed_prefix_keymap() -\u003e Keymap::binding_hints(). ViewState::transient_prefix IS portable (gotcha 13 stores a prefix as an ADDRESS, not a keymap, precisely so ViewState stays free of improvise-command types) -- but THE KEYMAP SET IS NOT ON THE CLIENT, so a browser which-key cannot be derived from ViewState alone.\n\nTWO ANSWERS, and the choice is the ticket:\n * THE RESOLVED HINT LIST CROSSES -- a Projection, or a ViewState field. Cheap, and it keeps the keymap a client-only concept, which is the stated direction (\"keys should be converted into commands in the browser: the keymap should not be known server side at all\"). But it puts a UI string list on the wire, which is what improvise-k099 is already unhappy about for AppMode::hint_text/label.\n * THE CLIENT GETS THE KEYMAPS. Consistent with keys being a client concept, and the wasm client already has keys.rs -- but the hint list is currently derived from the SERVER-side KeymapSet, so this means the client owns the keymaps outright and the server stops having them. Larger, and probably where this is heading anyway.\n\nNote the second reading is the one that fits improvise-bpq2. Decide them together.","acceptance_criteria":"A browser can draw which-key hints. Decide between the resolved hint list crossing as a Projection, and the client owning the keymaps outright — the second is where improvise-bpq2 already went (the table now lives in improvise-protocol), so prefer it unless there is a reason not to, and decide it TOGETHER with improvise-k099, which is the same question about UI strings on a wire enum. Whichever, ViewState::transient_prefix stays an ADDRESS and not a keymap (gotcha 13).","status":"closed","priority":3,"issue_type":"task","assignee":"wave2-h-frontend","owner":"el-github@elangley.org","created_at":"2026-09-20T00:58:33Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:18:05Z","started_at":"2026-09-20T17:53:39Z","closed_at":"2026-09-20T18:18:05Z","close_reason":"Closed","labels":["area:shared-frontend"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-2yc2","title":"DataStore::live_records walks a HashMap -- decide its order before writing the caller","description":"The next instance of improvise-shyt, found while fixing it, and deliberately LEFT ALONE rather than guessed at.\n\nDataStore::live_records walks the records: HashMap, so it yields a different order in every process -- the same defect that made records-mode rows reorder between sessions, misdirected drill staged edits, and drifted SUM/AVG in the last f64 bits.\n\nIt differs from shyt in one way that matters: IT HAS NO CALLERS YET. So this is not a bug report, it is \"decide the order deliberately before something depends on it\". The ADR 002 surrogate path will be the caller -- the RecordHash -\u003e RecordEntry index is what live_records walks -- and whoever writes it should pick an order on purpose rather than inherit a hash walk and then discover it, which is how shyt happened.\n\nTHE ANSWER SHYT LANDED ON, for reference: the index narrows, the primary IndexMap orders (matching_positions resolves each candidate with get_index_of, sorts, reads back with get_index). Measured at 18,000 cells that cost at worst +20% on one path and was 7% FASTER on the other. Whether that is the right answer here depends on what the surrogate path wants; a hash-keyed index may legitimately want no order at all, in which case say so and return something whose type admits it.","status":"closed","priority":3,"issue_type":"task","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T00:45:22Z","created_by":"Edward Langley","updated_at":"2026-09-20T05:54:43Z","started_at":"2026-09-20T05:32:37Z","closed_at":"2026-09-20T05:54:43Z","close_reason":"Closed by PR #20 (WU-K). DECIDED: live_records answers in the STORE'S OWN ORDER -- it walks the primary IndexMap's cells and dedupes by RecordHash, so a record sits where its FIRST cell sits. That is the order matching_positions already gives (gotcha 18), projected onto records, rather than a second answer to \"what order is this store in\".\n\nDerived on each call rather than memoised on RecordEntry, because a stored position is a copy and the tombstone a deleted record leaves would keep it while its cells moved to the end. That case is now tested (a_recreated_record_takes_its_new_place_not_its_old_one) -- and it was found because Copilot noticed the doc comment ASSERTED the derive-don't-memoise choice mattered for delete-and-recreate while nothing tested it; a memoising implementation passed all three other tests.\n\nTHE TICKET'S ALTERNATIVE WAS REJECTED WITH A REASON WORTH KEEPING: \"a hash-keyed index may want no order at all -- return something whose type admits it\" cannot be built. Every iterator has an order, and a type that merely DISCLAIMS one still hands the caller hash order. Disclaiming is not making unrepresentable.\n\nCost moves O(records) -\u003e O(cells). With no caller to benchmark, correctness is the defensible default and the doc says what a hot-path caller should bring.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-odbc","title":"[bug] bank-info.improv in the repo root does not parse","description":"Incidental finding from improvise-cr3 (2026-09-19), outside that ticket scope, unreproduced beyond the one load.\n\n Parse error at line 293, column 3: - JORDAN MARTINEZ (expected pipe_quoted)\n\nTWO READINGS, and they want different actions:\n * The file is STALE -- written before a grammar change -- in which case delete it or regenerate it, and the finding is that a sample file in the repo root is not exercised by anything.\n * The GRAMMAR HAS A GAP -- some name shape a writer emits that the reader rejects. That is the serious reading, because save/load round-trip is a property test (persistence proptests, parse(format(parse(generate())))) and a gap that survives it is a gap the GENERATOR does not reach.\n\nDecide which by checking whether improvise can WRITE that line today. If it can, the round-trip property is incomplete and the generator is the thing to fix, not the file.","status":"closed","priority":3,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T00:37:30Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:28:58Z","started_at":"2026-09-20T05:32:40Z","closed_at":"2026-09-20T06:28:58Z","close_reason":"Closed by PR #22 (WU-L). THE TICKET'S PREMISE WAS FALSE, AND TESTING IT RATHER THAN ASSUMING EITHER READING IS WHAT MADE THIS CHEAP.\n\nThe ticket offered two readings and hoped for the serious one: that the grammar had a gap some writer could emit, which would mean the round-trip property is incomplete and the GENERATOR is the thing to fix. It said to decide by checking whether improvise can WRITE the offending line. IT CANNOT. item_list's name is `pipe_quoted | bare_name`, bare_name is [A-Za-z_][A-Za-z0-9_-]* with no space, and quote_name mirrors it character for character -- so improvise writes |JORDAN MARTINEZ|. Reader and writer agree, the round-trip property is COMPLETE, and the grammar-walking generator needs no change.\n\nThe file was stale BY HAND: c48a5cd migrated it to v2025-04-09 by editing in place, pipe-quoting the Date items and not the Entity ones, and every line of its ## Data section names a category `Measure` where the file declares `_Measure`. Unloadable since April. Nothing loaded it.\n\nDELETED RATHER THAN REGENERATED, on the user's ruling 2026-09-19, together with foo.improv (the same class, one file over, with no version line at all). There is no generator for either -- they are hand-written fake data -- so regenerating meant inventing a canonical form and checking in 1200 lines whose only property is that they parse, which the grammar proptests already establish over a far wider input space.\n\nTHE EVIDENCE KEPT IS WORTH MORE THAN THE FILE:\n * an_item_name_the_item_line_cannot_carry_round_trips -- the deleted file's own names through format_md/parse_md.\n * a_bare_item_name_with_a_space_is_rejected -- the reader really refuses the unquoted line, with the quoted form parsing as a precondition. Without this the first test is consistent with BOTH spellings being accepted.\n * tests/shipped_documents_load.rs -- a DIRECTORY WALK over the two directories .gitignore un-ignores, not a list of names, because a list is a second place to remember and the forgotten file is the one nobody added to it. Fault injected and failed. Then HARDENED: it matched Path::extension() == \"improv\", which of x.improv.gz is \"gz\", so a gzipped shipped document would have been skipped -- the very hole the test exists to close, put back inside the fix for it.\n\nTHE FINDING, which is the opposite of what the ticket expected: a checked-in document in a format the repo parses is exercised by NOTHING unless something loads it, and grammar proptests cannot see it because they GENERATE their inputs.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-qddo","title":"Make DataStore an implementation of a source trait, not the implementation","description":"REWRITTEN 2026-09-19 to match context/adr/003-live-backed-models.md, and DEFERRED by user ruling in the same session, together with improvise-rucf and improvise-mjy7. The ADR calls this \"worth doing first regardless of the eventual shape\"; the user's ruling is that the whole direction waits, so it waits as a unit rather than landing a seam with nothing on the other side of it.\n\nTHE WORK IS UNCHANGED: introduce a source trait in improvise-core and make the current in-RAM DataStore AN implementation of it rather than THE implementation. No behaviour change -- doctrine(characterization_survives_the_move) applies and the existing tests must carry across unchanged in meaning.\n\nTODAY Model holds a DataStore directly and a Workbook loads ENTIRE; there is no partial-load path anywhere in the tree (verified while declining improvise-9ix). So \"where cell values come from\" is not a seam, it is an assumption spread across GridLayout, evaluation and persistence.\n\nWHAT ADR 003 ADDS TO THE ORIGINAL FRAMING, and it is the part worth re-reading before starting:\n\n * AGGREGATION IS THE INTERFACE, AND IT IS ALREADY SQL. apply_agg computes Sum/Avg/Min/Max/Count over a matching cell set -- exactly SQL's aggregates -- and GridLayout asks for a VALUE, not for the rows behind it. Shape the trait around \"give me the aggregate for this coordinate set\", never around \"give me the cells\": shaping it around cells forecloses pushdown, which is the entire point of the eventual live source.\n\n * WRITES MUST NOT BE IN THE TRAIT. This is new since the original text and it is a real constraint. If writing were in the trait a query source implements, a read-only source would have to FAIL AT RUNTIME for a method it structurally cannot serve. Keep reads and aggregates in the trait every source implements; let writing be a capability only the owned in-RAM implementation has. Read-only then becomes UNREPRESENTABLE rather than enforced -- the same discipline as CellId (ADR 002) and doctrine(an_invariant_is_made_unrepresentable_not_validated).\n\n * FORMULAS DO NOT MOVE. Evaluation is fixed-point and derived values live in Model::formula_cache, not in DataStore, so the split is clean: raw data is queried, derived data stays in RAM.\n\nCONSTRAINT: forbidden(improvise_core, io). The TRAIT belongs in improvise-core; any IO-touching implementation belongs in improvise-io. Do not let the trait signature drag IO types into core; if it needs Result, that is a boundary decision worth arguing rather than defaulting (AGENTS.md keeps Option/Result at the edges).\n\nWHY IT IS STILL WORTH DOING ON ITS OWN WHEN IT IS UN-DEFERRED: it is the seam every later option needs -- live-backing, a test double, or the import-once path ADR 003 rejected as the ONLY answer but did not forbid as A path; it makes \"the model is fully in RAM\" an explicit property of one implementation rather than an invisible assumption of the whole crate; and done as a pure refactor it is cheap and reviewable, where doing it AS PART OF adding a SQLite source mixes a large refactor with a large feature and makes both unreviewable.\n","notes":"\n--- READ-ONLY DECIDED (user, 2026-09-19), and it shapes the trait ---\nA live-backed model is READ-ONLY, and not reluctantly: for many use cases\nediting capabilities would be a DOWNSIDE. Pointing a pivot tool at a production\ndatabase and being unable to write to it is a feature.\n\nSO DO NOT PUT WRITES IN THE TRAIT. If writing were a trait method, a read-only\nsource would have to fail at RUNTIME for something it structurally cannot\nserve. Keep reads -- and crucially AGGREGATES, see the note above about shaping\nit around \"give me the aggregate for this coordinate set\" -- in the trait every\nsource implements, and let writing be a capability only the owned in-RAM\nimplementation has. Then read-only is UNREPRESENTABLE rather than enforced,\nwhich is the same discipline ADR 002 CellId uses and the same design-principles\n3 rule.\n\nRELATED CONVERGENCE WORTH KNOWING: ADR 002 already makes SetCell take a\nCellId::Record and nothing else, so a live-backed model that projects no Record\nids makes a client-side write unconstructible with no runtime check and no\n\"this model is read-only\" dialog. The settability distinction built for\naggregates carries read-only sources for free -- so the client half of this may\nalready be done.","status":"deferred","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T00:16:29Z","created_by":"Edward Langley","updated_at":"2026-09-20T05:31:47Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-qddo","title":"Make DataStore an implementation of a source trait, not the implementation","description":"REWRITTEN 2026-09-19 to match context/adr/003-live-backed-models.md, and DEFERRED by user ruling in the same session, together with improvise-rucf and improvise-mjy7. The ADR calls this \"worth doing first regardless of the eventual shape\"; the user's ruling is that the whole direction waits, so it waits as a unit rather than landing a seam with nothing on the other side of it.\n\nTHE WORK IS UNCHANGED: introduce a source trait in improvise-core and make the current in-RAM DataStore AN implementation of it rather than THE implementation. No behaviour change -- doctrine(characterization_survives_the_move) applies and the existing tests must carry across unchanged in meaning.\n\nTODAY Model holds a DataStore directly and a Workbook loads ENTIRE; there is no partial-load path anywhere in the tree (verified while declining improvise-9ix). So \"where cell values come from\" is not a seam, it is an assumption spread across GridLayout, evaluation and persistence.\n\nWHAT ADR 003 ADDS TO THE ORIGINAL FRAMING, and it is the part worth re-reading before starting:\n\n * AGGREGATION IS THE INTERFACE, AND IT IS ALREADY SQL. apply_agg computes Sum/Avg/Min/Max/Count over a matching cell set -- exactly SQL's aggregates -- and GridLayout asks for a VALUE, not for the rows behind it. Shape the trait around \"give me the aggregate for this coordinate set\", never around \"give me the cells\": shaping it around cells forecloses pushdown, which is the entire point of the eventual live source.\n\n * WRITES MUST NOT BE IN THE TRAIT. This is new since the original text and it is a real constraint. If writing were in the trait a query source implements, a read-only source would have to FAIL AT RUNTIME for a method it structurally cannot serve. Keep reads and aggregates in the trait every source implements; let writing be a capability only the owned in-RAM implementation has. Read-only then becomes UNREPRESENTABLE rather than enforced -- the same discipline as CellId (ADR 002) and doctrine(an_invariant_is_made_unrepresentable_not_validated).\n\n * FORMULAS DO NOT MOVE. Evaluation is fixed-point and derived values live in Model::formula_cache, not in DataStore, so the split is clean: raw data is queried, derived data stays in RAM.\n\nCONSTRAINT: forbidden(improvise_core, io). The TRAIT belongs in improvise-core; any IO-touching implementation belongs in improvise-io. Do not let the trait signature drag IO types into core; if it needs Result, that is a boundary decision worth arguing rather than defaulting (AGENTS.md keeps Option/Result at the edges).\n\nWHY IT IS STILL WORTH DOING ON ITS OWN WHEN IT IS UN-DEFERRED: it is the seam every later option needs -- live-backing, a test double, or the import-once path ADR 003 rejected as the ONLY answer but did not forbid as A path; it makes \"the model is fully in RAM\" an explicit property of one implementation rather than an invisible assumption of the whole crate; and done as a pure refactor it is cheap and reviewable, where doing it AS PART OF adding a SQLite source mixes a large refactor with a large feature and makes both unreviewable.\n","notes":"\n--- READ-ONLY DECIDED (user, 2026-09-19), and it shapes the trait ---\nA live-backed model is READ-ONLY, and not reluctantly: for many use cases\nediting capabilities would be a DOWNSIDE. Pointing a pivot tool at a production\ndatabase and being unable to write to it is a feature.\n\nSO DO NOT PUT WRITES IN THE TRAIT. If writing were a trait method, a read-only\nsource would have to fail at RUNTIME for something it structurally cannot\nserve. Keep reads -- and crucially AGGREGATES, see the note above about shaping\nit around \"give me the aggregate for this coordinate set\" -- in the trait every\nsource implements, and let writing be a capability only the owned in-RAM\nimplementation has. Then read-only is UNREPRESENTABLE rather than enforced,\nwhich is the same discipline ADR 002 CellId uses and the same design-principles\n3 rule.\n\nRELATED CONVERGENCE WORTH KNOWING: ADR 002 already makes SetCell take a\nCellId::Record and nothing else, so a live-backed model that projects no Record\nids makes a client-side write unconstructible with no runtime check and no\n\"this model is read-only\" dialog. The settability distinction built for\naggregates carries read-only sources for free -- so the client half of this may\nalready be done.","status":"deferred","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T00:16:29Z","created_by":"Edward Langley","updated_at":"2026-09-20T05:31:47Z","dependencies":[{"issue_id":"improvise-qddo","depends_on_id":"improvise-rucf","type":"parent-child","created_at":"2026-09-25T01:04:37Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-i9lt","title":"View::axis_of panics for a category the view has not seen","description":"Noticed during improvise-35e (2026-09-19), reported not fixed per the mention-do-not-fix rule for pre-existing issues.\n\nView::axis_of(cat) panics for a category the view has never seen, and View::tileable_cats admits exactly such categories -- it filters on try_axis_of(c).is_none_or(...). So the pairing is a live panic waiting for the right view state.\n\nThe old TileBar called axis_of over that list. The migrated widget cannot panic there any more, because tile_entries uses try_axis_of(...).unwrap_or(Axis::None) -- so the reachable path is gone and no failing test was written, since the old path no longer exists to fail.\n\nWorth a guard anyway: either make axis_of total (returning Axis::None like tile_entries does) or make it private and force callers through try_axis_of. A panicking accessor over a list built to include the panicking case is a trap for the next caller, and the next caller will not have the migration to protect them.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T23:40:38Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:25:57Z","closed_at":"2026-09-20T00:25:57Z","close_reason":"The reachable path is gone. improvise-edp migrated CategoryContent to take (\u0026CategoryTreeCache, \u0026HashSet\u003cString\u003e) instead of \u0026View, and the cache is built with try_axis_of(...).unwrap_or(Axis::None) -- so the widget that called view.axis_of(name) over a list that admits unseen categories no longer exists. Same resolution as the TileBar path in improvise-35e.\n\nView::axis_of itself is unchanged and still panics for a category the view has not seen. Left deliberately: it is a pre-existing API sharpness with no remaining caller that can reach it, and the mention-do-not-fix rule applies. If it acquires a new caller, make it total (returning Axis::None like the cache does) or make it private and force callers through try_axis_of -- a panicking accessor over a list built to include the panicking case is a trap for whoever arrives next.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-j31x","title":"Server hosts several documents: a registry above Sessions","description":"DEFERRED by the user (2026-09-19) -- recorded so nothing forecloses it, not to be built now.\n\nimprovise is a multi-document app and the server should be able to host several separate models. The shape:\n server -\u003e many documents\n document -\u003e one App + one Sessions + many ClientSessions\nThat is NOT one App per session (what improvise-q08 ticket text says, written before the session model landed) and NOT one App total.\n\nNOTHING THAT HAS LANDED NEEDS TO CHANGE. Sessions does not own the App -- connect(\u0026mut self, app: \u0026App, ..), apply(\u0026mut self, app: \u0026mut App, ..) and project_all(\u0026mut self, app: \u0026App, ..) all take it as a parameter. So Sessions is already document-agnostic and a Sessions instance is implicitly bound to one document generation sequence, which is right: document_generation lives on the App. Multi-document is a REGISTRY ABOVE Sessions mapping a document id to its (App, Sessions) pair.\n\nWHY IT MATTERS BEYOND TIDINESS: sessions editing independent models are trivially non-conflicting, so ADR 001 concurrency design scopes per document FOR FREE -- the basis version tiers live on the App and each document has its own. A server that shared one App across documents would throw that away.\n\nWHAT TO AVOID IN THE MEANTIME: improvise-q08 must not hard-code a single App into a static or a lazily-initialised singleton. The document should be a PARAMETER even while there is only one, and a connection should be attachable to a document even if the MVP answers that from a CLI argument naming one .improv file.\n\nScope when picked up: the registry, document lifecycle (open/close/last-session-leaves), and how a client names the document it wants. Auth and access control are explicitly NOT in scope here -- file them separately if wanted.","status":"closed","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-19T23:32:31Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:33:15Z","closed_at":"2026-09-20T00:33:15Z","close_reason":"Registry landed in crates/improvise-ws-server/src/registry.rs. 1051 tests pass.\n\nIT CAUGHT A REAL BUG BY VERIFYING THE SEAM INSTEAD OF TRUSTING IT. My brief and the q08 notes both said nothing below the registry needs to change. Nearly true -- but Sessions::next_id is a PER-Sessions counter (confirmed: project.rs holds next_id on Sessions, not globally), so EVERY documents first client is SessionId(0), and transport.rs keyed its outs: HashMap\u003cSessionId, _\u003e on it. A registry that kept that key would have delivered document Bs frames to document As client the moment two documents each had one client.\n\nThe server-wide address is the pair, ClientId { document, session }. Registry::route(document, Vec\u003cAddressed\u003e) is the ONLY place a SessionId acquires a document -- the one it came out of, since that is the only one in scope there -- so cross-document delivery of a projection, a SwitchView broadcast (gotcha 36) or an Applied::Rebased fan-out (gotcha 39) is UNREPRESENTABLE rather than checked. The test names the hazard: an_edit_is_projected_only_to_the_edited_documents_clients asserts on_alpha.session == on_beta.session FIRST.\n\nLIFECYCLE, and the non-obvious half. Open at STARTUP from the files named on the command line -- open-on-request was rejected because deciding which paths a client may open IS access control, which this ticket put out of scope; serving only what was named leaves the question unanswered rather than answered badly. NOT closed when the last session leaves: eviction loses unsaved edits, and worse, document_generation and record_versions start at ZERO in a fresh App (gotcha 40), so a client reconnecting with a STALE basis would have it accepted as current -- a hole in the exact mechanism ADR 001 is building, bought for a memory saving nobody asked for. Registry::is_idle is the hook a future policy asks from; nothing evicts.\n\nA client names its document in the URL PATH (ws://host:port/quarterly), which leaves wire.rs COMPLETELY unchanged so the wasm-client frames keep working; / resolves to the only document when there is exactly one, and with two is ConnectError::Ambiguous rather than a coin toss. A refusal is a websocket CLOSE FRAME carrying the reason -- no ServerMsg::Error variant invented, because that is a connection fact and wire.rs only grows variants the mutations demand.\n\nEvery required claim has a counter-test beside it so it cannot pass vacuously -- e.g. the broadcast-isolation fixture gives BOTH documents a view named Second, so betas silence is the routing and not a name it would have rejected anyway.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-2wi8","title":"ADR-001 e: a forced overwrite is visible to the client it overwrote","description":"ADR 001 layer 1, step e. Depends on step c.\n\nWithout this, force is silent data loss with a confirmation step in front of it. The overwritten client edit was already committed, so this is a NOTIFICATION rather than a conflict -- but it must exist, or \"someone else won\" is indistinguishable from \"my edit never happened\".\n\nDesign note: the notification is about the loser OWN prior edit, so it is per-client and therefore UI -- the model is shared, the UI is not. Do not broadcast it as document state.","acceptance_criteria":"A client whose committed edit was overwritten by a force is TOLD. Without this, force is silent data loss with a confirmation step in front of it — 'someone else won' must be distinguishable from 'my edit never happened'. It is a NOTIFICATION rather than a conflict, because the overwritten edit was already committed. Per-client and therefore UI: do not broadcast it as document state (model-shared-ui-not-shared).","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-19T23:27:34Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:56:22Z","labels":["area:protocol"],"dependencies":[{"issue_id":"improvise-2wi8","depends_on_id":"improvise-3al1","type":"blocks","created_at":"2026-09-19T23:27:47Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-vb6l","title":"ADR-001 d: when an intent cannot be re-composed, show what changed","description":"ADR 001 layer 1, step d -- the expensive one, and the honest cost of this design. Depends on step c.\n\nConflict class 2 (shape change vs cell edit) has NOTHING COHERENT TO FORCE: the key names a coordinate that no longer exists, and applying anyway writes a CellKey naming a deleted category -- the ghost-coordinate corruption the whole ADR exists to prevent.\n\nSo for class 2 the UI owes the user WHAT CHANGED plus the concrete alternatives -- re-target the edit, or re-create the structure first -- rather than a bare refusal or a force button that cannot be honoured.\n\nFORCE NEVER CREATES STRUCTURE: re-instating a category someone deleted so an edit can land is a different and LARGER intent than the user expressed. It must be an explicit action, never a side effect of forcing a cell edit.\n\nClass 3 (a formula now shadows the value) is forceable WITH A WARNING: the write lands, the display stays derived.\n\nNOTE FOR WHOEVER PICKS THIS UP: the ADR says plainly that if this dialogue is more than we want to build, the fallback is making shape changes rarer under contention -- e.g. a category deletion being explicit about the cells it destroys -- rather than making the conflict cheaper to resolve. That is a product call, not an implementation detail.","acceptance_criteria":"For conflict class 2 the user is shown WHAT CHANGED plus the concrete alternatives — re-target the edit, or re-create the structure first — rather than a bare refusal or a force button that cannot be honoured. FORCE NEVER CREATES STRUCTURE: re-instating a deleted category so an edit can land is a larger intent than the user expressed and must be an explicit action. Class 3 (a formula now shadows the value) is forceable WITH A WARNING. AND THE PRODUCT CALL COMES FIRST: the ADR says plainly that if this dialogue is more than we want to build, the fallback is making shape changes rarer under contention — e.g. a category deletion being explicit about the cells it destroys — rather than making the conflict cheaper to resolve. Answer that before building.","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-19T23:27:33Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:56:22Z","labels":["area:protocol"],"dependencies":[{"issue_id":"improvise-vb6l","depends_on_id":"improvise-3al1","type":"blocks","created_at":"2026-09-19T23:27:46Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-7b8v","title":"Concurrent edits: advisory reservations (ADR 001, layer 2 — UX only)","description":"Implements layer 2 of context/adr/001-concurrent-edits.md. BLOCKED ON layer 1 by design, not by dependency: correctness must not rest on this layer.\n\nGeneralise the drill staging mechanism so a client publishes the KEYS it has staged, letting other clients grey them out and warn before someone types into a cell that is about to be taken. The VALUES stay private -- the user ruled on that in improvise-tom1: staged edits are uncommitted writes belonging to whoever staged them, which is why gotcha 30 keeps drill_state off ViewState.\n\nTHE DESIGN CONSTRAINT: this layer is ADVISORY ONLY. If a reservation is lost, never arrives, or leaks from a crashed client, the system degrades to \"you find out at commit\" -- never to corruption. That is what makes it safe to be sloppy about timeouts and disconnects, and it is exactly why the basis check must land first and stand alone.\n\nNote the split it implies: the reservation KEY SET is shared (everyone must see it to be warned) while the VALUE is private. That maps onto the existing EffectKind distinction rather than fighting it.\n\nDo not add lease renewal or lock timeouts as correctness machinery. If a reservation outlives its client, the worst outcome is a stale warning, and the basis check still rejects the actual conflict.","acceptance_criteria":"A client publishes the KEYS it has staged; other clients grey them out and warn before typing into a cell about to be taken. THE VALUES STAY PRIVATE — the user ruled on that in improvise-tom1. ADVISORY ONLY: if a reservation is lost, never arrives, or leaks from a crashed client, the system degrades to 'you find out at commit' and NEVER to corruption — which is what makes it safe to be sloppy about timeouts and is why the basis check must land first and stand alone. Do NOT add lease renewal or lock timeouts as correctness machinery.","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-19T23:09:54Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:56:23Z","labels":["area:protocol"],"dependencies":[{"issue_id":"improvise-7b8v","depends_on_id":"improvise-3al1","type":"blocks","created_at":"2026-09-19T23:27:48Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-2wi8","title":"ADR-001 e: a forced overwrite is visible to the client it overwrote","description":"ADR 001 layer 1, step e. Depends on step c.\n\nWithout this, force is silent data loss with a confirmation step in front of it. The overwritten client edit was already committed, so this is a NOTIFICATION rather than a conflict -- but it must exist, or \"someone else won\" is indistinguishable from \"my edit never happened\".\n\nDesign note: the notification is about the loser OWN prior edit, so it is per-client and therefore UI -- the model is shared, the UI is not. Do not broadcast it as document state.","acceptance_criteria":"A client whose committed edit was overwritten by a force is TOLD. Without this, force is silent data loss with a confirmation step in front of it — 'someone else won' must be distinguishable from 'my edit never happened'. It is a NOTIFICATION rather than a conflict, because the overwritten edit was already committed. Per-client and therefore UI: do not broadcast it as document state (model-shared-ui-not-shared).","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-19T23:27:34Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:56:22Z","labels":["area:protocol"],"dependencies":[{"issue_id":"improvise-2wi8","depends_on_id":"improvise-3al1","type":"blocks","created_at":"2026-09-19T23:27:47Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-2wi8","depends_on_id":"improvise-5krd","type":"parent-child","created_at":"2026-09-25T01:04:56Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-vb6l","title":"ADR-001 d: when an intent cannot be re-composed, show what changed","description":"ADR 001 layer 1, step d -- the expensive one, and the honest cost of this design. Depends on step c.\n\nConflict class 2 (shape change vs cell edit) has NOTHING COHERENT TO FORCE: the key names a coordinate that no longer exists, and applying anyway writes a CellKey naming a deleted category -- the ghost-coordinate corruption the whole ADR exists to prevent.\n\nSo for class 2 the UI owes the user WHAT CHANGED plus the concrete alternatives -- re-target the edit, or re-create the structure first -- rather than a bare refusal or a force button that cannot be honoured.\n\nFORCE NEVER CREATES STRUCTURE: re-instating a category someone deleted so an edit can land is a different and LARGER intent than the user expressed. It must be an explicit action, never a side effect of forcing a cell edit.\n\nClass 3 (a formula now shadows the value) is forceable WITH A WARNING: the write lands, the display stays derived.\n\nNOTE FOR WHOEVER PICKS THIS UP: the ADR says plainly that if this dialogue is more than we want to build, the fallback is making shape changes rarer under contention -- e.g. a category deletion being explicit about the cells it destroys -- rather than making the conflict cheaper to resolve. That is a product call, not an implementation detail.","acceptance_criteria":"For conflict class 2 the user is shown WHAT CHANGED plus the concrete alternatives — re-target the edit, or re-create the structure first — rather than a bare refusal or a force button that cannot be honoured. FORCE NEVER CREATES STRUCTURE: re-instating a deleted category so an edit can land is a larger intent than the user expressed and must be an explicit action. Class 3 (a formula now shadows the value) is forceable WITH A WARNING. AND THE PRODUCT CALL COMES FIRST: the ADR says plainly that if this dialogue is more than we want to build, the fallback is making shape changes rarer under contention — e.g. a category deletion being explicit about the cells it destroys — rather than making the conflict cheaper to resolve. Answer that before building.","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-19T23:27:33Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:56:22Z","labels":["area:protocol"],"dependencies":[{"issue_id":"improvise-vb6l","depends_on_id":"improvise-3al1","type":"blocks","created_at":"2026-09-19T23:27:46Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-vb6l","depends_on_id":"improvise-5krd","type":"parent-child","created_at":"2026-09-25T01:04:56Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-7b8v","title":"Concurrent edits: advisory reservations (ADR 001, layer 2 — UX only)","description":"Implements layer 2 of context/adr/001-concurrent-edits.md. BLOCKED ON layer 1 by design, not by dependency: correctness must not rest on this layer.\n\nGeneralise the drill staging mechanism so a client publishes the KEYS it has staged, letting other clients grey them out and warn before someone types into a cell that is about to be taken. The VALUES stay private -- the user ruled on that in improvise-tom1: staged edits are uncommitted writes belonging to whoever staged them, which is why gotcha 30 keeps drill_state off ViewState.\n\nTHE DESIGN CONSTRAINT: this layer is ADVISORY ONLY. If a reservation is lost, never arrives, or leaks from a crashed client, the system degrades to \"you find out at commit\" -- never to corruption. That is what makes it safe to be sloppy about timeouts and disconnects, and it is exactly why the basis check must land first and stand alone.\n\nNote the split it implies: the reservation KEY SET is shared (everyone must see it to be warned) while the VALUE is private. That maps onto the existing EffectKind distinction rather than fighting it.\n\nDo not add lease renewal or lock timeouts as correctness machinery. If a reservation outlives its client, the worst outcome is a stale warning, and the basis check still rejects the actual conflict.","acceptance_criteria":"A client publishes the KEYS it has staged; other clients grey them out and warn before typing into a cell about to be taken. THE VALUES STAY PRIVATE — the user ruled on that in improvise-tom1. ADVISORY ONLY: if a reservation is lost, never arrives, or leaks from a crashed client, the system degrades to 'you find out at commit' and NEVER to corruption — which is what makes it safe to be sloppy about timeouts and is why the basis check must land first and stand alone. Do NOT add lease renewal or lock timeouts as correctness machinery.","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-19T23:09:54Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:56:23Z","labels":["area:protocol"],"dependencies":[{"issue_id":"improvise-7b8v","depends_on_id":"improvise-3al1","type":"blocks","created_at":"2026-09-19T23:27:48Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-7b8v","depends_on_id":"improvise-5krd","type":"parent-child","created_at":"2026-09-25T01:04:57Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-ry89","title":"cv_regress/cv_classify with k=1 now return plausible numbers instead of NaN","description":"Wave-4 review (2026-09-19), CONFIRMED, LOW. A signal loss, not a wrong answer.\n\nmean_over(total, 0) == 0.0 replaced a 0.0/0.0 NaN, which is right everywhere it is used -- the reviewer looked for a case where an empty set WINS a comparison it should lose and found none (inside fit every candidate scores the same rows; cv_* pool pairs rather than reduce to a per-fold metric).\n\nBut with k=1, folds(n,1) puts every index in the single fold, so the training complement is EMPTY and the model trains on nothing. Pre-change, standardize(\u0026[]) gave mean = NaN and every pooled prediction was NaN -- the degenerate call announced itself. Post-change:\n k=1 pooled[0] = (1.48e-6, 8.0), nan_predictions = 0\nSo a library caller passing k=1 gets 37 near-zero cross-validated predictions from a tree fit on nothing, and anyone computing R2 off them gets a number rather than a NaN.\n\nexamples/fit_csv.rs guards with k \u003e= 2, so no shipped path hits it. Fix by documenting on cv_regress/cv_classify that k\u003c2 is meaningless, or by returning empty for k\u003c2. Note the folds doc argues max(1) is \"the answer the shape gives\" -- true for folds, and no longer true for the cross-validators.\n\nNIT from the same review: standardize_is_bit_for_bit_what_it_was is insensitive -- both perturbations of standardize left it green (one was caught by cv_regress instead). Its four witnesses do not separate accumulation orders on the 37-row fixture. Worth strengthening while here.","notes":"\n--- PARTIALLY ADDRESSED on PR #11 (2026-09-19) ---\nCopilot independently re-found this. The PR agent DOCUMENTED it rather than\nguarding it, and the reasoning is right: returning empty for k \u003c 2 is a\nbehaviour change to the exact functions scoring_known_answers.rs pins\nbit-for-bit, inside a PR whose entire claim is bit-identity. It deserves its\nown commit where it is the headline rather than a rider on a reshape.\n\nSTILL OPEN: decide guard vs document-only. If guarding, the known-answer\nvectors must be re-established afterwards, not assumed -- that is the whole\npoint of them.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T22:18:04Z","created_by":"Edward Langley","updated_at":"2026-09-19T23:40:24Z","closed_at":"2026-09-19T23:40:24Z","close_reason":"GUARDED, not documented. The returned value is pooled predictions from trees that never saw the row; with one fold there are none, so EMPTY IS THE TRUE COUNT, not a sentinel. Documenting would leave a library function whose degenerate answer looks like a models output.\n\nThe asymmetry is resolved by splitting the QUESTION, not by clamping differently: folds keeps its max(1) (a partition into one piece is still a partition) and gains a note that the answer is its alone; a new private cv_folds(n,k,rng) yields no fold below k=2 and is what both cross-validators ask. Declining BEFORE the shuffle costs the caller generator nothing.\n\nTHE TICKET PREMISE WAS FALSE and was checked rather than assumed: it said returning empty for k\u003c2 would change numbers the known-answer vectors pin bit-for-bit. Those vectors only ever call k=4 and k=3, so the guard changes none of them -- all six pass unchanged, not one pinned number edited.\n\nThe insensitive-witness NIT is fixed too: standardize_is_bit_for_bit_what_it_was pinned four numbers and let r.target/std - mean/std (same algebra, different rounding) through AND past both CV vectors; it now pins all 37 normalized targets, which that injection moves 21 of.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-k2i4","title":"The two nullary model commands are unaddressed and act on one client's staged state","description":"Wave-4 review (2026-09-19), CONFIRMED, LOW. Worth a decision and a note rather than an immediate change, since multi-client is not wired.\n\nApplyAndClearDrill and AcceptWizardModel are safe no-ops when nothing is staged (both early-return), so a client cannot corrupt the document by sending them blind. Two residual issues:\n\n(a) SPURIOUS PROJECTIONS. Dirty comes from the EffectKind, not from whether anything happened, so a no-op still dirties everything at that level and reaches every client. Measured: ApplyAndClearDrill dirties grid+category_tree+formulas; AcceptWizardModel dirties those plus view_list+tile_bar, emitting a non-empty projection list for a no-op.\n\n(b) THE STAGED STATE IS GLOBAL AND THE COMMAND IS NOT ADDRESSED. Sessions::apply routes Command::Model with no SessionId, while drill_state and wizard live on the single authoritative App. So client B sending Model(ApplyAndClearDrill) commits client A uncommitted staged edits, and Model(AcceptWizardModel) installs the workbook A half-finished wizard built -- replacing the document under everyone.\n\nThat collides with the user ruling on improvise-tom1: staged drill edits belong to whoever staged them and must not be visible to others. Committing them on someone else behalf is the stronger version of the same violation. Gotcha 30 calls this state session-local residue; the command layer now lets an unaddressed message act on it.\n\nDECIDE: either these commands become addressed (a SessionId, and the effect acts only on that session staged state -- which means the staging map moves off the single App), or they are removed from the wire vocabulary and stay key-path only. The second is cheaper and consistent with tom1.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T22:18:03Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:58:04Z","closed_at":"2026-09-20T00:58:04Z","close_reason":"Stale -- already resolved by improvise-3x3u, verified during improvise-fkfg. 3x3u removed ApplyAndClearDrill and AcceptWizardModel from ModelCommand entirely; only prose mentions remain in reduce.rs, no variants. So the premise of this ticket (two nullary wire variants acting on one client's staged state) no longer holds: those commands have NO WIRE FORM to carry a basis or a SessionId.\n\nConsequences, both checked while landing ADR 001 layer 1: layer 1 does not make this worse, and layer 1 LANDS HONESTLY WITHOUT IT. What survives is the successor 3x3u itself names -- multi-client drill wants the ADDRESSED form of those two commands, which is ADR 001 layer 2 work (improvise-tom1 and the reservations ticket), not this.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-ls3y","title":"[bug] A wire-driven SwitchView does not push the view history; the effect does","description":"Found during improvise-kmgs (2026-09-19), pre-existing, out of that ticket scope.\n\napply_command SwitchView arm calls app.switch_to_view(name) DIRECTLY, while effect::SwitchView first pushes a ViewFrame onto view_back_stack and clears the forward stack. So a switch driven over the wire is NOT undoable by ViewBack, and a key-driven one is -- two spellings of one user action that differ in whether history records it.\n\nSurfaced while building a ViewBack session test: the test had to walk forward through the EFFECT to fill the stack, because the command path never fills it. That asymmetry is the tell.\n\nDecide which is right rather than blindly making them agree: history is arguably UI state (the model is shared, the UI is not), in which case the per-client stack should be fed by whichever door the client used -- and then the command path is the one that is wrong. Note improvise-jytm will make active_view per-client, which touches the same ground.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T22:11:30Z","created_by":"Edward Langley","updated_at":"2026-09-19T22:50:02Z","closed_at":"2026-09-19T22:50:02Z","close_reason":"Found again by Copilot on PR #11 and fixed in ee767f9, merged as eb0c271. apply_command performed Command::SwitchView itself, calling App::switch_to_view directly instead of running effect::SwitchView -- and the EFFECT is the only thing that pushes onto view_back_stack and clears view_forward_stack. It was also the last hand-rolled mutation left in apply_command, which sat badly against the PR own \"effects are the only mutation site\" thesis.\n\nWAVE 4 IS WHAT MADE IT BITE: ModelCommand::ViewBack pops exactly that stack, so a wire client could switch views and then be unable to go back, while the same two keypresses worked. Before ModelCommand there was no wire way to ask, so the missing push was invisible.\n\nDemonstrated first: a_wire_view_switch_records_history_the_way_a_key_switch_does failed with the document still on Second. Nothing else in the suite could see it -- switch_view_moves_the_document_and_the_client_together checks where a switch LANDED, not what it RECORDED.\n\nPart of Copilot suggestion was declined with reasons on the thread: the arm still returns a hand-written Dirty rather than Dirty::of(Model(LayoutGeometry)), because Dirty::of is a per-KIND over-approximation and a view switch is the one member of that kind which leaves the view list alone -- which view is ACTIVE is not in ViewListCache at all. switching_views_projects_the_axis_widgets_only pins that.","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -720,7 +847,7 @@ {"_type":"issue","id":"improvise-o1c","title":"Import wizard has no way back; Esc discards all field decisions silently","description":"Every browsing step binds Esc to close-the-wizard, and no step binds a back key. A user who reaches ConfigureDates or NameModel and wants to change a field kind must Esc (losing every proposal toggle, formula, and name they entered) and restart the import. This is sharpest with the category-limit guard: advance() refuses to leave ReviewProposals/ConfigureDates telling the user to fix their proposals, but from ConfigureDates there is no key that returns to ReviewProposals. Suggest binding a back key (e.g. Backspace or Left) to a WizardStep::previous(), and/or requiring a confirm on Esc once proposals have been edited. Noticed during improvise-2wm.","acceptance_criteria":"A user can move backwards through wizard steps without losing entered state; Esc's destructiveness is either confirmed or reversible.","status":"closed","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-07-31T07:21:42Z","created_by":"Edward Langley","updated_at":"2026-08-14T01:10:30Z","closed_at":"2026-08-14T01:10:30Z","dependencies":[{"issue_id":"improvise-o1c","depends_on_id":"improvise-907","type":"supersedes","created_at":"2026-08-13T18:10:29Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-c6j","title":"WizardStep::Done is unreachable from the UI","description":"ImportWizard::advance() maps NameModel -\u003e Done, but the key layer never calls advance() on NameModel: Enter there commits the build (BuildWizardModel), which closes the wizard outright. So Done is only reachable from wizard.rs unit tests calling advance() directly. Either drop the variant and its close-on-any-key branch in command/cmd/wizard.rs, or route the successful build through Done so there is a real confirmation step. Noticed while writing characterization tests for improvise-2wm — the test for Done has to force the step by hand.","acceptance_criteria":"Either WizardStep::Done is removed (with its key branch and advance() arm) or a UI path reaches it; no state in the enum is unreachable.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-07-31T07:21:37Z","created_by":"Edward Langley","updated_at":"2026-08-14T01:10:30Z","closed_at":"2026-08-14T01:10:30Z","dependencies":[{"issue_id":"improvise-c6j","depends_on_id":"improvise-907","type":"supersedes","created_at":"2026-08-13T18:10:30Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-ych","title":"Null category values become the literal item \"null\" on import","description":"ImportPipeline::build_model_with_warnings resolves a category value as v.as_str() or else v.to_string(). A JSON null therefore yields the item name \"null\" rather than being treated as missing. The Label branch a few lines below explicitly maps null to \"\", so the two paths disagree on what a null means. Noticed while implementing improvise-5j9 (skipped-record warnings).","acceptance_criteria":"A record with a null category value is either skipped-and-counted (like an absent key) or normalized consistently with the Label path; test covers the chosen behavior.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-07-31T06:02:57Z","created_by":"Edward Langley","updated_at":"2026-09-19T20:33:47Z","closed_at":"2026-09-19T20:33:47Z","close_reason":"Fixed via a single scalar_name(\u0026Value) -\u003e Option\u003cString\u003e helper used by both the Category and Label branches. Value::Null is absence (csv_parser already maps a blank CSV field to Null) so the record is skipped and counted in the existing improvise-5j9 warning; the literal string 'null' stays a real item; numbers/booleans still stringify. Two failing tests written first demonstrated the bug, plus two pinning tests that must not break. NOTE beyond the ticket's letter: empty/whitespace-only strings are now also treated as missing, so JSON '' and a blank CSV field behave alike; revert by dropping the .trim().is_empty() clause in scalar_name and the distinct_values filter. wasm check clean.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-2ud","title":"CmdContext copies individual fields instead of borrowing ModelState/ViewState slices","description":"crates/improvise-command/src/command/cmd/core.rs:15-56 + crates/improvise-command/src/ui/app.rs cmd_context(): after the ModelState/ViewState split (improvise-x2c/ew0), CmdContext still copies ~20 individual fields (dirty, search_query, panel flags/cursors, nav stacks) out of the slices. Follow-up: have CmdContext borrow \u0026ModelState/\u0026ViewState (or narrowed sub-views) so new fields don't require threading through cmd_context() and there is one source of truth. Also: display_value is computed eagerly on every context build (app.rs ~388-406) even when the command never reads it — consider lazy/on-demand. Complements improvise-drg (narrow Effect::apply signatures). Found in systematic review 2026-06-09.","acceptance_criteria":"CmdContext borrows \u0026ModelState/\u0026ViewState (or narrowed sub-views) instead of copying ~20 individual fields, so a new field does not have to be threaded through cmd_context(). display_value stops being computed eagerly on every context build when no command reads it. Note improvise-rw3a is waiting on this: it needs file_path on the context and should not add a 21st copied field ahead of the refactor.","notes":"SIZED, NOT DONE (wave 2, 2026-09-20). Attempted and reverted rather than\nhalf-landed. The bead reads as a cheap cleanup and is not one; here are the\ncounts, so the next attempt is planned rather than discovered.\n\nMEASURED SCOPE\n- 104 `ctx.\u003cfield\u003e` reads across 14 files (panel.rs 21, commit.rs 16,\n registry.rs 15, text_buffer.rs 13, search.rs 8, grid.rs 7, mode.rs 6,\n keymap.rs 4, navigation.rs 4, tile.rs 3, cell.rs 2, core.rs 2, wizard.rs 1,\n and 2 in improvise-wasm-client/src/render.rs).\n- 46 sites that MUTATE a ctx field in place to set a scenario up\n (`ctx.buffers = \u0026bufs;`, `ctx.mode = \u0026item_add_mode;`, ...). These cannot be\n rewritten mechanically: once CmdContext borrows the slice, the ViewState has\n to be built and mutated BEFORE the context exists, so each is a per-test\n restructure.\n- 99 `make_ctx` call sites, whose helper has to change shape to own or take a\n \u0026ViewState. That reaches essentially every command-layer test.\n\nDESIGN THAT WAS REACHED (worth keeping, it is most of the work)\n CmdContext { model, workbook, view, layout, registry,\n view_state: \u0026ViewState, model_state: \u0026ModelState,\n drill: Option\u003c\u0026DrillState\u003e, wizard, visible_rows, visible_cols,\n key_code }\nEach removed field is read at its one home (`ctx.view_state.mode`,\n`ctx.model_state.dirty`) rather than through an accessor, because an accessor\nbeside the field is a second answer to the same question. `has_drill_state: bool`\nbecomes `drill: Option\u003c\u0026DrillState\u003e` -- strictly more information, and it is what\nmakes the laziness possible.\n\nDISPLAY_VALUE: the mechanism is to lift App::display_value_of into a free\n`display_value_of(layout, model, drill, key)` in ui/app.rs that both\n`App::display_value_of` and a new `CmdContext::display_value()` call, so there is\none derivation with two callers. Cheap and self-contained.\n\nTHE EAGER COST IS NOT display_value, AND THE BEAD NAMES THE WRONG FIELD.\n`display_value_at_cursor` is one `get_cell`. `visible_cols` is the expensive one:\n`cmd_context()` computes it by calling `ui::viewport::compute_col_widths`, which\nwalks the WHOLE GRID TWICE (see improvise-jmjz). Benchmarked this wave at 169ms\nfor 18,000 cells -- and `cmd_context` is built on every keypress. Gotcha 23\nalready warns \"don't build a CmdContext inside apply (it recomputes column\nwidths)\" without saying what that costs. Making `visible_cols` lazy is worth more\nthan making `display_value` lazy by about four orders of magnitude, and it is\ncomputable from the fields the context already holds plus the terminal width.\nGROOMED 2026-09-19 (restored; a --notes above replaced it): still true; CmdContext is cmd/core.rs:18-59 (cited 15-56) and still copies 25 fields, including the eagerly-computed display_value.","status":"open","priority":3,"issue_type":"task","assignee":"wave2-n-cleanups","owner":"el-github@elangley.org","created_at":"2026-06-10T03:59:05Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:10:30Z","started_at":"2026-09-20T18:08:15Z","labels":["area:command"],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"improvise-2ud","title":"CmdContext copies individual fields instead of borrowing ModelState/ViewState slices","description":"crates/improvise-command/src/command/cmd/core.rs:15-56 + crates/improvise-command/src/ui/app.rs cmd_context(): after the ModelState/ViewState split (improvise-x2c/ew0), CmdContext still copies ~20 individual fields (dirty, search_query, panel flags/cursors, nav stacks) out of the slices. Follow-up: have CmdContext borrow \u0026ModelState/\u0026ViewState (or narrowed sub-views) so new fields don't require threading through cmd_context() and there is one source of truth. Also: display_value is computed eagerly on every context build (app.rs ~388-406) even when the command never reads it — consider lazy/on-demand. Complements improvise-drg (narrow Effect::apply signatures). Found in systematic review 2026-06-09.","acceptance_criteria":"CmdContext borrows \u0026ModelState/\u0026ViewState (or narrowed sub-views) instead of copying ~20 individual fields, so a new field does not have to be threaded through cmd_context(). display_value stops being computed eagerly on every context build when no command reads it. Note improvise-rw3a is waiting on this: it needs file_path on the context and should not add a 21st copied field ahead of the refactor.","notes":"SIZED, NOT DONE (wave 2, 2026-09-20). Attempted and reverted rather than\nhalf-landed. The bead reads as a cheap cleanup and is not one; here are the\ncounts, so the next attempt is planned rather than discovered.\n\nMEASURED SCOPE\n- 104 `ctx.\u003cfield\u003e` reads across 14 files (panel.rs 21, commit.rs 16,\n registry.rs 15, text_buffer.rs 13, search.rs 8, grid.rs 7, mode.rs 6,\n keymap.rs 4, navigation.rs 4, tile.rs 3, cell.rs 2, core.rs 2, wizard.rs 1,\n and 2 in improvise-wasm-client/src/render.rs).\n- 46 sites that MUTATE a ctx field in place to set a scenario up\n (`ctx.buffers = \u0026bufs;`, `ctx.mode = \u0026item_add_mode;`, ...). These cannot be\n rewritten mechanically: once CmdContext borrows the slice, the ViewState has\n to be built and mutated BEFORE the context exists, so each is a per-test\n restructure.\n- 99 `make_ctx` call sites, whose helper has to change shape to own or take a\n \u0026ViewState. That reaches essentially every command-layer test.\n\nDESIGN THAT WAS REACHED (worth keeping, it is most of the work)\n CmdContext { model, workbook, view, layout, registry,\n view_state: \u0026ViewState, model_state: \u0026ModelState,\n drill: Option\u003c\u0026DrillState\u003e, wizard, visible_rows, visible_cols,\n key_code }\nEach removed field is read at its one home (`ctx.view_state.mode`,\n`ctx.model_state.dirty`) rather than through an accessor, because an accessor\nbeside the field is a second answer to the same question. `has_drill_state: bool`\nbecomes `drill: Option\u003c\u0026DrillState\u003e` -- strictly more information, and it is what\nmakes the laziness possible.\n\nDISPLAY_VALUE: the mechanism is to lift App::display_value_of into a free\n`display_value_of(layout, model, drill, key)` in ui/app.rs that both\n`App::display_value_of` and a new `CmdContext::display_value()` call, so there is\none derivation with two callers. Cheap and self-contained.\n\nTHE EAGER COST IS NOT display_value, AND THE BEAD NAMES THE WRONG FIELD.\n`display_value_at_cursor` is one `get_cell`. `visible_cols` is the expensive one:\n`cmd_context()` computes it by calling `ui::viewport::compute_col_widths`, which\nwalks the WHOLE GRID TWICE (see improvise-jmjz). Benchmarked this wave at 169ms\nfor 18,000 cells -- and `cmd_context` is built on every keypress. Gotcha 23\nalready warns \"don't build a CmdContext inside apply (it recomputes column\nwidths)\" without saying what that costs. Making `visible_cols` lazy is worth more\nthan making `display_value` lazy by about four orders of magnitude, and it is\ncomputable from the fields the context already holds plus the terminal width.\nGROOMED 2026-09-19 (restored; a --notes above replaced it): still true; CmdContext is cmd/core.rs:18-59 (cited 15-56) and still copies 25 fields, including the eagerly-computed display_value.","status":"open","priority":3,"issue_type":"task","assignee":"wave2-n-cleanups","owner":"el-github@elangley.org","created_at":"2026-06-10T03:59:05Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:10:30Z","started_at":"2026-09-20T18:08:15Z","labels":["area:command"],"dependencies":[{"issue_id":"improvise-2ud","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T01:05:10Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"improvise-gai","title":"AGENTS.md session-completion workflow has bare 'git merge' with no target","description":"AGENTS.md Session Completion step 4 reads 'git merge' with no ref (CLAUDE.md's copy correctly says 'git merge origin/main'). An agent following AGENTS.md literally runs bare git merge, which errors or merges the wrong thing. The two files also duplicate the whole beads/session block with this one divergence — consider a single source of truth. Found in systematic review 2026-06-09.","status":"closed","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-06-10T03:59:03Z","created_by":"Edward Langley","updated_at":"2026-06-10T04:46:48Z","closed_at":"2026-06-10T04:46:48Z","close_reason":"Fixed (test-first where applicable): records_display returns None for OOB columns (f04fe51); try_axis_of added + axis_of invariant documented + cycle_axis no longer panics on unregistered categories (f04fe51); scrolled header suppression fixed via pure show_sublabel helper for both row and column headers (df9a02b); CyclePanelFocus cycles from current panel with wrap (9e02245); AGENTS.md merge target corrected (9634c95).","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-wg1","title":"Add GNU Global tags and graphviz artifacts to .gitignore","description":"git status shows untracked GPATH, GRTAGS, GTAGS (GNU Global, generated by the gtags devShell tooling added in 62d18ff) and graph.dot/graph.html/graph.svg at the repo root. None are covered by .gitignore, risking accidental commits. Add: GPATH, GRTAGS, GTAGS, /graph.dot, /graph.html, /graph.svg (or /graph.*). Found in systematic review 2026-06-09.","notes":"2026-06-09: the git-smart-commit hook added these artifacts to .git/info/exclude (machine-local), so they no longer show in status here — but the shared .gitignore still lacks them; issue remains valid for other clones.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-06-10T03:59:00Z","created_by":"Edward Langley","updated_at":"2026-09-19T20:32:19Z","closed_at":"2026-09-19T20:32:19Z","close_reason":"Added exact-name ignores to the root .gitignore: GTAGS/GRTAGS/GPATH/GSYMS for GNU Global, and root-anchored /graph.dot, /graph.html, /graph.svg for the graphviz artifacts. Deliberately not globs like *.svg, which would swallow checked-in assets. Verified no tracked file becomes ignored: 'git ls-files | git check-ignore --stdin' printed nothing.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-v9w","title":"Remove unused pest/pest_derive from root crate [dependencies]","description":"Root Cargo.toml:38-39 lists pest and pest_derive under [dependencies], but nothing under src/ uses them (grammar parsing lives in improvise-io; examples use pest_meta from dev-dependencies). They are also duplicated under [dev-dependencies]. Remove from [dependencies]; verify with cargo build --workspace \u0026\u0026 cargo test --workspace. Found in systematic review 2026-06-09.","notes":"Also unused: dirs = \"6\" (root Cargo.toml:36). Found while doing improvise-6mq, whose acceptance criterion 3 was 'dirs crate usage is isolated behind a function that can be stubbed for wasm' — there is no dirs usage anywhere under src/ or crates/*/src/ to isolate, so that criterion is vacuous. Same class as the pest/pest_derive removal this ticket already covers; fold it in.\nGROOMED 2026-09-19: still true; line numbers shifted — pest/pest_derive are Cargo.toml:40-41 under [dependencies] and 44-46 under [dev-dependencies] (with pest_meta).","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-06-10T03:58:53Z","created_by":"Edward Langley","updated_at":"2026-09-19T20:32:18Z","closed_at":"2026-09-19T20:32:18Z","close_reason":"Removed pest, pest_derive and dirs from the root crate [dependencies]. Premise verified by build, not by reading: examples/gen-grammar.rs uses pest_meta but gets it from [dev-dependencies], so cargo build --examples stays green. dirs had no usage anywhere under src/ or crates/*/src/ and was folded in per this ticket's own notes. cargo build --workspace, cargo test --workspace (801 passed), cargo build --examples and the four-crate wasm32 check all clean. Cargo.lock drops only dirs + transitives; pest* remain for improvise-formula/improvise-io.","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -742,44 +869,48 @@ {"_type":"issue","id":"improvise-t8s","title":"View::axis_of panics on missing category","description":"crates/improvise-core/src/view/types.rs:118 uses expect('axis_of called for category not registered with this view'). The invariant is that Workbook::add_category registers every category with every view, but the panic surface is fragile for new callers. Fix: either audit all callers and document the invariant clearly, or change signature to fn axis_of(\u0026self, cat: \u0026str) -\u003e Option\u003cAxis\u003e and let callers handle None.","status":"closed","priority":3,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:48Z","created_by":"Ed L","updated_at":"2026-06-10T04:46:47Z","closed_at":"2026-06-10T04:46:47Z","close_reason":"Fixed (test-first where applicable): records_display returns None for OOB columns (f04fe51); try_axis_of added + axis_of invariant documented + cycle_axis no longer panics on unregistered categories (f04fe51); scrolled header suppression fixed via pure show_sublabel helper for both row and column headers (df9a02b); CyclePanelFocus cycles from current panel with wrap (9e02245); AGENTS.md merge target corrected (9634c95).","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-oaq","title":"Redundant file re-read in ImportJsonHeadless effect","description":"src/ui/effect.rs:846 chains 'serde_json::from_str(\u0026std::fs::read_to_string(\u0026self.path).unwrap_or_default()).unwrap_or(serde_json::Value::Array(records.clone()))'. This re-reads and re-parses the JSON file after records were already parsed earlier (lines 799-812). Dead code path from earlier refactor. Fix: remove the redundant read, use the already-parsed records array.","status":"closed","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:45Z","created_by":"Ed L","updated_at":"2026-06-10T04:46:39Z","closed_at":"2026-06-10T04:46:39Z","close_reason":"Fixed in f0b9227/77a5124 (test-first): short CSV rows padded with explicit nulls + per-column warnings threaded to status line (stderr on CLI path); wizard pre-checks 12-category limit at advance with actionable message rendered in UI; ImportJsonHeadless parses the file exactly once via extracted json_import_records helper.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-mzv","title":"Import wizard does not pre-check 12-category limit","description":"crates/improvise-io/src/import/wizard.rs:84-150 build_model() fails when \u003e12 categories are proposed, but the ImportWizard UI does not pre-check before allowing the user to confirm. A 20-column CSV imported as 20 categories fails late with 'Category limit exceeded' after the user commits. Fix: validate category count in the UI step and require the user to mark some as Measure/Skip before proceeding. Also add a test for the \u003e12 case.","status":"closed","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:44Z","created_by":"Ed L","updated_at":"2026-06-10T04:46:38Z","closed_at":"2026-06-10T04:46:38Z","close_reason":"Fixed in f0b9227/77a5124 (test-first): short CSV rows padded with explicit nulls + per-column warnings threaded to status line (stderr on CLI path); wizard pre-checks 12-category limit at advance with actionable message rendered in UI; ImportJsonHeadless parses the file exactly once via extracted json_import_records helper.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-0hb","title":"Make Sequence bindings transactional at command level","description":"Currently Keymap::dispatch (crates/improvise-command/src/command/keymap.rs:261-267) calls each Sequence step's execute(ctx) with the same pre-dispatch ctx. Effects accumulate in order and apply atomically as a batch via App::apply_effects, but later commands in a Sequence cannot observe earlier commands' effects through ctx — only effects-after-effects can see prior mutations.\n\nFor example, the records 'o' sequence [add-record-row, enter-edit-at-cursor records-editing] works only because the EnterEditAtCursor effect itself calls app.rebuild_layout() before reading display_value. Effects can self-heal mid-batch, but commands cannot.\n\nTrue command-level transactionality would require, between Sequence steps: apply effects-so-far, rebuild layout, build a fresh ctx, then dispatch the next command. This would change the semantics of every existing Sequence and is worth its own design pass.\n\nAcceptance: design doc + implementation; existing Sequences keep working; new tests prove a later step sees prior steps' model mutations.","notes":"Surfaced while parameterizing EnterEditAtCursor / CommitAndAdvance to remove is_records() runtime checks (improvise-4ju).\nGROOMED 2026-09-19: still true; Keymap::dispatch is keymap.rs:255-272 now (cited 261-267) and the Sequence arm still feeds every step the same pre-dispatch ctx.\nDEFERRED by PR #27 (WU-G) with the design note and its guard, rather than built. The user accepted the deferral.\n\nKeymap::dispatch calls each Sequence step's execute(ctx) with the SAME PRE-DISPATCH ctx, so a later command cannot observe an earlier one's effects. True command-level transactionality means, between steps: apply effects so far, rebuild layout, build a fresh CmdContext, dispatch the next.\n\nTHREE FINDINGS, ALL CHECKABLE, AND THE FIRST IS DISQUALIFYING:\n\n1. IT WOULD SILENTLY REOPEN improvise-8tk, A P1 DATA LOSS. apply_effects resets abort_effects at entry and when it breaks, because the flag is per batch. Applying between steps dispatches step 2 into a FRESH batch with the flag cleared -- so the Quit after a failed Save lands and the model is discarded. Gotcha 21's claim that the abort carries across Sequence steps is true BECAUSE OF the flattening 0hb removes, and it was UNTESTED until this PR.\n\n2. ALMOST NOTHING BENEFITS. Of 21 shipped Sequence bindings, 20 are TextEntry's [\u003ccommit\u003e, clear-buffer] or [clear-buffer, enter-mode], whose second step reads nothing from ctx. The one real composition, records `o`, already works because EnterEditAtCursor's effect self-heals with rebuild_layout. Every Esc and commit key would pay a layout rebuild plus a CmdContext build, which recomputes column widths.\n\n3. THE MOTIVATING REWRITE IS NOT DISPATCHABLE -- see improvise-git.\n\nLANDED AS THE GUARD: a_sequence_whose_first_step_aborts_does_not_run_the_second, the first test to drive a Sequence whose first step aborts. Fault injected by making the abort per-effect, which is what 0hb would do; it failed.\n\nTO UN-DEFER: the blocker is (1). Any design that applies between steps must first answer what abort_effects means across batch boundaries -- and note PR #26 has since made that harder rather than easier, because clause 4 (improvise-yqpd) wants a ROLLBACK at apply_effects, and an abort is not a rollback. Decide those two together or not at all.","status":"deferred","priority":3,"issue_type":"feature","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-04-16T05:45:49Z","created_by":"fido","updated_at":"2026-09-20T07:54:40Z","started_at":"2026-09-20T05:32:32Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-m91","title":"(Stretch) Command log + native undo/replay","description":"Once Commands are the universal unit of intent, log them per-session and support undo/redo via inverse commands or periodic snapshots. Enabled by the unified reducer; valuable for all three deployment modes but especially natural in the native TUI. Also enables debugging and reproducing bugs: a command log is a deterministic replay.","design":"Two approaches: (1) Inverse commands — each command definition includes how to undo it; undo-stack walks the log in reverse applying inverses. Clean but requires per-command inverse logic. (2) Snapshot-and-replay — periodic snapshots of ModelState + current log index; undo rolls back to nearest snapshot and replays up to target index. Simpler, works for any command, costs memory + replay time. Start with (2) for simplicity. The log is per-session (ClientSession.command_log) and survives only within a session. Persistence to disk as a .improv-journal companion file is a further enhancement. Replay use case: load a file, enable log recording, hit a bug, save the log, bug-report-as-replay.","acceptance_criteria":"(1) Every dispatched Command is logged in the ClientSession (or equivalent server-side state). (2) An Undo Command pops the log and restores state via snapshot+replay. (3) Redo re-applies. (4) Native TUI bindings for Ctrl-Z / Ctrl-Shift-Z. (5) Replay test: record a sequence, reset, replay, assert final state equals record-end state.","notes":"Stretch goal — valuable across all deployment modes but not part of the core architectural unification. Depends on issue improvise-gxi (reduce_full) and ideally on a persistence mechanism (improvise-6mq VFS abstraction) if logs should survive restart.\nDEFERRED 2026-09-19 by user ruling. Stretch item, and its natural home is the e0u restructuring (also deferred): a command log is most useful once Commands are genuinely the universal unit of intent through one reducer, which is what e0u finishes.","status":"deferred","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:53:02Z","created_by":"spot","updated_at":"2026-09-20T05:31:02Z","dependencies":[{"issue_id":"improvise-m91","depends_on_id":"improvise-gxi","type":"blocks","created_at":"2026-04-14T00:53:44Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-uq7","title":"(Stretch) Hybrid mode: native TUI with remote browser observer","description":"Once the native binary has the in-process client/server split (issue improvise-e0u), allow a second subscriber to attach via a local ws-server thread. Result: a developer running the native TUI can share their session with a teammate in a browser, or have their own browser tab observing while they work in the terminal. Both subscribers receive projection commands from the same in-process server; commands from either side flow through the same reduce_full.","design":"Native binary gains a --serve PORT flag (or similar) that, when set, spawns a ws-server thread alongside the native client. The ws-server is exactly improvise-q08's server logic, attached to the same in-process App as the native client. Two ClientSessions: one for the local ratatui client (zero-transport), one (or more) for remote websocket clients. Each has its own ViewState + render cache + subscription set. The projection layer fans out effect-driven updates to every session. The two subscribers see independent cursor/scroll/mode state (because view state is per-session) but the same model state (because model is shared). This is the deployment mode that fully justifies per-session view state tracking on the server.","acceptance_criteria":"(1) Native binary has an optional --serve flag that spawns a ws-server in the same process. (2) A remote browser can connect and see the native user's current session (shared model, independent view). (3) Edits from either side show up on the other after a round trip. (4) Graceful disconnect/reconnect of remote clients while native keeps running.","notes":"Stretch goal — not required for the core unification. Depends on issue e0u (binary split done) and improvise-q08 (ws-server exists). Orthogonal to the browser and standalone epics. The architectural investment in per-session view state and subscription-based projections is what makes this possible with minimal additional code.\nDEFERRED 2026-09-19: blocked by improvise-e0u, which was deferred by user ruling this session. Hybrid mode needs the in-process client/server split before a second subscriber can attach.","status":"deferred","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:52:57Z","created_by":"spot","updated_at":"2026-09-20T05:31:02Z","dependencies":[{"issue_id":"improvise-uq7","depends_on_id":"improvise-e0u","type":"blocks","created_at":"2026-04-14T00:53:42Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-uq7","depends_on_id":"improvise-q08","type":"blocks","created_at":"2026-04-14T00:53:43Z","created_by":"spot","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-0hb","title":"Make Sequence bindings transactional at command level","description":"Currently Keymap::dispatch (crates/improvise-command/src/command/keymap.rs:261-267) calls each Sequence step's execute(ctx) with the same pre-dispatch ctx. Effects accumulate in order and apply atomically as a batch via App::apply_effects, but later commands in a Sequence cannot observe earlier commands' effects through ctx — only effects-after-effects can see prior mutations.\n\nFor example, the records 'o' sequence [add-record-row, enter-edit-at-cursor records-editing] works only because the EnterEditAtCursor effect itself calls app.rebuild_layout() before reading display_value. Effects can self-heal mid-batch, but commands cannot.\n\nTrue command-level transactionality would require, between Sequence steps: apply effects-so-far, rebuild layout, build a fresh ctx, then dispatch the next command. This would change the semantics of every existing Sequence and is worth its own design pass.\n\nAcceptance: design doc + implementation; existing Sequences keep working; new tests prove a later step sees prior steps' model mutations.","notes":"Surfaced while parameterizing EnterEditAtCursor / CommitAndAdvance to remove is_records() runtime checks (improvise-4ju).\nGROOMED 2026-09-19: still true; Keymap::dispatch is keymap.rs:255-272 now (cited 261-267) and the Sequence arm still feeds every step the same pre-dispatch ctx.\nDEFERRED by PR #27 (WU-G) with the design note and its guard, rather than built. The user accepted the deferral.\n\nKeymap::dispatch calls each Sequence step's execute(ctx) with the SAME PRE-DISPATCH ctx, so a later command cannot observe an earlier one's effects. True command-level transactionality means, between steps: apply effects so far, rebuild layout, build a fresh CmdContext, dispatch the next.\n\nTHREE FINDINGS, ALL CHECKABLE, AND THE FIRST IS DISQUALIFYING:\n\n1. IT WOULD SILENTLY REOPEN improvise-8tk, A P1 DATA LOSS. apply_effects resets abort_effects at entry and when it breaks, because the flag is per batch. Applying between steps dispatches step 2 into a FRESH batch with the flag cleared -- so the Quit after a failed Save lands and the model is discarded. Gotcha 21's claim that the abort carries across Sequence steps is true BECAUSE OF the flattening 0hb removes, and it was UNTESTED until this PR.\n\n2. ALMOST NOTHING BENEFITS. Of 21 shipped Sequence bindings, 20 are TextEntry's [\u003ccommit\u003e, clear-buffer] or [clear-buffer, enter-mode], whose second step reads nothing from ctx. The one real composition, records `o`, already works because EnterEditAtCursor's effect self-heals with rebuild_layout. Every Esc and commit key would pay a layout rebuild plus a CmdContext build, which recomputes column widths.\n\n3. THE MOTIVATING REWRITE IS NOT DISPATCHABLE -- see improvise-git.\n\nLANDED AS THE GUARD: a_sequence_whose_first_step_aborts_does_not_run_the_second, the first test to drive a Sequence whose first step aborts. Fault injected by making the abort per-effect, which is what 0hb would do; it failed.\n\nTO UN-DEFER: the blocker is (1). Any design that applies between steps must first answer what abort_effects means across batch boundaries -- and note PR #26 has since made that harder rather than easier, because clause 4 (improvise-yqpd) wants a ROLLBACK at apply_effects, and an abort is not a rollback. Decide those two together or not at all.","status":"deferred","priority":3,"issue_type":"feature","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-04-16T05:45:49Z","created_by":"fido","updated_at":"2026-09-20T07:54:40Z","started_at":"2026-09-20T05:32:32Z","dependencies":[{"issue_id":"improvise-0hb","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T01:05:10Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-m91","title":"(Stretch) Command log + native undo/replay","description":"Once Commands are the universal unit of intent, log them per-session and support undo/redo via inverse commands or periodic snapshots. Enabled by the unified reducer; valuable for all three deployment modes but especially natural in the native TUI. Also enables debugging and reproducing bugs: a command log is a deterministic replay.","design":"Two approaches: (1) Inverse commands — each command definition includes how to undo it; undo-stack walks the log in reverse applying inverses. Clean but requires per-command inverse logic. (2) Snapshot-and-replay — periodic snapshots of ModelState + current log index; undo rolls back to nearest snapshot and replays up to target index. Simpler, works for any command, costs memory + replay time. Start with (2) for simplicity. The log is per-session (ClientSession.command_log) and survives only within a session. Persistence to disk as a .improv-journal companion file is a further enhancement. Replay use case: load a file, enable log recording, hit a bug, save the log, bug-report-as-replay.","acceptance_criteria":"(1) Every dispatched Command is logged in the ClientSession (or equivalent server-side state). (2) An Undo Command pops the log and restores state via snapshot+replay. (3) Redo re-applies. (4) Native TUI bindings for Ctrl-Z / Ctrl-Shift-Z. (5) Replay test: record a sequence, reset, replay, assert final state equals record-end state.","notes":"Stretch goal — valuable across all deployment modes but not part of the core architectural unification. Depends on issue improvise-gxi (reduce_full) and ideally on a persistence mechanism (improvise-6mq VFS abstraction) if logs should survive restart.\nDEFERRED 2026-09-19 by user ruling. Stretch item, and its natural home is the e0u restructuring (also deferred): a command log is most useful once Commands are genuinely the universal unit of intent through one reducer, which is what e0u finishes.","status":"deferred","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:53:02Z","created_by":"spot","updated_at":"2026-09-20T05:31:02Z","dependencies":[{"issue_id":"improvise-m91","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T01:05:08Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-m91","depends_on_id":"improvise-gxi","type":"blocks","created_at":"2026-04-14T00:53:44Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-uq7","title":"(Stretch) Hybrid mode: native TUI with remote browser observer","description":"Once the native binary has the in-process client/server split (issue improvise-e0u), allow a second subscriber to attach via a local ws-server thread. Result: a developer running the native TUI can share their session with a teammate in a browser, or have their own browser tab observing while they work in the terminal. Both subscribers receive projection commands from the same in-process server; commands from either side flow through the same reduce_full.","design":"Native binary gains a --serve PORT flag (or similar) that, when set, spawns a ws-server thread alongside the native client. The ws-server is exactly improvise-q08's server logic, attached to the same in-process App as the native client. Two ClientSessions: one for the local ratatui client (zero-transport), one (or more) for remote websocket clients. Each has its own ViewState + render cache + subscription set. The projection layer fans out effect-driven updates to every session. The two subscribers see independent cursor/scroll/mode state (because view state is per-session) but the same model state (because model is shared). This is the deployment mode that fully justifies per-session view state tracking on the server.","acceptance_criteria":"(1) Native binary has an optional --serve flag that spawns a ws-server in the same process. (2) A remote browser can connect and see the native user's current session (shared model, independent view). (3) Edits from either side show up on the other after a round trip. (4) Graceful disconnect/reconnect of remote clients while native keeps running.","notes":"Stretch goal — not required for the core unification. Depends on issue e0u (binary split done) and improvise-q08 (ws-server exists). Orthogonal to the browser and standalone epics. The architectural investment in per-session view state and subscription-based projections is what makes this possible with minimal additional code.\nDEFERRED 2026-09-19: blocked by improvise-e0u, which was deferred by user ruling this session. Hybrid mode needs the in-process client/server split before a second subscriber can attach.","status":"deferred","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:52:57Z","created_by":"spot","updated_at":"2026-09-20T05:31:02Z","dependencies":[{"issue_id":"improvise-uq7","depends_on_id":"improvise-e0u","type":"blocks","created_at":"2026-04-14T00:53:42Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-uq7","depends_on_id":"improvise-ltq","type":"parent-child","created_at":"2026-09-25T01:04:49Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-uq7","depends_on_id":"improvise-q08","type":"blocks","created_at":"2026-04-14T00:53:43Z","created_by":"spot","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-9ix","title":"SQLite persistence format (alternative save/restore)","description":"Implement .sqlite as an alternative file format alongside .improv. Tables for categories, items, cells, formulas, views. Works over any VFS backend — native filesystem, OPFS, etc. — because the underlying storage layer is abstracted. Enables structured queries over model state and provides a more scalable container than plaintext for very large models. Orthogonal to the core standalone deployment; filed as a backlog follow-on since the MVP doesn't need it.","design":"Use rusqlite with the 'bundled' feature so sqlite itself compiles into the wasm bundle. Schema: one row per cell keyed by (coord_hash, category_coords...); one row per category with kind + ordering; one row per item with group membership; one row per formula with raw + target. Views stored as JSON or as structured rows. Persistence API mirrors .improv: save_sqlite(storage, path) / load_sqlite(storage, path). Rusqlite on wasm may need custom VFS hookup — investigate sqlite-wasm-rs or similar for browser-friendly builds. Round-trip tests: model→sqlite→model must be identity.","acceptance_criteria":"(1) save_sqlite and load_sqlite functions exist and round-trip cleanly. (2) rusqlite links against the VFS abstraction correctly on both native and wasm. (3) Unit tests for schema, round-trip, and edge cases. (4) File manager UI (if any) offers .sqlite as a save format option.","notes":"Depends on improvise-6mq (VFS abstraction). Independent of the standalone deployment critical path — can be tackled any time after the Storage trait lands. Filed as backlog (P3).","status":"closed","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:39:41Z","created_by":"spot","updated_at":"2026-09-20T00:08:35Z","closed_at":"2026-09-20T00:08:35Z","close_reason":"DECLINED with measurements, not intuition. No code written; the tree was left untouched.\n\nALL THREE OF THE TICKET CLAIMS WERE TESTED:\n\n(1) \"Works over any VFS backend because the storage layer is abstracted\" -- FALSE. improvise-6mq did not deliver a VFS. Storage is four whole-document operations (read/write bytes, list, delete) with no offsets, seeking, handles or locking; invariant(storage_is_two_ops) states it as doctrine. An sqlite3_vfs needs xRead(offset,len)/xWrite/xTruncate/xLock and only the first two could even be faked -- by rewriting the whole database on every page access.\nBUT the agent PROVED SQLite can still fit before declining: rusqlite with features=[bundled,serialize] round-trips a database through bytes with zero filesystem contact (probe test passed; a one-row DB serializes to 8192 bytes). So the decline rests on VALUE, not on imagined plumbing trouble -- \"declining on a capability that actually exists would have been the wrong decline\".\n\n(2) \"A more scalable container than plaintext for very large models\" -- FALSE, MEASURED on an 18,000-cell model using the ticket own schema, VACUUMed:\n big.improv 1,280,846 bytes\n big.sqlite 1,568,768 +22%\n big.improv.gz 136,264\n big.sqlite.gz 642,897 4.7x LARGER\nFloor case: a 5-table schema holding 3 rows is 24,576 bytes against roughly 150 bytes of .improv -- about 160x. And because writes go through Storage::write(whole bytes), every save still rewrites the entire document, so the one dimension where SQLite normally wins -- incremental page writes -- is unavailable.\n\n(3) \"Enables structured queries over model state\" -- NOT DELIVERED by this design. The artifact is a closed blob; querying means deserializing the whole database into RAM beside the Model that is already fully in RAM. Anything you could ask it you can already ask DataStore::matching_values/matching_cells over interned, indexed data.\n\nALSO: cargo check --target wasm32-unknown-unknown fails in libsqlite3-sys C build. The agent was precise that this is the NIX CC-WRAPPER injecting -fzero-call-used-regs, not proof bundled SQLite can never target wasm -- but it is real evidence the repo wasm CI gate would break the day this landed.\n\nTHE COST THE TICKET DOES NOT PRICE: .improv is grammar-defined and the pest grammar is the single source of truth. A SQLite writer/reader is a SECOND IMPLEMENTATION OF THE WHOLE FORMAT -- groups, formula target categories, hidden items, collapsed groups, number formats, page/filter selections, IndexMap insertion order, the _Measure explicit-vs-formula-target split, virtual-view suppression -- which must track every future grammar change forever. And the ticket own acceptance test (model-\u003esqlite-\u003emodel identity) CANNOT DETECT DRIFT from .improv, because it never compares the two. That missing cross-format test is what made the ticket look cheap.\n\nFormat dispatch and naming were both checked and are NOT the problem: save_to/load_from are the only chokepoints, SQLite 16-byte header is a cleaner discriminator than the existing leading-brace JSON sniff, and .improv.sqlite would follow invariant(gzip_is_a_name_property).\n\nSALVAGE FILED SEPARATELY: the one claim with standing is INTEROP -- opening a model in sqlite3 or DB Browser. That is EXPORT, not persistence.","dependencies":[{"issue_id":"improvise-9ix","depends_on_id":"improvise-6mq","type":"blocks","created_at":"2026-04-14T00:40:34Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-avy","title":"Formula tokenizer: support quoted identifiers for ambiguous names","description":"The formula tokenizer currently uses heuristics to handle multi-word identifiers (greedy space consumption with keyword/operator break rules). This is fragile — we just fixed a bug where WHERE inside aggregates was consumed as part of an identifier.\n\nA more robust approach: support SQL/CL-style quoted identifiers. Two candidate syntaxes:\n- SQL style: double-quotes for identifiers, e.g. \"Total Revenue\" = \"Base Revenue\" + Bonus\n- CL style: pipe-delimited, e.g. |Total Revenue| = |Base Revenue| + Bonus\n\nSQL double-quoting is natural since the formula syntax already uses quotes for WHERE filter string literals. Disambiguation: in expression position, a quoted string becomes Token::Ident; in WHERE value position, it becomes Token::Str (parser already distinguishes these contexts).\n\nThis would let us simplify the tokenizer's space-handling heuristics and cleanly support category/item names that collide with keywords (WHERE, SUM, IF, etc.).","status":"closed","priority":3,"issue_type":"feature","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T06:45:27Z","created_by":"Edward Langley","updated_at":"2026-04-09T06:48:41Z","closed_at":"2026-04-09T06:48:41Z","close_reason":"Implemented pipe-quoted identifiers |...| in the formula tokenizer. Pipes produce Token::Ident, work in expressions, aggregates, and WHERE clauses. split_where and parse_where also updated. 6 new tests.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-0zf","title":"Bug: WHERE inside aggregate parens broken by greedy identifier tokenizer","description":"The tokenizer treats multi-word identifiers greedily — 'Revenue WHERE' becomes a single token. This means SUM(Revenue WHERE Region=East) doesn't parse correctly. The WHERE keyword is consumed as part of the identifier. Top-level WHERE (outside parens) works because split_where handles it before tokenization. Fix: either stop allowing spaces in identifiers inside parens, or detect WHERE as a keyword break point in the tokenizer.","status":"closed","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T06:06:15Z","created_by":"Edward Langley","updated_at":"2026-04-09T06:38:08Z","closed_at":"2026-04-09T06:38:08Z","close_reason":"Fixed tokenizer to break multi-word identifiers at keywords (WHERE, SUM, AVG, MIN, MAX, COUNT, IF). Two-pronged: (1) break when current identifier IS a keyword, (2) break when next word IS a keyword. SUM(Revenue WHERE Region=East) now parses correctly.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-w82","title":"Test audit: persistence/mod.rs at 82% coverage","description":"persistence/mod.rs is at 82% - just above the floor. Missing coverage for edge cases: empty models, special characters in names, view state persistence details, and formula preservation corner cases.","status":"closed","priority":3,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T05:04:46Z","created_by":"Edward Langley","updated_at":"2026-04-09T06:15:10Z","closed_at":"2026-04-09T06:15:10Z","close_reason":"Added 16 tests covering save/load roundtrip (plain + gzip), autosave_path, export_csv, collapsed groups, page-without-selection, none axis, number format, text values, multiple views, and full feature roundtrip. Coverage from 82% to 97%.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-kq6","title":"Test audit: formula/parser.rs at 76% coverage","description":"formula/parser.rs is at 76% line coverage, just under the 80% target. Missing coverage for MIN, MAX, COUNT aggregate functions, complex nested expressions, and error/edge case paths.","status":"closed","priority":3,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T05:04:44Z","created_by":"Edward Langley","updated_at":"2026-04-09T06:10:12Z","closed_at":"2026-04-09T06:10:12Z","close_reason":"Added 19 tests covering MIN/MAX/COUNT aggregates, comparison operators, power/unary/mul/div, WHERE with quotes, error paths, multi-word identifiers, aggregate-name-as-ref edge case. Coverage from 76% to 89%. Found bug: WHERE inside aggregate parens broken by greedy tokenizer (improvise-0zf).","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-3tj","title":"3.3 Tag v0.1.0 release","description":"Create git tag v0.1.0, push it, verify cargo dist workflow produces release artifacts. Update README prebuilt binaries link to point at actual release.","acceptance_criteria":"Tag v0.1.0 exists and is pushed, the cargo-dist workflow has produced release artifacts for Linux x86_64 and macOS (Intel + Apple Silicon), and README's prebuilt-binaries link points at the actual release rather than a placeholder. NEEDS THE USER: tagging and pushing a release is theirs to authorise, and it is ordered after improvise-l36.","notes":"STATE CHECKED 2026-09-20 at 52507a6 (wave 2, WU-P), and one premise of this\nticket is already false.\n\nThe README's prebuilt-binaries link is NOT a placeholder. `v0.1.0-rc1` and\n`v0.1.0-rc2` both exist as prereleases and `v0.1.0-rc2` carries real cargo-dist\nartifacts: improvise-{aarch64,x86_64}-apple-darwin.tar.xz,\nimprovise-{aarch64,x86_64}-unknown-linux-gnu.tar.xz, each with a .sha256, plus\nimprovise-installer.sh, sha256.sum, dist-manifest.json and source.tar.gz. So\ncargo-dist demonstrably works on this repository, and the four targets in\ndist-workspace.toml are the four that were built.\n\nWhat remains is therefore narrower than the ticket reads: there is no `v0.1.0`\ntag, only the two rc tags, and the rcs are marked prerelease so the releases\npage has no \"Latest\". README was updated in wave 2 to say exactly that rather\nthan to imply a stable release exists.\n\nMECHANISM: `.github/workflows/release.yml` fires on a pushed version tag and\nruns dist itself. Nothing needs to be built by hand; the tag is the trigger.\n\nBEFORE TAGGING: ten manifests say `0.1.0-rc2` (and `crates/gp-tree` says\n`0.1.0`). A `v0.1.0` tag on manifests that say `0.1.0-rc2` makes dist announce\na version nothing in the tree claims, so the bump comes first, in the same\ncommit, and gp-tree's disagreement is resolved with it.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-09T04:08:02Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:01:39Z","labels":["area:release","needs:user"],"dependencies":[{"issue_id":"improvise-3tj","depends_on_id":"improvise-11a","type":"blocks","created_at":"2026-04-08T21:09:30Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-3tj","depends_on_id":"improvise-gx5d","type":"blocks","created_at":"2026-09-22T05:26:55Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-3tj","depends_on_id":"improvise-l36","type":"blocks","created_at":"2026-04-08T21:09:30Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":3,"dependent_count":1,"comment_count":0} -{"_type":"issue","id":"improvise-l36","title":"3.2 Publish to crates.io","description":"After cargo publish --dry-run is clean and user confirms, run cargo publish. Verify crate appears at crates.io/crates/improvise and cargo install improvise works.","acceptance_criteria":"improvise is on crates.io and 'cargo install improvise' works from a clean machine. NEEDS THE USER: crates.io credentials, and their confirmation after 'cargo publish --dry-run' is clean. Note the workspace has grown to nine crates since this was filed — decide which are published and in what order, since a dependent cannot publish before its dependency, and improvise-io/protocol/command are all now real crates rather than internal modules.","notes":"DRY RUN ACTUALLY RUN, 2026-09-20 at 52507a6 (wave 2, WU-P). It is NOT clean,\nand the blocker is mechanical rather than a judgement call:\n\n error: failed to verify manifest at Cargo.toml\n all dependencies must have a version requirement specified when publishing.\n dependency `gp-tree` does not specify a version\n\ncargo stops at the FIRST offender, and gp-tree is not the only one. Every\ninter-crate dependency in this workspace is `{ path = \"...\" }` with no\n`version`, which crates.io forbids for normal dependencies:\n\n Cargo.toml (root) gp-tree, improvise-core, improvise-formula,\n improvise-io, improvise-command\n improvise-core improvise-formula\n improvise-formula (none)\n improvise-protocol improvise-core\n improvise-io improvise-core, improvise-formula\n improvise-gp gp-tree, improvise-core, improvise-formula\n improvise-command improvise-core, improvise-protocol,\n improvise-formula, improvise-io, improvise-gp\n\nDev-dependencies are exempt — cargo strips them on publish — so the root's\n`improvise-wasm-client`/`improvise-protocol` dev deps and improvise-wasm-\nclient's dev dep on the unpublishable improvise-ws-server are fine as they are.\n\nWHICH CRATES PUBLISH. `improvise-ws-server` and `improvise-worker-server` both\ncarry `publish = false` already, so the set is eight, and a dependent cannot go\nbefore its dependency. The only order that works:\n\n 1. improvise-formula (no intra-workspace deps)\n 2. improvise-core\n 3. gp-tree (no intra-workspace deps; can go any time before 4)\n 4. improvise-gp\n 5. improvise-protocol\n 6. improvise-io\n 7. improvise-command\n 8. improvise (the binary)\n\ncrates.io needs a minute or two to index each one before the next can resolve\nit, so this is eight sequential `cargo publish` calls, not one.\n\nTWO VERSION FACTS THE USER MUST DECIDE. `gp-tree` is at `0.1.0` while every\nother crate is at `0.1.0-rc2`; and a `-rc2` version requirement in a published\nmanifest pins consumers to a prerelease. If the intent is to publish 0.1.0\n(which improvise-3tj's tag implies), the version bump across ten manifests\nshould happen BEFORE the version requirements are written, or they will be\nwritten twice.\n\nNAME AVAILABILITY CHECKED: crates.io returns \"crate `improvise` does not exist\",\nso the name is free. Not checked: the other seven names.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:58Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:01:38Z","labels":["area:release","needs:user"],"dependencies":[{"issue_id":"improvise-l36","depends_on_id":"improvise-11a","type":"blocks","created_at":"2026-04-08T21:09:29Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-l36","depends_on_id":"improvise-2fr","type":"blocks","created_at":"2026-04-08T21:09:28Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":2,"comment_count":0} +{"_type":"issue","id":"improvise-3tj","title":"3.3 Tag v0.1.0 release","description":"Create git tag v0.1.0, push it, verify cargo dist workflow produces release artifacts. Update README prebuilt binaries link to point at actual release.","acceptance_criteria":"Tag v0.1.0 exists and is pushed, the cargo-dist workflow has produced release artifacts for Linux x86_64 and macOS (Intel + Apple Silicon), and README's prebuilt-binaries link points at the actual release rather than a placeholder. NEEDS THE USER: tagging and pushing a release is theirs to authorise, and it is ordered after improvise-l36.","notes":"STATE CHECKED 2026-09-20 at 52507a6 (wave 2, WU-P), and one premise of this\nticket is already false.\n\nThe README's prebuilt-binaries link is NOT a placeholder. `v0.1.0-rc1` and\n`v0.1.0-rc2` both exist as prereleases and `v0.1.0-rc2` carries real cargo-dist\nartifacts: improvise-{aarch64,x86_64}-apple-darwin.tar.xz,\nimprovise-{aarch64,x86_64}-unknown-linux-gnu.tar.xz, each with a .sha256, plus\nimprovise-installer.sh, sha256.sum, dist-manifest.json and source.tar.gz. So\ncargo-dist demonstrably works on this repository, and the four targets in\ndist-workspace.toml are the four that were built.\n\nWhat remains is therefore narrower than the ticket reads: there is no `v0.1.0`\ntag, only the two rc tags, and the rcs are marked prerelease so the releases\npage has no \"Latest\". README was updated in wave 2 to say exactly that rather\nthan to imply a stable release exists.\n\nMECHANISM: `.github/workflows/release.yml` fires on a pushed version tag and\nruns dist itself. Nothing needs to be built by hand; the tag is the trigger.\n\nBEFORE TAGGING: ten manifests say `0.1.0-rc2` (and `crates/gp-tree` says\n`0.1.0`). A `v0.1.0` tag on manifests that say `0.1.0-rc2` makes dist announce\na version nothing in the tree claims, so the bump comes first, in the same\ncommit, and gp-tree's disagreement is resolved with it.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-09T04:08:02Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:01:39Z","labels":["area:release","needs:user"],"dependencies":[{"issue_id":"improvise-3tj","depends_on_id":"improvise-0s6","type":"parent-child","created_at":"2026-09-25T01:07:35Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-3tj","depends_on_id":"improvise-11a","type":"blocks","created_at":"2026-04-08T21:09:30Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-3tj","depends_on_id":"improvise-gx5d","type":"blocks","created_at":"2026-09-22T05:26:55Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-3tj","depends_on_id":"improvise-l36","type":"blocks","created_at":"2026-04-08T21:09:30Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":3,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-l36","title":"3.2 Publish to crates.io","description":"After cargo publish --dry-run is clean and user confirms, run cargo publish. Verify crate appears at crates.io/crates/improvise and cargo install improvise works.","acceptance_criteria":"improvise is on crates.io and 'cargo install improvise' works from a clean machine. NEEDS THE USER: crates.io credentials, and their confirmation after 'cargo publish --dry-run' is clean. Note the workspace has grown to nine crates since this was filed — decide which are published and in what order, since a dependent cannot publish before its dependency, and improvise-io/protocol/command are all now real crates rather than internal modules.","notes":"DRY RUN ACTUALLY RUN, 2026-09-20 at 52507a6 (wave 2, WU-P). It is NOT clean,\nand the blocker is mechanical rather than a judgement call:\n\n error: failed to verify manifest at Cargo.toml\n all dependencies must have a version requirement specified when publishing.\n dependency `gp-tree` does not specify a version\n\ncargo stops at the FIRST offender, and gp-tree is not the only one. Every\ninter-crate dependency in this workspace is `{ path = \"...\" }` with no\n`version`, which crates.io forbids for normal dependencies:\n\n Cargo.toml (root) gp-tree, improvise-core, improvise-formula,\n improvise-io, improvise-command\n improvise-core improvise-formula\n improvise-formula (none)\n improvise-protocol improvise-core\n improvise-io improvise-core, improvise-formula\n improvise-gp gp-tree, improvise-core, improvise-formula\n improvise-command improvise-core, improvise-protocol,\n improvise-formula, improvise-io, improvise-gp\n\nDev-dependencies are exempt — cargo strips them on publish — so the root's\n`improvise-wasm-client`/`improvise-protocol` dev deps and improvise-wasm-\nclient's dev dep on the unpublishable improvise-ws-server are fine as they are.\n\nWHICH CRATES PUBLISH. `improvise-ws-server` and `improvise-worker-server` both\ncarry `publish = false` already, so the set is eight, and a dependent cannot go\nbefore its dependency. The only order that works:\n\n 1. improvise-formula (no intra-workspace deps)\n 2. improvise-core\n 3. gp-tree (no intra-workspace deps; can go any time before 4)\n 4. improvise-gp\n 5. improvise-protocol\n 6. improvise-io\n 7. improvise-command\n 8. improvise (the binary)\n\ncrates.io needs a minute or two to index each one before the next can resolve\nit, so this is eight sequential `cargo publish` calls, not one.\n\nTWO VERSION FACTS THE USER MUST DECIDE. `gp-tree` is at `0.1.0` while every\nother crate is at `0.1.0-rc2`; and a `-rc2` version requirement in a published\nmanifest pins consumers to a prerelease. If the intent is to publish 0.1.0\n(which improvise-3tj's tag implies), the version bump across ten manifests\nshould happen BEFORE the version requirements are written, or they will be\nwritten twice.\n\nNAME AVAILABILITY CHECKED: crates.io returns \"crate `improvise` does not exist\",\nso the name is free. Not checked: the other seven names.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:58Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:01:38Z","labels":["area:release","needs:user"],"dependencies":[{"issue_id":"improvise-l36","depends_on_id":"improvise-0s6","type":"parent-child","created_at":"2026-09-25T01:07:36Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-l36","depends_on_id":"improvise-11a","type":"blocks","created_at":"2026-04-08T21:09:29Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-l36","depends_on_id":"improvise-2fr","type":"blocks","created_at":"2026-04-08T21:09:28Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"improvise-11a","title":"3.1 Configure cargo dist","description":"Run cargo dist init targeting x86_64-unknown-linux-gnu, aarch64-apple-darwin, x86_64-apple-darwin. Skip Windows and musl. Commit generated .github/workflows/release.yml and Cargo.toml additions. Test with v0.1.0-rc1 tag, verify builds, delete rc tag.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:55Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-11T07:49:57Z","close_reason":"Closed","dependencies":[{"issue_id":"improvise-11a","depends_on_id":"improvise-abz","type":"blocks","created_at":"2026-04-08T21:09:28Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":3,"comment_count":0} -{"_type":"issue","id":"improvise-0s6","title":"Phase 3: Distribution","description":"Configure cargo dist, publish to crates.io, tag v0.1.0 release. Produces prebuilt binaries for Linux x86_64 and macOS (Intel + Apple Silicon).","acceptance_criteria":"Closes when improvise-l36 and improvise-3tj close. Both need the user; nothing here is agent work. improvise-11a (cargo dist) is already closed.","notes":"CHECKED, wave 2 WU-P: STAYS OPEN, correctly — both children need the user and\nneither was touched. Nothing was published and nothing was tagged.\n\nWhat this pass DID do is make them ready, and one of them is not as ready as\nit looked. `cargo publish --dry-run` was actually run and FAILS: every\ninter-crate dependency in the workspace is `{ path = ... }` with no `version`,\nwhich crates.io forbids. cargo reports only the first offender (gp-tree), so\nthe dry run understates it — the full list, the publish order for the eight\npublishable crates, and the gp-tree/rc2 version disagreement are on\nimprovise-l36.\n\nimprovise-3tj's premise is already partly false and its note now says so: the\nREADME prebuilt-binaries link is not a placeholder, v0.1.0-rc2 carries real\ncargo-dist artifacts for all four targets, and what is actually missing is the\nv0.1.0 tag and the manifest bump that must precede it.","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-09T04:05:51Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:05:07Z","labels":["area:release","needs:user"],"dependencies":[{"issue_id":"improvise-0s6","depends_on_id":"improvise-11a","type":"blocks","created_at":"2026-04-08T23:37:39Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-0s6","depends_on_id":"improvise-3tj","type":"blocks","created_at":"2026-04-08T23:37:41Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-0s6","depends_on_id":"improvise-l36","type":"blocks","created_at":"2026-04-08T23:37:40Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":3,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-07i0","title":"spanned_values' admits filter never re-checks base coordinates: a narrowing bug is a wrong answer, not a slow one","description":"From review of PR #239 at 500d4609 (improvise-jq0m). DataStore::spanned_positions chooses between base-key narrowing and per-item narrowing; admits() applies the Exact length check and span membership but not the base key's coordinates, so correctness rests entirely on which narrowing ran. A per-item path that drops the base is caught only by two DataStore-level tests, nothing above. Suggested: admits also checks base coordinates (cheap), turning any future narrowing bug into a speed cost only.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-24T02:16:39Z","created_by":"orchestrator","updated_at":"2026-09-24T02:16:39Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-gc9t","title":"check-ci-graph refuses a BOM-prefixed workflow and crashes with a traceback on an undecodable one (both fail closed)","description":"## Summary\nFound in adversarial delta review of PR #228 at head 1bec7c0f (xpzh). Both issues fail closed, not open, so this is a P4 nit.\n\n`read_workflow` opens the workflow with `encoding=\"utf8\"`:\n1. **A BOM at the start of the file (U+FEFF) is refused.** It stays on line 1, so `_TOP_KEY` rejects `name: CI` with \"line 1 is a key or `needs:` value this gate cannot read\". go-yaml and PyYAML read that file normally, with `gate.if == false`. The gate refuses a legal workflow loudly.\n2. **An undecodable file crashes with a traceback.** Invalid UTF-8, or a whole-file UTF-16 workflow (which go-yaml and PyYAML both read), raises an uncaught `UnicodeDecodeError`. The exit code is 1, so CI fails, but there is no named complaint.\n\n## Fix\nUse `encoding=\"utf-8-sig\"`, which strips a leading BOM only; a mid-line BOM is invalid YAML and all three parsers reject it. Catch `UnicodeDecodeError` in `main`, and name the file and the byte offset. Add one file case for each.","status":"open","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T02:07:38Z","created_by":"agent-review-tooling-delta","updated_at":"2026-09-24T02:07:38Z","labels":["adversarial-review","pr-228"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-vav0","title":"Shipped-document walk is polluted by a trace2 target set in config","description":"The shipped-document walk's `git()` in tests/shipped_documents_load.rs does not null the global or system config. That is deliberate, since `every_shipped_document_loads` reads the real checkout. Trace2 can also be enabled from config: `trace2.eventTarget`, `trace2.normalTarget` and `trace2.perfTarget`, which git reads from system and global config. Pointed at `/dev/stdout`, these write into `ls-files -z`'s output exactly as the `GIT_TRACE*` variables did (improvise-2iws). Removing the environment variables, which is what PR #276 does, does not reach config.\n\n## Steps to Reproduce\nAt PR #276 head ddcdf93a (git 2.55.0), write a config file containing `[trace2] eventTarget = /dev/stdout`. Run the test binary with `GIT_CONFIG_GLOBAL=\u003cfile\u003e` (or `GIT_CONFIG_SYSTEM`) and `every_shipped_document_loads --exact`. It panics with `examples/demo.improv does not load: Cannot open \u003croot\u003e/{\"event\":...`. `a_gzipped_document_is_walked` also fails. `normalTarget` pollutes stdout the same way.\n\n## Fix candidate\nSet `GIT_TRACE2`, `GIT_TRACE2_EVENT` and `GIT_TRACE2_PERF` to `0` in `untraced_git()` instead of removing them. The env var outranks the config target. Verified: with the config above plus `GIT_TRACE2_EVENT=0`, or with `normalTarget` plus `GIT_TRACE2=0`, `ls-files -z` prints only the path.\n\n## Acceptance Criteria\n- A child-process test sets `trace2.eventTarget` (and `normalTarget`) to `/dev/stdout` in a `GIT_CONFIG_GLOBAL` file and re-runs the walk tests, which pass.\n- The same test goes red when the `=0` overrides are dropped.\n\nSame severity as improvise-2iws: the failure is loud and the setting is self-inflicted. Found in the adversarial review of #276.\n","status":"in_progress","priority":4,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-24T01:08:19Z","created_by":"agent-review-255","updated_at":"2026-09-24T01:11:22Z","started_at":"2026-09-24T01:11:22Z","lease_expires_at":"2026-09-24T01:16:22Z","heartbeat_at":"2026-09-24T01:11:22Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-2iws","title":"Shipped-document walk parses trace output sent to stdout as paths","description":"tests/shipped_documents_load.rs's `git()` strips repo-local vars (--local-env-vars) and, since improvise-a0i3, five pathspec/attribute settings. The GIT_TRACE family is also process-wide and is not stripped. When any of these points at stdout, git writes trace lines into the `ls-files -z` output, and `shipped_documents` parses them as paths:\n- `GIT_TRACE=/dev/stdout`\n- `GIT_TRACE2_EVENT=/dev/stdout`\n- `GIT_TRACE_PERFORMANCE=/dev/stdout`\n\n## Steps to Reproduce\nAt PR #269 head 4cef40c9 (git 2.55.0), run the test binary with `GIT_TRACE=/dev/stdout every_shipped_document_loads --exact`. It panics with \"examples/demo.improv does not load: Cannot open \u003croot\u003e/17:39:40.198221 git.c:502 trace: built-in: git ls-files ...\". The same three settings also fail all four scratch-repo shape tests.\n\nThe failure is loud, not a silent pass. The setting is also self-inflicted: `=1` (stderr) does not break anything. So this is low priority.\n\n## Acceptance Criteria\n- The walk's git removes every `GIT_TRACE*` variable. A prefix match is needed because the family is open-ended.\n- A child-process test covers at least `GIT_TRACE=/dev/stdout` and `GIT_TRACE2_EVENT=/dev/stdout`, and fails with the removal dropped.\n\nFound in the adversarial review of #269.\n","status":"in_progress","priority":4,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-24T00:39:56Z","created_by":"agent-review-255","updated_at":"2026-09-24T00:50:14Z","started_at":"2026-09-24T00:50:14Z","lease_expires_at":"2026-09-24T00:55:14Z","heartbeat_at":"2026-09-24T00:50:14Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-0r01","title":"innermost_group walks and clones the whole axis per z keypress since open_runs became the one reader (Codex P2 on #263)","description":"PR #263 (improvise-6u6s) at 2ea24074 made innermost_group call open_runs, which clones the open-run stack for EVERY data item on the axis and keeps all of them, then indexes one. The previous version stopped at idx with O(depth) state. ChatGPT Codex's P2 on #263 is factually right. The cost is O(items x depth) small allocations per z/gz keypress, and z/gz are the only production callers, via row_group_for and col_group_for. On the same keypress, landing() builds a fresh pruned GridLayout and apply_effects rebuilds the layout, and each of those costs O(items) or more with a String allocation per tuple. So the regression is dominated rather than noticeable, which is why it is P4. Fix without losing the single reader: have open_runs yield lazily, or take a stop index, so innermost_group stops at idx and the tests still collect the whole table.","status":"in_progress","priority":4,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-24T00:34:27Z","created_by":"agent-review-263","updated_at":"2026-09-24T00:36:00Z","started_at":"2026-09-24T00:36:00Z","lease_expires_at":"2026-09-24T00:41:00Z","heartbeat_at":"2026-09-24T00:36:00Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-ybum","title":"browser_round_trip's tile_to and column walk are unbounded loops: a stuck tile/column cursor hangs the binary instead of failing","description":"Found in the adversarial review of PR #256 at head eb146bcc. This predates #256.\n\nIn crates/improvise-wasm-client/tests/browser_round_trip.rs, the helper `tile_to` (line ~434) and the column walk in `the_tile_bar_route_into_records_mode_does_not_strand_the_user` (line ~475) are unbounded `while` loops that press l or h until an index is reached. A resolver fault that stops the cursor short of the target never ends the loop. The test binary hangs; it does not fail.\n\nMeasured: rival `left_clamp_one` changes `tile_cursor`'s lower clamp from `clamp(0, ..)` to `clamp(1, ..)`. With it, that test ran past 17 minutes and had to be killed. The fault is still detected, by two resolve.rs unit tests. But in CI a hang costs the job timeout and names no test, and locally it looks like a slow build.\n\nFix: bound each loop, for example by the tile count or the column count plus one. Assert progress on every press, so that a cursor which does not move fails with the index it is stuck on.\n","status":"in_progress","priority":4,"issue_type":"task","assignee":"agent-qnry-ybum","owner":"el-github@elangley.org","created_at":"2026-09-24T00:20:08Z","created_by":"agent-review-256","updated_at":"2026-09-24T00:21:21Z","started_at":"2026-09-24T00:21:21Z","lease_expires_at":"2026-09-24T00:26:21Z","heartbeat_at":"2026-09-24T00:21:21Z","labels":["adversarial-review","pr-256"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-aopa","title":"resolve.rs tests fold client-composed commands through fold (the host door), latent like xoxh's renaming test","description":"Found in the adversarial review of PR #259 (improvise-xoxh) at c349d14d. xoxh moved one test's client-composed commands off `ClientState::fold`, the door for what the HOST sends, and onto `fold_outgoing`. Eight test sites in crates/improvise-protocol/src/resolve.rs still fold what `resolve` composed through `fold`: lines 2296 (the Heard classifier), 2356 and 2523 (prefix arming and sequences), 3452 (press_folded), 3612, 3730, 3828 (arm) and 3874.\n\nToday this is latent. None of those tests read `in_flight` or `settled`, and `apply`, which moves `view`, is the same code behind both doors. But a composed `Command::Model` folded through `fold` never enters `in_flight` as awaited. Any future assertion on rollback or settle state in these helpers would therefore describe a client that cannot exist. That is the vacuity xoxh found in the renaming test.\n\nFix candidate: fold resolved commands with `fold_outgoing(\u0026commands)`, one frame per key, which is what web/app.js and lib.rs do. Rival: put `fold` back and assert that some awaited entry or settle-state check goes red. Otherwise the change is untestable, and that is an argument for P4.\n","status":"in_progress","priority":4,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-24T00:14:27Z","created_by":"agent-review-259-260","updated_at":"2026-09-24T00:20:20Z","started_at":"2026-09-24T00:20:20Z","lease_expires_at":"2026-09-24T00:25:20Z","heartbeat_at":"2026-09-24T00:20:20Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-1x9h","title":"Browser: a status written while a minibuffer is open hides after TOAST_MS (6s) with the prompt still open — su4u's bug on the toast timer (latent behind cl7y)","description":"Found in the adversarial review of PR #257 (improvise-su4u) at head 2b1c5bf7. The author noted this in the PR body and in `context/code/tui.md` and did not file it.\n\nIn the browser, `status_msg` is shown only as a toast. `web/shell.js::createToasts` hides it after `TOAST_MS` (6000 ms) whatever the mode. The minibuffer element (`web/app.js`, `mb.prompt + mb.value`) never shows the message. So a message that belongs beside an open minibuffer, such as a refusal the user is still fixing, disappears on a timer while the prompt is still open. This is su4u's bug with a different expiry mechanism.\n\nIt is LATENT for su4u's headline case, and that is the reason to file it rather than fix it now. A formula refusal never reaches a browser at all. The client folds `CommitBuffer`'s optimistic \"Formula added\" and `ChangeMode(FormulaPanel)`. The host's \"Formula error\" is a ViewOnly write to the host's own slice. `crates/improvise-wasm-client/tests/lockstep.rs::a_formula_the_client_cannot_parse_diverges_on_the_status_line` still asserts that divergence at 2b1c5bf7, although improvise-cl7y is closed. So this ticket becomes live when a host-addressed status reaches the composer, as `context/code/clients.md` recommends for cl7y, or for any client-composed refusal that keeps a minibuffer open.\n\nAcceptance: a message written while a text-entry mode stays open stays visible until the user leaves that mode or a newer message replaces it. The rule should be the one #257 gives the TUI (see improvise-sx7h for where that rule's mode-equality proxy is wrong). The test is a `web/tests` case on `createToasts` or its caller, plus a Playwright check if the DOM wiring changes.\n","status":"in_progress","priority":4,"issue_type":"bug","assignee":"agent-qnry-ybum","owner":"el-github@elangley.org","created_at":"2026-09-24T00:01:32Z","created_by":"agent-review-257","updated_at":"2026-09-24T00:40:40Z","started_at":"2026-09-24T00:40:40Z","lease_expires_at":"2026-09-24T00:45:40Z","heartbeat_at":"2026-09-24T00:40:40Z","labels":["adversarial-review","area:shared-frontend","pr-257"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-mgis","title":"OpenedEditor::awaiting is serde(default), against 1wl0's doctrine, and unpinned: deleting the attribute leaves the workspace green (#227)","description":"Found by the adversarial delta re-review of PR #227 at head baabe1a9.\n\nOpenedEditor::awaiting carries #[serde(default)]. OpenedEditor lives in ViewState, so it travels in the Snapshot. `awaiting: false` is not an absence. It is the claim \"the seed is settled; judge a commit against `seed`\".\n\nA peer that lost the field would deliver an editor that was really awaiting as a settled one with seed \"\". The client would never settle it: the editor stays empty, and an Enter is \"untouched\" and writes nothing. It fails silently. That is the exact shape protocol.md §3a / improvise-1wl0 rejected for AxisFrame::first: \"a missing field is now a loud parse failure at the transport, where a wire-format skew belongs. No skew ships today, both ends being one workspace at one commit.\"\n\nIt is also unpinned. Deleting the attribute leaves the whole workspace green (1950 passed; only my unrelated probes failed). So it is neither the mechanism of any test nor argued in protocol.md.\n\nFix: drop the attribute. Alternatively, keep it with a written argument for why this field differs from 1wl0, plus a test that loads an old-shape OpenedEditor as awaiting=false (per doctrine(populate_before_roundtrip)).\n","status":"in_progress","priority":4,"issue_type":"bug","assignee":"agent-227-fixes","owner":"el-github@elangley.org","created_at":"2026-09-23T18:26:45Z","created_by":"agent-review-227c","updated_at":"2026-09-23T18:29:20Z","started_at":"2026-09-23T18:29:20Z","lease_expires_at":"2026-09-23T18:34:20Z","heartbeat_at":"2026-09-23T18:29:20Z","labels":["adversarial-review","browser","pr-227"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-4cb5","title":"#227: an awaited seed settles after Esc, writing the landed cell's text into the edit buffer in Normal mode","description":"Found by the adversarial delta re-review of PR #227 at head baabe1a9.\n\nClientState::settle_awaited_seed checks that the editor is awaiting and untouched. It does not check that the mode is still an editing mode. Esc's spine is [clear-buffer edit, enter-mode normal], and entered(Normal) leaves opened_editor in place. So an Esc pressed before the answer arrives leaves an awaiting editor behind. When the answer lands, the client writes the landed cell's seed into the \"edit\" buffer and sets opened_editor = on(seed), all while in Normal mode.\n\nProbe: a real Host and ClientState. Keys i 9 Enter, then Esc before the Enter is answered, then both answers are delivered. The client ends at mode=Normal, edit=\"2\", opened=on(\"2\"). The TUI has edit=\"\" after Esc. This is a lockstep buffer divergence. Its visible effect is small, since `i` re-seeds explicitly. But the buffer is now present, so settle_editor's absent-buffer (ktlk) path will not seed a later editor that arrives without a buffer.\n\nFix: also require self.view.mode.is_editing() in `waiting`. Clearing opened_editor on leaving an editing mode would work too.\n","status":"in_progress","priority":4,"issue_type":"bug","assignee":"agent-227-fixes","owner":"el-github@elangley.org","created_at":"2026-09-23T18:26:43Z","created_by":"agent-review-227c","updated_at":"2026-09-23T18:29:19Z","started_at":"2026-09-23T18:29:19Z","lease_expires_at":"2026-09-23T18:34:19Z","heartbeat_at":"2026-09-23T18:29:19Z","labels":["adversarial-review","browser","pr-227"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-h3cv","title":"command.md says tile select's fallback is always _Measure, but in _Drill _Measure is on Filter and the Label/_Index ranks are live and untested","description":"Found reviewing PR #242 (improvise-bqm0/q69m/52od) at head de1524c0.\n\n`context/code/command.md` (the ranking paragraph #242 adds) says \"In practice the\nfallback is always `_Measure`, which every document has.\" That is false in the `_Drill`\nview. `drill_steps` puts every coordinate of the drilled key on `Axis::Filter`,\n`_Measure` included when the cell is a raw measure, and `tileable_cats` drops Filter.\nSo in a drill `_Measure` is not a tile at all.\n\nMeasured with a throwaway probe: Region on Row, `_Measure` on Column, a Label `Note`\non None, one cell, then `\u003e`. The `_Drill` tiles are `[_Index Row VirtualIndex,\n_Dim Column VirtualDim, Note None Label]`, and `opening_tile` returns 2. `T Enter`\nthen puts `Note` on Row next to `_Index`. With no Label and every regular category\ndrilled, only `_Index`/`_Dim` remain, so tile select opens on `_Index`. That is the\noriginal oplp shape, and there is nothing better to open on.\n\nSo the Label rank (and the `_Index` fallback) are live exactly in a drill. No lockstep\nor unit test drives that case: the unit test covers the Label rank with a hand-built\ntile list, and the two lockstep tests use a records view and a view with no regular\ncategory. Fix the sentence, and decide whether a drill case belongs in lockstep.\n","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-23T16:14:45Z","created_by":"agent-review-233-242","updated_at":"2026-09-23T16:14:45Z","labels":["adversarial-review","docs","pr-242"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-xbgm","title":"The client bundle's nontrapping-float-to-int flag is blamed on typed(); bisection says CellValue::edit_text() (#227)","description":"#227 (head a093d126) adds `--enable-nontrapping-float-to-int` to improvise-wasm-client's wasm-opt flags. Its Cargo.toml comment (\"needed here once the client parses typed text\") and the a093d126 commit message both blame `typed()`. Bisected by the adversarial review, building the client bundle with the flag removed:\n- gh/main 5932ad74: builds.\n- #227 head: wasm-opt rejects `i64.trunc_sat_f64_s`.\n- #227 head with `landed_seed` changed to `typed(text).map(|_| text.to_string())` (typed() kept, edit_text() dropped): builds.\n\nSo the instruction comes from `CellValue::edit_text()`, which formats an f64. It does not come from `typed()`, which is already in the bundle through `typed_cell_command`. The flag itself is right: the flag does not change codegen, rustc already emits the instruction, and the worker bundle has needed the flag since gotcha 45. Only the stated reason is wrong. Correct the comment so the next reader does not go looking in the parser.\n","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-23T16:01:53Z","created_by":"agent-review-227b-240","updated_at":"2026-09-23T16:01:53Z","labels":["adversarial-review","browser","pr-227"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-9n2z","title":"cli.md §10 says every key-path decline lands in dispatch's arg-error arm, including i; i never reaches it","description":"Found reviewing PR #238 at c9bbe32e. context/code/cli.md (the 'A bound key whose command will not build is a refusal' paragraph) says dispatch's Err arm 'is where every key-path decline lands, including p, x and i onto an aggregate, a fold, a coordinate column or inside a drill'. Measured through App::dispatch_key: p/x/yy/\u003e/Enter/Tab in Editing on an aggregate or fold do land there (status carries the '\u003ccmd\u003e: ' prefix run() adds). i does not: edit-or-drill is register_pure, its constructor only parses the mode, and its fold decline comes from EditOrDrill::execute (status 'Collapsed group — press z to expand it, then edit an item', no prefix). i on an aggregate drills rather than declines. And 'every key-path decline' is false in general: the three panel.rs declines this same PR converts are execute-side. Fix: name the constructors that can err on context (value_cell_key for paste/clear-cell, cell_key for yank, unfolded_cell_key for drill-into-cell, committable_cell_key for commit-cell-edit/commit-and-advance-right) and drop i.","notes":"Blocked on #238: the sentence exists only on #238's branch. Replacement text proposed in PR #249's body (ab44516). Apply on #238's branch at its next re-merge, or as follow-up after #238 merges.","status":"open","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T15:58:05Z","created_by":"agent-review-238","updated_at":"2026-09-23T18:15:52Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-bj61","title":"lasd's debug_assert reads the batch-wide refusal flag: a Model effect that aborts without refusing after an earlier refusal in the same batch passes it","description":"Found in adversarial delta review of PR #229 (head 66b6a973). App::apply_effects asserts matches!(kind, ViewOnly) || self.batch_refused when abort_effects is set. batch_refused is set by ANY earlier refusal in the batch, so the check asks 'has this batch refused' where it means 'did this effect refuse'. Demonstrated: with on_view's missing-view branch changed to write the status and abort without refusing (the lasd rival), the batch [refuse(\"earlier\"), SetAxis{view: Named(\"NoSuchView\")}] aborts (last_batch_aborted() == true) and the assert does not fire. The same rival alone fails two tests through the assert. The residual is stated in context/code/command.md (the lasd paragraph) but has no ticket. Cheap fix shape: snapshot a refusal counter (or the flag) before each effect and assert the effect itself refused. Low priority: no current Model effect aborts without refusing; this only narrows what a future fault can slip past. Related: improvise-lasd, improvise-we1a.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-23T15:46:02Z","created_by":"agent-review-222b-229b","updated_at":"2026-09-23T15:46:02Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-0s6","title":"Phase 3: Distribution","description":"Configure cargo dist, publish to crates.io, tag v0.1.0 release. Produces prebuilt binaries for Linux x86_64 and macOS (Intel + Apple Silicon).","acceptance_criteria":"Closes when improvise-l36 and improvise-3tj close. Both need the user; nothing here is agent work. improvise-11a (cargo dist) is already closed.","notes":"CHECKED, wave 2 WU-P: STAYS OPEN, correctly — both children need the user and\nneither was touched. Nothing was published and nothing was tagged.\n\nWhat this pass DID do is make them ready, and one of them is not as ready as\nit looked. `cargo publish --dry-run` was actually run and FAILS: every\ninter-crate dependency in the workspace is `{ path = ... }` with no `version`,\nwhich crates.io forbids. cargo reports only the first offender (gp-tree), so\nthe dry run understates it — the full list, the publish order for the eight\npublishable crates, and the gp-tree/rc2 version disagreement are on\nimprovise-l36.\n\nimprovise-3tj's premise is already partly false and its note now says so: the\nREADME prebuilt-binaries link is not a placeholder, v0.1.0-rc2 carries real\ncargo-dist artifacts for all four targets, and what is actually missing is the\nv0.1.0 tag and the manifest bump that must precede it.","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-09T04:05:51Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:05:07Z","labels":["area:release","needs:user"],"dependencies":[{"issue_id":"improvise-0s6","depends_on_id":"improvise-11a","type":"blocks","created_at":"2026-04-08T23:37:39Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-1xfb.5.2","title":"Bookkeeping nits from the PR #311 review","description":"Non-blocking findings from the #311 review (issuecomment-5826314337), for the next bookkeeping pass: (1) kb decision scroll_measured_where_drawn cites a tui.md heading imprecisely -- real heading is 'The grid's width is one answer, and a scroll is measured where it is drawn'; (2) repo-map calls the pre-#303 tile-bar code first_tile, a name #303 introduced; (3) category_cap_has_one_home says the literal 24 appears only in add_category_max_limit -- 'the only test' is exact; (4) two user quotes in kb clauses lack quotation marks and drop the final period; (5) three rewritten inventory rows keep stale line counts: import/wizard.rs 1967-\u003e2730, import_wizard_ui.rs 829-\u003e1316, draw.rs 1122-\u003e1189.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T03:44:37Z","created_by":"Edward Langley","updated_at":"2026-09-25T03:44:37Z","labels":["docs","kb","tooling"],"dependencies":[{"issue_id":"improvise-1xfb.5.2","depends_on_id":"improvise-1xfb.5","type":"parent-child","created_at":"2026-09-25T03:44:37Z","created_by":"Edward Langley","metadata":"{}"}],"comments":[{"id":"a6c01ac1-0730-5e63-8bb6-54a42a2b1046","issue_id":"improvise-1xfb.5.2","author":"Edward Langley","text":"Also for the next bookkeeping pass (PR #315 review, issuecomment-5827266982): (6) repo-map gotcha 45 is stale -- apply_effects raises dirty outright only for WholeWorkbook; for CellData/LayoutGeometry only when observed content/config changed (app.rs:1025-1052); the 'omitting it is unrepresentable' claim needs rewording. (7) repo-map Wasm section cites improvise-fuj (closed) for 'the two effects that still bypass Storage'; the live bypasses are autosave and the headless writes, improvise-31oe.11, and neither is an effect. (8) PR #314's proposed repo-map lines (folder import row, gotcha 27 Filter now persisted in per-file views, gotcha 7 sparse records) and kb facts. (9) PR #317's proposed gotcha 44 rewording and new gotcha 51 once it merges.","created_at":"2026-09-25T05:24:21Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} +{"_type":"issue","id":"improvise-loqe","title":"improvise import --on-duplicate is silently ignored without --no-wizard","description":"Found reviewing PR #304 (improvise-cgqt) at 92233cff. --on-duplicate is documented as applying with --no-wizard, but clap accepts it on a wizard import too, where run_wizard_import ignores the whole ImportConfig (a pre-existing TODO). A user passing --on-duplicate sum without --no-wizard is asked anyway, with no warning that the flag did nothing. Either reject the flag without --no-wizard (clap requires) or pre-seed the wizard's policy.","status":"open","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T01:55:40Z","created_by":"Edward Langley","updated_at":"2026-09-25T01:55:40Z","labels":["adversarial-review","cli","import"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-hkhw","title":"ClientState::bootstrap keeps status_seen/status_owner, so a snapshot with the same status text and an equal EditorSession inherits the old document's status ownership","description":"Codex P2 on #298, originally on #279 (https://github.com/fiddlerwoaroof/improvise/pull/279#discussion_r4090596331). bootstrap (crates/improvise-protocol/src/client.rs) resets every cache and in_flight, but it leaves status_seen and status_owner (improvise-1x9h) alone before calling settle_status. When the snapshot's status_msg equals status_seen and EditorSession::of(view) equals the old owner (same mode, same selected cell, same seed), the ownership survives the rebase. status_stands then keeps a toast standing that should expire. The effect is cosmetic: only a toast's lifetime is wrong. Fix: clear both fields in bootstrap before settling. Regression: equal text and an equal EditorSession across a bootstrap; the default state satisfies the other cases.","status":"open","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T19:05:35Z","created_by":"agent-review-298","updated_at":"2026-09-24T19:05:35Z","labels":["adversarial-review","browser","pr-279","pr-298"],"dependencies":[{"issue_id":"improvise-hkhw","depends_on_id":"improvise-6jk","type":"parent-child","created_at":"2026-09-25T01:04:42Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-dw2d","title":"core.md and walk_open_runs' doc call it 'the one reading of the markers', but prune_axis_items reads them a second time through open_at","description":"Found reviewing PR #250 at cfa8bf1e (improvise-e265). context/code/core.md (e265 section) and the doc comment on layout.rs walk_open_runs say it is 'the one reading of the markers'. prune_axis_items keeps its own reading: its input loop applies the same truncate/resize rules through open_at (OpenRuns), because it needs the header entries it moves, not only their positions. So core has two readers of the AxisEntry marker rules (three counting AxisFrame::groups on the wire type). Not a bug today: the enumeration judges prune's output through walk_open_runs, so a drift between the two fails pruning_keeps_every_surviving_rows_runs_at_every_level. Fix either the claim (the one reading for lookups; the pruner tracks its input by the same rules and the enumeration holds them together) or the code (prune reads via walk_open_runs over the undrained slice and clones items[at]).","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-24T06:53:15Z","created_by":"agent-review-250","updated_at":"2026-09-24T06:53:15Z","dependencies":[{"issue_id":"improvise-dw2d","depends_on_id":"improvise-e265","type":"discovered-from","created_at":"2026-09-24T06:53:21Z","created_by":"agent-review-250","metadata":"{}"},{"issue_id":"improvise-dw2d","depends_on_id":"improvise-felo","type":"parent-child","created_at":"2026-09-25T01:05:20Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-07i0","title":"spanned_values' admits filter never re-checks base coordinates: a narrowing bug is a wrong answer, not a slow one","description":"From review of PR #239 at 500d4609 (improvise-jq0m). DataStore::spanned_positions chooses between base-key narrowing and per-item narrowing; admits() applies the Exact length check and span membership but not the base key's coordinates, so correctness rests entirely on which narrowing ran. A per-item path that drops the base is caught only by two DataStore-level tests, nothing above. Suggested: admits also checks base coordinates (cheap), turning any future narrowing bug into a speed cost only.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-24T02:16:39Z","created_by":"orchestrator","updated_at":"2026-09-24T02:16:39Z","dependencies":[{"issue_id":"improvise-07i0","depends_on_id":"improvise-felo","type":"parent-child","created_at":"2026-09-25T01:05:17Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-gc9t","title":"check-ci-graph refuses a BOM-prefixed workflow and crashes with a traceback on an undecodable one (both fail closed)","description":"## Summary\nFound in adversarial delta review of PR #228 at head 1bec7c0f (xpzh). Both issues fail closed, not open, so this is a P4 nit.\n\n`read_workflow` opens the workflow with `encoding=\"utf8\"`:\n1. **A BOM at the start of the file (U+FEFF) is refused.** It stays on line 1, so `_TOP_KEY` rejects `name: CI` with \"line 1 is a key or `needs:` value this gate cannot read\". go-yaml and PyYAML read that file normally, with `gate.if == false`. The gate refuses a legal workflow loudly.\n2. **An undecodable file crashes with a traceback.** Invalid UTF-8, or a whole-file UTF-16 workflow (which go-yaml and PyYAML both read), raises an uncaught `UnicodeDecodeError`. The exit code is 1, so CI fails, but there is no named complaint.\n\n## Fix\nUse `encoding=\"utf-8-sig\"`, which strips a leading BOM only; a mid-line BOM is invalid YAML and all three parsers reject it. Catch `UnicodeDecodeError` in `main`, and name the file and the byte offset. Add one file case for each.","status":"open","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T02:07:38Z","created_by":"agent-review-tooling-delta","updated_at":"2026-09-24T02:07:38Z","labels":["adversarial-review","pr-228"],"dependencies":[{"issue_id":"improvise-gc9t","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T01:05:47Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-vav0","title":"Shipped-document walk is polluted by a trace2 target set in config","description":"The shipped-document walk's `git()` in tests/shipped_documents_load.rs does not null the global or system config. That is deliberate, since `every_shipped_document_loads` reads the real checkout. Trace2 can also be enabled from config: `trace2.eventTarget`, `trace2.normalTarget` and `trace2.perfTarget`, which git reads from system and global config. Pointed at `/dev/stdout`, these write into `ls-files -z`'s output exactly as the `GIT_TRACE*` variables did (improvise-2iws). Removing the environment variables, which is what PR #276 does, does not reach config.\n\n## Steps to Reproduce\nAt PR #276 head ddcdf93a (git 2.55.0), write a config file containing `[trace2] eventTarget = /dev/stdout`. Run the test binary with `GIT_CONFIG_GLOBAL=\u003cfile\u003e` (or `GIT_CONFIG_SYSTEM`) and `every_shipped_document_loads --exact`. It panics with `examples/demo.improv does not load: Cannot open \u003croot\u003e/{\"event\":...`. `a_gzipped_document_is_walked` also fails. `normalTarget` pollutes stdout the same way.\n\n## Fix candidate\nSet `GIT_TRACE2`, `GIT_TRACE2_EVENT` and `GIT_TRACE2_PERF` to `0` in `untraced_git()` instead of removing them. The env var outranks the config target. Verified: with the config above plus `GIT_TRACE2_EVENT=0`, or with `normalTarget` plus `GIT_TRACE2=0`, `ls-files -z` prints only the path.\n\n## Acceptance Criteria\n- A child-process test sets `trace2.eventTarget` (and `normalTarget`) to `/dev/stdout` in a `GIT_CONFIG_GLOBAL` file and re-runs the walk tests, which pass.\n- The same test goes red when the `=0` overrides are dropped.\n\nSame severity as improvise-2iws: the failure is loud and the setting is self-inflicted. Found in the adversarial review of #276.\n","status":"closed","priority":4,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-24T01:08:19Z","created_by":"agent-review-255","updated_at":"2026-09-24T21:44:50Z","started_at":"2026-09-24T01:11:22Z","closed_at":"2026-09-24T21:44:50Z","close_reason":"Landed in main via #276 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-2iws","title":"Shipped-document walk parses trace output sent to stdout as paths","description":"tests/shipped_documents_load.rs's `git()` strips repo-local vars (--local-env-vars) and, since improvise-a0i3, five pathspec/attribute settings. The GIT_TRACE family is also process-wide and is not stripped. When any of these points at stdout, git writes trace lines into the `ls-files -z` output, and `shipped_documents` parses them as paths:\n- `GIT_TRACE=/dev/stdout`\n- `GIT_TRACE2_EVENT=/dev/stdout`\n- `GIT_TRACE_PERFORMANCE=/dev/stdout`\n\n## Steps to Reproduce\nAt PR #269 head 4cef40c9 (git 2.55.0), run the test binary with `GIT_TRACE=/dev/stdout every_shipped_document_loads --exact`. It panics with \"examples/demo.improv does not load: Cannot open \u003croot\u003e/17:39:40.198221 git.c:502 trace: built-in: git ls-files ...\". The same three settings also fail all four scratch-repo shape tests.\n\nThe failure is loud, not a silent pass. The setting is also self-inflicted: `=1` (stderr) does not break anything. So this is low priority.\n\n## Acceptance Criteria\n- The walk's git removes every `GIT_TRACE*` variable. A prefix match is needed because the family is open-ended.\n- A child-process test covers at least `GIT_TRACE=/dev/stdout` and `GIT_TRACE2_EVENT=/dev/stdout`, and fails with the removal dropped.\n\nFound in the adversarial review of #269.\n","status":"closed","priority":4,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-24T00:39:56Z","created_by":"agent-review-255","updated_at":"2026-09-24T21:44:50Z","started_at":"2026-09-24T00:50:14Z","closed_at":"2026-09-24T21:44:50Z","close_reason":"Landed in main via #276 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-0r01","title":"innermost_group walks and clones the whole axis per z keypress since open_runs became the one reader (Codex P2 on #263)","description":"PR #263 (improvise-6u6s) at 2ea24074 made innermost_group call open_runs, which clones the open-run stack for EVERY data item on the axis and keeps all of them, then indexes one. The previous version stopped at idx with O(depth) state. ChatGPT Codex's P2 on #263 is factually right. The cost is O(items x depth) small allocations per z/gz keypress, and z/gz are the only production callers, via row_group_for and col_group_for. On the same keypress, landing() builds a fresh pruned GridLayout and apply_effects rebuilds the layout, and each of those costs O(items) or more with a String allocation per tuple. So the regression is dominated rather than noticeable, which is why it is P4. Fix without losing the single reader: have open_runs yield lazily, or take a stop index, so innermost_group stops at idx and the tests still collect the whole table.","status":"closed","priority":4,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-24T00:34:27Z","created_by":"agent-review-263","updated_at":"2026-09-24T21:44:42Z","started_at":"2026-09-24T00:36:00Z","closed_at":"2026-09-24T21:44:42Z","close_reason":"Landed in main via #273 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-ybum","title":"browser_round_trip's tile_to and column walk are unbounded loops: a stuck tile/column cursor hangs the binary instead of failing","description":"Found in the adversarial review of PR #256 at head eb146bcc. This predates #256.\n\nIn crates/improvise-wasm-client/tests/browser_round_trip.rs, the helper `tile_to` (line ~434) and the column walk in `the_tile_bar_route_into_records_mode_does_not_strand_the_user` (line ~475) are unbounded `while` loops that press l or h until an index is reached. A resolver fault that stops the cursor short of the target never ends the loop. The test binary hangs; it does not fail.\n\nMeasured: rival `left_clamp_one` changes `tile_cursor`'s lower clamp from `clamp(0, ..)` to `clamp(1, ..)`. With it, that test ran past 17 minutes and had to be killed. The fault is still detected, by two resolve.rs unit tests. But in CI a hang costs the job timeout and names no test, and locally it looks like a slow build.\n\nFix: bound each loop, for example by the tile count or the column count plus one. Assert progress on every press, so that a cursor which does not move fails with the index it is stuck on.\n","status":"closed","priority":4,"issue_type":"task","assignee":"agent-qnry-ybum","owner":"el-github@elangley.org","created_at":"2026-09-24T00:20:08Z","created_by":"agent-review-256","updated_at":"2026-09-24T21:45:20Z","started_at":"2026-09-24T00:21:21Z","closed_at":"2026-09-24T21:45:20Z","close_reason":"Landed in main via #271 (reviewed; merged 2026-09-24).","labels":["adversarial-review","pr-256"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-aopa","title":"resolve.rs tests fold client-composed commands through fold (the host door), latent like xoxh's renaming test","description":"Found in the adversarial review of PR #259 (improvise-xoxh) at c349d14d. xoxh moved one test's client-composed commands off `ClientState::fold`, the door for what the HOST sends, and onto `fold_outgoing`. Eight test sites in crates/improvise-protocol/src/resolve.rs still fold what `resolve` composed through `fold`: lines 2296 (the Heard classifier), 2356 and 2523 (prefix arming and sequences), 3452 (press_folded), 3612, 3730, 3828 (arm) and 3874.\n\nToday this is latent. None of those tests read `in_flight` or `settled`, and `apply`, which moves `view`, is the same code behind both doors. But a composed `Command::Model` folded through `fold` never enters `in_flight` as awaited. Any future assertion on rollback or settle state in these helpers would therefore describe a client that cannot exist. That is the vacuity xoxh found in the renaming test.\n\nFix candidate: fold resolved commands with `fold_outgoing(\u0026commands)`, one frame per key, which is what web/app.js and lib.rs do. Rival: put `fold` back and assert that some awaited entry or settle-state check goes red. Otherwise the change is untestable, and that is an argument for P4.\n","status":"closed","priority":4,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-24T00:14:27Z","created_by":"agent-review-259-260","updated_at":"2026-09-24T21:45:05Z","started_at":"2026-09-24T00:20:20Z","closed_at":"2026-09-24T21:45:05Z","close_reason":"Landed in main via #270 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-1x9h","title":"Browser: a status written while a minibuffer is open hides after TOAST_MS (6s) with the prompt still open — su4u's bug on the toast timer (latent behind cl7y)","description":"Found in the adversarial review of PR #257 (improvise-su4u) at head 2b1c5bf7. The author noted this in the PR body and in `context/code/tui.md` and did not file it.\n\nIn the browser, `status_msg` is shown only as a toast. `web/shell.js::createToasts` hides it after `TOAST_MS` (6000 ms) whatever the mode. The minibuffer element (`web/app.js`, `mb.prompt + mb.value`) never shows the message. So a message that belongs beside an open minibuffer, such as a refusal the user is still fixing, disappears on a timer while the prompt is still open. This is su4u's bug with a different expiry mechanism.\n\nIt is LATENT for su4u's headline case, and that is the reason to file it rather than fix it now. A formula refusal never reaches a browser at all. The client folds `CommitBuffer`'s optimistic \"Formula added\" and `ChangeMode(FormulaPanel)`. The host's \"Formula error\" is a ViewOnly write to the host's own slice. `crates/improvise-wasm-client/tests/lockstep.rs::a_formula_the_client_cannot_parse_diverges_on_the_status_line` still asserts that divergence at 2b1c5bf7, although improvise-cl7y is closed. So this ticket becomes live when a host-addressed status reaches the composer, as `context/code/clients.md` recommends for cl7y, or for any client-composed refusal that keeps a minibuffer open.\n\nAcceptance: a message written while a text-entry mode stays open stays visible until the user leaves that mode or a newer message replaces it. The rule should be the one #257 gives the TUI (see improvise-sx7h for where that rule's mode-equality proxy is wrong). The test is a `web/tests` case on `createToasts` or its caller, plus a Playwright check if the DOM wiring changes.\n","status":"closed","priority":4,"issue_type":"bug","assignee":"agent-qnry-ybum","owner":"el-github@elangley.org","created_at":"2026-09-24T00:01:32Z","created_by":"agent-review-257","updated_at":"2026-09-24T21:44:47Z","started_at":"2026-09-24T00:40:40Z","closed_at":"2026-09-24T21:44:47Z","close_reason":"Landed in main via #279 (reviewed; merged 2026-09-24).","labels":["adversarial-review","area:shared-frontend","pr-257"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-mgis","title":"OpenedEditor::awaiting is serde(default), against 1wl0's doctrine, and unpinned: deleting the attribute leaves the workspace green (#227)","description":"Found by the adversarial delta re-review of PR #227 at head baabe1a9.\n\nOpenedEditor::awaiting carries #[serde(default)]. OpenedEditor lives in ViewState, so it travels in the Snapshot. `awaiting: false` is not an absence. It is the claim \"the seed is settled; judge a commit against `seed`\".\n\nA peer that lost the field would deliver an editor that was really awaiting as a settled one with seed \"\". The client would never settle it: the editor stays empty, and an Enter is \"untouched\" and writes nothing. It fails silently. That is the exact shape protocol.md §3a / improvise-1wl0 rejected for AxisFrame::first: \"a missing field is now a loud parse failure at the transport, where a wire-format skew belongs. No skew ships today, both ends being one workspace at one commit.\"\n\nIt is also unpinned. Deleting the attribute leaves the whole workspace green (1950 passed; only my unrelated probes failed). So it is neither the mechanism of any test nor argued in protocol.md.\n\nFix: drop the attribute. Alternatively, keep it with a written argument for why this field differs from 1wl0, plus a test that loads an old-shape OpenedEditor as awaiting=false (per doctrine(populate_before_roundtrip)).\n","status":"closed","priority":4,"issue_type":"bug","assignee":"agent-227-fixes","owner":"el-github@elangley.org","created_at":"2026-09-23T18:26:45Z","created_by":"agent-review-227c","updated_at":"2026-09-24T21:44:56Z","started_at":"2026-09-23T18:29:20Z","closed_at":"2026-09-24T21:44:56Z","close_reason":"Landed in main via #227 (reviewed; merged 2026-09-24).","labels":["adversarial-review","browser","pr-227"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-4cb5","title":"#227: an awaited seed settles after Esc, writing the landed cell's text into the edit buffer in Normal mode","description":"Found by the adversarial delta re-review of PR #227 at head baabe1a9.\n\nClientState::settle_awaited_seed checks that the editor is awaiting and untouched. It does not check that the mode is still an editing mode. Esc's spine is [clear-buffer edit, enter-mode normal], and entered(Normal) leaves opened_editor in place. So an Esc pressed before the answer arrives leaves an awaiting editor behind. When the answer lands, the client writes the landed cell's seed into the \"edit\" buffer and sets opened_editor = on(seed), all while in Normal mode.\n\nProbe: a real Host and ClientState. Keys i 9 Enter, then Esc before the Enter is answered, then both answers are delivered. The client ends at mode=Normal, edit=\"2\", opened=on(\"2\"). The TUI has edit=\"\" after Esc. This is a lockstep buffer divergence. Its visible effect is small, since `i` re-seeds explicitly. But the buffer is now present, so settle_editor's absent-buffer (ktlk) path will not seed a later editor that arrives without a buffer.\n\nFix: also require self.view.mode.is_editing() in `waiting`. Clearing opened_editor on leaving an editing mode would work too.\n","status":"closed","priority":4,"issue_type":"bug","assignee":"agent-227-fixes","owner":"el-github@elangley.org","created_at":"2026-09-23T18:26:43Z","created_by":"agent-review-227c","updated_at":"2026-09-24T21:44:55Z","started_at":"2026-09-23T18:29:19Z","closed_at":"2026-09-24T21:44:55Z","close_reason":"Landed in main via #227 (reviewed; merged 2026-09-24).","labels":["adversarial-review","browser","pr-227"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-h3cv","title":"command.md says tile select's fallback is always _Measure, but in _Drill _Measure is on Filter and the Label/_Index ranks are live and untested","description":"Found reviewing PR #242 (improvise-bqm0/q69m/52od) at head de1524c0.\n\n`context/code/command.md` (the ranking paragraph #242 adds) says \"In practice the\nfallback is always `_Measure`, which every document has.\" That is false in the `_Drill`\nview. `drill_steps` puts every coordinate of the drilled key on `Axis::Filter`,\n`_Measure` included when the cell is a raw measure, and `tileable_cats` drops Filter.\nSo in a drill `_Measure` is not a tile at all.\n\nMeasured with a throwaway probe: Region on Row, `_Measure` on Column, a Label `Note`\non None, one cell, then `\u003e`. The `_Drill` tiles are `[_Index Row VirtualIndex,\n_Dim Column VirtualDim, Note None Label]`, and `opening_tile` returns 2. `T Enter`\nthen puts `Note` on Row next to `_Index`. With no Label and every regular category\ndrilled, only `_Index`/`_Dim` remain, so tile select opens on `_Index`. That is the\noriginal oplp shape, and there is nothing better to open on.\n\nSo the Label rank (and the `_Index` fallback) are live exactly in a drill. No lockstep\nor unit test drives that case: the unit test covers the Label rank with a hand-built\ntile list, and the two lockstep tests use a records view and a view with no regular\ncategory. Fix the sentence, and decide whether a drill case belongs in lockstep.\n","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-23T16:14:45Z","created_by":"agent-review-233-242","updated_at":"2026-09-23T16:14:45Z","labels":["adversarial-review","docs","pr-242"],"dependencies":[{"issue_id":"improvise-h3cv","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T01:05:16Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-xbgm","title":"The client bundle's nontrapping-float-to-int flag is blamed on typed(); bisection says CellValue::edit_text() (#227)","description":"#227 (head a093d126) adds `--enable-nontrapping-float-to-int` to improvise-wasm-client's wasm-opt flags. Its Cargo.toml comment (\"needed here once the client parses typed text\") and the a093d126 commit message both blame `typed()`. Bisected by the adversarial review, building the client bundle with the flag removed:\n- gh/main 5932ad74: builds.\n- #227 head: wasm-opt rejects `i64.trunc_sat_f64_s`.\n- #227 head with `landed_seed` changed to `typed(text).map(|_| text.to_string())` (typed() kept, edit_text() dropped): builds.\n\nSo the instruction comes from `CellValue::edit_text()`, which formats an f64. It does not come from `typed()`, which is already in the bundle through `typed_cell_command`. The flag itself is right: the flag does not change codegen, rustc already emits the instruction, and the worker bundle has needed the flag since gotcha 45. Only the stated reason is wrong. Correct the comment so the next reader does not go looking in the parser.\n","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-23T16:01:53Z","created_by":"agent-review-227b-240","updated_at":"2026-09-23T16:01:53Z","labels":["adversarial-review","browser","pr-227"],"dependencies":[{"issue_id":"improvise-xbgm","depends_on_id":"improvise-fhj0","type":"parent-child","created_at":"2026-09-25T01:05:42Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-9n2z","title":"cli.md §10 says every key-path decline lands in dispatch's arg-error arm, including i; i never reaches it","description":"Found reviewing PR #238 at c9bbe32e. context/code/cli.md (the 'A bound key whose command will not build is a refusal' paragraph) says dispatch's Err arm 'is where every key-path decline lands, including p, x and i onto an aggregate, a fold, a coordinate column or inside a drill'. Measured through App::dispatch_key: p/x/yy/\u003e/Enter/Tab in Editing on an aggregate or fold do land there (status carries the '\u003ccmd\u003e: ' prefix run() adds). i does not: edit-or-drill is register_pure, its constructor only parses the mode, and its fold decline comes from EditOrDrill::execute (status 'Collapsed group — press z to expand it, then edit an item', no prefix). i on an aggregate drills rather than declines. And 'every key-path decline' is false in general: the three panel.rs declines this same PR converts are execute-side. Fix: name the constructors that can err on context (value_cell_key for paste/clear-cell, cell_key for yank, unfolded_cell_key for drill-into-cell, committable_cell_key for commit-cell-edit/commit-and-advance-right) and drop i.","notes":"Blocked on #238: the sentence exists only on #238's branch. Replacement text proposed in PR #249's body (ab44516). Apply on #238's branch at its next re-merge, or as follow-up after #238 merges.","status":"open","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T15:58:05Z","created_by":"agent-review-238","updated_at":"2026-09-23T18:15:52Z","dependencies":[{"issue_id":"improvise-9n2z","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T01:05:16Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-bj61","title":"lasd's debug_assert reads the batch-wide refusal flag: a Model effect that aborts without refusing after an earlier refusal in the same batch passes it","description":"Found in adversarial delta review of PR #229 (head 66b6a973). App::apply_effects asserts matches!(kind, ViewOnly) || self.batch_refused when abort_effects is set. batch_refused is set by ANY earlier refusal in the batch, so the check asks 'has this batch refused' where it means 'did this effect refuse'. Demonstrated: with on_view's missing-view branch changed to write the status and abort without refusing (the lasd rival), the batch [refuse(\"earlier\"), SetAxis{view: Named(\"NoSuchView\")}] aborts (last_batch_aborted() == true) and the assert does not fire. The same rival alone fails two tests through the assert. The residual is stated in context/code/command.md (the lasd paragraph) but has no ticket. Cheap fix shape: snapshot a refusal counter (or the flag) before each effect and assert the effect itself refused. Low priority: no current Model effect aborts without refusing; this only narrows what a future fault can slip past. Related: improvise-lasd, improvise-we1a.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-23T15:46:02Z","created_by":"agent-review-222b-229b","updated_at":"2026-09-23T15:46:02Z","dependencies":[{"issue_id":"improvise-bj61","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T01:05:11Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-4vw8","title":"A click_formula doc comment still says the formula panel binds no Enter, after km71 made a click open the editor","description":"Found reviewing PR #214 at f3fb83ee. crates/improvise-wasm-client/src/lib.rs:137-138, on Client::click_formula, reads 'Navigation only — that panel binds no Enter (improvise-iioi)'. Since improvise-km71 the formula panel binds Enter to edit-formula-at-cursor, and resolve.rs::click_formula_row ends with edit_formula_at, so a click opens the editor. The comment is the wasm-bindgen surface's only description of what the page's click does. Fix: say the click focuses, moves the cursor and opens the editor on that row, as click_formula_row's own comment does.","status":"closed","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T15:28:26Z","created_by":"agent-review-214b-223","updated_at":"2026-09-23T16:00:12Z","closed_at":"2026-09-23T16:00:12Z","close_reason":"Fixed in PR #214 (formula editing, user-requested), merged after adversarial review (77f059c, then the kb1d delta at f3fb83e). Later changes were conflict-only gh/main merges, the doc-comment fix, and serde re-added for FormulaId only (orchestrator ruling, after tt22).","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-52od","title":"No test separates opening_tile's 'first regular' rule from 'first tile on Row'","description":"Found reviewing PR #231 (improvise-oplp), head 2383c37f. The fixture has two derivations agree. In lockstep's furnished_host, Region is both the first Regular tile and the first tile on Row. In web/demo.improv, Product is both too. Injected rival: opening_tile = position(|t| t.axis == Axis::Row).unwrap_or(0), which needs no wire field at all. Result: cargo test --workspace --no-fail-fast was 1858 passed, 0 failed. The rules differ observably in records mode, where _Index is on Row: the rival opens on _Index, and T Enter drops the user out of records mode. They also differ when the first regular category is on Page or Column. Guard: a unit test of opening_tile in render.rs, with a Row-placed virtual before a Page- or Column-placed regular, or a lockstep case started in records mode.","status":"in_progress","priority":4,"issue_type":"task","assignee":"agent-docs-wave","owner":"el-github@elangley.org","created_at":"2026-09-23T15:23:38Z","created_by":"agent-review-231","updated_at":"2026-09-23T15:27:34Z","started_at":"2026-09-23T15:27:34Z","lease_expires_at":"2026-09-23T15:32:34Z","heartbeat_at":"2026-09-23T15:27:34Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-swsi","title":"TUI help footer drops the close keys first below 78 columns (PR #220 footer)","description":"Found in adversarial review of PR #220 (improvise-17ak) at head 5e37973f.\n\nThe help popup footer is now \" page N of M \" + hint_line(Help), truncated in display cells with the LEFT kept (ui::text::truncate). The hint is \"Tab/l/n/→:next-page S-Tab/h/p/←:prev-page ?/Esc/q:close\" (57 cells), so the line is 74 cells and the popup's inner width is (terminal width - 4) below 91 columns. Rendered with a scratch test (not committed):\n- 80x24: whole hint fits, 2 cells spare.\n- 60x24: \"...S-Tab/h/p/←:prev-…\" — the close keys are gone.\n- 40x20: \"...Tab/l/n/→:next-pag…\" — prev and close gone.\nSo below 78 columns the escape hatch is the first thing dropped. The old hand-written footer (\"page 1 of 5 l/Tab: next q/Esc: close\", ~42 cells) kept the close keys down to ~46 columns. Gotcha 50's rule is to name which side yields; here the close keys are the one segment that must not.\n\nSecond, smaller: the old footer was page-aware (no \"next\" on the last page, no \"prev\" on the first); the derived hint advertises both on every page, and HelpPageNext/Prev clamp, so on page 5 \"next-page\" is a no-op key. Not wrong, just less useful.\n\nThe PR's test renders at 140x30 and cannot see either. A fix might put the close keys first, or truncate the page indicator before the hint, and pin a narrow width (e.g. 60) in a test.\n","status":"open","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T15:05:13Z","created_by":"agent-review-219-220","updated_at":"2026-09-23T15:05:13Z","labels":["adversarial-review","help","pr-220","tui"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-52od","title":"No test separates opening_tile's 'first regular' rule from 'first tile on Row'","description":"Found reviewing PR #231 (improvise-oplp), head 2383c37f. The fixture has two derivations agree. In lockstep's furnished_host, Region is both the first Regular tile and the first tile on Row. In web/demo.improv, Product is both too. Injected rival: opening_tile = position(|t| t.axis == Axis::Row).unwrap_or(0), which needs no wire field at all. Result: cargo test --workspace --no-fail-fast was 1858 passed, 0 failed. The rules differ observably in records mode, where _Index is on Row: the rival opens on _Index, and T Enter drops the user out of records mode. They also differ when the first regular category is on Page or Column. Guard: a unit test of opening_tile in render.rs, with a Row-placed virtual before a Page- or Column-placed regular, or a lockstep case started in records mode.","status":"closed","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-23T15:23:38Z","created_by":"agent-review-231","updated_at":"2026-09-25T01:00:01Z","closed_at":"2026-09-25T01:00:01Z","close_reason":"Landed in #242 (66bd3fcb on gh/main)","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-swsi","title":"TUI help footer drops the close keys first below 78 columns (PR #220 footer)","description":"Found in adversarial review of PR #220 (improvise-17ak) at head 5e37973f.\n\nThe help popup footer is now \" page N of M \" + hint_line(Help), truncated in display cells with the LEFT kept (ui::text::truncate). The hint is \"Tab/l/n/→:next-page S-Tab/h/p/←:prev-page ?/Esc/q:close\" (57 cells), so the line is 74 cells and the popup's inner width is (terminal width - 4) below 91 columns. Rendered with a scratch test (not committed):\n- 80x24: whole hint fits, 2 cells spare.\n- 60x24: \"...S-Tab/h/p/←:prev-…\" — the close keys are gone.\n- 40x20: \"...Tab/l/n/→:next-pag…\" — prev and close gone.\nSo below 78 columns the escape hatch is the first thing dropped. The old hand-written footer (\"page 1 of 5 l/Tab: next q/Esc: close\", ~42 cells) kept the close keys down to ~46 columns. Gotcha 50's rule is to name which side yields; here the close keys are the one segment that must not.\n\nSecond, smaller: the old footer was page-aware (no \"next\" on the last page, no \"prev\" on the first); the derived hint advertises both on every page, and HelpPageNext/Prev clamp, so on page 5 \"next-page\" is a no-op key. Not wrong, just less useful.\n\nThe PR's test renders at 140x30 and cannot see either. A fix might put the close keys first, or truncate the page indicator before the hint, and pin a narrow width (e.g. 60) in a test.\n","status":"open","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T15:05:13Z","created_by":"agent-review-219-220","updated_at":"2026-09-23T15:05:13Z","labels":["adversarial-review","help","pr-220","tui"],"dependencies":[{"issue_id":"improvise-swsi","depends_on_id":"improvise-gtn7","type":"parent-child","created_at":"2026-09-25T01:05:30Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-rtud","title":"command.md says every refuse_and_stop site has a key-path table case; load, import-json absent.json and AddFormula parse do not","description":"Found by the adversarial review of PR #213 at 3929196.\n\nAfter #213, context/code/command.md (the \"Every refusal site has a test\" bullet) says that each\n`refuse_and_stop` site needs a case in both tables: the key-path table\n`every_refusal_site_refuses_its_failure_and_only_its_failure` for its refusal, and\n`a_failed_load_or_import_does_not_run_the_quit_after_it` for its abort.\n\nThat is not what the code does. Three `refuse_and_stop` sites have no case in the key-path table:\n- `LoadModel` (`load absent.improv`)\n- `ImportJsonHeadless`'s JSON cannot-read arm (`import-json absent.json`)\n- `AddFormula`'s parse-error arm\n\nEach is still pinned. A per-site sweep reverting each one to a status-only write fails:\n- `LoadModel` and the JSON cannot-read arm only in\n `a_failed_load_or_import_does_not_run_the_quit_after_it`. Its refusal is caught by the assertion\n labelled \"setup:\", which checks `last_batch_refused() \u0026\u0026 status contains arm`.\n- `AddFormula`'s parse-error arm only in\n `a_formula_that_does_not_parse_keeps_the_user_in_the_editor_with_their_text`.\n\nTwo defects:\n1. The prose states a rule the tables do not follow. The next person will trust the rule and not\n check.\n2. Two refusals are held by an assertion that calls itself setup. A later edit that \"simplifies\" the\n setup check, for example to the status text alone, would unpin them with nothing to say so.\n\nFix: add the three cases to the key-path table, each with a succeeding twin. Or reword the prose to\nname where each refusal half is actually held, and stop calling that assertion a setup check.\n","status":"closed","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-23T14:51:53Z","created_by":"agent-review-213","updated_at":"2026-09-23T15:15:48Z","started_at":"2026-09-23T14:55:58Z","closed_at":"2026-09-23T15:15:48Z","close_reason":"Fixed in PR #225 (re-landed after missing #213's merge), merged as cb60166 after adversarial review at 7da905c.","labels":["adversarial-review","pr-213"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-we1a","title":"Host::mutating_frame's || last_batch_aborted() arm is unwitnessed: removing it leaves the workspace green","description":"Found by the adversarial review of PR #213 at 3929196 (the author flagged it; the reviewer confirmed it).\n\n`Host::mutating_frame` (crates/improvise-command/src/ui/host.rs) rejects a frame on\n`last_batch_refused() || last_batch_aborted()`. Deleting the `|| last_batch_aborted()` arm leaves\n`cargo test --workspace --no-fail-fast` fully green at 3929196. The arm is unwitnessed.\n\nWhy nothing can witness it today: every abort a wire ModelCommand can reach also refuses. The Model\neffects that set `abort_effects` are AddCategory (refuse + abort) and the `refuse_and_stop` sites\n(AddFormula parse, LoadModel, ImportJsonHeadless). `AbortChain` aborts without refusing, but it is\nViewOnly and no ModelCommand maps to it. So the arm is live only if a future Model effect aborts\nwithout refusing. In that case, dropping the arm would let the frame be accepted after one command\napplied only part of its effects, because an abort stops only that command's own batch.\n\nChoose one:\n1. Keep the arm and pin the invariant that makes it redundant today: every Model effect that aborts\n also refuses. For example, over the wire table's failure fixtures, assert\n `aborted implies refused`. Then say in context/code/command.md that the arm is defence for a\n future effect.\n2. Make it unrepresentable. For example, let Model effects set `abort_effects` only through\n `refuse_and_stop`. Then remove the arm.\nDo not leave an arm that no test sees in either direction.\n","status":"in_progress","priority":4,"issue_type":"task","assignee":"agent-uo2p","owner":"el-github@elangley.org","created_at":"2026-09-23T14:51:37Z","created_by":"agent-review-213","updated_at":"2026-09-23T14:55:57Z","started_at":"2026-09-23T14:55:57Z","lease_expires_at":"2026-09-23T15:00:57Z","heartbeat_at":"2026-09-23T14:55:57Z","labels":["adversarial-review","pr-213"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-we1a","title":"Host::mutating_frame's || last_batch_aborted() arm is unwitnessed: removing it leaves the workspace green","description":"Found by the adversarial review of PR #213 at 3929196 (the author flagged it; the reviewer confirmed it).\n\n`Host::mutating_frame` (crates/improvise-command/src/ui/host.rs) rejects a frame on\n`last_batch_refused() || last_batch_aborted()`. Deleting the `|| last_batch_aborted()` arm leaves\n`cargo test --workspace --no-fail-fast` fully green at 3929196. The arm is unwitnessed.\n\nWhy nothing can witness it today: every abort a wire ModelCommand can reach also refuses. The Model\neffects that set `abort_effects` are AddCategory (refuse + abort) and the `refuse_and_stop` sites\n(AddFormula parse, LoadModel, ImportJsonHeadless). `AbortChain` aborts without refusing, but it is\nViewOnly and no ModelCommand maps to it. So the arm is live only if a future Model effect aborts\nwithout refusing. In that case, dropping the arm would let the frame be accepted after one command\napplied only part of its effects, because an abort stops only that command's own batch.\n\nChoose one:\n1. Keep the arm and pin the invariant that makes it redundant today: every Model effect that aborts\n also refuses. For example, over the wire table's failure fixtures, assert\n `aborted implies refused`. Then say in context/code/command.md that the arm is defence for a\n future effect.\n2. Make it unrepresentable. For example, let Model effects set `abort_effects` only through\n `refuse_and_stop`. Then remove the arm.\nDo not leave an arm that no test sees in either direction.\n","status":"closed","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-23T14:51:37Z","created_by":"agent-review-213","updated_at":"2026-09-25T01:00:01Z","closed_at":"2026-09-25T01:00:01Z","close_reason":"Landed in #229 (on gh/main)","labels":["adversarial-review","pr-213"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-28ri","title":"AGENTS.md audit prose: -d skip rationale is wrong (grep -r never follows the skills symlinks), and 'spells no id' is false","description":"Found by the adversarial delta review of PR #201 at head 141be9fc, in AGENTS.md, \"Cite a ticket ID you have read back\". The command is right; only two sentences of its prose are wrong.\n\n1. The \"The skills twice\" bullet lists .claude/skills/* among the things \"a grep -r over the same roots reads\", and says `-d skip` is what makes the skills get read once. Both BSD /usr/bin/grep and GNU grep do NOT follow a symlink they meet while recursing. The text this PR replaced said exactly that.\n - Measured on macOS: `grep -rl 'name: wave' .claude .agents` returns only `.agents/skills/wave/SKILL.md`. GNU grep gives the same answer.\n - What -d skip really does: `git ls-files` lists each symlink as a file entry. grep, handed a symlink to a directory, prints `grep: .claude/skills/wave: Is a directory` on stderr. Without the flag that is five such lines.\n - No id is ever counted twice, with or without the flag. The reason to give is \"stderr noise from the symlink entries\".\n2. \"Note the recipe's own prose deliberately spells no live or dead id\" is false. The new build-output bullet cites improvise-8haj. The artifact bullet already cited improvise-oujb before #201. Either drop the ids or drop the sentence.\n\nAcceptance: both sentences corrected, and the audit still reports \"all resolve\".\n","status":"closed","priority":4,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T06:28:11Z","created_by":"agent-review-201b","updated_at":"2026-09-23T15:15:50Z","started_at":"2026-09-23T06:30:17Z","closed_at":"2026-09-23T15:15:50Z","close_reason":"Fixed in PR #211, merged as 968f65a after adversarial review (delta at 41aa407).","comments":[{"id":"045ec3d8-eae0-5af9-a571-579b19cdf81f","issue_id":"improvise-28ri","author":"agent-review-211","text":"Review of PR #211 at 6ccad4b8 (agent-review-211). The \"-d skip changes no answer\" conclusion holds. I measured it: the same 3092 matches with and without the flag, under both BSD grep 2.6.0 and GNU grep 3.12. The mechanism it now gives is still wrong, though.\n\nThe recipe's grep has no -r. The .claude/skills/* symlinks reach it as command-line operands, not as something it \"meets while recursing\". So what grep -r does while recursing has no bearing on the recipe. Nothing is read twice for a simpler reason: grep without -r never reads a directory operand.\n\nThe stated rule is also false for command-line operands. GNU grep -r DOES follow a symlink named on the command line. Adding -r to the recipe gives 3107 matches under GNU against 3092 under BSD. Those 15 are the skills read a second time through .claude.\n\nAnd \"no id is ever counted twice\" is moot anyway, because the pipeline goes through sort -u.\n\nSuggested wording: \"The grep has no -r, so a directory operand is never read, and -d skip only silences the 'Is a directory' lines. The output goes through sort -u, so a duplicate could not change the answer anyway.\"\n","created_at":"2026-09-23T14:48:01Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} {"_type":"issue","id":"improvise-hoxy","title":"names_its_column accepts CellId::Derived on any measure column, and records_app has no formula, so the arm only loosens the enumeration","description":"Found reviewing PR #206 (improvise-w1bu). In a_coordinate_cell_is_named_rather_than_left_as_no_cell, the helper's (Measure(_), Derived{..}) arm returns true unconditionally, ignoring target and whether a formula owns the column. records_app() defines no formula, so every correct Derived is impossible in this fixture and the arm can only admit a bug. Injected at b22f6e53: RecordCol::Measure(m) =\u003e return Some(CellId::Derived{target: m.clone()}) in cell_id. This test PASSES; the fault is caught elsewhere (3 cell_identity tests in improvise-command lib, 4 in wasm-client tests/lockstep.rs), so no bug ships, but the enumeration the ticket was meant to strengthen does not state that half of the contract. Fix: drop the Derived arm (fixture has no formulas), or add a formula-owned measure to the fixture and require Derived exactly there with target == the formula's.","status":"closed","priority":4,"issue_type":"task","assignee":"agent-viewhist","owner":"el-github@elangley.org","created_at":"2026-09-23T05:50:00Z","created_by":"agent-review-206","updated_at":"2026-09-23T06:35:10Z","started_at":"2026-09-23T05:51:47Z","closed_at":"2026-09-23T06:35:10Z","close_reason":"Fixed in PR #206, merged as 2a52888 after adversarial review at b22f6e5. hoxy was the reviewer-requested test fix; later merges touched evidence only. Follow-up: improvise-v8ab (#207).","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-17ak","title":"TUI help popup footer hand-writes its paging keys (l/Tab, h/S-Tab, q/Esc): a second key table no guard reads","description":"Found in adversarial review of PR #180 at head f2488276 while checking whether anything still teaches paging after the Welcome tip was removed.\n\nsrc/ui/help.rs render_footer hand-writes the help popup's paging keys: \"l/Tab: next\" on page 1, \"h/S-Tab: prev\" on the last page, \"h: prev l: next\" between, and \"q/Esc: close\". They are true today (keymap.rs binds l, n, Right, Tab to help-page-next; h, p, Left, BackTab to help-page-prev; q, Esc, ? to enter-mode normal), but they are a second key table nothing checks: every_hint_key_resolves_in_its_mode cannot see a string literal in a widget (improvise-rr8n / improvise-wk18 name the same blind spot), and a rebinding would leave the footer lying while the status row below it (hint_line(Help)) told the truth.\n\nPer improvise-rr8n's rule (keep the table derived from the thing it describes), the footer's key half should come from help::hints(\u0026AppMode::Help) / keys_for, or be dropped in favour of the status row the popup already leaves visible. The page indicator (\"page N of M\") is not a key table and can stay.\n","status":"closed","priority":4,"issue_type":"bug","assignee":"agent-docs-wave","owner":"el-github@elangley.org","created_at":"2026-09-23T05:21:59Z","created_by":"Edward Langley","updated_at":"2026-09-23T15:15:47Z","started_at":"2026-09-23T14:44:18Z","closed_at":"2026-09-23T15:15:47Z","close_reason":"Fixed in PR #220, merged as c7e4854 after adversarial review at 5e37973. Follow-ups: improvise-swsi, improvise-cq3d.","labels":["help","tui"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-e958","title":"source_scan panics on #![cfg(test)] preceded by another inner attribute such as #![allow(..)]","description":"Found by the adversarial review of PR #195 (improvise-59q8, head 43ba5e6). This is a loud failure, not a silent one, so it is low priority.\n\n`test_only_items` accepts an inner `#![cfg(test)]` only when everything before it in the blanked code is whitespace. A file that heads itself with another inner attribute first is ordinary Rust, but it **panics** with \"an inner test-only cfg could not be bounded\":\n\n```\n//! doc\n#![allow(dead_code)]\n#![cfg(test)]\nfn t() {}\n```\n\nThe fix is to let \"leading\" mean \"preceded only by whitespace and inner attributes\". No file in the workspace has this shape today; all 126 .rs files were scanned without a panic.\n","status":"open","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T05:13:29Z","created_by":"agent-review-195","updated_at":"2026-09-23T05:13:29Z","labels":["adversarial-review","pr-195","protocol"],"dependencies":[{"issue_id":"improvise-e958","depends_on_id":"improvise-59q8","type":"discovered-from","created_at":"2026-09-23T05:13:29Z","created_by":"agent-review-195","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-msg2","title":"The .improv writer's format_number is a second derivation of exact f64 text, and it drops the sign of -0.0 on save","description":"Found reviewing PR #194 (improvise-umi4). umi4 made CellValue::edit_text (improvise-core model/cell.rs) the one home of 'text that reads back as this f64', and its tests require Number(-0.0) to survive i then Enter bit for bit. improvise-io persistence/format.rs::format_number answers the same question separately: fract()==0 \u0026\u0026 abs\u003c1e15 writes 'n as i64', so -0.0 is written '0' and loads as +0.0, while edit_text writes '-0'. Its Debug fallback (format!(\"{n:?}\") when Display does not round-trip) is dead code, because f64 Display is always shortest round-trip (checked on 1M random finite f64s plus the extremes 1e300, 5e-324, f64::MAX, subnormals: 0 failures). Harm is tiny (only 1/x and atan2 see the sign), but it is the derivation-done-twice shape design-principles section 5 calls the defect. Suggested: format_number delegates to CellValue::Number(n).edit_text() for the non-integer arm, and the integer arm checks is_sign_negative, if the grammar's number rule accepts '-0'. Check that the grammar number rule accepts every edit_text spelling (no exponent, up to 327 chars) before delegating.","status":"open","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T05:04:29Z","created_by":"agent-review-194","updated_at":"2026-09-23T05:04:29Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-4zei","title":"kb-test case 25's restore-then-loss half passes against a content-hash-only ledger stamp","description":"Found reviewing PR #179 at 9472989.\n\nIn `scripts/kb-test.sh`, case 25 runs the production git-path ledger in a throwaway repo. Its name says \"...and a loss after a restore is refused\". That half does not discriminate for the property improvise-yea8 fixed, a stamp that carries file identity and not only content.\n\nBetween the `rm` and the restore, case 25 runs an `add`, so the ledger is stamped for HEAD minus X **plus the added doctrine**. The file it restores and then damages is HEAD minus X **without** that doctrine. Its bytes therefore differ from the published file, and a content-hash-only stamp refuses it too.\n\nRival injected in a scratch copy: `file_stamp` reduced to `stamp_of` alone (no inode, no mtime). The result was 44 passed, 1 failed, and only case 24 failed; case 25 passed. Case 24 still guards the mechanism on the `KB_FILE` path, so this is a coverage gap on the production path, not a hole in the tool.\n\n## Acceptance Criteria\n- In case 25, drop the intervening add, or restore and damage to bytes identical to the last publish (restore, then `grep -v` X from HEAD, which is byte-identical to the `rm` output), so that a hash-only stamp fails case 25 as well as case 24.\n","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-23T04:28:17Z","created_by":"agent-review-kb","updated_at":"2026-09-23T04:28:17Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-1dzx","title":"The formula panel's empty-state line lives only in the TUI","description":"The TUI draws '(no formulas — press n to add)' in src/ui/formula_panel.rs; the browser draws an empty, visible panel. PR #173 (df2w) deliberately did not copy the string into render.rs, which would give it a second home, and context/code/clients.md now says the page 'does not yet draw' it. That is a promise with no ticket behind it, and this bead is the ticket. Fix: move the string into improvise-protocol, the way help::pages was moved, and have both front ends read it. Cosmetic: the browser's hint bar already says 'n:new'.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-23T04:10:47Z","created_by":"Edward Langley","updated_at":"2026-09-23T04:10:47Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-e958","title":"source_scan panics on #![cfg(test)] preceded by another inner attribute such as #![allow(..)]","description":"Found by the adversarial review of PR #195 (improvise-59q8, head 43ba5e6). This is a loud failure, not a silent one, so it is low priority.\n\n`test_only_items` accepts an inner `#![cfg(test)]` only when everything before it in the blanked code is whitespace. A file that heads itself with another inner attribute first is ordinary Rust, but it **panics** with \"an inner test-only cfg could not be bounded\":\n\n```\n//! doc\n#![allow(dead_code)]\n#![cfg(test)]\nfn t() {}\n```\n\nThe fix is to let \"leading\" mean \"preceded only by whitespace and inner attributes\". No file in the workspace has this shape today; all 126 .rs files were scanned without a panic.\n","status":"open","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T05:13:29Z","created_by":"agent-review-195","updated_at":"2026-09-23T05:13:29Z","labels":["adversarial-review","pr-195","protocol"],"dependencies":[{"issue_id":"improvise-e958","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T01:05:45Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-e958","depends_on_id":"improvise-59q8","type":"discovered-from","created_at":"2026-09-23T05:13:29Z","created_by":"agent-review-195","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-msg2","title":"The .improv writer's format_number is a second derivation of exact f64 text, and it drops the sign of -0.0 on save","description":"Found reviewing PR #194 (improvise-umi4). umi4 made CellValue::edit_text (improvise-core model/cell.rs) the one home of 'text that reads back as this f64', and its tests require Number(-0.0) to survive i then Enter bit for bit. improvise-io persistence/format.rs::format_number answers the same question separately: fract()==0 \u0026\u0026 abs\u003c1e15 writes 'n as i64', so -0.0 is written '0' and loads as +0.0, while edit_text writes '-0'. Its Debug fallback (format!(\"{n:?}\") when Display does not round-trip) is dead code, because f64 Display is always shortest round-trip (checked on 1M random finite f64s plus the extremes 1e300, 5e-324, f64::MAX, subnormals: 0 failures). Harm is tiny (only 1/x and atan2 see the sign), but it is the derivation-done-twice shape design-principles section 5 calls the defect. Suggested: format_number delegates to CellValue::Number(n).edit_text() for the non-integer arm, and the integer arm checks is_sign_negative, if the grammar's number rule accepts '-0'. Check that the grammar number rule accepts every edit_text spelling (no exponent, up to 327 chars) before delegating.","status":"open","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T05:04:29Z","created_by":"agent-review-194","updated_at":"2026-09-23T05:04:29Z","dependencies":[{"issue_id":"improvise-msg2","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T01:05:29Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-4zei","title":"kb-test case 25's restore-then-loss half passes against a content-hash-only ledger stamp","description":"Found reviewing PR #179 at 9472989.\n\nIn `scripts/kb-test.sh`, case 25 runs the production git-path ledger in a throwaway repo. Its name says \"...and a loss after a restore is refused\". That half does not discriminate for the property improvise-yea8 fixed, a stamp that carries file identity and not only content.\n\nBetween the `rm` and the restore, case 25 runs an `add`, so the ledger is stamped for HEAD minus X **plus the added doctrine**. The file it restores and then damages is HEAD minus X **without** that doctrine. Its bytes therefore differ from the published file, and a content-hash-only stamp refuses it too.\n\nRival injected in a scratch copy: `file_stamp` reduced to `stamp_of` alone (no inode, no mtime). The result was 44 passed, 1 failed, and only case 24 failed; case 25 passed. Case 24 still guards the mechanism on the `KB_FILE` path, so this is a coverage gap on the production path, not a hole in the tool.\n\n## Acceptance Criteria\n- In case 25, drop the intervening add, or restore and damage to bytes identical to the last publish (restore, then `grep -v` X from HEAD, which is byte-identical to the `rm` output), so that a hash-only stamp fails case 25 as well as case 24.\n","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-23T04:28:17Z","created_by":"agent-review-kb","updated_at":"2026-09-23T04:28:17Z","dependencies":[{"issue_id":"improvise-4zei","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T01:05:43Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-1dzx","title":"The formula panel's empty-state line lives only in the TUI","description":"The TUI draws '(no formulas — press n to add)' in src/ui/formula_panel.rs; the browser draws an empty, visible panel. PR #173 (df2w) deliberately did not copy the string into render.rs, which would give it a second home, and context/code/clients.md now says the page 'does not yet draw' it. That is a promise with no ticket behind it, and this bead is the ticket. Fix: move the string into improvise-protocol, the way help::pages was moved, and have both front ends read it. Cosmetic: the browser's hint bar already says 'n:new'.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-23T04:10:47Z","created_by":"Edward Langley","updated_at":"2026-09-23T04:10:47Z","dependencies":[{"issue_id":"improvise-1dzx","depends_on_id":"improvise-gtn7","type":"parent-child","created_at":"2026-09-25T01:05:34Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-jhdf","title":"only_the_edit_key_is_captioned_as_editing_the_cell matches one exact phrase, so a paraphrased edit caption on Enter passes","description":"Found reviewing PR #168 (improvise-s8h7) at 334e218. The test filters help rows on d.contains(\"Edit current cell\") and checks only those rows are keyed by edit-or-drill's keys. Injected: Normal's enter-advance caption changed to \"Edit the cell (same as i)\" — the exact lie #168 removed, reworded. Both help tests pass (2 passed). The PR's own rival (restoring \"Edit current cell (same as i)\") does fail, so the guard holds only against the literal phrase. A tighter form: assert the enter-advance row's caption names movement (or does not contain \"Edit\"/\"edit\"), i.e. pin Enter's row by its keys the way the_edit_key_row_says_it_drills_into_an_aggregate pins i's. Low priority: no caption test can stop every false sentence, but this one's name promises more than it checks.","status":"closed","priority":4,"issue_type":"bug","assignee":"agent-helpkeys2","owner":"el-github@elangley.org","created_at":"2026-09-23T04:03:30Z","created_by":"agent-review-s8h7","updated_at":"2026-09-23T05:44:50Z","closed_at":"2026-09-23T05:44:50Z","close_reason":"Fixed in PR #180, merged after adversarial review (content reviewed at 544744c; the tip-removal delta and the evidence-only merges were reviewed at f248827). Follow-ups: improvise-1c5m, improvise-17ak.","labels":["adversarial-review","help","pr-168","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-pikk","title":"format_f64 renders a large finite number as inf","description":"Found by the core bug hunt, DEMONSTRATED.\n\nformat.rs:29 round_half_away multiplies by 10^decimals before truncating, so n * factor overflows to infinity for a large finite n and any nonzero precision.\n\n format_f64(1e307, false, 2) == \"inf\"\n\nThe model holds the number correctly; the display invents an infinity. Gotcha 8 says format_f64 is the whole display path and that display rounding never reaches eval — which is why this is a wrong pixel and not a wrong number, and also why nothing else catches it.\n\nLow reachability: the shipped default format is \",.0\", whose factor is 1, so it needs both an absurd magnitude and an explicit .N format. format_f64(1.5e300, true, 9) is fine — the threshold is where n * 10^decimals exceeds f64::MAX.\n\n## Acceptance Criteria\n- round_half_away returns n unchanged when n * factor is not finite.\n- A test at a magnitude that overflows the factor, and one just below it, so the guard is not satisfied by clamping everything.\n","status":"closed","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:23:06Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:58:30Z","closed_at":"2026-09-21T21:58:30Z","close_reason":"Fixed in the 2026-09-21 wave, integrated into main at 1574 passed / 0 failed / 7 ignored, all gates green, every guard fault-injected.\n\nround_half_away guards on (n * factor).is_finite(). Test pins the threshold (1e307*100 overflows, 1e306*100 does not) AND carries an anti-clamp assertion that half-away rounding still happens (2.5, 0 -\u003e \"3\", where a passthrough gives Rust's half-to-even \"2\") -- because 'return n unchanged' satisfies the overflow case and every other case too.\n\ngotcha 8 is why nothing else caught it: display rounding never reaches eval, so this is a wrong pixel with no wrong number behind it to trip a formula test.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-f86q","title":"Re-adding an existing category at the 12-category limit is refused with a false message","description":"Found by the core bug hunt, DEMONSTRATED.\n\nModel::add_category (model/types.rs:272) runs the `regular_count \u003e= MAX_CATEGORIES` check BEFORE the contains_key idempotence check. So with 12 regular categories, add_category(\"C0\") returns Err(\"Maximum of 12 categories reached\") although C0 already exists and nothing would be created.\n\n with C0..C11 added: wb.add_category(\"C0\") == Err(\"Maximum of 12 categories reached\")\n\nTwo things wrong. The message is false — the category exists. And per gotcha 21, effect::AddCategory sets abort_effects on refusal, so typing an EXISTING category name into the records-mode `+cat` column at 12 categories aborts the rest of the batch: the cursor does not advance and the editor does not reopen, which is the improvise-2zq failure mode arriving by a different route.\n\nNarrow — it needs 12 regular categories — but silent and misleading when reached.\n\n## Acceptance Criteria\n- One line, a move: hoist the contains_key early return above the limit check.\n- A test at exactly MAX_CATEGORIES re-adding an existing name, asserting Ok and asserting the batch is not aborted.\n","status":"closed","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:23:05Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:58:31Z","closed_at":"2026-09-21T21:58:31Z","close_reason":"Fixed in the 2026-09-21 wave, integrated into main at 1574 passed / 0 failed / 7 ignored, all gates green, every guard fault-injected.\n\nOne-line hoist of the contains_key early return above the MAX_CATEGORIES check. Test asserts the returned id, that nothing was created, AND that a genuinely-new name is still refused, so it cannot pass by deleting the limit.","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -788,10 +919,10 @@ {"_type":"issue","id":"improvise-r08u","title":"IS and WHERE differ on trailing word boundary in formula.pest","description":"improvise-4zl7 gave `IS` a trailing word-boundary lookahead: `is_expr` is compound-atomic so `Region ISLAND` no longer parses as `Is(Region, LAND)`. `^\"WHERE\"` in `where_clause` and `inline_where` still has the identical hole — `SUM(Rev WHEREAS x = y)` would read as a WHERE on a category `AS`. It is far less exposed than IS was (WHERE sits in one fixed position after an expression, where IS follows any identifier in expression position), which is why it was left alone rather than fixed in the same pass: it is pre-existing and changing it is a grammar change with its own round-trip risk.\n\n## Steps to Reproduce\n\n1. `parse_formula(\"X = SUM(Rev WHEREAS x = y)\", \"Cat\")`\n2. It parses, reading `AS` as the filter category, where it should be a syntax error.","acceptance_criteria":"Either `^\"WHERE\"` gets the same `!bare_char` lookahead `IS` has and a test asserting `WHEREAS` does not parse as a WHERE clause, or the asymmetry is declined in writing in context/code/formula-and-io.md with the reason. The grammar-walking generator proptests must still pass either way.","status":"closed","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T19:16:46Z","created_by":"wave3-4zl7","updated_at":"2026-09-21T04:42:55Z","closed_at":"2026-09-21T04:42:55Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-1l9o","title":"furnished_client hand-builds a basis instead of taking the server's","description":"A nit from the adversarial review of PR #50, explicitly not worth a push at\nthe time.\n\nlockstep.rs's furnished_client builds its BasisCache by walking the client's\nown grid for Record cells and calling basis::published(0, 0, records) with\nevery version hard-coded to 0. That is a small re-implementation of\nproject.rs::basis_update's record selection, in a test whose entire purpose is\nto rule out re-implementations.\n\nIt is harmless today -- a fresh App really is at generation 0, layout 0, and\nevery record at version 0 -- which is exactly why it will stop being harmless\nsilently if any of those three stops being true.\n\nThe reviewer demonstrated the alternative while checking something else: seed\nthe ClientState from a real Sessions::connect snapshot and let the server's own\nProjection::Basis populate the cache. That removes the fabrication and makes\nthe fixture a client the server actually produced.\n\nNote the same fabrication exists in client_ready_to_edit, which predates this\nPR, so a fix should take both.","acceptance_criteria":"The lockstep fixtures get their basis from a real Sessions::connect\nsnapshot rather than constructing one. Both furnished_client and\nclient_ready_to_edit, since they share the fabrication. The tests that use\nthem must still pass unchanged -- if seeding from the server changes an\noutcome, that is a finding about the fixture and worth stopping for.","status":"closed","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:58:19Z","created_by":"wave2-h-frontend","updated_at":"2026-09-21T12:39:37Z","closed_at":"2026-09-21T12:39:37Z","labels":["area:shared-frontend"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-thpe","title":"help::compact discriminates a chord by parsing a string it formatted","description":"Finding 3 of the adversarial review of PR #50. Not live-broken and\ndeliberately not fixed in that PR.\n\nimprovise_protocol::help::compact drops chorded keys from a status-bar hint by\ntesting whether the formatted label starts with \"Ctrl-\" or \"Alt-\". The\nreviewer enumerated every format_key_label output and none begins with either,\nso there is no false positive today.\n\nThe defect is the shape, not the behaviour. keymap.rs::labels_for HAS the\nstructured fact -- it matches KeyPattern::Key(code, mods) and then throws mods\naway into a string -- and compact then reconstructs a worse version of it by\nparsing. A future modifier spelled any other way (Super-, Cmd-, a bare glyph)\nis silently not a chord, lands in the status bar, and nothing fails.\n\nThe fix is to carry the fact rather than re-derive it: either Hint keeps the\nKeyModifiers beside each label, or labels_for returns (KeyCode, KeyModifiers)\npairs and help.rs formats at the end. The second is tidier and is what\nwhich_key already does with key_label(code, *mods).\n\n## Steps to Reproduce\n1. Add a modifier to keymap.rs::key_label spelled with any prefix other than\n \"Ctrl-\" or \"Alt-\" -- \"Super-x\", say.\n2. Bind a command advertised by a mode's hint to that chord only.\n3. hint_line puts it in the status bar. Nothing fails.","acceptance_criteria":"compact stops parsing a string to learn something the keymap already\nknew: either Hint carries the KeyModifiers beside each label, or labels_for\nreturns (KeyCode, KeyModifiers) and help.rs formats last. Proved by adding a\nmodifier spelled differently and watching a test fail -- the point is that\ntoday it would not.","status":"closed","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:45:36Z","created_by":"wave2-h-frontend","updated_at":"2026-09-21T17:09:50Z","closed_at":"2026-09-21T17:09:50Z","labels":["area:shared-frontend"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-h074","title":"Two edges of App::resolve_view: a worse panic message, and a host-side DeleteView bypass","description":"Found by the adversarial review of PR #48 (improvise-jytm). Two small things, both\nabout what happens at the edges of the fallback improvise-jytm introduced.\n\n**The panic message got worse.** `App::resolve_view` falls back to\n`workbook.active_view` for a name the document has not got, then indexes\n`workbook.views[default]`. `Workbook::active_view()` — which several of these call\nsites used before — carries `.expect(\"active_view always names an existing view\")`.\n`IndexMap`'s `Index` impl panics with no such message. So a `Workbook` whose\n`active_view` names nothing (which only a hand-built or deserialized-from-elsewhere\nworkbook can be, since `create_view` and `delete_view` both maintain it) now fails\nwith a bare index panic where it used to name the invariant that was broken. Cheap\nfix: keep the `.expect` string.\n\n**A host-initiated `DeleteView` bypasses `adopt_deleted_views`.** That function runs\nin `Sessions::apply_model`, which is the door a *client's* command comes through. A\nhost driving `App::apply_effects` directly — through `Host::app_mut()` — deletes a\nview without any session mirror being moved off it.\n\n**Read the unreachability argument carefully, because it is weaker than it looks.**\nA first version of this ticket said `app_mut()` had one caller, in\n`improvise-ws-server/src/main.rs`, at startup, before any client had connected. That\nwas wrong, and review caught it. The one caller is\n`crates/improvise-worker-server/src/worker.rs:64`, inside `persist()` — which runs\n**after every accepted model command, with a client connected**. The bypass is still\nunreachable, but only because `persist()` happens to pass `effect::Save`, which\ndeletes no view. \"The one existing caller passes a harmless effect\" is a much thinner\nguarantee than \"nobody is connected yet\", and it is the exact shape of premise this\nrepo has been burned by: gotcha 47 records that the lost update `CheckedBatch` later\nfixed had shipped under an unreachability argument too.\n\nThe second is the interesting one to decide rather than to patch: it is the general\nshape of \"session bookkeeping that only the session door performs\". `Host::project`\nis already where every outbound path funnels, so whether the adoption belongs there\ninstead is the design question.\n\n## Steps to Reproduce\n\nThe panic message:\n\n1. Build a `Workbook` whose `active_view` names a view not in `views` (only reachable\n by constructing the struct directly — the constructors maintain the invariant).\n2. Call `App::view()`. It panics from `IndexMap`'s `Index` impl with no message,\n where `Workbook::active_view()` would have said which invariant broke.\n\nThe bypass:\n\n1. A client connects and switches to \"Second\".\n2. The host calls `host.app_mut().apply_effects(vec![Box::new(DeleteView(\"Second\"))])`\n — the same door `improvise-worker-server`'s `persist()` uses for `effect::Save`\n after every accepted model command.\n3. The session's mirror still names \"Second\". Nothing moved it and nothing told the\n client, because `Sessions::adopt_deleted_views` runs only in `apply_model`.\n","acceptance_criteria":"App::resolve_view's fallback index carries the invariant message Workbook::active_view() had. And a decision is recorded on whether session adoption belongs at the Sessions door or at Host::project — with a test for a host-initiated DeleteView if the answer moves it.","notes":"PR #76 (branch wave/command-effect-gaps) does HALF of this: App::resolve_view's fallback carries back the .expect(\"active_view always names an existing view\") message, guarded by a_default_that_names_nothing_says_which_invariant_broke in ui/app.rs. The SECOND half is untouched — whether a host-initiated DeleteView's session adoption belongs at the Sessions door or at Host::project is a decision about ui/project.rs and ui/host.rs, which were another agent's ground in this wave. Reported rather than guessed.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:33:43Z","created_by":"wave2-e-active-view","updated_at":"2026-09-21T21:27:25Z","labels":["area:active-view"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-h074","title":"Two edges of App::resolve_view: a worse panic message, and a host-side DeleteView bypass","description":"Found by the adversarial review of PR #48 (improvise-jytm). Two small things, both\nabout what happens at the edges of the fallback improvise-jytm introduced.\n\n**The panic message got worse.** `App::resolve_view` falls back to\n`workbook.active_view` for a name the document has not got, then indexes\n`workbook.views[default]`. `Workbook::active_view()` — which several of these call\nsites used before — carries `.expect(\"active_view always names an existing view\")`.\n`IndexMap`'s `Index` impl panics with no such message. So a `Workbook` whose\n`active_view` names nothing (which only a hand-built or deserialized-from-elsewhere\nworkbook can be, since `create_view` and `delete_view` both maintain it) now fails\nwith a bare index panic where it used to name the invariant that was broken. Cheap\nfix: keep the `.expect` string.\n\n**A host-initiated `DeleteView` bypasses `adopt_deleted_views`.** That function runs\nin `Sessions::apply_model`, which is the door a *client's* command comes through. A\nhost driving `App::apply_effects` directly — through `Host::app_mut()` — deletes a\nview without any session mirror being moved off it.\n\n**Read the unreachability argument carefully, because it is weaker than it looks.**\nA first version of this ticket said `app_mut()` had one caller, in\n`improvise-ws-server/src/main.rs`, at startup, before any client had connected. That\nwas wrong, and review caught it. The one caller is\n`crates/improvise-worker-server/src/worker.rs:64`, inside `persist()` — which runs\n**after every accepted model command, with a client connected**. The bypass is still\nunreachable, but only because `persist()` happens to pass `effect::Save`, which\ndeletes no view. \"The one existing caller passes a harmless effect\" is a much thinner\nguarantee than \"nobody is connected yet\", and it is the exact shape of premise this\nrepo has been burned by: gotcha 47 records that the lost update `CheckedBatch` later\nfixed had shipped under an unreachability argument too.\n\nThe second is the interesting one to decide rather than to patch: it is the general\nshape of \"session bookkeeping that only the session door performs\". `Host::project`\nis already where every outbound path funnels, so whether the adoption belongs there\ninstead is the design question.\n\n## Steps to Reproduce\n\nThe panic message:\n\n1. Build a `Workbook` whose `active_view` names a view not in `views` (only reachable\n by constructing the struct directly — the constructors maintain the invariant).\n2. Call `App::view()`. It panics from `IndexMap`'s `Index` impl with no message,\n where `Workbook::active_view()` would have said which invariant broke.\n\nThe bypass:\n\n1. A client connects and switches to \"Second\".\n2. The host calls `host.app_mut().apply_effects(vec![Box::new(DeleteView(\"Second\"))])`\n — the same door `improvise-worker-server`'s `persist()` uses for `effect::Save`\n after every accepted model command.\n3. The session's mirror still names \"Second\". Nothing moved it and nothing told the\n client, because `Sessions::adopt_deleted_views` runs only in `apply_model`.\n","acceptance_criteria":"App::resolve_view's fallback index carries the invariant message Workbook::active_view() had. And a decision is recorded on whether session adoption belongs at the Sessions door or at Host::project — with a test for a host-initiated DeleteView if the answer moves it.","notes":"PR #76 (branch wave/command-effect-gaps) does HALF of this: App::resolve_view's fallback carries back the .expect(\"active_view always names an existing view\") message, guarded by a_default_that_names_nothing_says_which_invariant_broke in ui/app.rs. The SECOND half is untouched — whether a host-initiated DeleteView's session adoption belongs at the Sessions door or at Host::project is a decision about ui/project.rs and ui/host.rs, which were another agent's ground in this wave. Reported rather than guessed.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:33:43Z","created_by":"wave2-e-active-view","updated_at":"2026-09-21T21:27:25Z","labels":["area:active-view"],"dependencies":[{"issue_id":"improvise-h074","depends_on_id":"improvise-pmt0","type":"parent-child","created_at":"2026-09-25T01:05:04Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-x4wv","title":"assemble.sh's stamped guard under-reports multiple broken substitutions, and its comment reads as though it does not","description":"Found by injection during PR #47 (the landing-page inversion), by the WU-P agent.\n\nweb/assemble.sh's four 'stamped' checks exist because one grep over four files succeeds when ANY one of them matches, so three failed substitutions could still produce a successful assembly under set -e (found by Copilot on PR #24). The comment says each stamped reference 'is verified on its own and names itself when it is the one missing'.\n\nInjection result: breaking ONE substitution (a wrong sedi pattern for worker.js) produces exactly the claimed behaviour —\n\n assemble: .../app/worker.js is missing the cache-bust stamp: worker-pkg/improvise_worker_server.js?v=inj\n\nwith the app.js guard ahead of it passing and printing its match. Correct and isolated.\n\nBut 'stamped' calls exit 1 on the FIRST failure, so breaking BOTH app.js and worker.js reports app.js alone. The phrase 'when it is the one missing' is accurate for a single failure and misleading for a multiple one: a reader takes it as 'each missing stamp names itself'.\n\nThis FAILS SAFE — the build stops and a real problem is reported — so it is P4, not a correctness bug. It is filed because the prose is what a future agent will act on, and because the original guard was itself the product of a review finding about a check that passed when it should not have.\n\nDeliberately NOT fixed in PR #47: pre-existing, out of that unit, and reshaping stamped would rewrite a guard whose current form came out of the PR #24 finding. The cheap fix is probably the comment, not the code.\n\n## Steps to Reproduce\n\n1. In web/assemble.sh, corrupt the sedi patterns for BOTH app.js and worker.js.\n2. Run web/assemble.sh into a scratch dir.\n3. Only the app.js failure is reported; the worker.js one is never reached.","acceptance_criteria":"Either stamped reports every missing stamp before exiting, or its comment says plainly that it reports the FIRST failure only. Demonstrated by breaking two substitutions and seeing both named, or by the comment matching the single-report behaviour.","status":"closed","priority":4,"issue_type":"bug","assignee":"claude-agent-scripts","owner":"el-github@elangley.org","created_at":"2026-09-20T18:14:57Z","created_by":"orchestrator","updated_at":"2026-09-20T23:35:12Z","started_at":"2026-09-20T23:22:06Z","closed_at":"2026-09-20T23:35:12Z","close_reason":"fixed in PR #69, each proved by injection","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-gcvb","title":"The browser cannot commit a records-mode creating column, though it is now nameable","description":"`keys.rs::commit` returns early for EVERY records-mode commit:\n\n if state.view.mode.is_records() { return None; }\n\nThe comment gives the real reason — \"a category column renames every measure\ncell in the row, which needs `record_col`\" (gotcha 1) — and that reason is\nsound for the coordinate columns and for the measure columns' advance, which\n`CommitAndAdvance` handles with `AddRecordRow` at the bottom-right and a\n`RebuildLayout` when `+cat` inserts a column.\n\nBut the guard is broader than its reason. improvise-poia made the two CREATING\ncolumns nameable: a cell at the cursor now projects `CellId::Create(Measure)`\nor `CellId::Create(Category)`, and committing one is a single existing\n`ModelCommand` over the typed NAME — `AddItem { category: \"_Measure\", item }`\nor `AddCategory(name)`. Neither reads `record_col`, neither touches the record\nunder the cursor, and neither writes a cell. So this is the one records-mode\ncommit a thin client could already compose, and the early return is what stops\nit.\n\nWhat has to be answered before it lands, and why it is not a one-liner:\n\n- The ADVANCE differs. `CommitAndAdvance` returns straight after a `+cat`\n commit with a `RebuildLayout` and no cursor move, because an inserted column\n shifts every index to its right and the advance was computed from the\n pre-commit layout (gotcha 23). A client that advanced anyway would land one\n column off, silently.\n- `AddCategory` can be REFUSED (the 12-category limit), and it sets\n `abort_effects` (gotcha 21). A client composing `[AddCategory, ChangeMode]`\n is composing a batch whose first step may abort — which is exactly the hole\n clause 4 of gotcha 47 leaves open.\n\nBoth are arguments for doing this with a lockstep test against\n`CommitAndAdvance`, not by reading the resolver and hoping.\n","acceptance_criteria":"keys.rs::commit composes AddItem/AddCategory when the cell at the cursor is CellId::Create, instead of returning None for the whole of records mode — and a tests/lockstep.rs case proves the client and CommitAndAdvance land the document AND the cursor in the same place, including the +cat case where an inserted column means the TUI does not advance at all. The coordinate and measure columns stay the host's; the early return narrows rather than disappears.","status":"closed","priority":4,"issue_type":"task","assignee":"claude-wasm-keys-agent","owner":"el-github@elangley.org","created_at":"2026-09-20T18:13:31Z","created_by":"wave2-b-projection","updated_at":"2026-09-21T00:24:03Z","started_at":"2026-09-20T23:21:16Z","closed_at":"2026-09-21T00:24:03Z","close_reason":"Fixed and merged: rmvx/fdt6/gcvb in PR #71, the harness four in PR #72. Both adversarially reviewed, with each finding's injection re-run against the fix. Closed by the orchestrator with --force because the claiming agent has stood down; the claim guard refusing an unforced close is the mechanism working, not a problem.","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-trd3","title":"Help pages clamp at both ends; decide whether they should wrap","description":"Split out of improvise-e24v, where the divergence was found and closed the\nconservative way.\n\neffect::HelpPageNext clamps at HELP_PAGE_COUNT - 1 and HelpPagePrev saturates\nat 0. reduce_view's Command::HelpPage arm wrapped modulo HELP_PAGE_COUNT. Two\nderivations of one fact, disagreeing, and invisible because no client had ever\nsent Command::HelpPage: the overlay's h/l were upstream keys until e24v made\nthem a client's.\n\ne24v changed the WIRE to match the EFFECT, because the effect is what ships in\nthe TUI and changing that is a product decision nobody asked for. But wrapping\nmay well be the nicer behaviour on a five-page overlay, and if it is wanted it\nhas to change in the effect *and* the reducer together.\n\n## Steps to Reproduce\n1. Open the TUI and press ? for help.\n2. Press l five times.\n3. The overlay stops on page 5 of 5. Before e24v, a browser client folding the\n same binding would have been back on page 1.","acceptance_criteria":"A ruling: either both ends clamp (today's behaviour, in which case close\nthis and the existing tests are the record), or both wrap. If wrapping wins,\neffect::HelpPageNext/HelpPagePrev and reduce_view's HelpPage arm change in one\ncommit and walking_off_the_end_of_the_help_pages_agrees_with_the_host is\nupdated with them — the point of the ticket is that these two cannot be\nallowed to disagree again.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:04:16Z","created_by":"wave2-h-frontend","updated_at":"2026-09-20T18:04:16Z","labels":["area:shared-frontend"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-trd3","title":"Help pages clamp at both ends; decide whether they should wrap","description":"Split out of improvise-e24v, where the divergence was found and closed the\nconservative way.\n\neffect::HelpPageNext clamps at HELP_PAGE_COUNT - 1 and HelpPagePrev saturates\nat 0. reduce_view's Command::HelpPage arm wrapped modulo HELP_PAGE_COUNT. Two\nderivations of one fact, disagreeing, and invisible because no client had ever\nsent Command::HelpPage: the overlay's h/l were upstream keys until e24v made\nthem a client's.\n\ne24v changed the WIRE to match the EFFECT, because the effect is what ships in\nthe TUI and changing that is a product decision nobody asked for. But wrapping\nmay well be the nicer behaviour on a five-page overlay, and if it is wanted it\nhas to change in the effect *and* the reducer together.\n\n## Steps to Reproduce\n1. Open the TUI and press ? for help.\n2. Press l five times.\n3. The overlay stops on page 5 of 5. Before e24v, a browser client folding the\n same binding would have been back on page 1.","acceptance_criteria":"A ruling: either both ends clamp (today's behaviour, in which case close\nthis and the existing tests are the record), or both wrap. If wrapping wins,\neffect::HelpPageNext/HelpPagePrev and reduce_view's HelpPage arm change in one\ncommit and walking_off_the_end_of_the_help_pages_agrees_with_the_host is\nupdated with them — the point of the ticket is that these two cannot be\nallowed to disagree again.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:04:16Z","created_by":"wave2-h-frontend","updated_at":"2026-09-20T18:04:16Z","labels":["area:shared-frontend"],"dependencies":[{"issue_id":"improvise-trd3","depends_on_id":"improvise-gtn7","type":"parent-child","created_at":"2026-09-25T01:05:34Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-bgtf","title":"The landing page is served by the harness and asserted by nothing","description":"`docs/index.html` (improvise-e61) is copied into the deployed site at\n`/landing/` by `web/assemble.sh`, so the browser harness already serves it —\n`harness/serve` is the same assembled tree — and nothing asserts anything about\nit. That is the shape gotcha 48 names: argued and read, never executed.\n\nWHAT A CHECK MUST DO, and the reason this bead exists rather than a one-liner:\ncounting the four `.ap-player` elements PROVES NOTHING. `autoPlay` is false, so\nasciinema-player does not fetch a cast until the viewer presses play. A page\nwhose every cast path 404s constructs four players, throws no page error before\nthe click, and looks identical to a working one. This was verified by injecting\nexactly that fault (`./casts/` -\u003e `./cats/`) while building the page: the\nconstruction-only check passed on the broken page; pressing play and counting\n`.ap-line` returned 0 and surfaced four 404s.\n\nSo the spec must press play on each of the four and assert rendered lines, and\nits own fault-injection must be recorded in `context/test-evidence/`.\n\nSCOPE: one spec beside `harness/tests/shell.spec.js`, Chromium only (nothing\nhere is engine-dependent), navigating to `/landing/index.html`.\n\n## Steps to Reproduce\n\n1. `./web/build.sh \u0026\u0026 ./web/assemble.sh site dev`\n2. `python3 -m http.server 8099 --directory site`\n3. Open `http://127.0.0.1:8099/landing/index.html` — it renders, and no test\n in the repository would have noticed if it had not.\n4. Break a cast path in `docs/index.html`, repeat, and observe that the page\n still looks structurally correct until a play button is pressed.","acceptance_criteria":"A Playwright spec beside harness/tests/shell.spec.js loads /landing/index.html, presses play on each of the four casts, and asserts each renders a non-zero number of terminal lines with no failed request and no page error. The spec is proved able to fail by breaking one cast path and watching it fail, and that injection is recorded in context/test-evidence/.","notes":"PR #72. harness/tests/landing.spec.js presses play on all four casts and asserts rendered lines plus four distinct 200s; ./casts/ -\u003e ./cats/ fails it. Note the bead's path is stale: the landing page is at the site ROOT (/index.html) since improvise-kh8, not /landing/.","status":"closed","priority":4,"issue_type":"task","assignee":"wave4-harness","owner":"el-github@elangley.org","created_at":"2026-09-20T18:01:16Z","created_by":"wave2-p-milestones","updated_at":"2026-09-21T00:24:05Z","started_at":"2026-09-20T23:21:57Z","closed_at":"2026-09-21T00:24:05Z","close_reason":"Fixed and merged: rmvx/fdt6/gcvb in PR #71, the harness four in PR #72. Both adversarially reviewed, with each finding's injection re-run against the fix. Closed by the orchestrator with --force because the claiming agent has stood down; the claim guard refusing an unforced close is the mechanism working, not a problem.","labels":["area:release"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-5jm5","title":"Model::evaluate_aggregated takes a list it only tests for emptiness","description":"Found by CC-core while capping comments (2026-09-20). Not filed as a bug because nothing misbehaves today; filed because the signature promises something the body does not do.\n\n`Model::evaluate_aggregated` takes `none_cats: \u0026[String]` and only ever tests `none_cats.is_empty()`. The aggregation itself is over `key.0` via `matching_values`. So the parameter is a BOOLEAN WEARING A LIST'S TYPE.\n\n## Steps to Reproduce\n\n1. Read `Model::evaluate_aggregated`'s signature: it takes a slice of category names.\n2. Read the body: the slice is consulted once, for emptiness. No element is ever examined.\n3. Pass a list with different contents but the same emptiness — the result is identical.\n\n## Why it is worth an hour\n\nThe name promises the function filters by that list. It does not. A caller reasoning about which categories are on `Axis::None` will believe this function honours them, and the next person to add a case will thread a carefully-built list into a parameter that cannot use it. That is the same shape as `prune_axis_items` taking a flag vector before it took a predicate: a parameter whose type admits states the body cannot distinguish.\n\n## The fix is a decision, not a rename\n\nEither the emptiness test is the real dispatch — in which case take a `bool` (or better, an enum saying what the two states MEAN) and let the caller compute it — or the function was meant to filter and does not, which is a behaviour question and needs a test first. Establish which before touching it; `git log -S` on the parameter will say whether it ever filtered.\n","acceptance_criteria":"evaluate_aggregated's parameter admits only the states the body can distinguish — a bool or a named enum if the emptiness test is the real dispatch, or a filter that actually filters with a test proving it. Establish which it was MEANT to be first: git log -S on the parameter says whether it ever examined an element.","status":"closed","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T16:53:36Z","created_by":"Edward Langley","updated_at":"2026-09-21T04:42:55Z","closed_at":"2026-09-21T04:42:55Z","close_reason":"Closed","labels":["area:core-io"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-bidq","title":"Two answers to 'is this category virtual', four lines apart in view/types.rs","description":"Found by the post-wave code consistency review. PRE-EXISTING, carried across unchanged by PR #27's 8173b87.\n\ncrates/improvise-core/src/view/types.rs:112 and :118 test whether a category is virtual with `cat_name.starts_with('_')`. Four lines away, `is_records_layout()` (:67-74) uses `CategoryKind::of_name(..)`, and `CategoryKind` is imported at :6.\n\nTwo answers to \"is this category virtual\", in one file, four lines apart.\n\n## Why it is reachable rather than theoretical\n\n`Model::add_category` does not validate names. So a user category literally named `_Foo` is:\n * `CategoryKind::Regular` to records_columns, cell_id and is_empty_model;\n * \"virtual\" to these two lines — which would seat it on Axis::None and it would never appear in a pivot.\n\ndesign-principles §2 is explicit that business rules go through the enum rather than a name prefix, and names this exact class: \"enforced by matching on the enum, not by checking name prefixes\".\n\n## Note\n\nThe cheap fix is to use CategoryKind at both sites. The better question, worth one line of thought first: should `add_category` refuse a leading underscore outright? That would make the divergence unrepresentable rather than merely consistent — but it is a user-visible restriction and belongs in the commit message either way.\n\n## Steps to Reproduce\n\n1. `Model::add_category(\"_Foo\")` — the name is not validated.\n2. `CategoryKind::of_name(\"_Foo\")` makes it `Regular`, so `records_columns`, `cell_id` and `is_empty_model` treat it as an ordinary user category.\n3. `view/types.rs:112` and `:118` test `cat_name.starts_with('_')`, so *they* treat it as virtual — and seat it on `Axis::None`.\n4. The category therefore never appears in a pivot, while every other consumer believes it is a normal one.\n\nFour lines away, `is_records_layout()` (`:67-74`) asks `CategoryKind`, and `CategoryKind` is imported at `:6`.\n","acceptance_criteria":"Both sites use CategoryKind, so there is one answer. Decide explicitly whether Model::add_category should refuse a leading underscore — if yes, the divergence becomes unrepresentable and the restriction is stated in the commit; if no, say why a user category named _Foo is allowed and now behaves consistently.","notes":"SHARPENED by CC-core (comment wave, 2026-09-20), and the trap is subtler than this ticket stated.\n\nIt is not two sites four lines apart. View::on_category_added's early return is:\n\n cat_name.starts_with('_') || self.is_records_layout()\n\nso THE SAME EXPRESSION mixes both derivations, ONE OPERATOR APART -- the left operand tests a name prefix and the right operand asks CategoryKind. A reader checking this line sees the enum being used and has no reason to look at the operand beside it.\n\nConfirmed still present at the comment wave's base commit; deliberately untouched (a behaviour change is out of that wave's scope). The comment residue there no longer asserts either spelling, and context/code/core.md section 7 records it as an open defect.","status":"closed","priority":4,"issue_type":"bug","assignee":"agent-render-frame","owner":"el-github@elangley.org","created_at":"2026-09-20T07:50:12Z","created_by":"Edward Langley","updated_at":"2026-09-21T03:46:31Z","started_at":"2026-09-21T03:39:56Z","closed_at":"2026-09-21T03:46:31Z","close_reason":"Fixed on PR #77 (fix/render-frame-wave, head 8f3a976). Each guard proved by injecting the fault it names; GROUPED_FRAME and harness/tests/grouped.spec.js re-pinned deliberately.","labels":["area:core-io"],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -803,8 +934,8 @@ {"_type":"issue","id":"improvise-g6s1","title":"WizardStep::Preview has no characterization fixture","description":"The one import-wizard step improvise-764 could not pin. It needs a document that parses to ZERO RECORDS without needing path selection, and neither existing fixture reaches it.\n\nSmall, and worth doing while the WizardCache shape is fresh: every other step is pinned at buffer level, so Preview is the single arm where a change to wizard_cache could alter what is drawn with nothing failing.","status":"closed","priority":4,"issue_type":"task","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T00:58:36Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:28:59Z","started_at":"2026-09-20T05:32:39Z","closed_at":"2026-09-20T06:28:59Z","close_reason":"Closed by PR #22 (WU-L). WizardStep::Preview pinned byte for byte.\n\nPreview is only ever an ENTRY step -- no path selection needed, zero records parsed -- so nothing advances into it and neither existing fixture starts there. An empty array is the smallest document that reaches it. The step is asserted as a PRECONDITION so the fixture cannot quietly stop reaching Preview and keep passing, which is the failure mode a characterization test of an unreachable step would otherwise have.\n\nProved discriminating by perturbing project::wizard_cache (uppercasing preview_summary) and watching it fail.\n\nIt also records a wart AS-IS rather than quietly fixing it: with no records to take keys from, the summary ends \"Sample keys:\" with nothing after the colon. Pinning the wart is the point of a characterization test; fixing it silently inside one is how the evidence for a later change gets destroyed.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-k69y","title":"[bug] draw_status reads the document's active view, not the client's","description":"src/draw.rs::draw_status uses app.model_state.workbook.active_view; ViewState::active_view is the CLIENT copy (gotcha 29).\n\nHARMLESS TODAY -- they are in lockstep and the_two_active_view_names_never_diverge guards it. WRONG THE DAY improvise-jytm LANDS, which is exactly the ticket that makes them diverge on purpose (the document keeps the DEFAULT, the client is authoritative). On that day the TUI badge names the wrong view.\n\nOne line. Left unfixed because it is a behaviour change and belongs with jytm rather than ahead of it. Note the wasm client already reads the correct one (self.view.active_view), so the TUI is the outlier.\n\n## Steps to Reproduce\n\nNot reproducible today, and that is the point of the ticket. `src/draw.rs::draw_status` reads `app.model_state.workbook.active_view` where `ViewState::active_view` is the client's copy (gotcha 29). The two are held in lockstep and `the_two_active_view_names_never_diverge` guards it, so the TUI badge is correct **by accident**.\n\nIt becomes reproducible the day improvise-jytm lands, which is the ticket that makes them diverge on purpose — the document keeps the default, the client is authoritative. On that day: two clients, one switches view, and the TUI badge names the wrong one.\n\nThe wasm client already reads the correct one (`self.view.active_view`), so the TUI is the outlier.\n","acceptance_criteria":"src/draw.rs::draw_status reads ViewState::active_view, not workbook.active_view. Lands WITH improvise-jytm and not before — it is a behaviour change only once the two copies are allowed to diverge, and until then it is a no-op that would look like an unmotivated edit. The wasm client already reads the correct one, so the TUI is the outlier.","status":"closed","priority":4,"issue_type":"bug","assignee":"wave2-e-active-view","owner":"el-github@elangley.org","created_at":"2026-09-20T00:58:35Z","created_by":"Edward Langley","updated_at":"2026-09-21T04:43:47Z","started_at":"2026-09-20T17:53:50Z","closed_at":"2026-09-21T04:43:47Z","labels":["area:active-view"],"dependencies":[{"issue_id":"improvise-k69y","depends_on_id":"improvise-jytm","type":"blocks","created_at":"2026-09-20T16:20:25Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-poia","title":"The records-mode + column has no projected identity","description":"From the ADR 002 spine landing (2026-09-19). In records mode the trailing + (NewMeasure) column creates a new measure on the row (gotcha 1). It carries no CellId, so creating a measure over the wire has no address.\n\nThat is correct for now rather than an oversight -- a new measure is not a write to an existing cell, so it is not a SetCell and should not borrow one identity. But it means a browser client cannot do something the TUI can.\n\nDecide what it should be: a distinct command carrying the record surrogate plus the new measure name (derived from the effect that does it today, per doctrine(derive_the_enum_from_the_effects)), or a client-side flow that adds the measure and then writes the cell as two commands. The second is probably right -- it composes from operations that already exist -- but check whether the TUI treats it as one atomic action first, because splitting an atomic action across two commands is how a half-applied state appears.","acceptance_criteria":"Creating a measure from the records-mode + column has an address on the wire. Prefer the two-command client-side flow (add the measure, then write the cell) IF the TUI does not treat it as one atomic action — check that first, because splitting an atomic action across two commands is how a half-applied state appears, and clause 4 (improvise-yqpd) does not exist yet to catch it. If the TUI IS atomic, the answer is a distinct command carrying the record surrogate plus the new measure name, derived from the effect that does it today. Note improvise-2zq added a SECOND creating column (+cat) with the same question.","status":"closed","priority":4,"issue_type":"task","assignee":"wave2-b-projection","owner":"el-github@elangley.org","created_at":"2026-09-20T00:18:35Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:14:05Z","started_at":"2026-09-20T18:11:15Z","closed_at":"2026-09-20T18:14:05Z","close_reason":"Closed","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-mjy7","title":"Export a model as SQLite for interop (one-way, native-only)","description":"DEFERRED 2026-09-19 by user ruling during the whole-backlog campaign, together with improvise-qddo and improvise-rucf, and rewritten to say where it sits relative to ADR 003.\n\nTHE CLAIM IS STILL GOOD AND IS UNAFFECTED BY THE ADR. This is EXPORT for interop -- a user opening their model in sqlite3 or DB Browser to run their own queries. One-way, no round-trip obligation, and therefore none of the grammar-duplication risk that killed improvise-9ix: a second FORMAT must track every future change to improv.pest forever, a second VIEW need not, because nothing loads it back. It sits naturally beside export_csv/export_csv_to in crates/improvise-io/src/persistence/export.rs.\n\nWHY IT IS DEFERRED ANYWAY: it is the one bead in the SQLite cluster with no dependency on ADR 003, but it carries the same cost as the cluster's real work -- a rusqlite dependency and a wasm-gate verification -- for a capability nobody has asked for concretely. The user's ruling was to defer the cluster as a unit. Un-defer on a concrete interop request, or alongside improvise-rucf.2 (the SQLite source), where the dependency is being added anyway and the incremental cost of an exporter is small.\n\nNOTE THE DIRECTION IS OPPOSITE TO ADR 003'S. ADR 003 is about READING someone else's database as a live source. This is about WRITING ours out as a convenience. They share only the rusqlite dependency and the gating constraint; do not let one justify the other.\n\nTHE ONE REAL COST: cargo check --target wasm32-unknown-unknown fails in the libsqlite3-sys C build under this repo's nix cc-wrapper, so this must be NATIVE-ONLY AND FEATURE-GATED with the wasm CI job passing untouched. VERIFY that rather than assume it -- the improvise-9ix agent was careful to note the failure is the nix wrapper injecting -fzero-call-used-regs rather than proof that bundled SQLite cannot target wasm, so the gating is about protecting the browser bundle, not about an impossibility.\n\nSCOPE WHEN IT LANDS: choose a schema that is USEFUL TO QUERY rather than one that mirrors internal storage -- a reader wants cells joined to their coordinates, not an interned key blob. Do not add a reader; that is improvise-rucf's job and it has a different answer.\n","status":"deferred","priority":4,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-20T00:08:37Z","created_by":"Edward Langley","updated_at":"2026-09-20T05:31:48Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-zavn","title":"Drill previews on hover: a bounded record keyset per aggregate","description":"DEFERRED by the user (2026-09-19) -- a good idea, recorded so the shape is not lost and so nobody builds the UNBOUNDED version by accident.\n\nTHE IDEA: show the underlying rows when the user hovers an aggregate cell, without a round trip.\n\nTHE TRAP THIS AVOIDS. Projecting the full keyset for every aggregate is untenable and the cost runs OPPOSITE to the value. A stored cell has fixed coordinates so it lands in exactly one displayed cell -- the keysets PARTITION rather than multiply -- but that still makes the payload scale with N (the data) while everything else in a projection scales with the VIEWPORT. Aggregation is precisely what makes a large document fit a screen, so shipping the keysets undoes that compression. Worked numbers for a 1000-cell viewport at u128: ~16KB at one record per cell (where the cell would be settable anyway and no keyset is needed), ~160KB at ten, and ~16MB over a million-record document -- to render a thousand cells. Per client, re-diffed as data changes. And the cheap cases are the ones where drilling barely matters.\n\nTHE SHAPE THAT WORKS: ship at most k record hashes per aggregate PLUS A COUNT, so the payload is viewport-proportional again, and fetch the full set on demand when the user actually drills. k is a product decision -- enough rows to make a hover useful, e.g. 3 to 5.\n\nPREREQUISITE: ADR 002 (context/adr/002-naming-a-cell.md). An aggregate already carries CellId::Aggregate(AggregateHash), so drill-on-demand works without this ticket; this is purely the latency optimisation for hover. Do not conflate the two -- drilling must stay correct without any preview.\n\nNote the preview hashes are RECORD hashes, so they resolve through the same DataStore index and obey the same staleness rules: a preview row whose record has since gone simply fails to resolve.","notes":"\n--- BLOOM FILTER IDEA (user, 2026-09-19): right tolerance, different question ---\nProposal was a Bloom filter instead of an explicit keyset, on the grounds that\nfalse positives are fine given a deterministic aggregate id to ask\nauthoritatively. The tolerance argument is right; the direction needs\ncorrecting.\n\nA BLOOM FILTER CANNOT ENUMERATE. A hover preview needs ROWS -- identities and\nvalues -- while a filter answers \"is X probably in this set?\" for an X you\nalready hold. The client holds aggregates, not the underlying records, so it has\nno candidates to test. So this does not serve previews directly.\n\nWHERE IT FITS INSTEAD, and it fits well: the REVERSE question, \"which visible\naggregates does record R feed?\" A client staging an edit to R tests R against\neach visible cell filter and marks the hits pending -- greyed, spinner -- with\nno round trip. A false positive marks one extra cell pending; it never misses\none. That asymmetry is exactly gotcha 35 rule: over-reporting costs work,\nunder-reporting is the bug, which is why Dirty::of is a deliberate\nover-approximation. Same shape.\n\nSIZE CORRECTION THAT MAKES IT CHEAP: a conventionally-sized filter still scales\nwith N -- roughly 9.6 bits per element at 1% FP, so ~1.2KB for a cell\naggregating 1000 records, and the payload again scales with the DATA. FIX THE\nWIDTH instead: a fixed 64- or 128-bit filter per cell is viewport-proportional\nby construction. Small aggregates are precise; large ones SATURATE and answer\n\"maybe\" to everything, degrading to mark-everything -- the safe direction, and\nthe same failure the design already tolerates.\n\nThe deterministic aggregate id is what makes the sloppiness safe: the\nauthoritative answer is one Drill(AggregateHash) away, so the filter is never\nload-bearing for correctness. Same structure as ADR 001 advisory reservations,\nwhere correctness never rests on the advisory layer.\n\nSO THIS TICKET NOW HOLDS TWO SEPARABLE IDEAS -- split them when picked up:\n (a) hover previews: needs a BOUNDED EXPLICIT keyset (k hashes + a count),\n because a preview needs rows;\n (b) optimistic edit feedback: a FIXED-WIDTH Bloom filter per aggregate,\n answering which visible cells a pending record edit affects.\n(b) belongs with ADR 001 layer 2 rather than here.\nDEFERRAL RE-AFFIRMED 2026-09-19 during the whole-backlog campaign, status set to deferred so it stops appearing as ready work.\n\nThe reason it is recorded rather than closed is unchanged and is the important part: drilling is already correct without it (CellId::Aggregate resolves on demand), and the trap this bead exists to prevent is someone building the UNBOUNDED version -- projecting every aggregate's full keyset, whose payload scales with the data while every other projection scales with the viewport. The shape that works is at most k record hashes plus a count.","status":"deferred","priority":4,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-19T23:59:53Z","created_by":"Edward Langley","updated_at":"2026-09-20T05:31:03Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-mjy7","title":"Export a model as SQLite for interop (one-way, native-only)","description":"DEFERRED 2026-09-19 by user ruling during the whole-backlog campaign, together with improvise-qddo and improvise-rucf, and rewritten to say where it sits relative to ADR 003.\n\nTHE CLAIM IS STILL GOOD AND IS UNAFFECTED BY THE ADR. This is EXPORT for interop -- a user opening their model in sqlite3 or DB Browser to run their own queries. One-way, no round-trip obligation, and therefore none of the grammar-duplication risk that killed improvise-9ix: a second FORMAT must track every future change to improv.pest forever, a second VIEW need not, because nothing loads it back. It sits naturally beside export_csv/export_csv_to in crates/improvise-io/src/persistence/export.rs.\n\nWHY IT IS DEFERRED ANYWAY: it is the one bead in the SQLite cluster with no dependency on ADR 003, but it carries the same cost as the cluster's real work -- a rusqlite dependency and a wasm-gate verification -- for a capability nobody has asked for concretely. The user's ruling was to defer the cluster as a unit. Un-defer on a concrete interop request, or alongside improvise-rucf.2 (the SQLite source), where the dependency is being added anyway and the incremental cost of an exporter is small.\n\nNOTE THE DIRECTION IS OPPOSITE TO ADR 003'S. ADR 003 is about READING someone else's database as a live source. This is about WRITING ours out as a convenience. They share only the rusqlite dependency and the gating constraint; do not let one justify the other.\n\nTHE ONE REAL COST: cargo check --target wasm32-unknown-unknown fails in the libsqlite3-sys C build under this repo's nix cc-wrapper, so this must be NATIVE-ONLY AND FEATURE-GATED with the wasm CI job passing untouched. VERIFY that rather than assume it -- the improvise-9ix agent was careful to note the failure is the nix wrapper injecting -fzero-call-used-regs rather than proof that bundled SQLite cannot target wasm, so the gating is about protecting the browser bundle, not about an impossibility.\n\nSCOPE WHEN IT LANDS: choose a schema that is USEFUL TO QUERY rather than one that mirrors internal storage -- a reader wants cells joined to their coordinates, not an interned key blob. Do not add a reader; that is improvise-rucf's job and it has a different answer.\n","status":"deferred","priority":4,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-20T00:08:37Z","created_by":"Edward Langley","updated_at":"2026-09-20T05:31:48Z","dependencies":[{"issue_id":"improvise-mjy7","depends_on_id":"improvise-rucf","type":"parent-child","created_at":"2026-09-25T01:04:37Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-zavn","title":"Drill previews on hover: a bounded record keyset per aggregate","description":"DEFERRED by the user (2026-09-19) -- a good idea, recorded so the shape is not lost and so nobody builds the UNBOUNDED version by accident.\n\nTHE IDEA: show the underlying rows when the user hovers an aggregate cell, without a round trip.\n\nTHE TRAP THIS AVOIDS. Projecting the full keyset for every aggregate is untenable and the cost runs OPPOSITE to the value. A stored cell has fixed coordinates so it lands in exactly one displayed cell -- the keysets PARTITION rather than multiply -- but that still makes the payload scale with N (the data) while everything else in a projection scales with the VIEWPORT. Aggregation is precisely what makes a large document fit a screen, so shipping the keysets undoes that compression. Worked numbers for a 1000-cell viewport at u128: ~16KB at one record per cell (where the cell would be settable anyway and no keyset is needed), ~160KB at ten, and ~16MB over a million-record document -- to render a thousand cells. Per client, re-diffed as data changes. And the cheap cases are the ones where drilling barely matters.\n\nTHE SHAPE THAT WORKS: ship at most k record hashes per aggregate PLUS A COUNT, so the payload is viewport-proportional again, and fetch the full set on demand when the user actually drills. k is a product decision -- enough rows to make a hover useful, e.g. 3 to 5.\n\nPREREQUISITE: ADR 002 (context/adr/002-naming-a-cell.md). An aggregate already carries CellId::Aggregate(AggregateHash), so drill-on-demand works without this ticket; this is purely the latency optimisation for hover. Do not conflate the two -- drilling must stay correct without any preview.\n\nNote the preview hashes are RECORD hashes, so they resolve through the same DataStore index and obey the same staleness rules: a preview row whose record has since gone simply fails to resolve.","notes":"\n--- BLOOM FILTER IDEA (user, 2026-09-19): right tolerance, different question ---\nProposal was a Bloom filter instead of an explicit keyset, on the grounds that\nfalse positives are fine given a deterministic aggregate id to ask\nauthoritatively. The tolerance argument is right; the direction needs\ncorrecting.\n\nA BLOOM FILTER CANNOT ENUMERATE. A hover preview needs ROWS -- identities and\nvalues -- while a filter answers \"is X probably in this set?\" for an X you\nalready hold. The client holds aggregates, not the underlying records, so it has\nno candidates to test. So this does not serve previews directly.\n\nWHERE IT FITS INSTEAD, and it fits well: the REVERSE question, \"which visible\naggregates does record R feed?\" A client staging an edit to R tests R against\neach visible cell filter and marks the hits pending -- greyed, spinner -- with\nno round trip. A false positive marks one extra cell pending; it never misses\none. That asymmetry is exactly gotcha 35 rule: over-reporting costs work,\nunder-reporting is the bug, which is why Dirty::of is a deliberate\nover-approximation. Same shape.\n\nSIZE CORRECTION THAT MAKES IT CHEAP: a conventionally-sized filter still scales\nwith N -- roughly 9.6 bits per element at 1% FP, so ~1.2KB for a cell\naggregating 1000 records, and the payload again scales with the DATA. FIX THE\nWIDTH instead: a fixed 64- or 128-bit filter per cell is viewport-proportional\nby construction. Small aggregates are precise; large ones SATURATE and answer\n\"maybe\" to everything, degrading to mark-everything -- the safe direction, and\nthe same failure the design already tolerates.\n\nThe deterministic aggregate id is what makes the sloppiness safe: the\nauthoritative answer is one Drill(AggregateHash) away, so the filter is never\nload-bearing for correctness. Same structure as ADR 001 advisory reservations,\nwhere correctness never rests on the advisory layer.\n\nSO THIS TICKET NOW HOLDS TWO SEPARABLE IDEAS -- split them when picked up:\n (a) hover previews: needs a BOUNDED EXPLICIT keyset (k hashes + a count),\n because a preview needs rows;\n (b) optimistic edit feedback: a FIXED-WIDTH Bloom filter per aggregate,\n answering which visible cells a pending record edit affects.\n(b) belongs with ADR 001 layer 2 rather than here.\nDEFERRAL RE-AFFIRMED 2026-09-19 during the whole-backlog campaign, status set to deferred so it stops appearing as ready work.\n\nThe reason it is recorded rather than closed is unchanged and is the important part: drilling is already correct without it (CellId::Aggregate resolves on demand), and the trap this bead exists to prevent is someone building the UNBOUNDED version -- projecting every aggregate's full keyset, whose payload scales with the data while every other projection scales with the viewport. The shape that works is at most k record hashes plus a count.","status":"deferred","priority":4,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-19T23:59:53Z","created_by":"Edward Langley","updated_at":"2026-09-20T05:31:03Z","dependencies":[{"issue_id":"improvise-zavn","depends_on_id":"improvise-n28d","type":"parent-child","created_at":"2026-09-25T01:05:25Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-ut7p","title":"improvise-gp fit_from_gen0 is a third copy of the GA loop","description":"Noted during improvise-0ai (2026-09-19), deliberately not changed. The generation loop now lives in gp_tree::fit::fit. improvise-gp test helper fit_from_gen0 is still its own loop, kept because it also returns the GENERATION-0 best, which the library fit does not expose -- and that is exactly what breeding_improves_on_generation_zero needs in order to prove the search moved rather than merely differed. So the duplication is load-bearing today. Two honest options: expose the generation-0 champion from gp_tree::fit (a fit result carrying the starting cost, or fit_with_progress) and rewrite the helper onto it, or leave it and document why. Prefer the first -- did the search improve on where it started is a question any caller of a GA wants answerable, not only a test.","status":"closed","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T21:23:05Z","created_by":"Edward Langley","updated_at":"2026-09-19T23:40:24Z","closed_at":"2026-09-19T23:40:24Z","close_reason":"Took the preferred option. gp_tree::fit gains Fit { tree, cost, start, start_cost } and fit_detailed; fit is now fit_detailed(..).tree. start is the best member of GENERATION 0 of the restart that produced tree -- the stronger claim, and the one improvise-gp needed. improvise-gp fit_from_gen0 is gone, replaced by seeded_fit over fit_detailed, so the GA loop has one home.\n\nLoop shape preserved deliberately: generation i is still what is scored at the top of iteration i, the sort is still stable so member 0 wins its own ties and generation 0 champion equals the seed ancestor whenever nothing beat it -- that is what makes the restructure behaviour-preserving rather than merely plausible. breed runs the same number of times with the same arguments; the extra scoring pass draws nothing from the RNG.\n\nREPRODUCIBILITY EVIDENCE RE-ESTABLISHED, not assumed: all six known-answer vectors pass unchanged and the nine-scenario fit_csv harness is byte-identical including model.json, so every recorded seed still names the same tree. Two faults injected, both fired.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-0vh1","title":"number_text emits grammar-rejected 'inf' and panics on NaN","description":"From the wave-1 gp-tree review (2026-09-19). Both are guarded by documented preconditions, hence P4.\n\nimprovise-gp/src/lib.rs:124. number_text(f32::INFINITY): at p=0 it yields \"inf\", and \"inf\".parse::\u003cf64\u003e() succeeds, so the round-trip loop ACCEPTS it and emits 'inf' -- which the formula grammar rejects (number has no such form). fitted_formula then returns Err, which is fine, but a bare formula_text caller gets silently unparseable text. number_text(f32::NAN) never matches (NaN != NaN) and falls through to unreachable!(), i.e. panics.\n\nAlso a doc imprecision worth correcting while there (lib.rs:22-25 and 114-123): the comment says it emits 'the exact decimal value of the f32'. It emits the shortest decimal that ROUND-TRIPS to the same f64 -- which is exactly what the routing argument needs, and is a weaker and more accurate claim. Verified: over 20,000 random f32 bit patterns plus the extremes, every value found a width inside 0..=149, worst observed width 60, none emitted e/E/inf/NaN, all parsed and round-tripped.","notes":"\n--- FIXED, AWAITING MERGE (2026-09-19) ---\nThe fix is on branch wave-1-review-fixes and is in PR #9\n(https://github.com/fiddlerwoaroof/improvise/pull/9), which is open pending\nCopilot review. Moved to in_progress rather than left open so nobody picks\nthis up again -- see the agent-issue-drift-pattern memory. Close it when #9\nmerges, not before.","status":"closed","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T20:43:13Z","created_by":"Edward Langley","updated_at":"2026-09-19T21:50:38Z","started_at":"2026-09-19T21:15:05Z","closed_at":"2026-09-19T21:50:38Z","close_reason":"Merged to main in PR #9 as e1acac691d70992b5dfcce9f11ce2c2b50fa1c29 (2026-09-19). Verified a real merge commit with both parents, not a squash, so all 18 branch commits are reachable on main per AGENTS.md's preference for merges. Copilot's second review on that PR: approval recommended, findings none. All eight non-skipped CI checks green, including claude-review. Synced to origin and gitea.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-k099","title":"AppMode::hint_text/label put terminal UI strings on a serde wire enum","description":"From the wave-1 review (2026-09-19). Pre-existing, introduced by improvise-2hi, not by this wave -- filed so it is tracked rather than rediscovered.\n\ngotcha 26's principle is that wire tokens are not UI labels. But AppMode::hint_text and AppMode::label are English, terminal-specific, keybinding-naming UI strings hanging off an enum that is serialized into the client Snapshot. A non-terminal or localized client cannot use them and has to map AppMode itself anyway.\n\nThe established fix shape in this repo is the AxisColor/MinibufColor one: keep a semantic value in the portable crate and let the render layer resolve it (invariant(semantic_palette_at_the_core_boundary)). Decide whether hints are worth the same treatment or whether they should simply move out of the protocol crate.","acceptance_criteria":"AppMode carries a semantic value the render layer resolves, the way AxisColor -\u003e draw.rs::axis_fg and MinibufColor -\u003e minibuf_fg already do, or hint_text/label move out of improvise-protocol entirely. Either way a non-terminal or localized client stops being handed English keybinding-naming strings on a serde wire enum. Decide together with improvise-x7sw and improvise-zx8t — all three are the same question about where UI strings live relative to a portable enum.","status":"closed","priority":4,"issue_type":"task","assignee":"wave2-h-frontend","owner":"el-github@elangley.org","created_at":"2026-09-19T20:42:38Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:18:07Z","started_at":"2026-09-20T17:53:40Z","closed_at":"2026-09-20T18:18:07Z","close_reason":"Closed","labels":["area:shared-frontend"],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -815,7 +946,7 @@ {"_type":"issue","id":"improvise-ct5","title":"i on an empty records grid reports 'cannot drill', leaking a pivot-mode concept","description":"In records mode every regular category sits on Axis::None (ToggleRecordsMode, command/cmd/grid.rs:432), so EditOrDrill's regular_none is always true and the edit branch depends entirely on is_synthetic. On an empty records grid there is no synthetic record at the cursor, so i takes the drill branch, finds no cell key, and sets 'cannot drill — no cell at cursor'.\n\nThe outcome (nothing to edit) is defensible, but the message talks about drilling — a pivot-mode concept the user is not in. In records mode the user's model is 'a table I type into', and o one key away does add a row and open an edit.\n\nOptions: (a) reword the message for records mode; (b) make i on an empty records grid behave like o. (b) is more helpful but makes i mode-dependent, which cuts against edit-or-drill being mode-agnostic — decide deliberately.\n\nPinned by ui/app.rs::tests::i_and_o_diverge_on_an_empty_records_grid, which exists to record that i and o diverge here (see improvise-6sq). Changing this behavior means updating that test; read its doc-comment first — the divergence itself is load-bearing.","notes":"STALE REF (groomed 2026-08-30, line numbers shifted by this session's work): command/cmd/grid.rs:432 -\u003e the axis loop moved to crates/improvise-command/src/command/cmd/grid.rs:443 (the '!CategoryKind::of_name(name).is_index_or_dim()' loop in ToggleRecordsMode).","status":"closed","priority":4,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-08-29T17:52:53Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:54:39Z","started_at":"2026-09-20T05:32:33Z","closed_at":"2026-09-20T07:54:39Z","close_reason":"Closed by PR #27 (WU-G). Option (a): reword, do not make `i` behave like `o`.\n\nTHE DECISION AND WHY: the divergence between `i` and `o` on an empty records grid is what improvise-6sq exists to record, and it is why edit-or-drill and enter-edit-at-cursor are two commands. Option (b) collapses it. The OUTCOME (nothing to edit) was never the bug -- the WORDING was, because \"cannot drill\" leaks a pivot-mode concept into a mode whose model is \"a table I type into\".\n\nWHICH SENTENCE IS SHOWN is read off `edit_mode` -- THE ARGUMENT THE KEYMAP SUPPLIES -- not off ctx.mode, so no runtime is_records() check returns (improvise-4ju).\n\nEVIDENCE: the pinning test ui/app.rs::tests::i_and_o_diverge_on_an_empty_records_grid keeps its load-bearing assertion (`i` does not enter editing) and was watched FAILING on the old message before the fix. New the_empty_grid_message_is_not_the_records_one_in_pivot_mode is the negative arm -- without it, \"say the records sentence everywhere\" passes, and would promise `o` in pivot mode where `o` adds no row. Fault injected by collapsing to one sentence; failed.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-ont","title":"Migrate existing callers of workbook.views.get() to Workbook::active_view","description":"Workbook already exposes active_view() and active_view_mut() (crates/improvise-core/crates/improvise-core/src/workbook.rs:87-97) but many callers still do workbook.views.get(\u0026workbook.active_view).unwrap().X or wb.views.get('view_name').unwrap().axis_of(...).\\n\\n11 lookup sites identified:\\n- crates/improvise-core/crates/improvise-core/src/workbook.rs:167-168, 176-177 (within workbook.rs itself — these may be the implementations and be fine)\\n- crates/improvise-core/crates/improvise-core/src/model/types.rs:1868, 1871, 1873, 1886, 1890 (tests)\\n- crates/improvise-io/crates/improvise-io/src/persistence/mod.rs:943, 946, 1165\\n\\nFix: audit each site; migrate to Workbook::active_view() / Workbook::view(name) / add Workbook::view_axis(view_name, cat_name) -\u003e Option\u003cAxis\u003e to absorb the common chain.\\n\\nMostly a cleanup task but reduces future refactor exposure. ~15 LOC.","notes":"Groomed 2026-08-13 — re-verified against the tree; the premise has largely evaporated.\n\nCurrent state: 14 `views.get(` call sites remain, and 13 of them are inside #[cfg(test)] modules (workbook.rs, model/types.rs, persistence/mod.rs). Test code asserting on a NAMED view is not the chain this ticket wants to remove — `active_view()` is the wrong tool there, because the whole point of those assertions is to look at a view that is not active.\n\nThe single production site is src/ui/view_panel.rs:31:\n let Some(view) = self.workbook.views.get(view_name) else { ... }\nThat takes a dynamic view name from the panel list, so active_view() does not apply to it either. At most it wants a `Workbook::view(name) -\u003e Option\u003c\u0026View\u003e` accessor, which is a 3-line change with no correctness content.\n\nThe ticket's original count of '11 lookup sites' predates the improvise-tvt item-facade work, which removed most of the interesting ones. What is left is not worth a P3 slot. Dropped to P4.\n\nRECOMMEND: either close as wont-do, or rescope to exactly 'add Workbook::view(name) and use it in view_panel.rs' — which is small enough to fold into whatever next touches that file.","status":"closed","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T19:02:37Z","created_by":"Ed L","updated_at":"2026-08-30T17:07:07Z","closed_at":"2026-08-30T17:07:07Z","close_reason":"OBSOLETE — the pattern this ticket targets no longer exists (verified 2026-08-30 while grooming).\n\nThe description says 'many callers still do workbook.views.get(\u0026workbook.active_view).unwrap().X'. A grep for 'views.get(\u0026' across crates/ and src/ now returns ZERO matches; the migration to Workbook::active_view() has already happened, presumably in the crate-split work.\n\nOf the 14 remaining '.views.get(' call sites, 13 are in test modules (workbook.rs, persistence/mod.rs, model/types.rs — all asserting on a specific named view, which is the correct API for that) and exactly one is production code:\n\n src/ui/view_panel.rs:31 self.workbook.views.get(view_name)\n\nThat one looks up a view BY NAME from the panel's own list, so active_view() does not apply to it. There is nothing left to migrate.\n\nNot carried forward: the ticket also floated adding Workbook::view_axis(view_name, cat_name) to absorb the .unwrap().axis_of(...) chain. That chain now appears only in tests, so the absorption would buy little; file it fresh if it ever earns its keep.\n\nAlso noted while here: the description's paths were malformed — 'crates/improvise-core/crates/improvise-core/src/workbook.rs' — with the crate prefix doubled throughout, which is why they resolved to nothing.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-ete","title":"Add Model/Category getter methods to absorb .unwrap().items/groups chains","description":"Several callers reach through Model and Category to get item names, groups, or counts — each chain includes an .unwrap() that panics if the category is missing:\\n\\n- persistence/mod.rs:1635-1639, 2006-2007: model.category(name).unwrap().items.values().map(|i| i.name.clone()).collect() — 4 sites\\n- persistence/mod.rs:831: \u0026m2.model.category('Month').unwrap().groups — 1 site\\n- crates/improvise-command/src/ui/cat_tree.rs:32: cat.map(|c| c.items.len()) — item count\\n\\nAdd:\\n- Model::item_names(cat: \u0026str) -\u003e Option\u003cVec\u003cString\u003e\u003e\\n- Model::group_names(cat: \u0026str) -\u003e Option\u003cVec\u003cString\u003e\u003e\\n- Category::item_count() -\u003e usize\\n\\nCorrectness win: (a) prevents panics if the category lookup fails; (b) hides the items/groups IndexMap/HashMap from callers — internal-rep changes don't cascade; (c) the Option return type forces explicit handling.\\n\\n~15 LOC plus cleaner call sites.","notes":"GROOMED 2026-09-19 — DISSOLVED PREMISE, mostly. The four persistence/mod.rs sites this ticket was built on (model.category(name).unwrap().items.values()...) no longer exist in production code; grep for unwrap().items / unwrap().groups now hits only test modules (persistence/mod.rs is #[cfg(test)] from line 706 on, workbook.rs likewise). The stated correctness win — \"prevents panics if the category lookup fails\" — is therefore void: there is no production panic left to prevent. What survives is encapsulation in test code plus cat_tree.rs:32 (cat.map(|c| c.items.len()).unwrap_or(0)), which already handles the missing category. P3 -\u003e P4 on that basis; close it if the encapsulation alone is not worth 15 LOC.","status":"closed","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T19:02:34Z","created_by":"Ed L","updated_at":"2026-09-19T20:52:14Z","closed_at":"2026-09-19T20:52:14Z","close_reason":"OBSOLETE -- premise dissolved, verified item by item rather than accepted from the groom's note. (1) Model::item_names would DUPLICATE the existing Model::effective_item_names, and in a worse form: the ticket's version reading category(name).items would silently omit formula targets for _Measure, which is exactly the trap gotcha 7 warns about; the existing one routes _Measure through measure_item_names and returns Vec rather than Option\u003cVec\u003e, which is also the right call under 'keep Option at the boundaries'. (2) Model::group_names has no caller inside improvise-core -- the site the ticket cites now lives in an improvise-io test. (3) Category::item_count has no in-scope caller either; the live one is improvise-command's cat_tree.rs, which already handles the missing category.\n\nThe ticket's stated correctness win -- 'prevents panics if the category lookup fails' -- is void: every unwrap().items / unwrap().groups chain in the repo is now inside a #[cfg(test)] module. The single production reader of cat.items.values() is persistence/format.rs, the serializer legitimately walking items to write them, which is not an unwrap chain and not something a getter should hide from a formatter.\n\nIf the encapsulation is still wanted for improvise-command's cat_tree.rs, that is one method for whoever owns that crate, not the three proposed here.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-a5q","title":"Parquet columnar export (read-only)","description":"One-way export to Apache Parquet for interop with data tools (pandas, Arrow, BI tools). Columnar layout maps naturally to improvise's category/measure/cell structure: each category becomes a column of the key space, each measure a value column. Read-back not required for MVP; this is primarily a data-out path. Orthogonal to the core standalone deployment; filed as a backlog follow-on.","design":"Use arrow-rs + parquet crates (or polars as a simpler wrapper). Model shape → Arrow RecordBatch: one row per cell, columns are (category_1, category_2, ..., measure_name, value). Write the batch to a parquet file via the VFS. Read-back (parquet → model) is a stretch goal — most users will export once and consume the file elsewhere. Browser build: parquet + arrow crates are heavy; may push the wasm bundle size significantly. Consider making parquet export a separate optional wasm chunk loaded on demand.","acceptance_criteria":"(1) Export function writes a valid parquet file from the current model. (2) File opens correctly in pandas / pyarrow and reproduces the improv data. (3) Works over VFS (native filesystem + OPFS).","notes":"Depends on improvise-6mq (VFS abstraction). Very low priority — a nice data-science handoff story but not required for core functionality. Filed as P4 backlog.\nDEFERRED 2026-09-19 by user ruling during the whole-backlog campaign. Orthogonal data-out path with no consumer asking for it; it sits behind the ADR 003 data-source direction (improvise-rucf), which is itself filed not-a-priority. Revisit if an interop request is concrete.","status":"deferred","priority":4,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:39:46Z","created_by":"spot","updated_at":"2026-09-20T05:31:03Z","dependencies":[{"issue_id":"improvise-a5q","depends_on_id":"improvise-6mq","type":"blocks","created_at":"2026-04-14T00:40:35Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-a5q","title":"Parquet columnar export (read-only)","description":"One-way export to Apache Parquet for interop with data tools (pandas, Arrow, BI tools). Columnar layout maps naturally to improvise's category/measure/cell structure: each category becomes a column of the key space, each measure a value column. Read-back not required for MVP; this is primarily a data-out path. Orthogonal to the core standalone deployment; filed as a backlog follow-on.","design":"Use arrow-rs + parquet crates (or polars as a simpler wrapper). Model shape → Arrow RecordBatch: one row per cell, columns are (category_1, category_2, ..., measure_name, value). Write the batch to a parquet file via the VFS. Read-back (parquet → model) is a stretch goal — most users will export once and consume the file elsewhere. Browser build: parquet + arrow crates are heavy; may push the wasm bundle size significantly. Consider making parquet export a separate optional wasm chunk loaded on demand.","acceptance_criteria":"(1) Export function writes a valid parquet file from the current model. (2) File opens correctly in pandas / pyarrow and reproduces the improv data. (3) Works over VFS (native filesystem + OPFS).","notes":"Depends on improvise-6mq (VFS abstraction). Very low priority — a nice data-science handoff story but not required for core functionality. Filed as P4 backlog.\nDEFERRED 2026-09-19 by user ruling during the whole-backlog campaign. Orthogonal data-out path with no consumer asking for it; it sits behind the ADR 003 data-source direction (improvise-rucf), which is itself filed not-a-priority. Revisit if an interop request is concrete.","status":"deferred","priority":4,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:39:46Z","created_by":"spot","updated_at":"2026-09-20T05:31:03Z","dependencies":[{"issue_id":"improvise-a5q","depends_on_id":"improvise-6mq","type":"blocks","created_at":"2026-04-14T00:40:35Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-a5q","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T01:05:29Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-3gy","title":"4.2 Enable GitHub Pages","description":"Enable Pages in GitHub repo settings: source=main branch, folder=/docs. Verify site is live at the GitHub Pages URL.","notes":"CORRECTION (2026-09-20): this ticket's text says source=main branch, folder=/docs. That is the WRONG SETTING for the pipeline that landed in improvise-d31.\n\nThe correct setting is Settings -\u003e Pages -\u003e Source: GITHUB ACTIONS. The workflow uploads a Pages artifact from pages-build and deploys it with deploy-pages@v4; a branch/folder source would serve the repo instead and silently ignore the workflow.\n\nThe workflow attempts enablement itself (configure-pages@v5, enablement: true), so this may need no human at all -- verified as NOT currently enabled (the Pages API 404s). If the run fails at configure-pages, note that default_workflow_permissions on this repo is 'read'; the job declares permissions: {pages: write, id-token: write} explicitly, which should grant above the default, but it is the first thing to check on a 403.","status":"closed","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-09T04:08:10Z","created_by":"Edward Langley","updated_at":"2026-09-20T04:20:51Z","closed_at":"2026-09-20T04:20:51Z","close_reason":"Done. GitHub Pages is enabled and serving: https://fiddlerwoaroof.github.io/improvise/ renders the demo grid from the in-page worker, verified by the user in Safari 27 on 2026-09-20.\n\nNote the ticket's own text was WRONG about the setting and was corrected before this closed: it said source = main branch, folder = /docs. The pipeline improvise-d31 built uploads a Pages ARTIFACT and deploys it with deploy-pages@v4, so the correct source is GITHUB ACTIONS. configure-pages@v5 with enablement: true turned it on without a human touching the setting, which answers the open question about whether the workflow token may.","dependencies":[{"issue_id":"improvise-3gy","depends_on_id":"improvise-e61","type":"blocks","created_at":"2026-04-08T21:09:32Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"improvise-e61","title":"4.1 Create docs/index.html landing page","description":"Vanilla HTML, single file, under 200 lines. Dark background, monospace headings. Embed asciinema-player from jsdelivr CDN. Sections: title/tagline, pivot cast, drill cast, formulas cast, import cast, install commands + GitHub link. Player config: rows 30, cols 100, theme monokai, autoPlay false, loop true.","acceptance_criteria":"docs/index.html exists: vanilla HTML, single file, under 200 lines, dark background and monospace headings, asciinema-player embedded from jsdelivr, sections for title/tagline, the pivot/drill/formulas/import casts, install commands and a GitHub link. Player config rows 30, cols 100, theme monokai, autoPlay false, loop true. The four casts already exist (improvise-d4w is closed) — check they still reflect the current UI before embedding them, since the wave changed the records columns and the status line.","notes":"CRITERION REINTERPRETED, on review of PR #47, rather than a second bead filed.\n\n\"Under 200 lines\" is a PROXY for what this ticket actually cared about — single\nfile, vanilla, no build step, no framework — and those are the properties worth\nholding. The line count is not: it is met or missed by an arbitrary margin, it is\nchecked by nothing, and at 199 lines it was \"met\" only by accident, with the next\nperson to touch the file silently breaking it.\n\n`docs/index.html` is now 209 lines. The properties the number stood for are all\nintact: one file, no build step, no framework, no dependency but the pinned\nasciinema-player from a CDN.\n\nWhat took it over was a fix, not padding. At a 390px viewport the four players\ncomputed a 4.83px terminal font — measured in Chromium against the assembled\ntree — because a 120-column cast shrunk into a phone is unreadable. That was\ntolerable when the landing page sat at /landing/; since the user's ruling it is\nthe first thing a phone visitor sees. A media query under 900px now scrolls each\nplayer sideways at a legible size instead: 11.95px, measured the same way, with\nthe PAGE's own scrollWidth still equal to its clientWidth, so the overflow is\ncontained inside the player and there is no horizontal page scroll.\nTWO CORRECTIONS TO THE NOTE ABOVE, and one thing it should not be read as saying.\n\nFIRST, THE NUMBERS IN THAT NOTE ARE NOW WRONG. It says the file is 209 lines and\nthat a media query fixes the phone rendering. A user ruling has since reverted\nthat fix — the mobile defect is filed as improvise-o31u and the page is\nunchanged — so `docs/index.html` is **213 lines**, and what took it past 200 is\nthe drill-cast caption below, not the mobile fix. The criterion reinterpretation\nstands on its own reasoning and does not depend on which change crossed the\nline: \"under 200 lines\" was a proxy for single file, vanilla, no build step, no\nframework, and all four hold.\n\nSECOND, AND MORE IMPORTANT: THE CAPTION IS A LABEL ON AN UNMET CRITERION, NOT A\nMET ONE. This ticket asked that the casts be checked against the current UI\nbefore embedding. They were, and `drill.cast` fails: it shows the narrow records\nformat with a single `Value` column, which improvise-c5v and improvise-2zq\nreplaced with a column per measure plus the `+cat` and `+` columns. The user\nruled to ship it with a visible caption rather than re-record or drop the\nsection, and to keep this ticket closed. That ruling is about what to SHIP; it\ndoes not make the criterion satisfied.\n\nSo the honest reading of this ticket's state is: delivered, with one embedded\ncast known to demo a UI the software no longer has, labelled as such on the page\nat body size and above the player rather than in a footnote, and improvise-2oxt\nopen to fix it properly. Anyone auditing \"do the casts reflect the current UI\"\nshould read improvise-2oxt, not this close reason, for the answer.","status":"closed","priority":4,"issue_type":"task","assignee":"wave2-p-milestones","owner":"el-github@elangley.org","created_at":"2026-04-09T04:08:07Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:22:56Z","started_at":"2026-09-20T18:02:40Z","closed_at":"2026-09-20T18:04:48Z","close_reason":"docs/index.html on wave/p-milestones: 194 lines, vanilla, single file, dark\nbackground, monospace headings, asciinema-player 3.10.0 pinned from jsdelivr\n(both URLs checked live, 200), all six sections, and the four improvise-d4w\ncasts. Verified by driving it in Chromium against both docs/ and the assembled\nsite/landing/: four players construct, each renders 37 terminal lines after\nplay, zero failed requests, zero page errors. That check was proved able to\nfail first — with autoPlay false the cast is not fetched until the click, so\ncounting players passes on a page whose every cast path 404s; breaking\n./casts/ to ./cats/ passes the construction check and fails the play check.\nNothing in CI asserts it yet: improvise-bgtf.\n\nTWO DEVIATIONS, both stated rather than papered over.\n\nPlayer geometry is cols 120 / rows 37, not the 100x30 this ticket and\nimprovise-d4w both name. Every cast header says 120x37, so the recordings\nnever complied with d4w's own \"Terminal: 100x30\"; a 30-row player clips the\nbottom seven rows of a 37-row cast, which is where the tile bar and the status\nline are.\n\nThe \"check they still reflect the current UI\" criterion was performed and the\nanswer is partly no. The tile bar's short_label vocabulary and the mode line\nstill match; the casts predate improvise-2zq's +cat column by five months and\nevery one opens on the recorder's shell prompt and home directory. Filed as\nimprovise-2oxt rather than embedded as if current.","labels":["area:release"],"dependencies":[{"issue_id":"improvise-e61","depends_on_id":"improvise-d4w","type":"blocks","created_at":"2026-04-08T21:09:31Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} {"_type":"issue","id":"improvise-kh8","title":"Phase 4: Landing page (optional)","description":"Create docs/index.html with embedded asciinema casts and enable GitHub Pages. Optional but recommended for launch.","acceptance_criteria":"Closes when improvise-e61 closes; improvise-3gy (enable GitHub Pages) is already closed. NOTE A COLLISION worth resolving before starting: Pages is already serving the standalone web app from the CI assemble step (improvise-d31/bck). Decide whether the landing page replaces that, sits beside it at a path, or is dropped in favour of the live app being the landing page — which is arguably the better demo and would close this as won't-fix.","notes":"CORRECTION TO THE CLOSE REASON ABOVE — THE USER OVERRULED IT, same session.\n\nThe close reason records the landing page sitting BESIDE the app at /landing/,\nwith the app keeping the root. The user ruled the other way:\n\n \"The landing page should be served at the root of the repository and link\n to the in-browser demo.\"\n\nSo the arrangement is inverted. `docs/index.html` is the site root and links\nprominently to the app, which moves to `/app/`. The argument for the other\narrangement is left standing above rather than deleted, because it is still the\nreason the alternative was considered and the collision this ticket asked to\nresolve was a genuine one — but it is OVERRULED, not merely superseded, and the\nroot is now the landing page.\n\nWHAT THE INVERSION COST, which is the part worth carrying:\n\n* The app moves under `app/` WHOLE — `index.html`, `app.js`, `style.css`,\n `worker.js`, `demo.improv` and BOTH bundle directories. Leaving `pkg/` and\n `worker-pkg/` at the root would have meant editing the wasm-pack generated\n glue; every path in app.js and worker.js is relative, so moving the bundle\n together keeps all of them correct and the glue untouched. `worker.js`'s\n `fetch(\"demo.improv\")` resolves against the WORKER's url, so the seed follows\n the move for the same reason.\n* `web/assemble.sh`'s four `stamped` guards had to follow the files. They are\n the only check that the cache-bust substitution worked, and a guard pointed at\n a path nothing writes fails in the direction that looks like success.\n* `harness/tests/shell.spec.js` navigates to `/index.html` six times, including\n the `?ws=` and `?doc=` variants. Every one of those now lands on the landing\n page, so all six were repointed at `/app/index.html`. The browser harness is a\n CI job that can fail the build, so this was not optional and not someone\n else's to discover.\n\nVERIFIED against the ASSEMBLED tree, not against docs/ directly, because a path\nmove is exactly what re-arms the trap this page already caught once: the four\ncasts play 37 lines each at the root, the primary call-to-action resolves to\n/app/ and returns 200, and there are no failed requests and no page errors. The\ncall-to-action check was proved able to fail by pointing the link at ./aap/,\nwhich returns 404 while the page still looks entirely correct.","status":"closed","priority":4,"issue_type":"feature","assignee":"wave2-p-milestones","owner":"el-github@elangley.org","created_at":"2026-04-09T04:05:53Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:10:48Z","started_at":"2026-09-20T18:02:40Z","closed_at":"2026-09-20T18:04:51Z","close_reason":"Both children terminal: improvise-3gy was already closed, improvise-e61 closes\nwith this wave.\n\nTHE COLLISION THIS TICKET ASKED TO RESOLVE FIRST, resolved: Pages serves the\nstandalone web app at the root (improvise-d31/bck), and the landing page now\nsits BESIDE it at /landing/ rather than replacing it. The reasoning is this\nticket's own — the live app is the better front door — and the third option,\ndropping the landing page in favour of the app, was declined only because the\nrecorded terminal casts show the TUI, which the browser app does not.\n\nweb/assemble.sh copies docs/index.html and docs/casts/ into the deployed tree,\nso the page is reachable and the browser harness serves the same tree the\ndeploy publishes. A landing page the deploy never copies would have satisfied\nthis ticket's letter and been a file nobody can open.\n\nREADME links both.","labels":["area:release"],"dependencies":[{"issue_id":"improvise-kh8","depends_on_id":"improvise-3gy","type":"blocks","created_at":"2026-04-08T23:37:42Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-kh8","depends_on_id":"improvise-e61","type":"blocks","created_at":"2026-04-08T23:37:41Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0} From 14141771e4944e1b651a3cd57cac48b1e93bc043 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Fri, 25 Sep 2026 00:08:54 -0700 Subject: [PATCH 2/2] chore: update issues --- .beads/issues.jsonl | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index f31ad8de..cf30c644 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,7 +1,7 @@ {"_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","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-25T04:49:27Z","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.3","type":"blocks","created_at":"2026-09-25T04:50:50Z","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":2,"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","status":"open","priority":1,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T04:49:14Z","created_by":"agent-adr007","updated_at":"2026-09-25T04:49:14Z","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":1,"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":"in_progress","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-25T04:51:42Z","started_at":"2026-09-25T04:51:42Z","lease_expires_at":"2026-09-25T04:56:42Z","heartbeat_at":"2026-09-25T04:51:42Z","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":0,"comment_count":2} +{"_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.","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-25T06:42:12Z","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.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":4,"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","status":"open","priority":1,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T04:49:14Z","created_by":"agent-adr007","updated_at":"2026-09-25T04:49:14Z","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":2,"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":"in_progress","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-25T04:51:42Z","started_at":"2026-09-25T04:51:42Z","lease_expires_at":"2026-09-25T04:56:42Z","heartbeat_at":"2026-09-25T04:51:42Z","dependencies":[{"issue_id":"improvise-cq1q.2","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T04:49:02Z","created_by":"agent-adr007","metadata":"{}"}],"comments":[{"id":"a07b9c7f-8972-53a8-8b97-aed300617104","issue_id":"improvise-cq1q.2","author":"agent-adr007","text":"PR #318 opened, branch adr007/slugify, head a2ddb06e. It adds model::id (slugify, IdAllocator, claim) and the unicase dependency. All 11 tests were written first, and each of the six rivals was caught (context/test-evidence/core.md). Every gate is green locally after merging gh/main c91cc055.","created_at":"2026-09-25T05:15:38Z"},{"id":"13895058-8d4e-538d-9939-3ed93a325d69","issue_id":"improvise-cq1q.2","author":"agent-adr007","text":"Pushed 689480bf: Cargo.nix regenerated with crate2nix for unicase. The first CI run's crate2nix job would have failed without it. nix build .#improvise passes locally.","created_at":"2026-09-25T05:37:45Z"}],"dependency_count":0,"dependent_count":1,"comment_count":2} {"_type":"issue","id":"improvise-31oe.1.1","title":"Complete command names on the : command line","description":"First child of the auto-complete epic; the user's top priority. On the ':' command line, completing the command name offers every registered command (names read off the registry's prototypes, gotcha 15 -- no string list), filtered by the typed prefix. Decide and test: the key (Tab, with what on an exact match), single-match insertion, multi-match display (cycle vs list), and that the TUI and the browser use the same engine (improvise-y11g brings the ':' line to the browser). Tests first: every registered command is completable; a prefix narrows; an unknown prefix offers nothing; the candidate set equals the registry's name set (so a new command cannot be missing).","status":"open","priority":1,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-25T03:41:48Z","created_by":"Edward Langley","updated_at":"2026-09-25T03:41:48Z","labels":["command","protocol","ux"],"dependencies":[{"issue_id":"improvise-31oe.1.1","depends_on_id":"improvise-31oe.1","type":"parent-child","created_at":"2026-09-25T03:41:48Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-8jk6.2","title":"Import drops whole records that lack a category value; keep them with the coordinate absent","description":"USER 2026-09-25, verbatim: 'this limit in import should be fixed, it dates to when this wasn't sparse.' Screenshot: 'Skipped 6 of 342 records missing category values: Settlement Date (6)'. build_model_with_warnings (crates/improvise-io/src/import/wizard.rs ~262-284) skips any record with no value for an accepted Category field; improvise-5j9 only made the loss visible. Records are sparse, so such a record should be KEPT with that coordinate absent from its CellKey. Same mechanism as the sparse folder merge (improvise-lpzu), where a whole file lacks a category column -- so it belongs with that work. Decide and test how an absent coordinate behaves downstream: a view with that category on Row/Column (is the record invisible there, shown under a blank/(none) row, or counted only in totals?), on Page (which selection shows it), on None (aggregates include it), records mode (blank cell in that column), .improv round-trip (a Data line simply omits the category), drill, and ADR 007/identity. The display question for Row/Column is the user's to confirm; state the recommendation in the PR.","status":"closed","priority":1,"issue_type":"bug","assignee":"agent-lpzu","owner":"el-github@elangley.org","created_at":"2026-09-25T03:37:31Z","created_by":"Edward Langley","updated_at":"2026-09-25T05:02:21Z","started_at":"2026-09-25T03:37:52Z","closed_at":"2026-09-25T05:02:21Z","close_reason":"Landed in PR #314; reviewed PASS at c43ee724","labels":["import"],"dependencies":[{"issue_id":"improvise-8jk6.2","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T03:37:31Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-felo.1","title":"Raise MAX_CATEGORIES from 12 to 24","description":"USER DECISION 2026-09-25, verbatim: '12 categories is feeling small, we should increase to 24.' Prompted by an Etrade transactions import that hit exactly 12 categories, which left no room for the duplicate-disambiguating category of improvise-cgqt (PR #304). Supersedes context/plan.md:260 ('Do not raise or restructure the MAX_CATEGORIES = 12 limit'). The limit lives in crates/improvise-core/src/model/types.rs and is MIRRORED by a private copy in crates/improvise-io/src/import/wizard.rs — make the wizard read the model's (one home), rather than changing two numbers. Update every doc that states 12 (README, design-principles, context/code/*, plan.md) and every test that fills to the cap by literal count; tests should derive the cap, not spell it. Before changing it, find and state why 12 was chosen (any cost that scales with category count: CellKey size, the secondary index, layout cross products, formula fold) and measure one representative operation at 24 categories.","status":"closed","priority":1,"issue_type":"feature","assignee":"wave-felo1","owner":"el-github@elangley.org","created_at":"2026-09-25T02:06:57Z","created_by":"Edward Langley","updated_at":"2026-09-25T03:10:53Z","started_at":"2026-09-25T02:07:37Z","closed_at":"2026-09-25T03:10:53Z","close_reason":"Landed in PR #307; reviewed PASS at 74e460be","labels":["core","import"],"dependencies":[{"issue_id":"improvise-felo.1","depends_on_id":"improvise-felo","type":"parent-child","created_at":"2026-09-25T02:06:57Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -101,7 +101,7 @@ {"_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.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":"open","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-25T06:19:38Z","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":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_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":"open","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-25T06:19:38Z","dependencies":[{"issue_id":"improvise-cq1q.11","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T06:19:38Z","created_by":"Edward Langley","metadata":"{}"}],"comments":[{"id":"ca207052-0f62-5f31-b8d9-3d4a3a30b68f","issue_id":"improvise-cq1q.11","author":"agent-adr007","text":"agent-adr007: resolved by reordering, with no new beads. (1) cq1q.7 (projections and ViewState carry an id field) now depends only on cq1q.3 and lands before the re-key, using the display-first trick: id == key == name until cq1q.4 changes what the key is. So the wire never names a key that does not exist, and no interim resolver in model_effects is needed. (2) cq1q.4 now owns the old-format reader and writer edge: the loader slugifies, the writer writes displays, and a save/load test keeps the displays Region and Café intact. (3) The cq1q.3 -\u003e cq1q.2 dependency is removed. cq1q.4 now depends on .2, .3 and .7, and cq1q.8 depends on .4 and .7. Details are appended to the notes of cq1q.4 and cq1q.7.","created_at":"2026-09-25T06:42:12Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} {"_type":"issue","id":"improvise-8jk6.16","title":"A --measure value that does not read as a number is dropped silently and the import exits 0","description":"Found in the adversarial review of PR #321 at efaa5050. A value in a column the user forced to Measure that does not read as a number is dropped with no warning, and the import exits 0. This is the same \"saved less than was asked\" family that context/code/cli.md section 13 closes for --category, --formula and kept-nothing, but it is not covered there.\n\nRepro, identical on main at c91cc055 and on the PR head:\n\n printf 'k,v\\na,abc\\nb,7\\nc,\"1,000\"\\n' \u003e nn.csv\n improvise import nn.csv --no-wizard --measure v -o nn.improv; echo \"exit=$?\"\n\nWhat happens: only `_Measure=v, k=b = 7` is written, \"Saved to\" is printed, and the exit status is 0. The `abc` and `1,000` values are gone with no report. Row `a` also loses its whole record, which is the improvise-8jk6.14 loss, but here the value was present and could not be read. That is a different reason.\n\nSince 8jk6.4, `csv_parser::number_in` is the one gate that decides whether a measure's CSV text is a number (via `Scalars::number` in `Plan::place`). So there is one place to count what it refused. Decide: warn with a count and the column (the 5j9 and 8jk6.6 partial-skip shape), or refuse headless. A thousands separator such as \"1,000\" is worth deciding about explicitly, since `number_in` does not read it.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T06:14:58Z","created_by":"Edward Langley","updated_at":"2026-09-25T06:14:58Z","labels":["headless","import"],"dependencies":[{"issue_id":"improvise-8jk6.16","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T06:14:58Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-10o9","title":"Formula WHERE/IS naming a GROUP matches none of its members, so a date hierarchy cannot be crosstabbed by formula","description":"The crosstab half of improvise-8jk6.3 does not exist yet. The user: \"I think dates should usually be one category and we can use formulas to crosstab\". But a formula `WHERE` or `IS` that names a GROUP matches none of that group's members. Both compare the record's coordinate to the name exactly, and no record carries the group's name.\n\nMeasured by the adversarial review of PR #322 at 6b910889. The file was a headless import of a two-year CSV with `--extract Date:Year --extract Date:Quarter --extract Date:Month`, so `Date` has items `2024-01`… in groups `2024-Q1`… under years `2024`/`2025`:\n\n- `Y24 = SUM(Revenue WHERE Date = |2024|)` gives -0.00 on every row. The expected value is 1111.\n- `IsY = (Date IS |2024|) * Revenue` gives 0 on every row, including the 2024 months.\n- Naming an item works: `SUM(Revenue WHERE Date = |2024-01|)` = 11, and `(Date IS |2024-01|) * Revenue` = 11 on the 2024-01 row.\n\nThe PR author measured the same and reported it for filing (PR #322 body, and `context/code/formula-and-io.md` \"Crosstabs by formula are not available yet\").\n\nShape: resolve a name that is a group of the filtered category to its covered items, as the fold already does via `Category::items_in_group` (felo.6). Decide the item-vs-group name ambiguity first: improvise-vwc7 says an item may share a name with a group of its category. Tests first: a 3-level Year\u003eQuarter\u003eMonth fixture, with a WHERE and an IS on each level.\n","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-25T06:09:18Z","created_by":"Edward Langley","updated_at":"2026-09-25T06:09:18Z","labels":["core","formula"],"dependencies":[{"issue_id":"improvise-10o9","depends_on_id":"improvise-8jk6.3","type":"discovered-from","created_at":"2026-09-25T06:09:18Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-1xfb.9","title":"checkers.sh's env allowlist drops the Nix linker variables, so no suite can link Rust on macOS","description":"Found in the PR #313 review (issuecomment-5827613256): inside the .#ci shell on macOS, 'env -i HOME PATH TMPDIR cargo build --bin improvise' fails with 'ld: library not found for -liconv' (a trivial crate still links). scripts/checkers.sh scrubs the environment down to HOME/PATH/TMPDIR plus three NIX_* variables and drops the ones the Nix cc/linker wrapper needs (NIX_LDFLAGS, NIX_CFLAGS_COMPILE and friends). So any suite that builds Rust fails on macOS under checkers.sh; repo-stats-test.sh worked around it by requiring a prebuilt improvise. context/code/tooling.md (~line 911) overstates the allowlist. Fix: keep the wrapper's variables (or re-enter the dev shell inside the scrub); test by building a crate with a system-library dependency under checkers.sh's env on macOS. CI (Linux) is unaffected as far as known.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T06:00:52Z","created_by":"Edward Langley","updated_at":"2026-09-25T06:00:52Z","labels":["ci","tooling"],"dependencies":[{"issue_id":"improvise-1xfb.9","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T06:00:52Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -114,8 +114,8 @@ {"_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","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-25T04:50:26Z","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.7","type":"blocks","created_at":"2026-09-25T04:50:59Z","created_by":"agent-adr007","metadata":"{}"}],"dependency_count":1,"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","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-25T04:50:03Z","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.4","type":"blocks","created_at":"2026-09-25T04:50:57Z","created_by":"agent-adr007","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"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","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-25T04:49:52Z","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":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T04:48:44Z","created_by":"agent-adr007","updated_at":"2026-09-25T04:48:44Z","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":"in_progress","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-25T04:48:48Z","started_at":"2026-09-25T04:48:48Z","lease_expires_at":"2026-09-25T04:53:48Z","heartbeat_at":"2026-09-25T04:48:48Z","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}