From 56ff51e444d70e9e2324af1e2787e9ed648e7cdc Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Sun, 20 Sep 2026 10:50:30 -0700 Subject: [PATCH 01/17] docs: correct two stale gotchas, and an assertion message that lied MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Orchestrator doc pass over the comment wave. Each claim was verified against the tree rather than taken from the queue. Gotcha 17 recorded the *opposite* of the code: it said float equality was "currently mixed", with `=`/`!=` on a 1e-10 epsilon and the div-by-zero guard checking `rv == 0.0` exactly. Both now go through FLOAT_EQ_EPSILON, at four call sites across eval_expr and its aggregated twin. Rewritten to say so and to point at context/code/core.md §4. Gotcha 35 called the doc comment on `Dirty::of` "the only record" of its derivation. The comment wave moved that reasoning into context/code/command.md and left the comment holding one line, so the instruction to re-read on reclassification now names the prose. design-principles §5 named `Sessions::apply` as dispatch; `apply_checked` is what `Host` actually calls since improvise-955n answered a whole frame at once. Both are real, so the row names both. improvise-mbmf: the_ranking_is_ordered_shares_with_the_unused_features_dropped asserted a length of 2 from three features — the zero-importance one dropped — under the message "the zero-importance feature is listed". The message is read only on failure, which is precisely when it would have described the wrong behaviour as the requirement. The test name and doc comment were already right. cargo test --workspace: 1311 passing, unchanged. Closes improvise-mbmf. Co-Authored-By: Claude Opus 5 (1M context) --- .beads/issues.jsonl | 1 + context/design-principles.md | 2 +- context/repo-map.md | 4 ++-- crates/gp-tree/src/fit.rs | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 3c34b085..7501c1be 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -162,6 +162,7 @@ {"_type":"issue","id":"improvise-pby","title":"2.2 Write the README","description":"Replace existing README.md with new one under 250 lines. Sections in order: Title, one-sentence pitch, inline demo GIF, why this exists (Lotus Improv reference), quick start, key bindings, installation (Nix/crates.io/prebuilt - no Homebrew), codebase overview, expectations disclaimer, license. No badges, TOC, or contributing guide.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:32Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-09T07:02:52Z","close_reason":"README written with all 10 required sections, committed. License updated to Apache-2.0 per user request.","dependency_count":0,"dependent_count":2,"comment_count":0} {"_type":"issue","id":"improvise-ihv","title":"2.1 Add Nix tooling for asciinema, VHS, and cargo-dist","description":"Modify flake.nix to add pkgs.asciinema and pkgs.vhs to dev shell nativeBuildInputs. Add cargo-dist if packaged in nixpkgs. Add runtime deps for VHS (ttyd, ffmpeg) if needed. Verify tools work via nix develop --command. Add nix run app or shell script for demo recording workflow.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:30Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-09T07:30:19Z","close_reason":"asciinema, vhs, cargo-dist added to flake.nix dev shell and verified. cargo-dist wrapper enables 'cargo dist' subcommand. scripts/record-demo.sh added.","dependency_count":0,"dependent_count":3,"comment_count":0} {"_type":"issue","id":"improvise-yk4","title":"Phase 2: README and demo artifacts","description":"The main launch work. README is 80% of the launch, demo artifacts are 20%. Covers Nix tooling for asciinema/VHS, writing the README, creating demo GIF and asciinema casts, and verifying all artifacts.","status":"closed","priority":2,"issue_type":"feature","assignee":"spot","owner":"el-github@elangley.org","created_at":"2026-04-09T04:05:49Z","created_by":"Edward Langley","updated_at":"2026-04-14T07:47:43Z","closed_at":"2026-04-14T07:47:43Z","close_reason":"All 5 subtasks closed; artifacts verified present and tracked in git (README.md, docs/demo.gif, docs/demo.tape, docs/casts/*.cast).","dependencies":[{"issue_id":"improvise-yk4","depends_on_id":"improvise-abz","type":"blocks","created_at":"2026-04-08T23:37:37Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-yk4","depends_on_id":"improvise-d4w","type":"blocks","created_at":"2026-04-08T23:37:36Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-yk4","depends_on_id":"improvise-ihv","type":"blocks","created_at":"2026-04-08T23:37:34Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-yk4","depends_on_id":"improvise-odx","type":"blocks","created_at":"2026-04-08T23:37:35Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-yk4","depends_on_id":"improvise-pby","type":"blocks","created_at":"2026-04-08T23:37:36Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":5,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-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":"open","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-20T17:49:30Z","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-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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T07:49:56Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:57:52Z","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":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T07:49:56Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:49:56Z","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/context/design-principles.md b/context/design-principles.md index 1900164e..9c64748b 100644 --- a/context/design-principles.md +++ b/context/design-principles.md @@ -226,7 +226,7 @@ The pieces already exist and are worth naming as what they are: | action | `Command` / `ModelCommand` (`improvise-protocol`) | | reducer | `reduce_view`, and `WidgetCache::apply` per widget | | store | `ViewState` + the per-widget caches | -| dispatch | `Sessions::apply` — the one door (gotcha 36) | +| dispatch | `Sessions::apply` / `apply_checked` — the one door (gotcha 36) | So the question to ask of any code in a front end is: **would a third front end have to write this again?** If yes, it is behaviour and it belongs in the shared diff --git a/context/repo-map.md b/context/repo-map.md index 4ed7fbbe..50749930 100644 --- a/context/repo-map.md +++ b/context/repo-map.md @@ -175,7 +175,7 @@ Region=West, Measure=Revenue = |pending| # pipe-quoted text 14. **Keybindings are per-mode.** `ModeKey::from_app_mode()` resolves the mode; Normal + `search_mode=true` maps to `SearchMode`. Minibuffer modes bind Enter/Esc as `Binding::Sequence` so `clear-buffer` fires alongside commit/cancel. Hints are checked against the keymaps: `every_hint_key_resolves_in_its_mode` (`command/keymap.rs`) splits each `AppMode::hint_text()` and requires every key it names to resolve in that mode's map, because `only_quit_has_no_hint` asserts only that a hint is *present*. It checks which keys are named, not what they do. Its tokenizer knows the hint dialect explicitly (named keys, `Ctrl-`, `F/C/V` alternatives, character runs like `hjkl`/`[:]`, the em-dash prose tail, CommandMode's `:q` ex-commands) and panics on a token it cannot classify rather than skipping it. 15. **Registry names come from prototypes, not strings.** Every `register*` call reads the name off a prototype `Cmd` instance, so a typo is impossible (improvise-9cn) — there is no name literal in `registry.rs`. What types can't reach: four commands name themselves off a field (`ViewNavigate`, `TileAxisOp`, `ToggleGroupAtCursor`, `CommitAndAdvance`), so a prototype can name the wrong *variant* and still compile. That shows up as a **duplicate** registration, not a mismatch — the wrong-variant entry collides with its sibling and the sibling's name vanishes. `registered_names_are_unique` catches it; `every_bound_command_name_is_registered` catches the keymap side, and checks **both** halves of `dispatch`'s `registry.interactive(name, args, ctx).ok()?` — the name resolves *and* the args parse — because improvise-s13 moved `enter-mode ["export-prompt"]`/`["formula-edit"]` out of the nullary class, where a typo'd mode name compiles into a dead key. It builds the `CmdContext` in each keymap's own `AppMode`, since `commit-buffer item` reads `ctx.mode`. 16. **`effect_cmd!` macro** wraps a pre-built effect as a parseable command. Use it for simple wrappers; reach for a hand-written `Cmd` once there's real decision logic. Its counterpart on the effect side is `simple_effect!` in `ui/effect.rs`, which declares a one-field newtype that writes one place on the `App`. Its **kind argument is mandatory** — `simple_effect!(Name(T) => |app| place, EffectKind::ViewOnly)` — even though every call site so far answers `ViewOnly`: hard-coding it reintroduced exactly the default gotcha 3 does without, since `$place` is an unconstrained expression and a one-line `active_view_mut().number_format` setter would have reported `ViewOnly` with the suite green (improvise-8fy). Likewise, mode entry has no per-mode shim commands — bind `enter-mode ` (improvise-s13); the name is decoded by `parse_mode_name` in `registry.rs`, and `EnterMode` clears the mode's minibuffer on the way in. -17. **Float equality inside formula eval is currently mixed** — `=`/`!=` use a `1e-10` epsilon but the div-by-zero guard checks `rv == 0.0` exactly. Don't assume one or the other; tracked for cleanup. +17. **Float equality inside formula eval has one epsilon, and the div/0 guard shares it.** `FLOAT_EQ_EPSILON = 1e-10` in `model/types.rs` is used by `=`/`!=` **and** by the division-by-zero guard (`rv.abs() < FLOAT_EQ_EPSILON`), in both `eval_expr` and its aggregated twin — four call sites, one constant. This gotcha previously recorded the opposite (`=`/`!=` epsilon, div/0 exact) and was stale; the mix is gone. Why an epsilon at all and why this one: `context/code/core.md` §4. 18. **`IndexMap`** preserves insertion order for categories, views, and items. Persistence relies on this — and so, since improvise-shyt, do `DataStore::matching_cells`/`matching_values`: the secondary index **narrows** the candidates, then `matching_positions` resolves each to its position in the primary `IndexMap`, sorts, and reads back. **An index whose values are `HashSet`s is for O(1) lookup, never for iteration.** Walking it gave a different order in every process, which was not tidiness. Records-mode rows reordered between sessions; `SUM`/`AVG` drifted in the last bits because `f64` addition is not associative (`Min`/`Max`/`Count` and the text-unanimity rule of gotcha 7 are order-free, so they were never affected); and worst, the **drill snapshot** froze its record list in hash order while `drill_state.pending_edits` are keyed by *row index* (gotcha 1), so the same drill opened in a second process addressed **different records** — silent misdirected writes, and nothing in the tracker knew. Measured at 18,000 cells: +20% worst case on `matching_values`, and `matching_cells` 7% *faster*, sorted positions resolving `CellKey`s with better locality. `DataStore::live_records` was the open choice and is now decided (improvise-2yc2): it walks `cells` and projects insertion order onto records, deliberately paying O(cells) rather than O(records), because `cells`' order is already the answer `matching_positions` gives and a second answer to "what order is this store in" is the disease. It is derived per call rather than memoised on `RecordEntry`, since a stored position is a copy and a record deleted and re-created would hold its old one. Note what is *not* on offer: an unordered return — every iterator has an order, and a type that merely disclaims one still hands the caller hash order, which is the guard living one level away from the hazard and the shape this whole class of bug returns through. **The second real instance was `import::analyzer::analyze_records`** (improvise-7yx), which built its distinct values through a `HashSet`: `build_model` adds items in exactly that order and `IndexMap` preserves it, so the *same CSV imported twice* produced two different `.improv` files and two different row orders. Now first-seen order, pinned by a five-iteration test because each `HashSet` gets its own `RandomState`. Also answered here since: an **empty `partial`** matches every cell vacuously (improvise-hkt6) — it used to index `sets[0]` and panic, unreachable only because both callers guarded. 19. **Commit paths for cell edits** split across `commit_cell_value` → `commit_regular_cell_value` / `commit_plain_records_edit` in `command/cmd/commit.rs`. Keep the synthetic-records branch in sync with the plain branch when changing behavior. 20. **Minibuffer commits go through one command.** `commit-buffer ` (`CommitBuffer { kind: CommitKind }`) owns buffer→trim→bail-on-empty→dirty→status for all three; the varying parts are methods on `CommitKind`. The add effect is emitted **last** so its failure message wins over the command's optimistic status (improvise-6t3). `CommitKind::Item` carries its target category, resolved from `AppMode::ItemAdd` by `parse_commit_kind` at registry time — so `commit-buffer item` is not constructible from a script line. @@ -205,7 +205,7 @@ Region=West, Measure=Revenue = |pending| # pipe-quoted text 34. **A formula target is unquoted, and pipe quoting has one home.** `parse_formula` strips the pipes off a target exactly as it does off an `Expr::Ref` — the target names a `_Measure` item, so a target that kept its delimiters made `add_formula` create an item literally named `|Total Revenue|`: a ghost column the user's real item never matched, and `Expr::Ref("Total Revenue")` never resolved to. Both the TUI formula minibuffer (`AddFormula`) and `.improv` load go through that one call, so both were affected. The writer emits `Formula::raw`, not `target`, so file text is unaffected either way — which is exactly why the round-trip tests never saw it, and why the fix repairs existing files on next load with no migration. Pipe quoting has exactly **one home**: `improvise_formula::quote`, owning both halves — `quote_name`/`is_bare_name`, `pipe_quote` (unconditional, for a `.improv` text cell that must not read back as a number) and `unescape_pipe_body`. `improvise-gp` re-exports it, `improvise-io` calls it from `persistence/{format,parse}.rs`, `parser.rs::unquote_pipe` is a shim over it, and io's own copy is **deleted** (improvise-i2m5). **The keyword guard is canonical — user-confirmed, so do not revert it as over-caution**: an item literally named `SUM` is written `|SUM|`. Bare keywords do parse, but only because `agg_call` needs a `(` and PEG backtracking falls through to `ref_expr` — rule ordering, not a stated rule — and a `## Formulas` line carries formula text verbatim, so a name `format_md` writes is read back by `parse_formula`: one namespace, one spelling rule. One thing the guard does not cover, and one that used to be an exception and no longer is. **A section header takes its name as the whole line, totally** (improvise-pczv): `header_name = { pipe_quoted ~ &NEWLINE | line_name }` — the pipe-quoted arm needs the lookahead, or `|A|B` would parse as `A` and silently drop `B`, and `line_name` catches everything else, so a header name can no longer fail to parse. Four rules share it (`model_name`, `initial_view`, `category_section`, `view_section`), which is why it is a rule and not four copies. Note a header is still *written* raw where a reference is quoted — the grammar accepts both arms, so the quoting asymmetry between a header and a reference is a writer convention, not a format one. And the two former *unquoting* copies differed only on a trailing lone backslash, unreachable from the grammar, where io returned it and `parser.rs` panicked — the surviving `unescape_pipe_body` is total. -35. **`Dirty` over-reports on purpose, and its doc comment is the derivation.** `Dirty::of` maps an `EffectKind` to the widgets that kind can reach, and the two directions do not cost the same: over-reporting pays one redundant `Projection`, under-reporting leaves a client's cache wrong **forever**, with nothing in the stream to correct it. `LayoutGeometry` therefore includes `formulas`, because `RemoveCategory` wears it and `Model::remove_category` ends by dropping every formula targeting the category — a client's formula panel otherwise kept showing a formula the document no longer had. It now coincides with `Dirty::EVERYTHING` and is still spelled out, because the two are derived from different effects and need not stay equal. The doc comment on `Dirty::of` is the **only** record of that derivation, and it went stale once already — claiming `AddCategory` was `CellData` after it was reclassified — so reclassifying an effect means re-reading it. +35. **`Dirty` over-reports on purpose, and the asymmetry is the reason.** `Dirty::of` maps an `EffectKind` to the widgets that kind can reach, and the two directions do not cost the same: over-reporting pays one redundant `Projection`, under-reporting leaves a client's cache wrong **forever**, with nothing in the stream to correct it. `LayoutGeometry` therefore includes `formulas`, because `RemoveCategory` wears it and `Model::remove_category` ends by dropping every formula targeting the category — a client's formula panel otherwise kept showing a formula the document no longer had. It now coincides with `Dirty::EVERYTHING` and is still spelled out, because the two are derived from different effects and need not stay equal. The derivation now lives in `context/code/command.md` (the doc comment on `Dirty::of` keeps only the one-line reason `LayoutGeometry` is spelled out rather than written as `EVERYTHING`). It went stale once already — claiming `AddCategory` was `CellData` after it was reclassified — so reclassifying an effect means re-reading the prose, not just the comment. 36. **A client's command has one door, and the door takes the `App`.** `Sessions::apply(app, id, cmd)` is the only way a connected client's `Command` reaches the server; `ClientSession::fold` and the mirror's `ViewState` are private, so a transport cannot reach the mirror at all. The door takes the authoritative `App` because some commands have a **document half**: `SwitchView` routes through `apply_command`/`App::switch_to_view` (gotcha 29), and **every** mirror then takes what the document *accepted*, not what was asked — so a switch to a view that does not exist leaves the mirrors put. Not just the asker's: `grid_of` builds every client's grid from `workbook.active_view()`, so a bystander left naming the outgoing view has its cursor read under a stale key and is projected the new view's rows positioned by the old view's scroll — gotcha 24's mismatch arriving through somebody else's keystroke. `apply_switch` therefore takes **no `SessionId`**; the switch is addressed to no one session and that absence is the statement. A client's own slice lives on the client and stays in step only by folding the same command, so `Sessions::apply` returns `Applied { dirty, broadcast }`, where `broadcast` is a **`Vec`** and each item names its own audience (improvise-gvnb). **The audience follows from whether the asker folded.** `Broadcast::ToOthers` is a *correction* of something the asker already applied optimistically; `Broadcast::ToAll` is a *consequence* the asker is owed like everybody else. A `Command::Model` is the one kind a client **cannot** fold optimistically — it cannot predict what the document will accept — so **every** relay originating in `apply_model` is `ToAll`: the `ResetCursor` from `adopt_cursor_epoch`, and the `SwitchView` that `ViewBack`/`ViewForward` move. `apply_switch` is the exception, and only because its asker really did fold. **Copying `ToOthers` from `apply_switch` into `apply_model` is the trap, and it is invisible from the bystander's side** — `adopt_active_view` moves the asker's *mirror* either way, so the drift is between the server's picture of the asker and the asker's own slice, and a test asserting through a "what do the others hear" helper looks away from it by construction. (`apply_switch` has the mirror-image hole on its *refused* path, where the asker folded something the document turned down and is the one client not told: improvise-6c6q.) A `Vec` rather than an `Option` because one command can owe two relays with different audiences, and an `Option` would have had to drop one silently. Everything else is that client's alone and never touches the `App`'s own view slice: **the model is shared, the UI is not.** Folding `SwitchView` into the mirror alone was the bug — `viewport()` then read the cursor under the new view's key and the next `project()` built that client a grid out of the *other* view. diff --git a/crates/gp-tree/src/fit.rs b/crates/gp-tree/src/fit.rs index f39059d3..282a8676 100644 --- a/crates/gp-tree/src/fit.rs +++ b/crates/gp-tree/src/fit.rs @@ -457,7 +457,7 @@ mod quality_tests { #[test] fn the_ranking_is_ordered_shares_with_the_unused_features_dropped() { let ranked = importance_ranking(&[1.0, 0.0, 3.0]); - assert_eq!(ranked.len(), 2, "the zero-importance feature is listed"); + assert_eq!(ranked.len(), 2, "the zero-importance feature is dropped"); assert_eq!(ranked[0].0, 2); assert_eq!(ranked[1].0, 0); assert!((ranked[0].1 - 75.0).abs() < 1e-3, "{ranked:?}"); From 4ad0664376fceec0db8ebd9eee065de68bd631e5 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Sun, 20 Sep 2026 10:54:51 -0700 Subject: [PATCH 02/17] chore(bd): record that parallel agents must set BEADS_ACTOR bd --actor defaults to $BEADS_ACTOR, then git user.name, then $USER, so a wave of agents sharing a default actor cannot be told apart by the claim guards and two of them can claim one bead. Co-Authored-By: Claude Opus 5 (1M context) --- .beads/issues.jsonl | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 7501c1be..137c7f2a 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,5 +1,5 @@ {"_type":"issue","id":"improvise-9c8c","title":"Move the reasoning out of code comments into context/ prose, cap comments at two lines","description":"USER INSTRUCTION 2026-09-20: \"cleanup all the comments in code and put the important stuff as prose files in context/. If code comments are needed, at most two consecutive lines of comments and possibly a context/ doc reference for more details (but don't overdo this).\"\n\nSCOPE RULED BY THE USER, both maximal:\n * The two-line cap applies to `///` and `//!` DOC COMMENTS as well as `//` implementation comments.\n * It applies to TEST doc-comments too. The bug narratives this repo's doctrine puts on a regression test move to prose keyed by test name.\n\n## The measurement\n\n103 Rust files, 12,533 comment lines, 2,961 comment runs of which 1,306 are longer than two lines, and 12 doctest fences inside doc comments. Worst single runs: worker.rs 121 consecutive lines, ui/reduce.rs 119, improvise-gp/lib.rs 84, gp-tree/examples/fit_csv.rs 79, wasm-client/keys.rs 78, cmd/fit.rs 59, dataset.rs 57, registry.rs 56, basis.rs 56, vfs.rs 55.\n\n## What makes this hard, and why it is not a formatting change\n\nTHE REASONING IN THOSE COMMENTS IS THE MOST EXPENSIVE ARTEFACT IN THE REPOSITORY. The post-wave documentation review concluded that the code's doc comments are now BETTER than context/repo-map.md, naming src/ui/grid.rs, worker-server/src/lib.rs, ui/reduce.rs and protocol/render.rs as carrying the full argument correctly. Several of them were written in response to a bug that cost a review round to find, and they exist because someone could not re-derive the rule from the code.\n\nSO THIS IS A MOVE, NOT A DELETION. Every argument either lands in context/ prose or is dropped DELIBERATELY as a restatement of what the code plainly says. The failure mode to avoid is a tidy diff that quietly loses the one sentence explaining why a check exists — which is the shape that reintroduces the bug the check guards.\n\n## The convention\n\n * A comment run is AT MOST TWO CONSECUTIVE LINES.\n * It may carry a `context/` reference for the rest -- but SPARINGLY, per the user (\"don't overdo this\"). A pointer on every function is noise; a pointer where a reader would otherwise re-derive a rule wrongly is the point.\n * Prose lands in context/ files owned by ONE area each, so parallel work does not collide.\n * Test evidence -- what broke, what was injected, why the test can fail -- lands in a test-evidence file keyed by TEST NAME.\n\n## Known hazards\n\n * TWELVE DOCTEST FENCES live inside doc comments. Capping a `///` block that contains a ``` example DELETES A RUNNING TEST. Find them first and decide each: promote to a real test, or keep the fence and cap around it.\n * A `//!` module doc is often the only orientation a file has. Losing it entirely costs more than it saves; the cap applies, so what survives must be the two lines that matter.\n * `#[allow(...)]`, `#[cfg(...)]` and clippy directives are ATTRIBUTES, not comments -- do not touch them.\n * A test name is the key into the evidence file, so RENAMING A TEST ORPHANS ITS ENTRY. Whatever convention is chosen must make that detectable.\n","acceptance_criteria":"No comment run in any .rs file exceeds two consecutive lines, verified by a script that is kept in the repo so the rule is checkable rather than remembered. Every argument that was in a capped comment is findable in context/ -- spot-checked by sampling the worst ten files and confirming their reasoning survived. cargo test --workspace --no-fail-fast stays at 1309 passing (or higher, if a doctest fence was promoted to a real test), and git diff filtered for non-comment lines is EMPTY -- no behaviour changed. The twelve doctest fences are each accounted for explicitly. rustdoc still builds.","status":"open","priority":1,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T16:10:53Z","created_by":"Edward Langley","updated_at":"2026-09-20T16:10:53Z","labels":["area:docs"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-0yq8","title":"Move ClientState and keys.rs::step into improvise-protocol","description":"THE SINGLE HIGHEST-VALUE CONSOLIDATION LEFT, named by the agent that did the keymap move and measured rather than guessed.\n\ndesign-principles 5 (user ruling 2026-09-20): minimise the code a new UI framework needs; centralise logic in a redux-like store so a new front end DISPLAYS STATE rather than reimplementing behaviours. The test is: would a third front end have to write this again?\n\nkeys.rs::step is 99 LINES OF \"WHAT THIS COMMAND DOES\" and the answer is YES. It depends on NOTHING but ViewState + the protocol widget caches + the announced window -- every one of which is ALREADY A PROTOCOL TYPE. So it is in the wrong crate, and the only reason it is there is history.\n\nAFTER THIS MOVE A NEW FRONT END NEEDS EXACTLY TWO THINGS: browser-event (or terminal-event, or whatever) -\u003e KeyEvent, and painting. That is the whole of design-principles 5 made true rather than aspirational.\n\nWHY IT WAS NOT DONE IN THE SAME PASS: it is a crate move on top of a crate move (the keymap had just moved), and half-doing it would leave two homes. Deliberate, not forgotten.\n\nMEASURED CONTEXT from that pass: keys.rs grew 264 -\u003e 401 non-comment lines while GAINING the whole editing path, search typing, panels, transpose and tile select -- and the key TABLE left the crate entirely. web/app.js, the only genuinely framework-bound file, grew 344 -\u003e 359 while gaining a live editor and a hint line. So the trend is right; this ticket is what finishes it.\n\nNOTE THE SECOND-CONSUMER ARGUMENT: the more of the front end is SHARED, the more of it a second consumer can AUDIT. CellDisplay::edit_text exists because the browser asked a question the TUI had answered privately and the two answers differed -- see doctrine(being_the_second_consumer_audits_the_abstraction).","acceptance_criteria":"ClientState and keys.rs::step live in improvise-protocol, and a new front end needs exactly two things: an event -\u003e KeyEvent mapping, and painting. Both depend on nothing but ViewState, the protocol widget caches and the announced window — all already protocol types — so the move should cost no new dependency. tests/lockstep.rs still drives the client and the real reduce_key over the same keypresses; if the move makes that test trivially true by type identity, say so and delete it (doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)).","status":"open","priority":1,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T04:38:43Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:56:06Z","labels":["area:shared-frontend"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-0yq8","title":"Move ClientState and keys.rs::step into improvise-protocol","description":"THE SINGLE HIGHEST-VALUE CONSOLIDATION LEFT, named by the agent that did the keymap move and measured rather than guessed.\n\ndesign-principles 5 (user ruling 2026-09-20): minimise the code a new UI framework needs; centralise logic in a redux-like store so a new front end DISPLAYS STATE rather than reimplementing behaviours. The test is: would a third front end have to write this again?\n\nkeys.rs::step is 99 LINES OF \"WHAT THIS COMMAND DOES\" and the answer is YES. It depends on NOTHING but ViewState + the protocol widget caches + the announced window -- every one of which is ALREADY A PROTOCOL TYPE. So it is in the wrong crate, and the only reason it is there is history.\n\nAFTER THIS MOVE A NEW FRONT END NEEDS EXACTLY TWO THINGS: browser-event (or terminal-event, or whatever) -\u003e KeyEvent, and painting. That is the whole of design-principles 5 made true rather than aspirational.\n\nWHY IT WAS NOT DONE IN THE SAME PASS: it is a crate move on top of a crate move (the keymap had just moved), and half-doing it would leave two homes. Deliberate, not forgotten.\n\nMEASURED CONTEXT from that pass: keys.rs grew 264 -\u003e 401 non-comment lines while GAINING the whole editing path, search typing, panels, transpose and tile select -- and the key TABLE left the crate entirely. web/app.js, the only genuinely framework-bound file, grew 344 -\u003e 359 while gaining a live editor and a hint line. So the trend is right; this ticket is what finishes it.\n\nNOTE THE SECOND-CONSUMER ARGUMENT: the more of the front end is SHARED, the more of it a second consumer can AUDIT. CellDisplay::edit_text exists because the browser asked a question the TUI had answered privately and the two answers differed -- see doctrine(being_the_second_consumer_audits_the_abstraction).","acceptance_criteria":"ClientState and keys.rs::step live in improvise-protocol, and a new front end needs exactly two things: an event -\u003e KeyEvent mapping, and painting. Both depend on nothing but ViewState, the protocol widget caches and the announced window — all already protocol types — so the move should cost no new dependency. tests/lockstep.rs still drives the client and the real reduce_key over the same keypresses; if the move makes that test trivially true by type identity, say so and delete it (doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)).","status":"in_progress","priority":1,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-20T04:38:43Z","created_by":"Edward Langley","updated_at":"2026-09-20T17:53:20Z","started_at":"2026-09-20T17:53:20Z","lease_expires_at":"2026-09-20T17:58:20Z","heartbeat_at":"2026-09-20T17:53:20Z","labels":["area:shared-frontend"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-oai9","title":"[bug] The worker's errors reach the console with their cause stripped","description":"FOUND BY THE FIRST REAL BROWSER RUN of the deployed page (2026-09-20), and it is the reason that run produced no actionable information.\n\nThe console said:\n\n improvise worker: failed to start\n Error: Cannot open demo.improv\n\n\"Cannot open demo.improv\" is the CONTEXT vfs::open attaches with with_context. anyhow Display prints ONLY THE OUTERMOST CONTEXT, so bindings::start mapping through e.to_string() kept the label and threw away the cause -- the Storage failure or the parse failure that actually happened.\n\nWHY THIS IS THE WORST POSSIBLE PLACE FOR IT: bindings.rs is cfg(target_arch = wasm32) and is the one module in the crate that NO TEST IN THE REPOSITORY CAN REACH. A devtools console is the only instrument pointed at it. So the single code path with no test coverage was also the one discarding its own diagnostics.\n\nFIXED: a report() helper rendering with the alternate form {:#}, which walks the chain joining with \": \". It lives in lib.rs rather than bindings.rs SO IT CAN BE TESTED AT ALL -- bindings is wasm-gated. Five call sites (start, on_message, flush, documents, refresh).\n\nTEST WRITTEN AGAINST THE OLD RENDERING FIRST: a_reported_error_carries_its_cause_not_just_its_label asserts that anyhow to_string() IS just the label (pinning the trap, so the helper stops being needed if that ever changes) and that report() contains BOTH the label and the cause.\n\nTHIS DOES NOT FIX THE UNDERLYING FAILURE -- it makes the next reload name it. Redeploy and read the console again.","status":"closed","priority":1,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T03:55:27Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:22:07Z","started_at":"2026-09-20T05:32:34Z","closed_at":"2026-09-20T06:22:07Z","close_reason":"Closed by PR #21 (WU-I) ON EVIDENCE, with NO CODE WRITTEN -- the fix described in this ticket was already in the tree.\n\nVerified rather than assumed: report() renders with the alternate form {:#}, which walks the anyhow chain joining with \": \"; it lives in lib.rs rather than the wasm-gated bindings.rs so it can be tested at all; five call sites (start, on_message, flush, documents, refresh); and a_reported_error_carries_its_cause_not_just_its_label pins both halves -- that anyhow's to_string() IS just the outermost label, and that report() carries the cause too.\n\nFAULT INJECTED BEFORE CLOSING: changed {e:#} back to {e} and watched the test fail on contains(\"no such document\"). So the test is discriminating and the claim is evidence rather than description.\n\nThe ticket's own closing line still stands and is not discharged by this: it does not fix the underlying failure, it makes the next reload name it.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-anr9","title":"Project the five grid-frame facts: unblocks H, z, gz and fixes Food/Alice at once","description":"Two tickets turn out to be ONE piece of work, noticed by improvise-bpq2 while walking the bindings.\n\nimprovise-s1xw (multi-level grid labels -- the browser shows Food/Alice where the TUI shows two columns) and the bindings H / z / gz need THE SAME FIVE FACTS, which gotcha 44 already enumerates as the grid frame the cache does not carry:\n row_cats / col_cats -- how many header levels\n row_items -- multi-level label TUPLES and inline group headers\n col_group_for -- column group spans\n is_group_collapsed -- the collapse indicator\n(record_col is the sixth and is CORRECT to keep off: session-local, keys pending drill edits.)\n\nSo:\n * hide-selected-row-item (H) needs to know WHICH CATEGORY a row label belongs to -- impossible while row_labels slash-joins a tuple.\n * toggle-group / gz need group membership and collapsed state.\n * And the browser renders the slash-joined form, which the TUI own test asserts must not appear.\n\nDOING THEM SEPARATELY MEANS TOUCHING row_labels TWICE. It is a co-ordinated type change either way -- improvise-wasm-client/src/render.rs consumes both row_labels and col_labels -- so the cheap moment is once.\n\nDO NOT add row_levels beside row_labels. Two answers to \"what are the labels\" is the disease (improvise-edp), and bpq2 has just spent a wave demonstrating what one shared derivation buys.\n\nSupersedes/absorbs improvise-s1xw -- decide which id survives.","notes":"USER RULING 2026-09-20: 'Most of the view functionality should be a wasm blob that's shared between the TUI and the web UI' -- clarified as ONE SHARED CRATE, not the TUI loading wasm at runtime. The view derivations live in improvise-protocol; the browser gets them as wasm, the TUI links them natively, and ONLY THE PAINTING differs (ratatui vs DOM).\n\nTHIS TICKET IS NOW THE LAST STEP OF THAT, which is why it is P1.\n\nMeasured 2026-09-20: every other widget already takes a shared cache and no \u0026Model -- FormulaContent(\u0026FormulaCache), ViewContent(\u0026ViewListCache), TileBar(\u0026TileBarCache), CategoryContent(\u0026CategoryTreeCache), WizardWidget(\u0026WizardCache), HelpWidget(usize), WhichKeyWidget(\u0026[..]). THE REMAINING DUPLICATION IS ONE FILE: src/ui/grid.rs still derives 14 things straight off layout./view., and those are exactly the six frame facts in gotcha 44.\n\nSo closing this ticket is what makes 'most of the view functionality shared' TRUE rather than nearly true, and it fixes the Food/Alice divergence and unblocks H/z/gz as the same change.\n\nSEQUENCING NOTE: this touches improvise-protocol/src/render.rs, src/ui/grid.rs AND improvise-wasm-client/src/render.rs together -- it is a co-ordinated type change on row_labels/col_labels, not an additive one. Do not start it while another agent is in improvise-protocol; the client-side keymap work (improvise-bpq2 successor) is there now.","status":"closed","priority":1,"issue_type":"task","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T02:36:42Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:41:32Z","started_at":"2026-09-20T05:32:26Z","closed_at":"2026-09-20T07:41:32Z","close_reason":"Closed by PR #23 (WU-A). The coordinated type change gotcha 44 had been waiting for.\n\nGridCache::row_labels/col_labels were Vec\u003cString\u003e from GridLayout::row_label, which SLASH-JOINS a multi-level tuple -- the exact rendering src/ui/grid.rs::two_row_categories_produce_cross_product_labels asserts must not appear. They are now ONE AxisFrame PER AXIS carrying all five facts: cats (hence levels()), FrameEntry::Item(Vec\u003cString\u003e) tuples, FrameEntry::Group(FrameGroup) inline headers, group_of(idx) spans and FrameGroup::collapsed.\n\nIt is ONE TYPE, NOT A row_levels BESIDE A row_labels -- the ticket's explicit constraint, honoured. The rules that were written twice are now methods both ends call: shows_level/header_labels (repeat suppression), group_of/groups, shares_ancestry, body_from. GridWidget lost its \u0026View; nothing in it reads the view any more. RenderModel gained a RenderRow ENUM so a group header cannot carry cells.\n\nEVIDENCE: the_whole_frame_is_unchanged passes byte for byte, which is the proof the TUI did not move -- and its doc comment now states honestly what it does NOT cover (both its axes are single-level, so it exercises no multi-level suppression), with the multi-level half pinned by the new tests/grid_frame_agreement.rs. That test drives the ratatui widget into a buffer and the browser client through a Snapshot OF THE SAME CACHE and compares WHAT EACH DRAWS -- comparing caches would be vacuous, since it is one cache. Six faults injected, and an independent reviewer re-ran all six and confirmed each fails on the assertion it is named after.\n\nTHE USER'S P1, found on the PR and larger than it first looked: a cell's identity is cats zipped with its item tuple, and invalidate_relabelled compared items alone. The pre-anr9 code could not distinguish a category change either -- but it never HAD cats to compare, so this migration did not break a check, IT OUTGREW ONE. The sharp case is an AXIS SWAP rather than a rename: two categories may legally share an item name, so the preserved CellId::Record stays VALID and addresses the OTHER record, a genuine misdirected write. And it was wrong end to end, not only in apply: grid_updates diffs cells against the FRAMED mirror, so an apply that kept them made the producer agree they matched and SEND NOTHING AT ALL, leaving the client re-identified with nothing in the stream that could ever correct it -- wrong forever, the direction Dirty::of is deliberately sloppy to avoid. Fixed with frame-only regression tests (no Cells or Totals behind them) and a load-bearing negative arm.\n\nWHAT THE ACCESSOR MIGRATION COST, which no test could see: replacing GridLayout access with AxisFrame accessors put an O(n) scan behind every item(idx), so a draw loop calling it per position per level became quadratic AND proportional to the absolute scroll offset. The old widget's one materializing line was load-bearing. Cured with bulk forms (header_labels, groups, shares_ancestry) and by deleting the per-position siblings nothing needed; the four that survive now carry \"not for a draw loop\" on each.\n\nFOLLOW-UPS FILED: improvise-gd83 (group_of is nearest-preceding, not membership -- with THE_MEMBERSHIP_CORRECT_SPANS and an assert_ne! that starts failing the day it is fixed), improvise-jq8l (no scrollIntoView), improvise-plcx (the harness needs a grouped multi-level fixture), improvise-0o6x (a group header first on its axis is never drawn).","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-vvl3","title":"A headless browser harness: run storage_contract and the shell assertions under Playwright","description":"THE CONDITION THAT DEFERRED improvise-xf1 HAS BEEN MET. The user deferred browser testing on 2026-09-20 with: \"I am happy just putting off the browser test for now: WE HAVE TO HAVE SOMETHING TO TEST.\" After improvise-9x6, djm, hwpi and the demo model, we do -- a complete self-contained application whose ENTIRE untested surface is the part a harness would cover.\n\nTHE EVIDENCE THAT IT HAS FLIPPED, not just an argument that it would be nice:\n * Three of improvise-hwpi four fixes are ONE-ASSERTION tests (status text after a bad frame, status text with no peer, one repaint on commit). That ticket itself said \"consider whether the right first move is a headless DOM -- otherwise the fixes are as unverified as the bugs.\" The fixes are now shipped, so that sentence is true of them.\n * The first-frame watchdog was found BY READING, and only because someone went looking for it. The next one will not be.\n * OPFS is the specific thing MemoryFs cannot stand in for, and it is now on the critical path of the DEFAULT deployment rather than a future one. storage_contract in storage.rs exists and is written down precisely so it can be run against OpfsFs in a browser; NOTHING BUT A BROWSER CAN RUN IT.\n * Persistence across reload is the one improvise-bck acceptance criterion no test in this repo can structurally reach.\n\nSCOPE, deliberately small: python3 -m http.server --directory site, plus one Playwright spec, reusing the assemble step CI already performs. Two parts -- (a) storage_contract against OpfsFs, which is the thing improvise-xf1 is actually about; (b) three shell assertions from hwpi.\n\nShould block improvise-bck sign-off. Un-defer or close improvise-xf1 into this, whichever the user prefers -- xf1 predates the worker and is narrower than what is now needed.","notes":"ASSERTIONS OWED BY improvise-taxx's FIX (from WU-I / PR #21, so vvl3 need not re-derive them). The close-listener now distinguishes never-connected from was-connected-and-dropped via an `opened` flag, because readyState is already CLOSED in any close listener and both cases are code 1006 / wasClean:false. Four assertions:\n\n 1. close-before-open -\u003e status stays 'cannot reach \u003curl\u003e ...'\n 2. open-then-close -\u003e status reads 'disconnected'\n 3. ASSERTION 1 MUST FAIL against the previous unconditional close handler. That arm IS the regression guard; without it, 'always say cannot reach' passes.\n 4. after either arm, the handshake watchdog must NOT fire 8s later and overwrite the message.\n\nAlso note WU-I DECLINED Copilot's request to add JS regression tests in PR #21, on the grounds that there is no JavaScript test infrastructure in this repo at all (no package.json, no runner, nothing in CI executing a line of web/*.js) and that a parallel ad-hoc JS surface beside this ticket would be two answers to one question. So this bead now owns that infrastructure decision, not just the OPFS half.","status":"closed","priority":1,"issue_type":"task","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T02:29:52Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:44:42Z","started_at":"2026-09-20T05:32:35Z","closed_at":"2026-09-20T06:44:42Z","close_reason":"Closed by PR #24 (WU-J). The harness exists, it runs in CI, and the browser job can fail the build.\n\nWHAT IT RUNS: harness/ at the repo root serves the ASSEMBLED site (web/assemble.sh, extracted from ci.yml so there is ONE answer to how the site is arranged) plus its own /opfs/ page. Under Playwright it runs (a) improvise_io::storage::contract::storage_contract against OpfsFs in a real dedicated Web Worker, on Chromium AND Firefox, at dir \"\" and at \"docs\", plus targeted checks on the two things improvise-xf1 named -- the hand-rolled keys() walk and FileSystemReadWriteOptions' `at` -- plus attach, refresh, both halves of commit_deletes, and PERSISTENCE ACROSS A RELOAD; and (b) improvise-hwpi's shell assertions plus the watchdog, which is the first execution of web/app.js and web/worker.js in this repository's history.\n\nstorage_contract moved OUT of cfg(test) to make that possible: it is now a public function, not a test helper.\n\nTHE FINDING, and it is bigger than the harness: A CONTRACT NOBODY CAN EXECUTE IS PROSE. storage_contract had been cited in gotcha 28 for months as \"the written-down spec OpfsFs must satisfy in a browser\". The first time OpfsFs was handed to it, IT FAILED ON ITS FIRST ASSERTION -- and not on a subtle browser quirk but on a semantic disagreement about what `list` MEANS, sitting in plain sight in a trait doc everyone had read.\n\nTHREE BUGS NOTHING ELSE COULD HAVE FOUND:\n 1. list named ENTRIES, not DOCUMENTS. attach must create a file to get a handle and indexed it, so a zero-byte non-document was listed. improvise-9x6 had worked around it in vfs::holds_a_document; the wart it could not reach is the DOCUMENT PICKER, which vfs::documents feeds straight from list. Now: attach indexes nothing, write indexes and un-queues a pending delete of the name, refresh skips zero-byte entries.\n 2. A second tab could break the first's picker -- found by reading the fix for (1). refresh now measures sizes, and a file under another context's exclusive sync handle is not readable by the promise API. An unmeasurable entry counts as a document: wrong in the cheap direction.\n 3. A directory is not an unmeasurable file (Copilot). keys() yields directories; getFileHandle rejects TypeMismatchError, which the fail-open in (2) would have listed. Tested rather than argued -- the harness creates a real OPFS directory.\n\nIT ALSO ABSORBED THE JS TEST-INFRASTRUCTURE DECISION that PR #21 declined. playwright.config.js states that this is the one place a line of web/*.js is executed and why Playwright rather than a headless-DOM runner: what goes wrong in those files is a real Worker, a real WebSocket, real OPFS and real event ordering, none of which a DOM shim has. That decline now holds something.\n\nWHAT IT STILL CANNOT SEE: Safari (Playwright's WebKit provides no OPFS -- getDirectory() itself rejects; kept as a runtime PROBE-skip so it starts running the day that changes, and an unexpected skip on any OTHER engine FAILS as a regression). bindings.rs and web/worker.js as code paths. The two-tab exclusive-handle path. The CSS and the ResizeObserver arithmetic. And two socket-timing bugs whose trigger is the host's network stack -- see improvise-xps8.","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -52,10 +52,10 @@ {"_type":"issue","id":"improvise-o8fa","title":"improvise-ws-server speaks ws:// only, so the deployed page cannot reach it","description":"Found by opening the deployed page (2026-09-20). The guidance the page and the README carried was WRONG in a way that sends someone down a dead end.\n\nFROM AN https ORIGIN A ws:// ADDRESS IS BLOCKED AS MIXED CONTENT, so the deployed GitHub Pages site needs wss://. improvise-ws-server has NO TLS AT ALL -- no rustls, no native-tls, nothing in the crate or its dependencies -- so wss:// is not obtainable from it. The text said \"note that from an https origin a ws:// address is blocked\", which correctly named the symptom and then offered an address that can never work.\n\nTEXT CORRECTED ALREADY: both web/index.html and README.md now say the address must be wss:// from the deployed page, that the server speaks plain ws:// only, and that the ws:// form works when the page is served over http://localhost. This ticket is the missing CAPABILITY.\n\nTHREE WAYS TO PROVIDE IT, and they are not equivalent:\n * TERMINATE TLS IN FRONT (nginx, caddy, cloudflared, ngrok). Zero code, works today, and is what the corrected text points at. But it means the documented path to sharing a TUI-hosted document involves a second piece of software, which is a poor answer for a tool whose selling point is that it is one binary.\n * NATIVE wss:// IN improvise-ws-server -- rustls plus a cert. Real work, and it raises the certificate question: a self-signed cert makes the browser refuse the socket with no usable error, so this is only pleasant with a real cert or an explicitly trusted local CA (mkcert).\n * DECIDE THE DEPLOYED PAGE DOES NOT PAIR WITH A LOCAL SERVER at all, and say so. Defensible: the standalone worker deployment is the point of the Pages site, and ws:// pairing is a LOCAL development convenience that works fine from the local page. Cheapest, and it makes the docs shorter rather than longer.\n\nThe third is probably right for now and the first is the escape hatch; the second only earns its place if sharing a TUI-hosted document to a remote browser becomes a real use case. Do not implement the second by default.","status":"closed","priority":2,"issue_type":"feature","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T03:53:54Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:22:10Z","started_at":"2026-09-20T05:32:35Z","closed_at":"2026-09-20T06:22:10Z","close_reason":"Closed by PR #21 (WU-I). DOCUMENTATION ONLY, per the user's ruling; native wss:// DECLINED.\n\nTHE DECLINE'S SUBSTANCE IS THE CERTIFICATE, NOT rustls. A self-signed cert makes the browser refuse the socket with no usable error, because a WebSocket handshake has no interstitial -- there is no place to click through. A real cert or mkcert is a second piece of software too, so the choice is two-either-way, with one option additionally shipping a TLS stack and a cert-loading surface in a pivot-table binary.\n\nWHAT LANDED: a worked Caddy example, the nginx difference that bites (proxy_http_version 1.1 plus the Upgrade/Connection headers), the honest cost (a second binary, a poor answer for a one-binary tool), and the framing that the standalone worker deployment is the point of the Pages site while ws:// pairing works fine from a local http://localhost page.\n\nTHIS BEAD COST SEVEN COPILOT ROUNDS AND NINE FINDINGS, EVERY ONE IN PROSE. The sequence is the value and is recorded in kb.pl:\n * the first recipe exposed an unauthenticated world-writable server -- documenting a proxy documents an exposure, because TLS termination REMOVES the loopback binding that was the only control;\n * the \"fix\" put Caddy basic_auth in front of a socket the page opens as new WebSocket(url), an API that can set no headers over a handshake that shows no prompt -- BROKEN reads as protection, which is worse than absent;\n * an ngrok --cidr-allow recipe was written without being run (deprecated-but-accepted, so the reviewer's own \"fails at startup\" was also wrong);\n * \"the browser cannot send a credential on a WebSocket handshake\" over-generalised the governing fact and contradicted the Cloudflare Access bullet three paragraphs up, which works BECAUSE the handshake carries a cookie;\n * an IP allow-list cannot see a cross-site socket at all, which is improvise-kc7n;\n * an absolute loopback claim denied a browser mitigation that exists but is mid-rollout;\n * and \"the auth above\" called an allow-list authentication in a section whose opening sentence is that the server has none.\n\nThe governing fact was ALREADY IN THE SAME FILE at every step, as the basis of this very decline. The lesson the repo did not have: a reviewed paragraph is not evidence.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-955n","title":"[bug] Host::commands applies a batch partially before rejecting","description":"Found by Copilot on PR #13 (the retro review of the unreviewed batch), DECLINED there with reasons on the thread, and filed rather than patched.\n\nADR 001 is emphatic that a stale batch is never applied, NOT EVEN PARTIALLY -- and improvise-ondj went to the trouble of a TYPE (Checked) to make the unchecked path unconstructible. But Checked is PER-COMMAND: Checked::against answers for one Command against the App AS IT STANDS. A ClientFrame::Commands is a SEQUENCE of them, and Host::commands applies in order and abandons at the first rejection. So a frame carrying SwitchView followed by a stale Command::Model has already applied the SwitchView.\n\nSO ADR 001 GUARANTEE IS CURRENTLY TRUE OF A SINGLE COMMAND AND FALSE OF A BATCH.\n\nA SECOND-ORDER EFFECT NOBODY HAD WRITTEN DOWN, found while declining it: Worker::on_frame skips persist() when ANY frame in the answer is Rejected. So a batch that DID apply a SwitchView before being refused leaves Workbook::active_view moved in memory and NOT WRITTEN BACK -- the document on disk and the document in the worker disagree, silently.\n\nWHY IT WAS DECLINED RATHER THAN PATCHED: closing it means the basis check moving from the COMMAND to the BATCH -- every command answered before any is applied. That is an ADR 001 design change with a real question inside it: WHAT DOES A BATCH BASIS MEAN WHEN ITS LATER COMMANDS WERE COMPOSED AGAINST ITS EARLIER ONES EFFECTS? A client resolving one keypress produces SetSelected plus offsets computed from the selection it just set. Answer that before writing code.\n\nREACHABILITY IS BOUNDED TODAY: no client in this workspace composes a batch containing more than one basis-carrying Command::Model -- the batches improvise-bpq2 produces are view commands, which carry no basis. Do this before the second tranche of client-side resolution creates a reason to batch two model commands.\n\nSupersedes the earlier ticket filed from bpq2 if there is one -- dedupe.","status":"closed","priority":2,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T03:35:27Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:29:48Z","started_at":"2026-09-20T05:32:29Z","closed_at":"2026-09-20T07:29:48Z","close_reason":"Closed by PR #26 (WU-C). improvise-08wb is closed as a duplicate of this one.\n\nADR 001 said a stale batch is never applied, NOT EVEN PARTIALLY, and improvise-ondj went to the trouble of a type (Checked) to make the unchecked path unconstructible -- but Checked is PER-COMMAND and a ClientFrame::Commands is a sequence of them, so Host::commands applied in order and abandoned at the first rejection. The guarantee was true of a single command and false of a batch.\n\nCheckedBatch::against now answers for EVERY command before any is applied.\n\nTHE DESIGN QUESTION THE TICKET DEMANDED WAS ANSWERED BY THE USER, not by the code, and it is larger than the ticket imagined. The ticket asked \"what does a batch's basis mean when its later commands were composed against its earlier ones' effects\". The user's ruling: \"a sequence of commands sent to the server should assume that it's played in order from start to end and that each command sees the state after the previous command runs\", as \"a single transaction from a given base\". Four clauses -- one basis per frame; checked once before anything; applied in order with data dependence EXPECTED; all or nothing. Clauses 1-3 landed here; clause 4 (rollback) is improvise-yqpd.\n\nAN EARLIER \"at most one document mutation per frame\" RULE WAS SHIPPED AND THEN REJECTED BY THE USER as forbidding the capability. It was wrong twice over: it forbids data-dependent chains, and arity is not a property of a variant anyway -- a future Command::RunScript is one variant performing unboundedly many mutations.\n\nTHE FINDING THAT MATTERED MOST came from the independent adversarial review that substituted for Copilot (quota exhausted), and it was a REGRESSION THIS WORK INTRODUCED: hoisting the basis check to the frame hoisted ALL THREE TIERS. The document and layout tiers are legitimately frame-level facts; THE RECORD TIER IS NOT AND CANNOT BE, because which records are at stake is a property of the command. So every mutation after the first had its records checked against nothing -- a silent lost update, confirmed by executing a real Host: client A's stale [SetCell(North), SetCell(South)] clobbered client B's write to South with no rejection, where the base commit refused it. Fixed: every mutation answers for its own records against the FRAME'S basis, with an exemption stated over a property -- a record that neither resolves nor is claimed is one the frame is creating. The reviewer attacked that exemption at all four corners and could not break it: the predicate is a conjunction and the client controls only one conjunct.\n\nTHE SECOND-ORDER WORKER EFFECT the ticket named is closed twice over; its test fails only when BOTH old behaviours are restored.\n\nA CAPABILITY NOBODY HAD NAMED, now guarded: a frame may write THE SAME RECORD TWICE -- two measures on one record IS one record -- and that works only because every check happens before any apply. Moving the checks into the loop breaks it, which is what stops someone closing clause 4 by taking that trade unnoticed.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-08wb","title":"[bug] A refused ClientFrame::Commands keeps the commands before the refusal","description":"Found by improvise-bpq2 (2026-09-20), stated on the variant, and currently UNREACHABLE from any client in this workspace -- filed so it is a decision rather than a discovery.\n\nClientFrame::Commands(Vec\u003cCommand\u003e) batches, because one cursor key resolved client-side is SetSelected plus the offsets viewport_effects returns, and a host that folds the selection but not the offset projects the wrong window. Host::commands applies IN ORDER and ABANDONS AT THE FIRST REJECTION.\n\nTHAT LEAVES A HOLE ADR 001 DOES NOT HAVE. ADR 001 is emphatic that a stale batch is never applied, NOT EVEN PARTIALLY -- and improvise-ondj went to the trouble of a type (Checked) to make the unchecked path unconstructible. But Checked is per-COMMAND, and a ClientFrame::Commands is a sequence of them, so a frame whose third command is refused has already applied the first two.\n\nWHY IT IS NOT LIVE: no client in this workspace composes a Commands frame containing more than one basis-carrying Command::Model -- the batches bpq2 produces are view commands (SetSelected + offsets), which carry no basis.\n\nTHE FIX IS TO MOVE THE BASIS CHECK TO THE BATCH: check every Command::Model in the frame before applying any of them. That is the shape ADR 001 already describes for an effect batch, one level up, and it would make Host::commands match the guarantee the ADR states.\n\nDo this before a client has reason to batch two model commands -- which the second tranche of client-side resolution (the ~15 bindings bpq2 left) may well create.","status":"closed","priority":2,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T02:36:42Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:29:28Z","started_at":"2026-09-20T05:32:29Z","closed_at":"2026-09-20T07:29:28Z","close_reason":"Closed as a DUPLICATE of improvise-955n, which PR #26 fixed. Both beads describe the same defect -- Host::commands applied a ClientFrame::Commands in order and abandoned at the first rejection, so a frame whose later command was refused had already applied the earlier ones, leaving ADR 001's 'never applied, not even partially' true of a single command and false of a batch. 08wb was filed from improvise-bpq2's walk and 955n from Copilot's retro review of PR #13; 955n carries the fuller reasoning and the second-order worker effect, so it is the survivor.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-ir7l","title":"[bug] A formula cell is offered as editable and silently reverts","description":"Pre-existing and consistent with the TUI, but improvise-bck just put it on the DEMO FRONT PAGE, which changes who meets it: the first thing a stranger sees is Profit beside Revenue and Cost.\n\nA formula target cell projects CellId::Record, so render.rs::editable says true, so the browser offers an editor. Type into Profit and the write lands -- then recompute_formulas overwrites it on the next pass and the value REVERTS with no explanation.\n\nWHY IT IS NOT SIMPLY A BUG IN editable: a formula target IS a stored coordinate; the cell is a record. What makes it unwritable is not its identity but that a FORMULA OWNS IT. So the fix is a fact the projection does not currently carry -- something like \"this cell is derived\" -- and it belongs beside numeric/error on CellDisplay, which already exists precisely because the SERVER is the side that knows.\n\nCHECK THE TUI BEFORE CHANGING ANYTHING: it has the same behaviour, so whatever is decided should land in both, or the divergence becomes the new bug (gotcha 44 is what that looks like). And note ADR 002 deliberately made settability a fact about the CellId VARIANT -- adding a second, independent \"but not really\" flag is exactly the two-answers-to-one-question shape this codebase keeps curing. Consider instead whether a formula-owned cell should project a different CellId at all.\n\n## Steps to Reproduce\n\n1. Open `web/demo.improv` in the browser — it ships `Profit = Revenue - Cost` beside Revenue and Cost on the front page.\n2. Click the Profit cell. It is offered as editable, because a formula target projects `CellId::Record` and `render.rs::editable` reads the variant.\n3. Type a number and commit. The write lands.\n4. `recompute_formulas` overwrites it on the next pass and the value reverts, with no explanation.\n\nThe TUI behaves the same way, so whatever is decided must land in both or the divergence becomes the new bug.\n","acceptance_criteria":"A formula-owned cell projects a non-Record CellId, so the write is unconstructible rather than flagged — the discipline ADR 002 used for aggregates and ADR 003 reaches for again for read-only sources. It lands in the TUI in the same change, or the two front ends diverge (gotcha 44 is what that looks like). Do NOT add a second 'but not really' flag beside CellId: two answers to 'can I write here' is the disease. Demonstrated on web/demo.improv, where Profit sits beside Revenue and Cost on the front page.","notes":"VERIFIED CONCRETELY during the PR #13 review (2026-09-20), and the diagnosis is sharper than when this was filed.\n\nTHE ROOT CAUSE: project::cell_id decides Record vs Aggregate purely on whether a regular category sits on Axis::None. IT NEVER ASKS WHETHER THE _Measure COORDINATE NAMES A FORMULA TARGET. So a formula cell is a Record, render.rs::editable says true, and the browser offers an editor.\n\nMEASURED AGAINST THE SHIPPED DEMO:\n col 2 label=\"Profit\" is_record=true text=\"12,200\"\n after applying SetCell(key, Number(999.0)), Profit reads \"12,200\"\n\nThe write is ACCEPTED AND STORED. Model::evaluate_aggregated checks formula_cache BEFORE raw data (model/types.rs:409), so the typed value is shadowed forever with nothing telling the user. Not a revert -- a permanent silent shadow.\n\nAND THE TEST THAT COVERS THIS AREA IS LYING BY ITS NAME. every_visible_cell_in_the_web_demo_is_settable PASSES while its name is FALSE for the Profit column -- a third of the landing-page grid. It was proved discriminating against the one thing it CAN see (the aggregate at (0,0) in examples/demo.improv) and is structurally blind to this. That is exactly the shape AGENTS.md calls this repo's characteristic find, arriving in the test written to prevent it.\n\nTHE DECISION IS STILL THE DECISION, and ADR 002 is where it goes:\n * TEACH cell_id ABOUT FORMULA TARGETS -- changes what EVERY client calls a record, and record identity is also ADR 001's unit of atomicity, so this reaches the basis check too.\n * OR GIVE editable A SECOND INPUT -- but that is the conflation gotcha 42 exists to forbid, and ADR 002 deliberately made settability a fact about the CellId VARIANT precisely so there could not be a second answer.\n\nThe first is probably right and is not small. Until it lands, the demo's front page teaches a visitor that typing into Profit does nothing.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T02:29:51Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:57:39Z","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-ir7l","title":"[bug] A formula cell is offered as editable and silently reverts","description":"Pre-existing and consistent with the TUI, but improvise-bck just put it on the DEMO FRONT PAGE, which changes who meets it: the first thing a stranger sees is Profit beside Revenue and Cost.\n\nA formula target cell projects CellId::Record, so render.rs::editable says true, so the browser offers an editor. Type into Profit and the write lands -- then recompute_formulas overwrites it on the next pass and the value REVERTS with no explanation.\n\nWHY IT IS NOT SIMPLY A BUG IN editable: a formula target IS a stored coordinate; the cell is a record. What makes it unwritable is not its identity but that a FORMULA OWNS IT. So the fix is a fact the projection does not currently carry -- something like \"this cell is derived\" -- and it belongs beside numeric/error on CellDisplay, which already exists precisely because the SERVER is the side that knows.\n\nCHECK THE TUI BEFORE CHANGING ANYTHING: it has the same behaviour, so whatever is decided should land in both, or the divergence becomes the new bug (gotcha 44 is what that looks like). And note ADR 002 deliberately made settability a fact about the CellId VARIANT -- adding a second, independent \"but not really\" flag is exactly the two-answers-to-one-question shape this codebase keeps curing. Consider instead whether a formula-owned cell should project a different CellId at all.\n\n## Steps to Reproduce\n\n1. Open `web/demo.improv` in the browser — it ships `Profit = Revenue - Cost` beside Revenue and Cost on the front page.\n2. Click the Profit cell. It is offered as editable, because a formula target projects `CellId::Record` and `render.rs::editable` reads the variant.\n3. Type a number and commit. The write lands.\n4. `recompute_formulas` overwrites it on the next pass and the value reverts, with no explanation.\n\nThe TUI behaves the same way, so whatever is decided must land in both or the divergence becomes the new bug.\n","acceptance_criteria":"A formula-owned cell projects a non-Record CellId, so the write is unconstructible rather than flagged — the discipline ADR 002 used for aggregates and ADR 003 reaches for again for read-only sources. It lands in the TUI in the same change, or the two front ends diverge (gotcha 44 is what that looks like). Do NOT add a second 'but not really' flag beside CellId: two answers to 'can I write here' is the disease. Demonstrated on web/demo.improv, where Profit sits beside Revenue and Cost on the front page.","notes":"VERIFIED CONCRETELY during the PR #13 review (2026-09-20), and the diagnosis is sharper than when this was filed.\n\nTHE ROOT CAUSE: project::cell_id decides Record vs Aggregate purely on whether a regular category sits on Axis::None. IT NEVER ASKS WHETHER THE _Measure COORDINATE NAMES A FORMULA TARGET. So a formula cell is a Record, render.rs::editable says true, and the browser offers an editor.\n\nMEASURED AGAINST THE SHIPPED DEMO:\n col 2 label=\"Profit\" is_record=true text=\"12,200\"\n after applying SetCell(key, Number(999.0)), Profit reads \"12,200\"\n\nThe write is ACCEPTED AND STORED. Model::evaluate_aggregated checks formula_cache BEFORE raw data (model/types.rs:409), so the typed value is shadowed forever with nothing telling the user. Not a revert -- a permanent silent shadow.\n\nAND THE TEST THAT COVERS THIS AREA IS LYING BY ITS NAME. every_visible_cell_in_the_web_demo_is_settable PASSES while its name is FALSE for the Profit column -- a third of the landing-page grid. It was proved discriminating against the one thing it CAN see (the aggregate at (0,0) in examples/demo.improv) and is structurally blind to this. That is exactly the shape AGENTS.md calls this repo's characteristic find, arriving in the test written to prevent it.\n\nTHE DECISION IS STILL THE DECISION, and ADR 002 is where it goes:\n * TEACH cell_id ABOUT FORMULA TARGETS -- changes what EVERY client calls a record, and record identity is also ADR 001's unit of atomicity, so this reaches the basis check too.\n * OR GIVE editable A SECOND INPUT -- but that is the conflation gotcha 42 exists to forbid, and ADR 002 deliberately made settability a fact about the CellId VARIANT precisely so there could not be a second answer.\n\nThe first is probably right and is not small. Until it lands, the demo's front page teaches a visitor that typing into Profit does nothing.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-20T02:29:51Z","created_by":"Edward Langley","updated_at":"2026-09-20T17:53:19Z","started_at":"2026-09-20T17:53:19Z","lease_expires_at":"2026-09-20T17:58:19Z","heartbeat_at":"2026-09-20T17:53:19Z","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-vhcz","title":"[bug] OpfsFs::list disagrees with the other backends on path shape","description":"Found by improvise-9x6 (2026-09-20) and currently PAPERED OVER by always passing the flat root.\n\nOpfsFs::list returns BARE NAMES; MemoryFs and PhysicalFs return dir-JOINED ones. So Path::new(\"\") is the ONE dir at which all three agree, and the only one at which storage_contract actually describes OpfsFs.\n\nTHE TRAP: a worker that organised documents under docs/ would get an EMPTY PICKER in a browser and a FULL ONE in every test. The harness cannot see the disagreement because it never runs OpfsFs -- improvise-xf1 is deferred -- so this is precisely the class of defect the missing browser harness exists to catch, discovered by reading instead.\n\nTWO FIXES:\n * Make OpfsFs::list join dir, matching the other two. Straightforward, and makes storage_contract describe all three at every dir.\n * Or document the flat root as PART OF THE CONTRACT and assert it in storage_contract, so the constraint is stated rather than discovered.\n\nThe first is better unless there is a reason OPFS cannot -- note gotcha 28 says backends that are not trees treat dir as a NAME PREFIX, which is already the trait doc contemplating this, so check whether the doc or the impl is the thing that is wrong.","status":"closed","priority":2,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T02:10:14Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:44:42Z","started_at":"2026-09-20T05:32:36Z","closed_at":"2026-09-20T06:44:42Z","close_reason":"Closed by PR #24 (WU-J). THE TICKET ASKED WHICH OF THE DOC OR THE IMPL WAS WRONG: IT WAS THE IMPL.\n\nThe Storage trait doc already said that backends which are not trees treat `dir` as a NAME PREFIX. OpfsFs::list ignored `dir` outbound as well as inbound, returning bare names where MemoryFs and PhysicalFs return dir-joined ones -- so Path::new(\"\") was the one dir at which all three agreed, and the only one at which storage_contract actually described OpfsFs.\n\nNow it joins. The three backends agree at EVERY dir, and storage_contract runs against OpfsFs at both \"\" and \"docs\" in a real browser. `dir` stays DECORATION rather than a filter, because entry_name reduces every path to its file name and an OPFS entry name may not contain a separator.\n\nFAULT INJECTED: reverting to bare names fails storage_contract_at_\"docs\" on `lists the one document`.\n\nTHE TRAP THE TICKET NAMED IS CLOSED: a worker that organised documents under docs/ would have got an empty picker in a browser and a full one in every test. That divergence was invisible to the harness because nothing ran OpfsFs -- which is exactly why improvise-vvl3 had to land alongside this, and why this bead could only ever have been found by reading until it did.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-hmws","title":"Storage cannot expose an async seam through Box\u003cdyn Storage\u003e","description":"Found by improvise-9x6 (2026-09-20). Every worker host will hit this, so it is worth solving once rather than in each.\n\nOpfsFs::open / attach / refresh / commit_deletes are on the STRUCT, not on the Storage trait -- deliberately, per gotcha 28: Storage stays synchronous because createSyncAccessHandle yields a blocking handle, and only four operations are genuinely async. But App::storage is a Box\u003cdyn Storage\u003e, so HANDING THE BACKEND TO THE App PUTS ALL FOUR PERMANENTLY OUT OF REACH. The App owns the only copy and the trait object cannot name them.\n\nAND THE OBVIOUS FIX IS REFUSED BY THE COMPILER: impl Storage for Rc\u003cOpfsFs\u003e is two foreign types, so the orphan rule forbids it. improvise-9x6 worked around it with a local newtype (bindings::Shared) holding an Rc\u003cOpfsFs\u003e and forwarding the four sync methods. That works but every worker must write it again.\n\nTWO CANDIDATE FIXES:\n * Ship the shared wrapper FROM improvise-io, so the newtype exists once where OpfsFs does and the orphan rule is satisfied.\n * Or have OpfsFs hand out an attachable handle -- separate the thing the App holds from the thing the message loop awaits, which is arguably the more honest modelling since they have different lifetimes and different call sites.\n\nPrefer whichever keeps Storage synchronous; that part is not negotiable (gotcha 28 is the argument).","status":"closed","priority":2,"issue_type":"task","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T02:10:13Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:44:43Z","started_at":"2026-09-20T05:32:37Z","closed_at":"2026-09-20T06:44:43Z","close_reason":"Closed by PR #24 (WU-J). NEITHER OF THE TICKET'S TWO OPTIONS WAS NEEDED, and the reason is worth more than the fix.\n\nThe ticket said `impl Storage for Rc\u003cOpfsFs\u003e` is refused by the orphan rule. That is true IN improvise-worker-server, where both the trait and Rc are foreign -- which is where improvise-9x6 hit it and wrote bindings::Shared. It is NOT true in improvise-io, where the trait is LOCAL and the impl is simply allowed.\n\nSo: improvise-io ships a blanket `impl\u003cT: Storage + ?Sized\u003e Storage for Rc\u003cT\u003e`. bindings::Shared and its _shim_is_a_storage canary are DELETED, not moved, and no future worker writes one. Storage stays synchronous, which gotcha 28 says is not negotiable.\n\nTHE GUARD IS THE SIGNATURE: a_shared_backend_is_a_backend_and_a_clone_is_the_same_one does not compile without the impl. The body asserts what a hand-written newtype could still get wrong -- that a clone addresses the same backend -- and a fault injected into the forwarded `write` (made a no-op) fails it.\n\nTHE GENERALISABLE PART: an orphan-rule refusal is a fact about WHERE YOU ARE STANDING, not about the pair of types. Before working around one, check whether the impl belongs in the crate that owns the trait.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-9mo9","title":"[bug] Geometry effects that renumber the grid but never reset the cursor","description":"Found by the code review of improvise-gvnb (2026-09-19). A PRE-EXISTING gap that gvnb inherited rather than introduced -- but gvnb is what makes it matter, because the epoch is now what tells every client their cursor is invalid.\n\nApp::reset_cursor bumps cursor_epoch; Sessions::adopt_cursor_epoch watches the COUNTER and broadcasts Command::ResetCursor to all. Nothing declares which effects renumber the grid -- deliberately, per gotcha 31 -- which means a geometry effect that forgets reset_cursor is INVISIBLE IN BOTH DIRECTIONS: the host own cursor stays stale AND no client is told.\n\nToday only TransposeAxes and CycleAxis call it. These also renumber rows or columns and do not:\n SetAxis, HideItem, ShowItem, ToggleGroup, TogglePruneEmpty, AddCategory, RemoveCategory\n (plus AddItem/RemoveItem, which are CellData rather than LayoutGeometry)\n\nSetAxis IS THE SHARPEST CASE: CycleAxis is literally SetAxis to Axis::next() and resets; SetAxis does not.\n\nWHY THE REVIEW DID NOT JUST PATCH IT: SetAxis is used by DrillIntoCell (cmd/grid.rs:365-471) to build the _Drill view, and resetting there could discard the user position in the view they drilled FROM, depending on effect order. That is a decision about effect ordering and intended UX, not a patch.\n\nKEEP THE NEGATIVE ARM. a_model_command_that_does_not_reshape_the_grid_leaves_cursors_alone uses SetNumberFormat -- a LayoutGeometry effect that must NOT reset. Without it, \"reset on every model command\" would pass the positive test. Whatever you do here, do not lose it.\n\n## Steps to Reproduce\n\n1. In a view with a cursor somewhere other than the origin, apply any of `SetAxis`, `HideItem`, `ShowItem`, `ToggleGroup`, `TogglePruneEmpty`, `AddCategory`, `RemoveCategory`, `AddItem` or `RemoveItem`.\n2. The grid renumbers and the cursor is not reset, so it addresses a position that has moved.\n3. Worse for other clients: `reset_cursor` is what bumps `cursor_epoch`, and `Sessions::adopt_cursor_epoch` watches that counter to broadcast `Command::ResetCursor`. No reset means **no client is told either**.\n\n`SetAxis` is the sharpest case: `CycleAxis` *is* `SetAxis` to `Axis::next()` and does reset.\n","acceptance_criteria":"Each of SetAxis, HideItem, ShowItem, ToggleGroup, TogglePruneEmpty, AddCategory, RemoveCategory, AddItem and RemoveItem either calls reset_cursor or carries a one-line reason why it does not. SetAxis is the sharp case — CycleAxis IS SetAxis to Axis::next() and resets, while SetAxis does not — and DrillIntoCell uses SetAxis to build the _Drill view, where resetting could discard the position in the view drilled FROM, so that is a decision about effect ordering and intended UX, not a patch. KEEP THE NEGATIVE ARM: a_model_command_that_does_not_reshape_the_grid_leaves_cursors_alone uses SetNumberFormat, and without it 'reset on every model command' passes the positive test. Note improvise-2zq changed which axis a new category lands on, so re-read on_category_added before assuming AddCategory renumbers the way it used to.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T01:05:15Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:57:38Z","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-9mo9","title":"[bug] Geometry effects that renumber the grid but never reset the cursor","description":"Found by the code review of improvise-gvnb (2026-09-19). A PRE-EXISTING gap that gvnb inherited rather than introduced -- but gvnb is what makes it matter, because the epoch is now what tells every client their cursor is invalid.\n\nApp::reset_cursor bumps cursor_epoch; Sessions::adopt_cursor_epoch watches the COUNTER and broadcasts Command::ResetCursor to all. Nothing declares which effects renumber the grid -- deliberately, per gotcha 31 -- which means a geometry effect that forgets reset_cursor is INVISIBLE IN BOTH DIRECTIONS: the host own cursor stays stale AND no client is told.\n\nToday only TransposeAxes and CycleAxis call it. These also renumber rows or columns and do not:\n SetAxis, HideItem, ShowItem, ToggleGroup, TogglePruneEmpty, AddCategory, RemoveCategory\n (plus AddItem/RemoveItem, which are CellData rather than LayoutGeometry)\n\nSetAxis IS THE SHARPEST CASE: CycleAxis is literally SetAxis to Axis::next() and resets; SetAxis does not.\n\nWHY THE REVIEW DID NOT JUST PATCH IT: SetAxis is used by DrillIntoCell (cmd/grid.rs:365-471) to build the _Drill view, and resetting there could discard the user position in the view they drilled FROM, depending on effect order. That is a decision about effect ordering and intended UX, not a patch.\n\nKEEP THE NEGATIVE ARM. a_model_command_that_does_not_reshape_the_grid_leaves_cursors_alone uses SetNumberFormat -- a LayoutGeometry effect that must NOT reset. Without it, \"reset on every model command\" would pass the positive test. Whatever you do here, do not lose it.\n\n## Steps to Reproduce\n\n1. In a view with a cursor somewhere other than the origin, apply any of `SetAxis`, `HideItem`, `ShowItem`, `ToggleGroup`, `TogglePruneEmpty`, `AddCategory`, `RemoveCategory`, `AddItem` or `RemoveItem`.\n2. The grid renumbers and the cursor is not reset, so it addresses a position that has moved.\n3. Worse for other clients: `reset_cursor` is what bumps `cursor_epoch`, and `Sessions::adopt_cursor_epoch` watches that counter to broadcast `Command::ResetCursor`. No reset means **no client is told either**.\n\n`SetAxis` is the sharpest case: `CycleAxis` *is* `SetAxis` to `Axis::next()` and does reset.\n","acceptance_criteria":"Each of SetAxis, HideItem, ShowItem, ToggleGroup, TogglePruneEmpty, AddCategory, RemoveCategory, AddItem and RemoveItem either calls reset_cursor or carries a one-line reason why it does not. SetAxis is the sharp case — CycleAxis IS SetAxis to Axis::next() and resets, while SetAxis does not — and DrillIntoCell uses SetAxis to build the _Drill view, where resetting could discard the position in the view drilled FROM, so that is a decision about effect ordering and intended UX, not a patch. KEEP THE NEGATIVE ARM: a_model_command_that_does_not_reshape_the_grid_leaves_cursors_alone uses SetNumberFormat, and without it 'reset on every model command' passes the positive test. Note improvise-2zq changed which axis a new category lands on, so re-read on_category_added before assuming AddCategory renumbers the way it used to.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"wave2-n-cleanups","owner":"el-github@elangley.org","created_at":"2026-09-20T01:05:15Z","created_by":"Edward Langley","updated_at":"2026-09-20T17:53:45Z","started_at":"2026-09-20T17:53:45Z","lease_expires_at":"2026-09-20T17:58:45Z","heartbeat_at":"2026-09-20T17:53:45Z","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-s1xw","title":"Multi-level grid labels on the wire -- the browser shows Food/Alice where the TUI shows two columns","description":"THE LAST THING KEEPING GridLayout INSIDE GridWidget, and a LIVE DIVERGENCE today, not a hypothetical. Found closing improvise-n10.\n\nGridCache::row_labels / col_labels are Vec\u003cString\u003e produced by GridLayout::row_label, which JOINS A MULTI-LEVEL TUPLE WITH A SLASH. That is exactly the rendering src/ui/grid.rs::two_row_categories_produce_cross_product_labels asserts must NOT appear (\"slash-joined labels should be gone\"). So the protocol carries a form of the labels THE TUI ITSELF CONSIDERS WRONG, and crates/improvise-wasm-client/src/render.rs renders precisely that form.\n\nConsequence: the browser grid has no multi-level headers, no repeat suppression, no groups and no collapse indicators, and shows Food/Alice where the TUI shows two header columns.\n\nSIX FACTS THE CACHE DOES NOT CARRY (exhaustive as of improvise-n10, by grep over the non-test body of src/ui/grid.rs):\n 1. number of header levels -- layout.row_cats / col_cats\n 2. multi-level label TUPLES -- layout.row_items (AxisEntry::DataItem(Vec\u003cString\u003e))\n 3. inline GROUP HEADER rows -- layout.row_items (AxisEntry::GroupHeader)\n 4. column GROUP SPANS -- layout.col_group_for(ci)\n 5. collapse indicator (down/right triangle) -- view.is_group_collapsed(cat, group)\n 6. records-mode column identity -- layout.record_col(ci). THIS ONE IS CORRECT TO KEEP OFF: it keys pending drill edits and is session-local by design.\n\nSo 1-5 are the gap.\n\nTWO CONSTRAINTS ON THE FIX:\n * IT IS A CO-ORDINATED CHANGE, NOT AN ADDITIVE ONE. row_labels/col_labels change TYPE, and improvise-wasm-client/src/render.rs consumes both fields, so the client updates in the same change.\n * DO NOT ADD row_levels BESIDE row_labels. Two answers to \"what are the labels\" is the disease this whole projection architecture exists to cure -- see improvise-edp, where the category panel was walked twice, two different ways.","status":"closed","priority":2,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T00:58:33Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:41:34Z","started_at":"2026-09-20T05:32:27Z","closed_at":"2026-09-20T07:41:34Z","close_reason":"Closed as ABSORBED BY improvise-anr9, verified explicitly rather than assumed. s1xw enumerated six facts the cache did not carry; PR #23 landed 1-5 (header levels, multi-level label tuples, inline group headers, column group spans, the collapse indicator) and 6 is record_col, which s1xw itself says is CORRECT to keep off -- session-local, keys pending drill edits. Both of its stated constraints were honoured: it was a co-ordinated type change (row_labels/col_labels changed TYPE and improvise-wasm-client/src/render.rs updated in the same change) and no row_levels was added beside row_labels. The live divergence it reported -- the browser drawing Food/Alice where the TUI draws two header columns -- is closed, and tests/grid_frame_agreement.rs now drives both front ends over one cache and compares what each draws.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-4zl7","title":"A fitted tree must route per record and aggregate the results, not route on the aggregate","description":"Successor to improvise-8xhy, which shipped remedy 1 (refuse at emit) as an explicit interim. This is the remedy that produces a formula A HUMAN WOULD HAVE WRITTEN, and it is the right end state -- but it was costed during 8xhy and it is much larger than it looks.\n\nANSWER THIS FIRST, BEFORE TOUCHING THE GRAMMAR. A fitted measure cell is evaluated at a CellKey context. In any view that aggregates over the split category -- Region on Page or None -- context.get(\"Region\") is None, and today Formula::filter falls straight through to aggregate_raw. So a categorical fit would SILENTLY DEGRADE to something else in exactly the views a pivot-table user spends most of their time in. That is a product decision about what a fit MEANS for an aggregated cell, not an emitter detail.\n\nSCOPE, once that is answered:\n * Expr::Is(Filter) evaluating to 1.0/0.0. This keeps the f64-only evaluator and avoids introducing string comparison into eval_expr. The primitive already exists in the right shape -- types.rs reads context.get(\u0026filter.category), the current cell own coordinate.\n * A grammar production plus tokenizer handling in the 1109-line parser.rs, with quoting rules for the category and item inside it (gotcha 34 -- one home, improvise_formula::quote).\n * Arms in BOTH evaluators: eval_expr and eval_expr_cached.\n * Coverage in the grammar-walking generator.\n * Thread Dataset::columns (the ColumnSpec/ColumnKind plan) into the emitter, so feature index -\u003e role is read off preprocess own plan. Do NOT reconstruct it: Spec::names is \u0026[\u0026str], and reconstructing index-\u003erole is a second implementation of preprocess expansion order, exactly the drift risk gotcha 41 names in another key.\n * .improv is free -- the writer emits Formula::raw.\n\nIT CLOSES ONLY A THIRD OF 8xhy EITHER WAY. A WHERE predicate covers the one-hots. col.missing means \"this record has no value for measure X\", a different vocabulary. The date sub-features (when.year, when.month) need date arithmetic improvise does not have at all. Those stay refused, or get their own tickets.\n\nPAIRS WITH improvise-vx3 (the fit-measure command), which is where a user would actually learn about the restriction.","acceptance_criteria":"A fitted measure evaluated at an aggregating cell routes PER RECORD and aggregates the results, rather than routing on the aggregate. fit-measure on web/demo.improv succeeds and the resulting formula means the same thing on a record row and one row up. The seed-dependence is gone: whether the command succeeds no longer depends on which features the search happened to pick. Note what this costs — evaluate_aggregated deliberately does not iterate the records behind a formula cell — and state how it interacts with gotcha 9's fixed point and Model::formula_cache. col.missing and the date sub-features remain out of scope.","notes":"USER RULING 2026-09-20: AGGREGATE THE RULE OVER THE MEMBERS. Route-then-aggregate, per record. The other three options -- Error(\"aggregated\"), refuse at fit time, and fall through to aggregate_raw -- are all rejected.\n\nTHE BEAD'S SCOPE HAS CHANGED AND THE TITLE NOW SAYS SO. It asked what a CATEGORICAL split means in a cell that aggregates over the split category. PR #25's review established the question has nothing to do with the split being categorical:\n\nA tree is fitted on individual RECORDS. A formula is evaluated at a CellKey, and in any aggregating cell evaluate_aggregated aggregates each Expr::Ref FIRST and then evaluates the IF. A linear formula survives that -- SUM(Revenue) - SUM(Cost) is SUM(Revenue - Cost) -- and a tree does not, because AGGREGATE-THEN-ROUTE IS NOT ROUTE-THEN-AGGREGATE. So EVERY fitted tree, numeric splits included, means one thing on a record row and something else one row up.\n\nIT IS NOT THEORETICAL: fit-measure Revenue on the repo's own shipped web/demo.improv is refused, naming Product=Espresso/Product=Latte. And because which features a champion picks is a property of the SEARCH, on a document with several categories the same command refuses or succeeds DEPENDING ON THE SEED. That is not a contract.\n\nWHAT THE RULING COSTS, stated plainly because it is the expensive answer and was chosen anyway: evaluate_aggregated deliberately does NOT iterate the underlying records for a formula cell -- it aggregates the refs and evaluates once. Routing per record means a formula cell must be able to ask for the records behind it and fold over them. That touches the evaluator, and it interacts with gotcha 9 (fixed-point recomputation) and with Model::formula_cache, which is where derived values live precisely so raw data and derived data stay separate.\n\nNOTE WHAT IT BUYS BESIDES CORRECTNESS: it is what a user means. A fitted rule applied to a group should say what the rule says about the members, summed -- not what the rule says about a fictional average member. The second is the thing that looks right and is wrong, which is the failure mode vx3's own ticket warned about ('a rule the user will believe').\n\nALSO NOTE: this ruling supersedes improvise-8xhy's remedy 1 (refuse at emit) as the END STATE, but not as the interim -- the refusal stays until this lands, or fit-measure silently produces the wrong number in aggregating views.\n\nTHE TWO THIRDS THIS STILL DOES NOT COVER, unchanged from the original text: col.missing means 'this record has no value for measure X', a different vocabulary; and the date sub-features (when.year, when.month) need date arithmetic improvise does not have at all. Those stay refused or get their own beads.","status":"open","priority":2,"issue_type":"feature","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T00:45:22Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:45:26Z","started_at":"2026-09-20T05:32:41Z","labels":["area:gp"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-hwpi","title":"The browser shell fails quietly, and never reads a key","description":"Found by the improvise-cr3 code review (2026-09-19) by READING web/app.js. There is no browser and no headless DOM in this environment, so none of this is observed -- it is what the code says will happen, and it is filed rather than fixed because each item is a judgement call about the shell rather than a defect in the Rust.\n\nFOUR THINGS, in descending order of how much they would cost a person at a demo:\n\n1. NO KEYBOARD AT ALL. #grid carries tabindex=\"0\" but the page registers no keydown handler, so Client::on_key_event -- and the whole keys.rs classifier and the lockstep machinery behind it -- is UNREACHED from the browser. Consistent with improvise-bpq2 (ClientMsg has no Key variant server-side) but larger than it: even a LOCAL key, one that needs no round trip, does nothing. improvise-1ey acceptance (3) is \"arrow keys move the cursor locally with no round-trip\", and nothing in the page can do it.\n\n2. A MALFORMED FRAME IS QUIET. client.on_frame(event.data) returns Result\u003c_, JsError\u003e, which wasm-bindgen turns into a THROWN EXCEPTION. It is thrown inside the message listener with no try, so it surfaces as an uncaught error in the devtools console and the following repaint() is skipped. Nothing in the UI says so: the grid simply stops updating. A try/catch writing to el.status would make it visible.\n\n3. NO error LISTENER ON THE SOCKET. A connection that never opens leaves #status blank -- only close writes \"disconnected\". So \"server not running\" and \"still connecting\" look the same.\n\n4. close() RUNS TWICE ON COMMIT, benignly. Enter -\u003e close(true) -\u003e td.replaceChildren(original) removes the focused \u003cinput\u003e -\u003e blur fires -\u003e close(false). editing is already null so it is harmless (one extra repaint), but there is no re-entry guard and only a real browser would show it.\n\nNOTE WHAT MAKES THIS TICKET DIFFERENT: items 2-4 are the kind of thing a test would catch instantly and we have no way to run one. Before fixing them, consider whether the right first move is a headless DOM in CI (jsdom or playwright) -- otherwise the fixes are as unverified as the bugs. improvise-xf1 (verify OpfsFs against a real browser) wants the same harness.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T00:40:36Z","created_by":"Edward Langley","updated_at":"2026-09-20T02:29:50Z","closed_at":"2026-09-20T02:29:50Z","close_reason":"All four items done, plus a fifth the ticket could not have predicted.\n\n1. KEYDOWN WIRED -- a window listener feeding Client::on_key_event. preventDefault fires only when a frame was produced and no Ctrl/Alt is held, so browser shortcuts stay the browser's; metaKey is skipped entirely, matching BrowserKey::to_key_event dropping Meta so Cmd-R cannot arrive as a bare r. The inline editor stopPropagation keeps it out of the way, with an if (editing) return as belt and braces.\n2. MALFORMED FRAME -- try/catch around on_frame writing to #status, plus console.error with the offending frame. render_model() is guarded the same way; it also returns a Result.\n3. SOCKET error LISTENER -- 'cannot reach ... -- is improvise-ws-server running?', so that no longer reads like 'still connecting'.\n4. close() RE-ENTRY GUARD -- a closed flag in edit().\n\nTHE FIFTH, AND IT IS THE INTERESTING ONE: a worker whose start() REJECTS is caught INSIDE worker.js, so it fires NO error event on the main thread. The page would have sat on 'in-page worker - demo.improv' over an empty grid forever -- THIS TICKET'S EXACT DISEASE, REBUILT IN THE NEW DEPLOYMENT ON DAY ONE. The guard is a first-frame watchdog (8s, cleared by the first frame), and it deliberately covers BOTH transports rather than each peer's own failure modes, because 'the peer never answered the handshake' is the one condition that means the same thing over a socket and over a port -- a check aimed per-peer would have missed exactly this one.\n\nKEYS ARE INERT AND THAT IS STATED, NOT HIDDEN. Host::handle answers ClientFrame::Key with an empty vec; improvise-bpq2 is the other half.\n\nSTILL UNRUN -- and this ticket predicted that would be the problem: 'consider whether the right first move is a headless DOM, otherwise the fixes are as unverified as the bugs.' That sentence is now true OF THESE FIXES. Three of the four are one-assertion Playwright tests. See the harness ticket.","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -73,7 +73,7 @@ {"_type":"issue","id":"improvise-gvnb","title":"[bug] A geometry command resets the App cursor but not the session mirrors","description":"Found during improvise-kmgs (2026-09-19). Pre-existing for the key path; the model half of the wire vocabulary makes it reachable remotely, which is what raises it to P2.\n\nCycleAxis and TransposeAxes call app.reset_cursor() because renumbering rows or columns invalidates the cursor (gotcha 24). But each ClientSession keeps its OWN cursor in its mirrored ViewState, and nothing resets those. So a bystander is projected renumbered rows at its old scroll -- gotcha 24 cursor-vs-geometry mismatch, arriving through somebody else command.\n\nThis is the same SHAPE as improvise-kvyt (a view switch moved only the asking session mirror) and should be fixed the same way: the geometry change is addressed to no one session, so every mirror owes the reset. See doctrine(lockstep_binds_every_session_not_the_asker) -- ask of any shared value, who else derives something from it. Here the derived thing is the cursor whose validity depends on the grid shape.\n\nTest it the way kvyt was tested: a bystander session, not the one that issued the command.","notes":"\n--- CONFIRMED AGAIN on PR #11 by Copilot, and deliberately NOT fixed there ---\nThe PR agent reproduced it and declined to fix it in that PR with three\nreasons, all of which stand:\n (a) Applied::Diff.broadcast is a single Option\u003cCommand\u003e and there is NO\n cursor-reset command -- a ViewCursor is selected plus both offsets, so\n this needs a new wire variant, derived not invented.\n (b) it read the policy question as improvise-g0f, \"deliberately unanswered\".\n (c) A PARTIAL FIX IS WORSE THAN NONE: resetting only the server-side mirrors\n would make the diff logic believe each client already holds origin-based\n content and send nothing -- leaving clients stale PERMANENTLY instead of\n until their next scroll. Note that carefully when implementing.\nObserved effect today: Viewport::window clips (start = offset.min(total)), so\na bystander gets a BLANK grid rather than garbage -- blank until it scrolls.\n\n--- THE POLICY QUESTION IS ANSWERED; (b) rests on a stale reading ---\nimprovise-g0f is decided (per-client active view), and the standing principle\nsettles this case directly: THE MODEL IS SHARED, THE UI IS NOT. Two things\nthat sound alike and are not:\n * Another client transposing must NOT move my cursor -- where I am looking\n is UI, and it is mine.\n * It MUST invalidate my cursor, because the grid my cursor indexes just\n changed shape. Validity is derived from shared geometry even though\n position is private.\nSo the answer is neither \"reset everyone to origin\" (that moves my UI on\nsomeone else behalf) nor \"leave it\" (that indexes a grid that no longer has\nthose rows). It is: the geometry change invalidates every client cursor, and\neach client re-establishes its own -- which is exactly what a cursor-reset\nwire variant should mean, and why it is a variant rather than a broadcast of\nsomebody else position.\n\nSame family as improvise-kvyt and doctrine(lockstep_binds_every_session_not_the_asker):\nask of any shared value, who else DERIVES something from it. Here the derived\nthing is the validity of a private cursor.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T22:11:31Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:58:01Z","closed_at":"2026-09-20T00:58:01Z","close_reason":"Fixed. reset_cursor owed the session mirrors BOTH halves, and doing only one is the trap the ticket named.\n\nTHREE PIECES:\n * Command::ResetCursor -- a new view-half variant, derived from the App::reset_cursor write the two geometry effects make on the portable slice. A variant rather than SetSelected{0,0} plus two offsets because the message is 'your cursor is invalid', not 'be here'.\n * App::cursor_epoch, bumped only by reset_cursor, OBSERVED by Sessions::adopt_cursor_epoch across apply_command. The trigger is the counter moving, not the command variant, for exactly the reason gotcha 39 watches document_generation instead of matching on LoadModel: which effects renumber the grid is a per-effect decision (gotcha 24 asks new ones to make it), so a variant table here would be a second description free to disagree.\n * It resets every mirror AND returns the broadcast. Resetting the mirrors alone is STRICTLY WORSE THAN DOING NOTHING: the diff then believes each client already holds origin-positioned content and sends nothing, so clients stay stale PERMANENTLY rather than until their next scroll.\n\nTHE AUDIENCE HAD TO BECOME PART OF THE MESSAGE. Applied::Diff::broadcast is now Vec\u003cBroadcast\u003e over Broadcast::{ToOthers, ToAll}. A SwitchView broadcast CORRECTS state the asker folded optimistically and must not go back to it; a cursor invalidation is a CONSEQUENCE the asker is owed on the same terms as everyone else, because a Command::Model is the one kind a client cannot fold optimistically. Vec not Option because one command can owe two relays with different audiences, and an Option would silently drop one.\n\nWHAT IS NOT BROADCAST IS A POSITION. Another client's transpose must not move where I am looking -- that is UI and it is mine (improvise-g0f). It may only declare that where I was looking stopped meaning anything.\n\nThe negative arm matters as much as the regression guard: SetNumberFormat moves nobody's cursor. Without it, 'reset on every model command' would pass.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-tym1","title":"[bug] folds(n, 0) panicked and mse divided by a length it had not scored","description":"Copilot finding on PR #10 (2026-09-19), partly real, fixed in 27a4cbd with both tests written first.\n\nfolds(n, 0) did not merely misbehave -- it PANICKED on out[pos % 0]. k arrives from a CLI flag, so zero is a value a host supplies, and gotcha 37 says gp-tree is a library a TUI or a wasm worker may embed, where a panic is not a usage error message. Now k.max(1), matching fit()s existing restarts.max(1).\n\nmse divided by x.len() rather than the number of rows it actually scored, so a length mismatch between the parallel slices silently skewed the result through zip truncation. Now it counts the rows it scored. Bit-identical where the slices match -- Sum for f32 is the same left fold -- so wave 3 nine-scenario byte-identical CLI evidence still holds.\n\nProven first: the folds test panicked; the mse test failed 16.912476 vs 17.802607.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T22:01:09Z","created_by":"Edward Langley","updated_at":"2026-09-19T22:01:10Z","closed_at":"2026-09-19T22:01:10Z","close_reason":"Fixed in 27a4cbd, merged to main in PR #10 as 336533b.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-tom1","title":"Drill pending edits are invisible to remote clients, and there are now three answers to 'what text is in this cell'","description":"Wave-2 review (2026-09-19), CONFIRMED by reading.\n\nproject.rs cell_display resolves records-mode text through layout.records_display(row,col). The TUI grid widget overlays staged drill edits (src/ui/grid.rs, drill_state.pending_edits) and so does App::display_value_at_cursor. SetDrillPendingEdit is ViewOnly, so Dirty is empty too. A remote client in drill mode sees neither the staged edit nor any update for it, until ApplyAndClearDrill (CellData) lands.\n\nThis is the 'second, silently divergent description' the producer's own module header warns against: three answers to what text is in a cell (GridLayout::display_text, src/ui/grid.rs, project::cell_display) and only two agree.\n\nNote the tension with the user's principle 'the model is shared, the UI isn't': pending drill edits are UNCOMMITTED writes belonging to whoever staged them (that is why gotcha 30 keeps drill_state off ViewState). So the answer may legitimately be 'a remote client SHOULD NOT see them' -- in which case the defect is only the three-way divergence, not the invisibility. Decide which before coding.\n\n## Steps to Reproduce\n\n1. Two clients on one document; client A drills into an aggregate cell and types an edit, which stages into `drill_state.pending_edits`.\n2. Client B sees neither the staged value nor any update for it — `SetDrillPendingEdit` is `ViewOnly`, so the `Dirty` is empty and nothing is projected — until `ApplyAndClearDrill` (`CellData`) lands.\n3. Meanwhile A's own TUI *does* show it, because `src/ui/grid.rs` overlays `drill_state.pending_edits` and so does `App::display_value_at_cursor`, while `project.rs::cell_display` resolves through `layout.records_display(row, col)`.\n\nSo there are three answers to \"what text is in this cell\" and only two agree. The invisibility is the *correct* half (the user has ruled staged edits are private); the divergence is the defect.\n","acceptance_criteria":"One answer to 'what text is in this cell'. The user has ruled (model-shared-ui-not-shared) that a remote client must NOT see another client's staged drill edits — pending edits are uncommitted writes belonging to whoever staged them, which is why gotcha 30 keeps drill_state off ViewState. So the invisibility is CORRECT and the defect is only the three-way divergence between GridLayout::display_text, src/ui/grid.rs and project::cell_display. Close that to one derivation without making staged edits visible.","notes":"\n--- DECIDED (user, 2026-09-19): NO ---\nA remote client must NOT see another client staged drill edits. This follows\nfrom the standing principle -- the model is shared, the UI is not -- and from\ngotcha 30, which keeps drill_state off ViewState precisely because pending\nedits are UNCOMMITTED WRITES BELONGING TO WHOEVER STAGED THEM. They become\neveryone business only at ApplyAndClearDrill, which is CellData and already\nreaches every client.\n\nSO THE INVISIBILITY IS CORRECT AND IS NOT THE BUG. Do not \"fix\" it by teaching\nthe producer to read drill_state; that would put one client uncommitted writes\non another client screen and contradict the decision.\n\nRE-SCOPED -- the defect that remains is the THREE-WAY DIVERGENCE in what text\nis in this cell:\n * App::display_value_of -- editable RAW text, resolves a staged drill\n edit, via layout.resolve_display\n * project::cell_display -- FORMATTED text + numeric/error flags; in\n records mode calls layout.records_display\n and then INFERS numeric-ness by\n text.parse::\u003cf64\u003e() -- the one place this\n module guesses rather than knows\n * src/ui/grid.rs -- formats for the terminal, also overlays\n pending edits\nThree answers, two of which agree. The cheap collapse identified during the\nreview is between the first two, on the records branch only: both want\n\"resolve a synthetic record key through the layout\", so cell_display records\narm can share the resolve_display path via a helper on GridLayout, keeping the\nPENDING-EDIT lookup out of it. That removes the duplicated resolution and,\nwith a real CellValue in hand, the parse::\u003cf64\u003e() inference -- while leaving\nthe staging map session-local and unread by the producer, as decided.\n\nIt is a behaviour change on the records projection, so it wants the failing\ntest first and a check that no existing records test changes meaning.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T21:04:41Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:57:42Z","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-jytm","title":"Split active_view: document default vs per-client authoritative (improvise-g0f)","description":"Implements the user's improvise-g0f decision (2026-09-19): two clients MAY view different views.\n\nTODAY (gotcha 29): Workbook::active_view and ViewState::active_view are two copies of one fact, kept identical by App::switch_to_view and App::install_workbook, guarded by the_two_active_view_names_never_diverge. The second copy exists because reduce_view runs on a client holding no Workbook.\n\nTARGET: Workbook::active_view = the document's default, i.e. what a freshly connected client opens. ViewState::active_view = authoritative for that client. They may differ.\n\nWORK:\n1. Replace the_two_active_view_names_never_diverge with a test of the NEW contract -- do not simply delete it. A deliberate split with no guard is how this silently regresses. Assert that a fresh session opens the document default, and that one client switching does not move another.\n2. Re-examine SwitchView's EffectKind. Under lockstep it is a Model change; per-client it looks like ViewOnly (it must not reach other clients). Check what else SwitchView does before changing the kind -- gotcha 3, a mis-declared kind is silent.\n3. ClientSession::project must emit that client's whole widget set on a switch: every projection is view-dependent, and the grid mirror describes the OLD view.\n4. Persistence: Initial View in .improv is the document default, which is now exactly the right meaning -- confirm the writer still takes it from Workbook and not from any client.\n5. Update gotcha 29 in context/repo-map.md (orchestrator owns that file) and the kb decision recording the old lockstep rule.\n\nUnblocks improvise-cqq.","acceptance_criteria":"Workbook::active_view is the document default; ViewState::active_view is authoritative per client; two clients can view different views. the_two_active_view_names_never_diverge is REPLACED by a test of the new contract — a fresh session opens the document default, and one client switching does not move another — not deleted, because a deliberate split with no guard is how this silently regresses. SwitchView's EffectKind is re-examined and the reasoning recorded (gotcha 3: a mis-declared kind is silent). ClientSession::project emits that client's whole widget set on a switch, since every projection is view-dependent. The .improv writer still takes Initial View from the Workbook.","notes":"\n--- GUIDING PRINCIPLE (user, 2026-09-19) ---\n'The model is shared and the UI isn't.' g0f follows from this rather than\nbeing an independent choice: which view you are looking at is UI, not\ndocument. The principle is already embodied in EffectKind -- ViewOnly is UI\n(reaches nobody else), Model(...) is document (reaches everyone) -- so the\nsplit should make active_view agree with that existing rule rather than\nintroduce a new mechanism.\n\nDO NOT RULE OUT A FOLLOW MODE. The user explicitly wants the option of\nbinding two UIs together later (one client mirroring another's view, cursor\nand scroll) and explicitly does NOT want it implemented yet. Design\nconsequence for THIS ticket: keep per-client UI state addressable per\nsession -- ClientSession already does this -- so a follow relationship can\nlater be expressed as one session DERIVING its ViewState from another's.\nDo not 'simplify' by making any UI state globally shared to get follow\nbehaviour for free; that would trade the principle for the feature and is\nthe one shape that forecloses it.\n\n--- MUST REVISIT improvise-kvyt WHEN DOING THIS (2026-09-19) ---\nCopilot found on PR #10 that a view switch moved only the ASKING session mirror,\nstaling every bystander cursor: each client grid is built by grid_of from\nworkbook.active_view(), so a bystander left naming the outgoing view was\nprojected the NEW view rows positioned by the OLD view scroll. Fixed in 8475b2d\nby broadcasting the ACCEPTED view to every mirror, with Sessions::apply now\nreturning Applied { dirty, broadcast } so the host can forward the switch to the\nother clients, whose slices live on them.\n\nThat fix is correct ONLY for the current lockstep model. Under this ticket split\n-- active_view per-client, the model is shared and the UI is not -- a view switch\nbecomes the asker business alone and the broadcast either disappears or changes\nmeaning entirely. Do NOT keep it by inertia: re-derive whether anything still has\nto reach the other sessions when the asker switches, and if the answer is nothing,\ndelete the broadcast and the test that pins it rather than leaving a mechanism\nwhose reason has gone. If the answer is something (e.g. a follow-mode client\nbound to the switcher), that is the shape follow mode wants -- see the\nmodel-shared-ui-not-shared memory, which says explicitly not to foreclose it.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T21:00:51Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:55:54Z","labels":["area:active-view"],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"improvise-jytm","title":"Split active_view: document default vs per-client authoritative (improvise-g0f)","description":"Implements the user's improvise-g0f decision (2026-09-19): two clients MAY view different views.\n\nTODAY (gotcha 29): Workbook::active_view and ViewState::active_view are two copies of one fact, kept identical by App::switch_to_view and App::install_workbook, guarded by the_two_active_view_names_never_diverge. The second copy exists because reduce_view runs on a client holding no Workbook.\n\nTARGET: Workbook::active_view = the document's default, i.e. what a freshly connected client opens. ViewState::active_view = authoritative for that client. They may differ.\n\nWORK:\n1. Replace the_two_active_view_names_never_diverge with a test of the NEW contract -- do not simply delete it. A deliberate split with no guard is how this silently regresses. Assert that a fresh session opens the document default, and that one client switching does not move another.\n2. Re-examine SwitchView's EffectKind. Under lockstep it is a Model change; per-client it looks like ViewOnly (it must not reach other clients). Check what else SwitchView does before changing the kind -- gotcha 3, a mis-declared kind is silent.\n3. ClientSession::project must emit that client's whole widget set on a switch: every projection is view-dependent, and the grid mirror describes the OLD view.\n4. Persistence: Initial View in .improv is the document default, which is now exactly the right meaning -- confirm the writer still takes it from Workbook and not from any client.\n5. Update gotcha 29 in context/repo-map.md (orchestrator owns that file) and the kb decision recording the old lockstep rule.\n\nUnblocks improvise-cqq.","acceptance_criteria":"Workbook::active_view is the document default; ViewState::active_view is authoritative per client; two clients can view different views. the_two_active_view_names_never_diverge is REPLACED by a test of the new contract — a fresh session opens the document default, and one client switching does not move another — not deleted, because a deliberate split with no guard is how this silently regresses. SwitchView's EffectKind is re-examined and the reasoning recorded (gotcha 3: a mis-declared kind is silent). ClientSession::project emits that client's whole widget set on a switch, since every projection is view-dependent. The .improv writer still takes Initial View from the Workbook.","notes":"\n--- GUIDING PRINCIPLE (user, 2026-09-19) ---\n'The model is shared and the UI isn't.' g0f follows from this rather than\nbeing an independent choice: which view you are looking at is UI, not\ndocument. The principle is already embodied in EffectKind -- ViewOnly is UI\n(reaches nobody else), Model(...) is document (reaches everyone) -- so the\nsplit should make active_view agree with that existing rule rather than\nintroduce a new mechanism.\n\nDO NOT RULE OUT A FOLLOW MODE. The user explicitly wants the option of\nbinding two UIs together later (one client mirroring another's view, cursor\nand scroll) and explicitly does NOT want it implemented yet. Design\nconsequence for THIS ticket: keep per-client UI state addressable per\nsession -- ClientSession already does this -- so a follow relationship can\nlater be expressed as one session DERIVING its ViewState from another's.\nDo not 'simplify' by making any UI state globally shared to get follow\nbehaviour for free; that would trade the principle for the feature and is\nthe one shape that forecloses it.\n\n--- MUST REVISIT improvise-kvyt WHEN DOING THIS (2026-09-19) ---\nCopilot found on PR #10 that a view switch moved only the ASKING session mirror,\nstaling every bystander cursor: each client grid is built by grid_of from\nworkbook.active_view(), so a bystander left naming the outgoing view was\nprojected the NEW view rows positioned by the OLD view scroll. Fixed in 8475b2d\nby broadcasting the ACCEPTED view to every mirror, with Sessions::apply now\nreturning Applied { dirty, broadcast } so the host can forward the switch to the\nother clients, whose slices live on them.\n\nThat fix is correct ONLY for the current lockstep model. Under this ticket split\n-- active_view per-client, the model is shared and the UI is not -- a view switch\nbecomes the asker business alone and the broadcast either disappears or changes\nmeaning entirely. Do NOT keep it by inertia: re-derive whether anything still has\nto reach the other sessions when the asker switches, and if the answer is nothing,\ndelete the broadcast and the test that pins it rather than leaving a mechanism\nwhose reason has gone. If the answer is something (e.g. a follow-mode client\nbound to the switcher), that is the shape follow mode wants -- see the\nmodel-shared-ui-not-shared memory, which says explicitly not to foreclose it.","status":"in_progress","priority":2,"issue_type":"task","assignee":"wave2-e-active-view","owner":"el-github@elangley.org","created_at":"2026-09-19T21:00:51Z","created_by":"Edward Langley","updated_at":"2026-09-20T17:53:50Z","started_at":"2026-09-20T17:53:50Z","lease_expires_at":"2026-09-20T17:58:50Z","heartbeat_at":"2026-09-20T17:53:50Z","labels":["area:active-view"],"dependency_count":0,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"improvise-kmgs","title":"Derive the model half of the Command vocabulary from the EffectKind::Model effects","description":"From improvise-gxi (2026-09-19). Blocks that ticket's ACs 4 and 5, which the producer agent correctly declined to force.\n\ngxi asks for 'keymap lookup returns Command values rather than Box\u003cdyn Cmd\u003e' and 'CmdRegistry::dispatch_command(cmd: \u0026Command, ...)'. Neither is reachable today: the Command enum's client-\u003eserver half is exactly the ViewOnly portable-slice mutations, because decision(improvise-cqi, command_set_derived_not_invented) derived it from those effects and nothing else. There is no SetCell, AddCategory, SetAxis or Save in it. So a keymap cannot return a Command for most bindings, and reduce_full over a Command cannot drive a model mutation. The key path remains the only door the model has, and ui/reduce.rs's module doc says so.\n\nDO THIS THE SAME WAY cqi did the view half -- derive, do not invent. Enumerate the effects wearing EffectKind::Model(...) (29 of them; the agent's recipe: awk '/^pub struct /{n=$3} /ModelChange::/{print n}' over ui/effect.rs), let that set BE the enum, then finish gxi ACs 4-5 on top. Inventing variants ahead of the mutations produces a protocol nobody can implement against -- doctrine(derive_the_enum_from_the_effects).\n\nOnce this lands, reduce_full becomes the single door and the two-door note in ui/reduce.rs can go.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T20:56:08Z","created_by":"Edward Langley","updated_at":"2026-09-19T22:11:22Z","closed_at":"2026-09-19T22:11:22Z","close_reason":"ModelCommand = the 29 EffectKind::Model effects minus SwitchView = 28 variants, in improvise-protocol.\n\nEXTRACTION VERIFIED RATHER THAN TRUSTED, and the ticket own awk recipe was WRONG: it reports 30. The extra is HelpPageSet, which is ViewOnly -- it is the last pub struct before #[cfg(test)] and the test module mentions ModelChange::, so the running variable bleeds across the module boundary. Exactly the failure doctrine(derive_then_verify) records. The agent brace-matched every impl Effect block instead; the orchestrator independently confirmed 50 impls, 29 wearing Model(..).\n\nSwitchView rejected as a NEW variant, not as a mutation: Command::SwitchView already exists in the view half with its document half routed through apply_command/App::switch_to_view, and naming it twice would give two spellings for one mutation with only one wired to apply_switch. AcceptWizardModel and ApplyAndClearDrill were CONSIDERED for rejection on gotcha 30 grounds and kept: both commit session-local staged state, but the COMMAND carries none of it -- it is nullary and the protocol crate never names ImportWizard or DrillState. Rejecting them would have meant inventing a rule nothing in the codebase states.\n\nNo second mutation path: model_effects(\u0026ModelCommand) maps back to the effect and apply_command runs it through App::apply_effects, so effects stay the only mutation site and Dirty comes from the effect own EffectKind. Sessions::apply routes Command::Model to apply_model, which takes no SessionId and shares adopt_active_view -- ViewBack, ViewForward and a document-replacing LoadModel all end in App::switch_to_view, so every mirror takes the accepted name (doctrine(lockstep_binds_every_session_not_the_asker)). 7 tests, each proven by injection -- including one that did NOT fire at first because the shared fixture had a single category, so swapping axes and rotating Row-\u003eColumn landed on the same view; it got its own pivoted fixture and the doc comment records why.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-cu7s","title":"[bug] Formula target keeps its pipe delimiters, so a quoted target lands on the wrong _Measure item","description":"From the wave-1 gp-tree review (2026-09-19). CONFIRMED by probe. Likely PRE-EXISTING and user-facing, not introduced by the gp work.\n\nimprovise-formula/src/parser.rs:55 does target = Some(inner.as_str().trim().to_string()), taking the RAW span; formula.pest:20 documents this deliberately ('The target keeps its raw text (including pipes, if any)'). So parsing '|Total Revenue| = ...' yields Formula { target: \"|Total Revenue|\" } -- 15 characters including the pipes -- whereas an Expr::Ref for the same name is correctly unquoted.\n\nConsequence: Model::add_formula then targets a _Measure item whose literal name contains pipes, which never matches the user's item 'Total Revenue', and on save the writer quotes it a SECOND time. Probe confirmed targets 'Total Revenue', 'SUM', 'a|b' and '' all come back pipe-wrapped.\n\nINVESTIGATE FIRST -- the reviewer found this via improvise-gp, but the same parse path serves the formula minibuffer and the .improv '## Formulas' load path. Check whether a user typing a pipe-quoted target in the TUI, and a .improv file containing one, hit the same bug. If so this is a user-facing data bug, not a gp-only one, and its priority should rise.\n\nFIX LOCATION IS A DECISION: unquote in improvise-formula so target matches Expr::Ref's treatment (consistent, but changes a documented grammar behaviour and anything relying on the raw span), or unquote at each call site. Whichever, improvise-gp must stop asserting the broken shape: lib.rs:267-276 an_awkward_target_name_is_quoted_and_round_trips asserts parsed.target == \"|Total Revenue|\" and calls it correct.","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":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T20:43:11Z","created_by":"Edward Langley","updated_at":"2026-09-19T21:50:36Z","started_at":"2026-09-19T21:15:04Z","closed_at":"2026-09-19T21:50:36Z","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-35jb","title":"every_bound_command_name_is_registered checks the name but never that the args parse","description":"From the wave-1 review (2026-09-19). The most useful finding of the review.\n\nkeymap.rs's every_bound_command_name_is_registered walks every binding and checks the command NAME is in registry.names(). It never checks that the bound ARGS parse. But Keymap::dispatch does registry.interactive(name, args, ctx).ok()? -- so a binding whose args are unparseable is ALSO a silently dead key, which is the exact failure mode the test was written to prevent.\n\nimprovise-s13 made this live: it moved two bindings out of the covered class (nullary) into the uncovered one (arg-carrying) -- enter-mode [\"export-prompt\"] and enter-mode [\"formula-edit\"]. A typo such as \"export_prompt\" would compile, pass the whole suite, and produce a dead Ctrl-e. The reviewer verified by hand that both resolve TODAY, so there is no live defect -- the check is what is missing.\n\nFIX: in that same walk, build a CmdContext and assert ks.registry().interactive(name, args, \u0026ctx).is_ok() rather than only checking membership in names(). Per doctrine(check_the_checker), prove it fires by injecting a typo'd arg before keeping it.","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":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T20:42:36Z","created_by":"Edward Langley","updated_at":"2026-09-19T21:50:34Z","started_at":"2026-09-19T21:15:02Z","closed_at":"2026-09-19T21:50:34Z","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} @@ -180,15 +180,15 @@ {"_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":"deferred","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T05:31:33Z","created_by":"Edward Langley","updated_at":"2026-09-20T05:31:35Z","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":"deferred","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T05:31:32Z","created_by":"Edward Langley","updated_at":"2026-09-20T05:31:34Z","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":"deferred","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T05:31:31Z","created_by":"Edward Langley","updated_at":"2026-09-20T05:31:33Z","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} -{"_type":"issue","id":"improvise-ktv1","title":"Move the help content into the shared layer so a browser can show help","description":"? and F1 now enter AppMode::Help in the browser -- the shared keymap delivered that for free -- BUT THE BROWSER HAS NO HELP OVERLAY, so the mode changes and nothing appears. src/ui/help.rs is ~600 lines of ratatui and nothing shared carries its content.\n\nTHE INTERIM, already shipped: the page draws AppMode::hint_text() in a #hint element, so each mode states its own keys. That is genuinely useful and is not a placeholder to rip out -- but it is one line, not five pages.\n\nWHY IT WAS DONE THAT WAY RATHER THAN SPECIAL-CASING: the alternative was to make the client refuse \"enter-mode help\", which is exactly the hand-written exception the keymap consolidation had just deleted. Declining to re-add one was right.\n\nTHE REAL FIX is the same shape as every other consolidation this week (design-principles 5): the help CONTENT is data -- pages of (key, description) -- and it belongs in improvise-protocol where both front ends read it. The TUI keeps its ratatui LAYOUT; the browser writes its own DOM; neither restates what the keys are.\n\nNOTE THE OPPORTUNITY: help content that lives beside the keymap can be DERIVED from it rather than written twice, which would close a drift nobody currently checks -- there is no test that the help text and the bindings agree, and doctrine(binding_dump_diff) exists because bindings have almost no direct tests. A help page generated from Keymaps::default_keymaps() cannot go stale.","acceptance_criteria":"A browser shows help. The content is data — pages of (key, description) — in improvise-protocol, with the TUI keeping its ratatui layout and the browser writing its own DOM; neither restates what the keys are. PREFER DERIVING IT FROM Keymaps::default_keymaps(), which closes a drift nothing currently checks: there is no test that the help text and the bindings agree. The AppMode::hint_text() interim in the browser is NOT a placeholder to rip out — it is one line per mode and genuinely useful; say what survives.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T04:38:45Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:56:07Z","labels":["area:shared-frontend"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-e24v","title":"Second tranche of client-side key resolution: ~15 bindings needing no enrichment","description":"improvise-bpq2 closed the cursor half and DELIBERATELY STOPPED rather than half-doing a second tranche. These need NOTHING added to the protocol -- the operands are already in ViewState or a widget cache -- so they are the cheapest remaining keys, and each one currently does NOTHING in a browser (Host::handle answers ClientFrame::Key with an empty vec).\n\n * enter-mode \u003cx\u003e (: ? F1 C F V Esc Tab in most modes) -\u003e ChangeMode\n * toggle-panel-visibility / toggle-panel-and-focus -\u003e SetPanelOpen + ChangeMode\n * cycle-panel-focus\n * help-page-next / help-page-prev\n * transpose, toggle-prune-empty, view-back\n * switch-view-at-cursor / delete-view-at-cursor / create-and-switch-view (ViewListCache is flat)\n * commit-buffer category|formula|item (all operands in ViewState)\n * commit-cell-edit (ADR 002 CellId + the buffer)\n * clear-cell / paste (CellId + ViewState::yanked)\n * set-axis-for-tile / cycle-axis-for-tile\n\nALSO IN REACH, and bpq2 listed them under \"needs enrichment\" before establishing they do not: filter-to-item (Enter), open-item-add-at-cursor (a/o), delete-category-at-cursor (d), cycle-axis-at-cursor (Space) -- CatTreeEntry already carries the name and the axis. Each is a distinct Command shape, which is why they were left.\n\nMETHOD IS SETTLED, follow it: mirror each Cmd LINE FOR LINE including where siblings disagree, and add a lockstep test that drives the client and the real reduce_key over the same keypresses and compares ViewState. Do not tidy sibling commands into one rule -- that makes the client a second derivation, which is what lockstep testing exists to rule out.\n\nAnd update the_normal_mode_keys_that_still_need_the_host_are_these as you go: it is a named list precisely so closing one has to come there and say so.","acceptance_criteria":"Each listed binding resolves client-side with a lockstep test driving the client and the real reduce_key over the same keypresses. Mirror each Cmd LINE FOR LINE including where siblings disagree — Move and MoveTileCursor emit even when they clamp, MovePanelCursor returns nothing when the cursor does not move — and do NOT tidy them into one rule, which is what makes the client a second derivation. the_command_names_that_still_need_the_host_are_these shrinks by exactly the bindings closed, and closing one has to come to that list and say so. Note improvise-fdt6: commit-buffer formula is on this list and is also why a browser cannot add a formula at all.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T02:36:43Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:56:06Z","labels":["area:shared-frontend"],"dependencies":[{"issue_id":"improvise-e24v","depends_on_id":"improvise-fdt6","type":"relates-to","created_at":"2026-09-20T07:55:25Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-ktv1","title":"Move the help content into the shared layer so a browser can show help","description":"? and F1 now enter AppMode::Help in the browser -- the shared keymap delivered that for free -- BUT THE BROWSER HAS NO HELP OVERLAY, so the mode changes and nothing appears. src/ui/help.rs is ~600 lines of ratatui and nothing shared carries its content.\n\nTHE INTERIM, already shipped: the page draws AppMode::hint_text() in a #hint element, so each mode states its own keys. That is genuinely useful and is not a placeholder to rip out -- but it is one line, not five pages.\n\nWHY IT WAS DONE THAT WAY RATHER THAN SPECIAL-CASING: the alternative was to make the client refuse \"enter-mode help\", which is exactly the hand-written exception the keymap consolidation had just deleted. Declining to re-add one was right.\n\nTHE REAL FIX is the same shape as every other consolidation this week (design-principles 5): the help CONTENT is data -- pages of (key, description) -- and it belongs in improvise-protocol where both front ends read it. The TUI keeps its ratatui LAYOUT; the browser writes its own DOM; neither restates what the keys are.\n\nNOTE THE OPPORTUNITY: help content that lives beside the keymap can be DERIVED from it rather than written twice, which would close a drift nobody currently checks -- there is no test that the help text and the bindings agree, and doctrine(binding_dump_diff) exists because bindings have almost no direct tests. A help page generated from Keymaps::default_keymaps() cannot go stale.","acceptance_criteria":"A browser shows help. The content is data — pages of (key, description) — in improvise-protocol, with the TUI keeping its ratatui layout and the browser writing its own DOM; neither restates what the keys are. PREFER DERIVING IT FROM Keymaps::default_keymaps(), which closes a drift nothing currently checks: there is no test that the help text and the bindings agree. The AppMode::hint_text() interim in the browser is NOT a placeholder to rip out — it is one line per mode and genuinely useful; say what survives.","status":"in_progress","priority":3,"issue_type":"task","assignee":"wave2-h-frontend","owner":"el-github@elangley.org","created_at":"2026-09-20T04:38:45Z","created_by":"Edward Langley","updated_at":"2026-09-20T17:53:40Z","started_at":"2026-09-20T17:53:40Z","lease_expires_at":"2026-09-20T17:58:40Z","heartbeat_at":"2026-09-20T17:53:40Z","labels":["area:shared-frontend"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-e24v","title":"Second tranche of client-side key resolution: ~15 bindings needing no enrichment","description":"improvise-bpq2 closed the cursor half and DELIBERATELY STOPPED rather than half-doing a second tranche. These need NOTHING added to the protocol -- the operands are already in ViewState or a widget cache -- so they are the cheapest remaining keys, and each one currently does NOTHING in a browser (Host::handle answers ClientFrame::Key with an empty vec).\n\n * enter-mode \u003cx\u003e (: ? F1 C F V Esc Tab in most modes) -\u003e ChangeMode\n * toggle-panel-visibility / toggle-panel-and-focus -\u003e SetPanelOpen + ChangeMode\n * cycle-panel-focus\n * help-page-next / help-page-prev\n * transpose, toggle-prune-empty, view-back\n * switch-view-at-cursor / delete-view-at-cursor / create-and-switch-view (ViewListCache is flat)\n * commit-buffer category|formula|item (all operands in ViewState)\n * commit-cell-edit (ADR 002 CellId + the buffer)\n * clear-cell / paste (CellId + ViewState::yanked)\n * set-axis-for-tile / cycle-axis-for-tile\n\nALSO IN REACH, and bpq2 listed them under \"needs enrichment\" before establishing they do not: filter-to-item (Enter), open-item-add-at-cursor (a/o), delete-category-at-cursor (d), cycle-axis-at-cursor (Space) -- CatTreeEntry already carries the name and the axis. Each is a distinct Command shape, which is why they were left.\n\nMETHOD IS SETTLED, follow it: mirror each Cmd LINE FOR LINE including where siblings disagree, and add a lockstep test that drives the client and the real reduce_key over the same keypresses and compares ViewState. Do not tidy sibling commands into one rule -- that makes the client a second derivation, which is what lockstep testing exists to rule out.\n\nAnd update the_normal_mode_keys_that_still_need_the_host_are_these as you go: it is a named list precisely so closing one has to come there and say so.","acceptance_criteria":"Each listed binding resolves client-side with a lockstep test driving the client and the real reduce_key over the same keypresses. Mirror each Cmd LINE FOR LINE including where siblings disagree — Move and MoveTileCursor emit even when they clamp, MovePanelCursor returns nothing when the cursor does not move — and do NOT tidy them into one rule, which is what makes the client a second derivation. the_command_names_that_still_need_the_host_are_these shrinks by exactly the bindings closed, and closing one has to come to that list and say so. Note improvise-fdt6: commit-buffer formula is on this list and is also why a browser cannot add a formula at all.","status":"in_progress","priority":3,"issue_type":"task","assignee":"wave2-h-frontend","owner":"el-github@elangley.org","created_at":"2026-09-20T02:36:43Z","created_by":"Edward Langley","updated_at":"2026-09-20T17:53:38Z","started_at":"2026-09-20T17:53:38Z","lease_expires_at":"2026-09-20T17:58:38Z","heartbeat_at":"2026-09-20T17:53:38Z","labels":["area:shared-frontend"],"dependencies":[{"issue_id":"improvise-e24v","depends_on_id":"improvise-fdt6","type":"relates-to","created_at":"2026-09-20T07:55:25Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-s17o","title":"reduce_full and apply_command's view-slice arm have no production caller","description":"Found while fixing improvise-ondj (2026-09-20), and it is the reason that defect could sit unnoticed.\n\nui/reduce.rs module doc says: \"the one place a command is applied to the authoritative state\" and \"Every deployment mode -- the native TUI, a websocket server, a browser worker -- reaches the model through the functions here.\"\n\nTHAT IS ASPIRATIONAL. reduce_full has NO PRODUCTION CALLER ANYWHERE IN THE WORKSPACE. Every host reaches the model through Sessions::apply or reduce_key. Nothing exercised reduce_full Command::Model arm, so nothing could notice it skipped the basis check -- a door advertised by documentation and walked by nobody is exactly the shape that hides a defect until the next host arrives.\n\nSECOND HALF, same shape: apply_command other =\u003e reduce_view(\u0026mut app.view_state, other) folds a wire VIEW command into the AUTHORITATIVE App own slice. Nothing in production does that -- Sessions::apply folds view commands into the CLIENT MIRROR instead. So view_slice_commands_land_in_the_authoritative_view_state is testing a path no host walks, and passing tells you nothing about any deployment.\n\nTHE DECISION: are these a host API worth keeping, or test-only scaffolding wearing a module doc? If the former, HAVE A HOST USE ONE -- improvise-9x6 (the browser worker) is the natural candidate and is being built now, so this is decidable immediately rather than in principle. If the latter, delete them and correct the module doc, which is currently making a promise the code does not keep.\n\nDo not just delete the test: decide what the function is for first.","acceptance_criteria":"reduce_full and apply_command's view-slice arm either have a production caller or are deleted with the module doc corrected. The module doc currently promises 'every deployment mode reaches the model through the functions here', which is false and is what let improvise-ondj's defect sit unnoticed. NOTE: the post-wave review found Sessions::apply has ALSO lost its production caller (Host::command now calls apply_checked), and reduce.rs:48 still advertises it as 'the door that does it for you' — same question, same file, decide them together.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T01:54:30Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:56:09Z","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-r4lo","title":"Two answers to what a typed string means, and they are adjacent","description":"Noticed during improvise-xks6 (2026-09-20) and left alone, because the envelopes were that ticket. Searched first: nothing open covers it.\n\nTWO CONTRADICTORY DOC COMMENTS, now one level below the envelopes they used to hide behind:\n\n * Client::set_cell_at says classifying a typed string \"is the HOST decision, not one to re-implement here\", and takes a SERIALIZED CellValue.\n * socket.rs::typed says the OPPOSITE -- \"Putting it in Rust rather than in the page JavaScript is what makes it testable\" -- and typed_cell takes a PLAIN STRING.\n\nBoth now return the same ClientFrame, so this is the same disease improvise-xks6 just cured, one level down: TWO ANSWERS TO ONE QUESTION, kept apart only by the fact that nothing called both.\n\nWHICH ONE IS RIGHT: typed_cell is the one web/app.js calls and the one repo-map gotcha 42 blesses -- the browser has no App to ask, and ModelCommand::SetCell takes a CellValue, so somebody on the client side must decide; putting it in Rust rather than JS is what makes it testable and keeps one spelling per language boundary. set_cell_at is UNREFERENCED by the page.\n\nSo the likely answer is to delete set_cell_at and fix the doc, but check first whether anything else (a test, the lockstep harness, a future worker bootstrap) wants the pre-classified form -- a host that HAS an App genuinely can classify, and improvise-9x6 puts an App in the browser, which may make set_cell_at the right entry point for the WORKER deployment and typed_cell right for the socket one. If so the fix is to say that in both doc comments rather than to delete either. Decide it, do not leave two claims standing.","status":"closed","priority":3,"issue_type":"task","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T01:52:37Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:22:09Z","started_at":"2026-09-20T05:32:35Z","closed_at":"2026-09-20T06:22:09Z","close_reason":"Closed by PR #21 (WU-I). DECIDED: DELETE, do not re-document.\n\nThe ticket offered an escape hatch -- that improvise-9x6's browser App might want the pre-classified form, in which case the fix would be to say so in both doc comments rather than delete either. THAT ESCAPE HATCH DOES NOT EXIST. ModelCommand::SetCell carries a CellValue, so nothing that crosses a frame is ever a string, and the worker's App sits BEHIND the frame boundary and is handed a value. So set_cell_at's claim -- that classifying a typed string is \"the HOST's decision\" -- is false on every deployment, not just on the socket one.\n\nBoth set_cell_at AND set_cell are gone. set_cell cannot express ClearCell, which makes it a STRICTLY WEAKER DUPLICATE of typed_cell_frame rather than a peer -- and its the_json_wrapper_composes_the_same_message was exactly the compatibility test doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished) says a finished consolidation deletes.\n\nTEST COUNT DROPPED 5 AND IS FULLY ACCOUNTED: 2 aimed at deleted code; 3 were None-shape tests already asserted against typed_cell_frame in socket.rs. The 4th None shape -- ADR 001's absent basis -- had no twin, so a_client_with_no_basis_composes_nothing was RETARGETED rather than dropped, and proved discriminating by seeding a Projection::Basis into its fixture and watching it flip to Some.\n\nThree doc comments elsewhere named the deleted fn (keys.rs, state.rs, socket.rs); all re-pointed, per the grep rule.\n\nWHAT REMAINS, filed as improvise-j7v6: socket.rs::typed still duplicates commit_regular_cell_value on the host key path. Both are legitimate -- the browser has no App to ask -- but the RULE is written twice, and a_typed_string_is_classified_as_the_host_classifies_it states the obligation in its name while asserting three outcomes by hand rather than comparing against the host's function, so the two can drift and stay green.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-i5tk","title":"Narrow the TUI's GridCache to its viewport -- benchmark first","description":"Disclosed as a caveat of PR #12 rather than discovered by it: it is in the PR body, in the aa7a6d3 commit message, and in repo-map gotcha 44. Copilot raised it independently on round 2.\n\nproject::grid_of evaluates EVERY CELL PER FRAME, where GridWidget used to evaluate only the visible ones.\n\nBE PRECISE ABOUT WHAT CHANGED: this is a WIDENING, not an introduction. compute_col_widths was already whole-grid and the totals loop was already O(rows x visible cols). And it is UNBENCHMARKED IN BOTH DIRECTIONS -- \"this can dominate TUI rendering\" is exactly as unevidenced as \"it is fine\" would be. Do not fix it on the strength of the reasoning.\n\nSO ACCEPTANCE CRITERION #1 IS A BENCHMARK. The fix and the evidence that it was needed are the same work.\n\nWHAT THE FIX COSTS, if the benchmark says do it: teaching GridWidget that a cache position is RELATIVE, which is the browser client arrangement (gotcha 44 -- the TUI builds at offsets zero deliberately, and that is what let the scrolling arithmetic stay untouched through improvise-n10). So this is not a narrowing of a loop, it is a change to what a cache index MEANS in the TUI, and every scroll site is in scope.","acceptance_criteria":"ACCEPTANCE CRITERION 1 IS A BENCHMARK, and the fix happens only if it says so — 'this can dominate TUI rendering' is exactly as unevidenced as 'it is fine'. If it does: GridWidget learns that a cache position is RELATIVE, which is the browser's arrangement, and every scroll site is in scope — this is a change to what a cache index MEANS in the TUI, not a narrowed loop. Note two things the wave added: project::grid_of now evaluates every cell per frame (a widening of an existing whole-grid cost, flagged in gotcha 44), and improvise-rucf.3 may force this anyway, because a live-backed source turns the viewport-bounded projection from an optimisation into a correctness requirement.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T01:26:47Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:56:20Z","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-6c6q","title":"[bug] apply_switch relays a refused switch to everyone but the asker","description":"Found by the code review of ADR 001 layer 1 (2026-09-19). Pre-existing -- the pre-wave broadcast: Option\u003cCommand\u003e was explicitly \"every OTHER client\" -- and the doc overclaims.\n\napply_switch doc says: \"a client that optimistically folded a name the document turned down is corrected by the same message that tells the others to follow.\" But it relays Broadcast::ToOthers, so THE ASKER IS NOT SENT THE CORRECTION. If the workbook refuses the switch (the view does not exist), the asker slice keeps the asked-for name while its mirror holds the accepted one -- gotcha 24 mismatch, and gotcha 29 says the two copies must move together.\n\nUNLIKE the apply_model case the review fixed, a blanket ToAll is WRONG here: the asker really did fold optimistically on the accepted path, so ToAll would double-apply on the common path.\n\nTHE HONEST FIX IS CONDITIONAL: ToAll when accepted != asked, ToOthers otherwise. Which is to say the audience is a function of whether the asker \"folded the right thing\", not of the command variant -- a slightly sharper version of doctrine(the_audience_follows_the_optimistic_fold).\n\nCheap. The reason it is P3 and not higher is that switching to a view that does not exist is not a thing a well-behaved client does; it is reachable through the : command line.\n\n## Steps to Reproduce\n\n1. Two clients on one document.\n2. Client A asks to switch to a view that does NOT exist, reachable through the `:` command line.\n3. The workbook refuses; `apply_switch` relays `Broadcast::ToOthers`, so every OTHER client is told the accepted name and **the asker is not**.\n4. A's own slice keeps the asked-for name while its server-side mirror holds the accepted one — gotcha 24's mismatch, and gotcha 29 says the two copies must move together.\n\n`apply_switch`'s own doc claims \"a client that optimistically folded a name the document turned down is corrected by the same message that tells the others to follow\". It is not.\n","acceptance_criteria":"apply_switch's audience is conditional: ToAll when the accepted view differs from the asked-for one, ToOthers otherwise. A blanket ToAll is WRONG here — the asker really did fold optimistically on the accepted path, so it would double-apply on the common path, which is what distinguishes this from the apply_model case. A test drives a switch to a view that does not exist and asserts the ASKER is corrected; note that arm is reachable only through the : command line.","notes":"ALSO FIX THE DOC COMMENT, and treat it as the more dangerous half. apply_switch's doc (project.rs:~714, verbatim on main) actively claims 'a client that optimistically folded a name the document turned down is corrected by the same message that tells the others to follow.' It is not -- the relay is ToOthers. A comment that says the hole is closed is worse than the hole, because the next reader stops looking.\n\nCopilot surfaced this independently on PR #12 and its suggested shape is right and cheap: apply_switch ALREADY RECEIVES the asked-for name -- it takes the whole cmd, which IS Command::SwitchView(asked) -- it simply never destructures it. So the conditional audience (ToAll when accepted != asked, ToOthers otherwise) needs no new parameter.\n\nTHE REAL COST OF THIS TICKET IS THE REGRESSION TEST, NOT THE CHANGE. It must drive THE ASKER'S OWN SLICE, not the to_others helper -- that helper looks away from the asker by construction, and that is exactly how the sibling bug in apply_model (fixed in 9cb16ce) shipped green.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T01:05:15Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:57:36Z","labels":["area:active-view"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-6c6q","title":"[bug] apply_switch relays a refused switch to everyone but the asker","description":"Found by the code review of ADR 001 layer 1 (2026-09-19). Pre-existing -- the pre-wave broadcast: Option\u003cCommand\u003e was explicitly \"every OTHER client\" -- and the doc overclaims.\n\napply_switch doc says: \"a client that optimistically folded a name the document turned down is corrected by the same message that tells the others to follow.\" But it relays Broadcast::ToOthers, so THE ASKER IS NOT SENT THE CORRECTION. If the workbook refuses the switch (the view does not exist), the asker slice keeps the asked-for name while its mirror holds the accepted one -- gotcha 24 mismatch, and gotcha 29 says the two copies must move together.\n\nUNLIKE the apply_model case the review fixed, a blanket ToAll is WRONG here: the asker really did fold optimistically on the accepted path, so ToAll would double-apply on the common path.\n\nTHE HONEST FIX IS CONDITIONAL: ToAll when accepted != asked, ToOthers otherwise. Which is to say the audience is a function of whether the asker \"folded the right thing\", not of the command variant -- a slightly sharper version of doctrine(the_audience_follows_the_optimistic_fold).\n\nCheap. The reason it is P3 and not higher is that switching to a view that does not exist is not a thing a well-behaved client does; it is reachable through the : command line.\n\n## Steps to Reproduce\n\n1. Two clients on one document.\n2. Client A asks to switch to a view that does NOT exist, reachable through the `:` command line.\n3. The workbook refuses; `apply_switch` relays `Broadcast::ToOthers`, so every OTHER client is told the accepted name and **the asker is not**.\n4. A's own slice keeps the asked-for name while its server-side mirror holds the accepted one — gotcha 24's mismatch, and gotcha 29 says the two copies must move together.\n\n`apply_switch`'s own doc claims \"a client that optimistically folded a name the document turned down is corrected by the same message that tells the others to follow\". It is not.\n","acceptance_criteria":"apply_switch's audience is conditional: ToAll when the accepted view differs from the asked-for one, ToOthers otherwise. A blanket ToAll is WRONG here — the asker really did fold optimistically on the accepted path, so it would double-apply on the common path, which is what distinguishes this from the apply_model case. A test drives a switch to a view that does not exist and asserts the ASKER is corrected; note that arm is reachable only through the : command line.","notes":"ALSO FIX THE DOC COMMENT, and treat it as the more dangerous half. apply_switch's doc (project.rs:~714, verbatim on main) actively claims 'a client that optimistically folded a name the document turned down is corrected by the same message that tells the others to follow.' It is not -- the relay is ToOthers. A comment that says the hole is closed is worse than the hole, because the next reader stops looking.\n\nCopilot surfaced this independently on PR #12 and its suggested shape is right and cheap: apply_switch ALREADY RECEIVES the asked-for name -- it takes the whole cmd, which IS Command::SwitchView(asked) -- it simply never destructures it. So the conditional audience (ToAll when accepted != asked, ToOthers otherwise) needs no new parameter.\n\nTHE REAL COST OF THIS TICKET IS THE REGRESSION TEST, NOT THE CHANGE. It must drive THE ASKER'S OWN SLICE, not the to_others helper -- that helper looks away from the asker by construction, and that is exactly how the sibling bug in apply_model (fixed in 9cb16ce) shipped green.","status":"in_progress","priority":3,"issue_type":"bug","assignee":"wave2-e-active-view","owner":"el-github@elangley.org","created_at":"2026-09-20T01:05:15Z","created_by":"Edward Langley","updated_at":"2026-09-20T17:53:51Z","started_at":"2026-09-20T17:53:51Z","lease_expires_at":"2026-09-20T17:58:51Z","heartbeat_at":"2026-09-20T17:53:51Z","labels":["area:active-view"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-z754","title":"Document name, dirty flag and file path have no home a client can read","description":"draw_title reads workbook.model.name, model_state.dirty and model_state.file_path. NONE IS PROJECTED. improvise-wasm-client RenderModel carries mode, status and view_name but not these, so A BROWSER TITLE BAR CANNOT SHOW THE DOCUMENT NAME OR THE DIRTY MARKER.\n\nFound closing improvise-n10, alongside the grid-frame gaps.\n\nWORTH DECIDING RATHER THAN JUST ADDING: dirty is a fact about the document that every client shares, and name/path likewise -- so these want a projection or a ViewState field, not a per-widget cache. But note file_path is also the thing Save uses and a browser has no filesystem path in the same sense (gotcha 2, gotcha 28), so \"what a remote client should be told about where the document lives\" is a real question and not obviously \"the path\".","acceptance_criteria":"A client can render a title bar: document name, dirty marker and whatever the answer to 'where does this document live' turns out to be. These are facts about the DOCUMENT that every client shares, so they want a projection or a ViewState field rather than a per-widget cache — say which and why. Note file_path is the thing Save uses and a browser has no filesystem path in the same sense (gotchas 2, 28, and now improvise-368h's ClientFrame::Save which deliberately carries none), so 'what a remote client should be told about where the document lives' is a real question and the answer is probably not the path.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T00:58:35Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:55:51Z","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-1125","title":"Row-header width is computed per frame in the widget","description":"src/ui/grid.rs::render_grid computes sub_widths / row_header_width by walking layout.row_items per level. GridCache carries col_widths but NO row-header width.\n\nIts sibling compute_col_widths already moved into improvise-command::ui::viewport; this one did not. So a browser client sizing its row header will invent a second rule -- the exact shape of improvise-edp (the category panel walked twice) and of the multi-level-labels divergence.\n\nSmall, and it pairs naturally with the multi-level labels ticket, because both are facts about the grid FRAME rather than its contents, and the frame is what improvise-n10 deliberately left behind.","status":"closed","priority":3,"issue_type":"task","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T00:58:34Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:41:34Z","started_at":"2026-09-20T05:32:27Z","closed_at":"2026-09-20T07:41:34Z","close_reason":"Closed by PR #23 (WU-A), and the ticket's premise was understated: the row-header width rule existed TWICE ALREADY -- inline in src/ui/grid.rs::render_grid and in viewport::compute_row_header_width -- rather than once-and-unshared.\n\nIt is now improvise_protocol::header_level_widths, with both callers reading it.\n\nTHE TICKET SAID TO PUT IT BESIDE compute_col_widths IN improvise-command::ui::viewport. THAT HOME IS UNREACHABLE and taking it would have guaranteed the second derivation this ticket exists to prevent: improvise-wasm-client cannot depend on improvise-command, so a browser sizing its own row header would have had to invent a third copy. improvise-protocol is the only shared home.\n\nIT TAKES AN ITERATOR OF BORROWED TUPLES rather than an AxisFrame, and that is load-bearing: its other caller is compute_row_header_width, reached from App::cmd_context ON EVERY KEYPRESS. Building a frame there would clone every row tuple per key. An independent reviewer read that path line by line and confirmed no allocation on the keypress path.\n\nimprovise-protocol gained unicode-width to do it, judged acceptable: forbidden(improvise_protocol, ui) is about UI FRAMEWORK types (the worked examples are AxisColor -\u003e ratatui::Color and MinibufColor), text measurement is not one, and GridCache::col_widths was already a Vec\u003cu16\u003e of display cells on the wire -- the unit was already there. Noted honestly: header_widths() has exactly one production consumer today, the TUI widget, so the \"a browser would have been a third copy\" justification is anticipatory rather than actual.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_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":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T00:58:33Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:56:07Z","labels":["area:shared-frontend"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_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":"in_progress","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-20T17:53:39Z","started_at":"2026-09-20T17:53:39Z","lease_expires_at":"2026-09-20T17:58:39Z","heartbeat_at":"2026-09-20T17:53:39Z","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-gd28","title":"Drill over the wire needs the addressed command form","description":"From the ADR 002 spine landing (2026-09-19). The RESOLVER exists and is tested -- App::resolve_aggregate, layout-only by necessity since an aggregate has no existence outside the layout that derives it -- and CellId::Aggregate is emitted. What is missing is the command.\n\nModelCommand::Drill was deliberately NOT added. Drilling creates the _Drill view and writes drill_state, which gotchas 30 and 38 rule session-local and therefore unaddressable by a NULLARY Command -- exactly the reason ApplyAndClearDrill was removed from the wire in improvise-3x3u.\n\nSo a wire Drill needs the ADDRESSED form: the variant carries a SessionId and the staging map moves off the single App onto the session. That is ADR 001 layer 2 (context/adr/001-concurrent-edits.md), so this is blocked on it rather than being independent work.\n\nThe seam is ready: emit, resolve and the type-level guarantee (Drill takes an Aggregate and nothing else) are all in place and tested.","acceptance_criteria":"Drilling works over the wire. It needs the ADDRESSED command form — the variant carries a SessionId and the staging map moves off the single App onto the session — because drilling writes drill_state, which gotchas 30 and 38 rule session-local and therefore unaddressable by a nullary Command. That is ADR 001 layer 2, so this is blocked on improvise-7b8v rather than independent. The seam is ready: App::resolve_aggregate, CellId::Aggregate emission and the type-level guarantee that Drill takes an Aggregate and nothing else all exist and are tested.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T00:18:34Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:56:23Z","labels":["area:protocol"],"dependencies":[{"issue_id":"improvise-gd28","depends_on_id":"improvise-7b8v","type":"blocks","created_at":"2026-09-20T16:20:30Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} @@ -279,13 +279,13 @@ {"_type":"issue","id":"improvise-q0vb","title":"Pad the import wizard's hint line to the panel width","description":"Pre-existing, found by Copilot on PR #12 and deliberately NOT fixed there -- see the pushback at https://github.com/fiddlerwoaroof/improvise/pull/12#discussion_r4055516143\n\nFour buf.set_string(x, hint_y, ...) sites in src/ui/import_wizard_ui.rs write the hint without padding to the panel width, so a SHORTER message leaves the tail of the longer previous string visible.\n\nTHE REPRODUCTION IS ALREADY IN THE SUITE, as an EXPECTATION: the DefineFormulas characterization test asserts \"|something went wrongcel\" -- the \"cel\" being the tail of \"Esc: cancel\". The artifact is currently pinned as correct output.\n\nWHY IT WAS NOT FIXED IN THE MIGRATION PR: those exact-frame characterization tests were that PR's ONLY evidence that the widget migration changed nothing (doctrine(characterization_survives_the_move)). Rewriting them in the same change would have destroyed the evidence for the change. The sibling finding in the same file (the non-ASCII truncate panic) WAS applied, precisely because it is byte-identical on ASCII and therefore costs no evidence. The two findings differ in what they cost, not in whether they are right.\n\nACCEPTANCE: the tail is gone AND the affected characterization expectations are updated IN THE SAME COMMIT, so the frame change is the point of the commit rather than a side effect of one.","status":"closed","priority":4,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T01:26:46Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:29:00Z","started_at":"2026-09-20T05:32:39Z","closed_at":"2026-09-20T06:29:00Z","close_reason":"Closed by PR #22 (WU-L). The acceptance clause was met exactly: the tail is gone AND the affected characterization expectation moved in the same commit, with the commit message naming it.\n\nTHE DIAGNOSIS WAS BIGGER THAN THE TICKET'S. The bottom row had FIVE WRITERS -- four step hints plus the message that overrides them -- and none owned clearing it. So the defect was not a forgotten pad at four call sites; it was a row with five writers and no owner. One helper, set_bottom_line, now truncates to the panel width and pads to it, and all five go through it.\n\nEXACTLY ONE EXPECTATION MOVED, of eight:\n FORMULA_STEP bottom row: \"|something went wrongcel ...\" -\u003e \"|something went wrong ...\"\nThe \"cel\" was three characters of a hint the message had replaced; it was never part of the message. The other seven frames are BYTE-IDENTICAL, because padding with spaces is invisible to buf_text (which trims each row) -- so the change can only show where it ERASES something. That is the evidence this is a fix and not a rewrite of the characterization PR #12 was protecting.\n\nTWO OF COPILOT'S FOUR FINDINGS LANDED IN THIS FILE and both were about measurement rather than logic: padding by chars().count() over-pads a wide character past the panel border and under-pads a combining mark (now UnicodeWidthStr::width, as grid.rs does); and the test written to catch the second of those PASSED FOR THE WRONG REASON, because drawing through the widget cannot see under-padding at all -- the hint ran first and had already padded the row, so the cells the message failed to reach held spaces either way. Replaced with a sentinel-filled row asserted directly against set_bottom_line.\n\nTHE GENERAL LESSON, now doctrine(an_ascii_fixture_cannot_see_a_width_bug): the eight exact-frame fixtures in that file are ASCII, so bytes, chars and cells all coincide and they are blind to every question about display width. Eight green frames sat either side of both bugs.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-hkt6","title":"Three small totality gaps found in review","description":"Three PLAUSIBLE/minor findings from the 2026-09-19 wave review, each unreachable today and each one refactor away from not being. Grouped because none is worth a ticket alone.\n\n1. DataStore::matching_positions panics on an empty partial -- it indexes sets[0]. Private, and both callers guard with `if partial.is_empty()`, so it is unreachable. But THE GUARD NOW LIVES ONE LEVEL AWAY FROM THE INDEXING, which is how this class of bug returns. A sets.first()?-shaped total function costs nothing.\n\n2. invalidate_relabelled does not blank `totals` the way Invalidated does. By gotcha 33(b) own argument -- preserve-by-position is only right where the label did not change -- a RENAMED column should drop its total. Unreachable in practice: grid_updates diffs mirror.totals != desired.totals and emits Totals after ColLabels in the same batch, so a changed total is always sent. Worth fixing anyway, because 33(b) exists PRECISELY to stop `apply` trusting producers.\n\n3. improvise-gp mark_features indexes used[*feat] without a bound, where expr_text uses .get().expect(...) for the same precondition. Same failure, less legible.","status":"closed","priority":4,"issue_type":"task","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T01:05:16Z","created_by":"Edward Langley","updated_at":"2026-09-20T05:54:44Z","started_at":"2026-09-20T05:32:38Z","closed_at":"2026-09-20T05:54:44Z","close_reason":"Closed by PR #20 (WU-K). All three gaps fixed, each fault-injected.\n\n(a) matching_positions total-ised via split_first. The fix is NOT vec![]: an empty partial constrains NOTHING, so EVERY cell matches -- answering \"none\" one level down would have made deleting a caller's fast path silently flip \"all\" to \"none\". The callers' partial.is_empty() branches are fast paths, not guards, and the test asserts the two doors agree.\n\n(b) invalidate_relabelled now blanks a relabelled COLUMN's total. Rows are the negative arm and stay untouched -- a total spans every row, so no one row frames it, and that arm is what stops \"blank on any relabel\" passing.\n\n(c) mark_features uses .get_mut().expect(...), matching expr_text.\n\nTHE THING THE TICKET DID NOT PREDICT, and the reason this bead was not independent of its own framing: (b) was filed as \"unreachable in practice, fix it anyway because gotcha 33(b) exists to stop apply trusting producers\". Right about the rule, wrong about the cost. Adding a blanking rule on the CONSUMER side changes what every PRODUCER owes, and grid_updates did not pay it: it diffed mirror.totals against desired.totals, so a column renamed while its total stayed the same NUMBER emitted ColLabels and no Totals -- the client blanked its own total and project_diff's self.grid = desired left the server believing it was still there. A client cache wrong forever, gotcha 35's exact failure direction. Found by Copilot. Fixed by diffing totals against the label-folded frame.\n\nWHEN YOU MAKE apply STOP TRUSTING A PRODUCER, GO READ THE PRODUCER.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_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":"open","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T00:58:35Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:57:41Z","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-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":"in_progress","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-20T17:53:50Z","started_at":"2026-09-20T17:53:50Z","lease_expires_at":"2026-09-20T17:58:50Z","heartbeat_at":"2026-09-20T17:53:50Z","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":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T00:18:35Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:55:52Z","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-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":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T20:42:38Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:56:07Z","labels":["area:shared-frontend"],"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":"in_progress","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-20T17:53:40Z","started_at":"2026-09-20T17:53:40Z","lease_expires_at":"2026-09-20T17:58:40Z","heartbeat_at":"2026-09-20T17:53:40Z","labels":["area:shared-frontend"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-n3wt","title":"prune_axis_items: keep-slice length invariant is only a debug_assert","description":"From the wave-1 review (2026-09-19). NOT a defect today -- filed so the latent risk is recorded rather than rediscovered.\n\nimprovise-ai7 replaced the old guard 'data_idx \u003c rc \u0026\u0026 keep_row[data_idx]' with 'keep.get(data_idx).copied().unwrap_or(false)' plus a debug_assert_eq!(data_idx, keep.len()). The reviewer confirmed the lengths cannot diverge today: keep_row/keep_col are built as (0..rc)/(0..cc) collections, rc is row_count() which is itself data_items(\u0026row_items).count(), nothing mutates the axis vectors between building keep and consuming it, and the fn is private with exactly two call sites. The debug_assert can never fire, and cargo test --workspace --release (assert compiled out) is green.\n\nThe risk is what happens IF a third caller ever derives keep from something other than row_count()/col_count(), because debug_assert is compiled out in release and the two builds then differ in OPPOSITE directions from the old code:\n * keep too SHORT: old code panicked (loud, index out of bounds); new code silently drops the item in release -- a crash becomes silent data loss from the grid.\n * keep too LONG: old dropped every item at index \u003e= rc; new keeps them if flagged.\n\nFix by making the count unsupplied-able rather than asserted -- e.g. have prune_axis_items derive keep's length from items itself, or take a newtype that can only be built from the same vector. Failing that, a doc-comment on the fn naming the invariant and both failure directions.","status":"closed","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T20:41:44Z","created_by":"Edward Langley","updated_at":"2026-09-19T20:52:13Z","closed_at":"2026-09-19T20:52:13Z","close_reason":"Fixed structurally rather than by assertion: prune_axis_items now takes impl Fn(usize) -\u003e bool over the DATA index instead of a \u0026[bool] plus a debug_assert. The function no longer receives a length at all -- the index set is generated by walking items -- so there is no parallel quantity a caller can derive wrongly, and debug and release cannot diverge because both the assertion and the unwrap_or fallback are gone. Both failure directions the ticket named become unrepresentable rather than detected. prune_empty now consults the has_value grid directly and the two Vec\u003cbool\u003e allocations disappear; the eager-to-lazy change is safe because pruning row_items touches neither col_items nor has_value, so the column pass still reads the original grid with the original rc (reasoning left in a comment at the call site). Two tests added, including prune_axis_items_indexes_data_positions_not_visual_ones, which pins the contract the old debug_assert only gestured at -- a group header must not shift the numbering the predicate sees. Records mode untouched.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-th7m","title":"Dead F(1) binding: bound to bare 'enter-mode' then immediately rebound","description":"Noticed while working improvise-s13 (2026-09-19), not touched per AGENTS.md (mention dead code, do not delete unasked).\n\nIn crates/improvise-command/src/command/keymap.rs, F(1) is bound to a bare \"enter-mode\" with NO args -- which would fail require_args if it were ever dispatched -- and is then rebound on the very next line with [\"help\"]. The first line is dead: it can never take effect.\n\nTrivial deletion, but worth confirming the second binding is the intended one rather than deleting the wrong line.","status":"closed","priority":4,"issue_type":"chore","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-19T20:36:45Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:54:15Z","started_at":"2026-09-20T05:32:32Z","closed_at":"2026-09-20T07:54:15Z","close_reason":"Closed by PR #27 (WU-G). The dead F(1) line is deleted.\n\nF(1) was bound to a bare \"enter-mode\" with NO ARGS -- which would have failed require_args if it were ever dispatched -- and rebound on the very next line with [\"help\"]. Keymap::bind inserts, so the second won; had the first survived, F1 would have done nothing.\n\nTHE SURVIVOR WAS CONFIRMED TWO WAYS BEFORE DELETING, which is the whole reason this was filed rather than done blind: the binding dump resolves F1 to enter-mode [\"help\"], and `?` on the next line carries the identical binding.\n\nEVIDENCE: IMPROVISE_BINDING_DUMP diff BYTE-IDENTICAL across the deletion (4021 resolved lookups) -- the strongest form of \"this changed nothing\". Plus a new f1_and_question_mark_both_open_help, fault-injected by restoring the deleted line and watched to fail.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-2zq","title":"Records mode has no inline way to add a category, unlike the + column for measures","description":"improvise-c5v gave records mode a trailing '+' column: type a name, the measure is created, and it becomes a real column you can immediately type values into — without leaving the grid. There is no equivalent for categories, which is an asymmetry the wide-format work introduced.\n\nWHAT EXISTS TODAY (all leave the grid):\n- N in normal or records mode -\u003e search-or-category-add (command/cmd/search.rs:172). Emits SetPanelOpen{Category} + enter-mode category-add, so it opens the side panel and switches mode. Records-normal inherits it from the normal keymap (keymap.rs:645 builds rn with_parent(normal)).\n- n inside the category panel -\u003e the same CategoryAdd mode.\n- :add-cat Region (alias for add-category).\n\nNone of them set a coordinate on the row you were standing on, so after adding 'Region' you still have to navigate to that column and type the value.\n\nWHY IT LOOKS TRACTABLE:\n- GridLayout::records_columns filters on cat.kind.is_regular() and does NOT require the category to have items, so a freshly added empty category appears as a records column immediately.\n- CellKey::with(cat, item) ADDS a coordinate when absent, so the existing rename_record_coordinate path already handles a row that has no coordinate for the new category — it will move every measure cell in the row onto the new coordinate, which is the correct semantics.\n\nSo the shape could mirror '+': a second trailing column (say '+cat') whose commit runs add-category and then, optionally, sets that coordinate for the current row.\n\nOPEN QUESTIONS, decide before building:\n1. Two trailing columns ('+' for measure, '+cat' for category) may be more clutter than it is worth. An alternative is one '+' column that disambiguates by prefix, or a key rather than a column.\n2. Adding a category is more consequential than adding a measure: every existing record lacks a coordinate for it, so the column is blank everywhere and MAX_CATEGORIES (12) applies. Adding a measure only adds a blank column. Worth deciding whether inline creation is even desirable, or whether the panel is the right home for a schema change of that weight.\n3. Should creating the category also set its value on the current row in one commit, or just create the column?\n\nFiled after the user asked whether such a bead existed; it did not.","status":"closed","priority":4,"issue_type":"feature","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-08-29T19:32:40Z","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). Built in full on the user's ruling (2026-09-20), which chose the larger option over the agent's recommendation to decline.\n\nTHE PROBE CHANGED THE FIX, and this is the part worth keeping. Breaking on_category_added in two directions first:\n * removing the virtual bump entirely: ZERO tests fail\n * seating every category on None: TWELVE tests fail\nSo the auto-assignment was load-bearing and well covered, while THE BUMP WAS COMPLETELY UNGUARDED -- which is how it came to be wrong about two different virtuals with nothing noticing.\n\nA SECOND LIVE BUG NOBODY HAD REPORTED, found by reading the rule once the probe had located it: _Measure on Column is an ordinary configuration -- it is what web/demo.improv ships -- and adding a second regular category KICKED IT OFF, making every measure vanish from the grid. On the demo's own front page.\n\nTHE NEW RULE, stated for every view and not just records: A VIRTUAL ON AN AXIS IS A CONFIGURATION, NOT A VACANCY. It is never moved. A records view seats a new regular category on Axis::None (where records mode keeps every other regular category, and where records_columns picks it up anyway); a pivot view is unchanged except that a new category now JOINS a virtual already on the target axis, nesting a header, instead of evicting it. View::is_records_layout() is new and is the ONE definition -- GridLayout now asks it instead of re-deriving the _Index/_Dim test.\n\nTHE COLUMN: one `+` per block, positioned -- [coords, +cat, measures, +] -- so POSITION tells them apart rather than a label. A prefix was rejected because it puts a sigil into the name space where a category and a measure may legitimately share a name, the exact ambiguity _Dim carries an index to avoid (gotcha 1). Committing a name does NOT set the coordinate: that would move every measure cell in the row (gotcha 1) on the same Enter as a schema change, in an app with no undo.\n\nMAX_CATEGORIES refuses VISIBLY, which took a second fix: the refusal was written and never drawn, because CommitAndAdvance re-enters editing and draw_bottom_bar renders the minibuffer instead of the status line. AddCategory now aborts the batch and CommitAndAdvance emits its mode change FIRST. The limit stays checked in exactly one place, Model::add_category.\n\nFOUR characterization tests updated knowingly, each recording why.\n\nFOLLOW-UP FOUND BY THE POST-WAVE REVIEW: a refused or DUPLICATE AddCategory still bumps layout_version and dirty -- improvise-75ld.","dependencies":[{"issue_id":"improvise-2zq","depends_on_id":"improvise-c5v","type":"blocks","created_at":"2026-08-29T12:32:45Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} From 102d085c35b63d80e1d3099c20a1a9d14da9cb17 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Sun, 20 Sep 2026 10:55:12 -0700 Subject: [PATCH 03/17] chore(bd): file improvise-864a, the Pages deploy race Co-Authored-By: Claude Opus 5 (1M context) --- .beads/issues.jsonl | 1 + 1 file changed, 1 insertion(+) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 137c7f2a..018083fc 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -37,6 +37,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-864a","title":"Concurrent merges to main race on one Pages environment and the superseded deploys fail","description":"Three PRs merged within 20 seconds on 2026-09-20 (#37, #41, #42). Each CI run built the site and uploaded a github-pages artifact, and each queued a deploy behind 'concurrency: group: pages, cancel-in-progress: false'.\n\nOnly the newest run kept its artifact:\n\n run 35526758561 (04e4e3d, #37) conclusion=failure artifacts=0\n run 35526767814 (5f871f7, #41) conclusion=success artifacts=1\n run 35526774007 (52507a6, #42) conclusion=failure artifacts=0\n\nThe two superseded deploys failed with:\n\n Fetching artifact metadata for \"github-pages\" in this workflow run\n Found 0 artifact(s)\n Error: No artifacts named \"github-pages\" were found for this workflow run.\n\nThe upload step was GREEN in all three runs, so this is not a build failure —\nthe artifact is gone by the time the losing deploy runs. The successful deploy\n(17:47:28Z) preceded the failing one (17:47:44Z) by 16 seconds.\n\nDeploying a superseded commit is pointless anyway: the tip of main is what\nshould be published. Cancelling is deliberately NOT the answer — the existing\ncomment on the concurrency block records that a deploy-pages cancelled midway\ncan leave the Pages deployment half-applied.\n\n## Steps to Reproduce\n\n1. Merge three PRs to main within about a minute.\n2. Watch the CI runs: the newest deploy succeeds, the older ones fail on an\n empty artifact fetch and mark otherwise-green runs as failed.\n\nReported by the user from the Actions log.","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.","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-20T17:55:01Z","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":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T07:55:22Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:57:55Z","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":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T07:49:43Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:49:43Z","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":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T07:49:42Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:49:42Z","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} From dc2f4a06ad20870d083b4e96f345a2f699c841c6 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Sun, 20 Sep 2026 11:06:10 -0700 Subject: [PATCH 04/17] docs: complete the corrections, per adversarial review of this PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four findings, all verified against the tree before acting. F10 — this PR's own edit to design-principles §5 made a sibling note in context/code/command.md false: it said §5 "still names Sessions::apply" and recommended apply_checked. A PR whose whole subject is stale prose had left a new stale copy behind, which is gotcha 48 exactly ("changing it is a grep, not an edit"). Note deleted; the verified claim above it stands. F11 — §5 named both apply and apply_checked as "the one door". apply has no production caller anywhere in the workspace; Host calls apply_checked. Naming two weakens the claim §5 makes, so it now names the one production uses. F12 — gotcha 17 said "four call sites". There are six uses across the two evaluators: a div/0 guard plus the Eq and Ne arms in each. In a file agents follow literally, a reader who greps and finds six cannot tell whether the prose or the code moved. F13 — gotcha 17's sibling said the Dirty::of doc comment "keeps only the one-line reason LayoutGeometry is spelled out rather than EVERYTHING". It says no such thing: it points at context/code/command.md, and the EVERYTHING reason is a two-line comment inside the match body. A correction to prose that asserted the negation of the code should not itself misdescribe the code. Two findings deliberately not fixed here and filed instead: improvise-ahdf (repo-map gotchas 36 and 43 carry the same stale Sessions::apply claim — pre-existing, and inside long paragraphs that need reading rather than a sed) and improvise-rm4s (--check-evidence accepts headings naming tests that never run). Co-Authored-By: Claude Opus 5 (1M context) --- .beads/issues.jsonl | 46 ++++++++++++++++++++++-------------- context/code/command.md | 2 -- context/design-principles.md | 2 +- context/repo-map.md | 4 ++-- 4 files changed, 31 insertions(+), 23 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 018083fc..236b0eb8 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,5 +1,5 @@ {"_type":"issue","id":"improvise-9c8c","title":"Move the reasoning out of code comments into context/ prose, cap comments at two lines","description":"USER INSTRUCTION 2026-09-20: \"cleanup all the comments in code and put the important stuff as prose files in context/. If code comments are needed, at most two consecutive lines of comments and possibly a context/ doc reference for more details (but don't overdo this).\"\n\nSCOPE RULED BY THE USER, both maximal:\n * The two-line cap applies to `///` and `//!` DOC COMMENTS as well as `//` implementation comments.\n * It applies to TEST doc-comments too. The bug narratives this repo's doctrine puts on a regression test move to prose keyed by test name.\n\n## The measurement\n\n103 Rust files, 12,533 comment lines, 2,961 comment runs of which 1,306 are longer than two lines, and 12 doctest fences inside doc comments. Worst single runs: worker.rs 121 consecutive lines, ui/reduce.rs 119, improvise-gp/lib.rs 84, gp-tree/examples/fit_csv.rs 79, wasm-client/keys.rs 78, cmd/fit.rs 59, dataset.rs 57, registry.rs 56, basis.rs 56, vfs.rs 55.\n\n## What makes this hard, and why it is not a formatting change\n\nTHE REASONING IN THOSE COMMENTS IS THE MOST EXPENSIVE ARTEFACT IN THE REPOSITORY. The post-wave documentation review concluded that the code's doc comments are now BETTER than context/repo-map.md, naming src/ui/grid.rs, worker-server/src/lib.rs, ui/reduce.rs and protocol/render.rs as carrying the full argument correctly. Several of them were written in response to a bug that cost a review round to find, and they exist because someone could not re-derive the rule from the code.\n\nSO THIS IS A MOVE, NOT A DELETION. Every argument either lands in context/ prose or is dropped DELIBERATELY as a restatement of what the code plainly says. The failure mode to avoid is a tidy diff that quietly loses the one sentence explaining why a check exists — which is the shape that reintroduces the bug the check guards.\n\n## The convention\n\n * A comment run is AT MOST TWO CONSECUTIVE LINES.\n * It may carry a `context/` reference for the rest -- but SPARINGLY, per the user (\"don't overdo this\"). A pointer on every function is noise; a pointer where a reader would otherwise re-derive a rule wrongly is the point.\n * Prose lands in context/ files owned by ONE area each, so parallel work does not collide.\n * Test evidence -- what broke, what was injected, why the test can fail -- lands in a test-evidence file keyed by TEST NAME.\n\n## Known hazards\n\n * TWELVE DOCTEST FENCES live inside doc comments. Capping a `///` block that contains a ``` example DELETES A RUNNING TEST. Find them first and decide each: promote to a real test, or keep the fence and cap around it.\n * A `//!` module doc is often the only orientation a file has. Losing it entirely costs more than it saves; the cap applies, so what survives must be the two lines that matter.\n * `#[allow(...)]`, `#[cfg(...)]` and clippy directives are ATTRIBUTES, not comments -- do not touch them.\n * A test name is the key into the evidence file, so RENAMING A TEST ORPHANS ITS ENTRY. Whatever convention is chosen must make that detectable.\n","acceptance_criteria":"No comment run in any .rs file exceeds two consecutive lines, verified by a script that is kept in the repo so the rule is checkable rather than remembered. Every argument that was in a capped comment is findable in context/ -- spot-checked by sampling the worst ten files and confirming their reasoning survived. cargo test --workspace --no-fail-fast stays at 1309 passing (or higher, if a doctest fence was promoted to a real test), and git diff filtered for non-comment lines is EMPTY -- no behaviour changed. The twelve doctest fences are each accounted for explicitly. rustdoc still builds.","status":"open","priority":1,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T16:10:53Z","created_by":"Edward Langley","updated_at":"2026-09-20T16:10:53Z","labels":["area:docs"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-0yq8","title":"Move ClientState and keys.rs::step into improvise-protocol","description":"THE SINGLE HIGHEST-VALUE CONSOLIDATION LEFT, named by the agent that did the keymap move and measured rather than guessed.\n\ndesign-principles 5 (user ruling 2026-09-20): minimise the code a new UI framework needs; centralise logic in a redux-like store so a new front end DISPLAYS STATE rather than reimplementing behaviours. The test is: would a third front end have to write this again?\n\nkeys.rs::step is 99 LINES OF \"WHAT THIS COMMAND DOES\" and the answer is YES. It depends on NOTHING but ViewState + the protocol widget caches + the announced window -- every one of which is ALREADY A PROTOCOL TYPE. So it is in the wrong crate, and the only reason it is there is history.\n\nAFTER THIS MOVE A NEW FRONT END NEEDS EXACTLY TWO THINGS: browser-event (or terminal-event, or whatever) -\u003e KeyEvent, and painting. That is the whole of design-principles 5 made true rather than aspirational.\n\nWHY IT WAS NOT DONE IN THE SAME PASS: it is a crate move on top of a crate move (the keymap had just moved), and half-doing it would leave two homes. Deliberate, not forgotten.\n\nMEASURED CONTEXT from that pass: keys.rs grew 264 -\u003e 401 non-comment lines while GAINING the whole editing path, search typing, panels, transpose and tile select -- and the key TABLE left the crate entirely. web/app.js, the only genuinely framework-bound file, grew 344 -\u003e 359 while gaining a live editor and a hint line. So the trend is right; this ticket is what finishes it.\n\nNOTE THE SECOND-CONSUMER ARGUMENT: the more of the front end is SHARED, the more of it a second consumer can AUDIT. CellDisplay::edit_text exists because the browser asked a question the TUI had answered privately and the two answers differed -- see doctrine(being_the_second_consumer_audits_the_abstraction).","acceptance_criteria":"ClientState and keys.rs::step live in improvise-protocol, and a new front end needs exactly two things: an event -\u003e KeyEvent mapping, and painting. Both depend on nothing but ViewState, the protocol widget caches and the announced window — all already protocol types — so the move should cost no new dependency. tests/lockstep.rs still drives the client and the real reduce_key over the same keypresses; if the move makes that test trivially true by type identity, say so and delete it (doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)).","status":"in_progress","priority":1,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-20T04:38:43Z","created_by":"Edward Langley","updated_at":"2026-09-20T17:53:20Z","started_at":"2026-09-20T17:53:20Z","lease_expires_at":"2026-09-20T17:58:20Z","heartbeat_at":"2026-09-20T17:53:20Z","labels":["area:shared-frontend"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-0yq8","title":"Move ClientState and keys.rs::step into improvise-protocol","description":"THE SINGLE HIGHEST-VALUE CONSOLIDATION LEFT, named by the agent that did the keymap move and measured rather than guessed.\n\ndesign-principles 5 (user ruling 2026-09-20): minimise the code a new UI framework needs; centralise logic in a redux-like store so a new front end DISPLAYS STATE rather than reimplementing behaviours. The test is: would a third front end have to write this again?\n\nkeys.rs::step is 99 LINES OF \"WHAT THIS COMMAND DOES\" and the answer is YES. It depends on NOTHING but ViewState + the protocol widget caches + the announced window -- every one of which is ALREADY A PROTOCOL TYPE. So it is in the wrong crate, and the only reason it is there is history.\n\nAFTER THIS MOVE A NEW FRONT END NEEDS EXACTLY TWO THINGS: browser-event (or terminal-event, or whatever) -\u003e KeyEvent, and painting. That is the whole of design-principles 5 made true rather than aspirational.\n\nWHY IT WAS NOT DONE IN THE SAME PASS: it is a crate move on top of a crate move (the keymap had just moved), and half-doing it would leave two homes. Deliberate, not forgotten.\n\nMEASURED CONTEXT from that pass: keys.rs grew 264 -\u003e 401 non-comment lines while GAINING the whole editing path, search typing, panels, transpose and tile select -- and the key TABLE left the crate entirely. web/app.js, the only genuinely framework-bound file, grew 344 -\u003e 359 while gaining a live editor and a hint line. So the trend is right; this ticket is what finishes it.\n\nNOTE THE SECOND-CONSUMER ARGUMENT: the more of the front end is SHARED, the more of it a second consumer can AUDIT. CellDisplay::edit_text exists because the browser asked a question the TUI had answered privately and the two answers differed -- see doctrine(being_the_second_consumer_audits_the_abstraction).","acceptance_criteria":"ClientState and keys.rs::step live in improvise-protocol, and a new front end needs exactly two things: an event -\u003e KeyEvent mapping, and painting. Both depend on nothing but ViewState, the protocol widget caches and the announced window — all already protocol types — so the move should cost no new dependency. tests/lockstep.rs still drives the client and the real reduce_key over the same keypresses; if the move makes that test trivially true by type identity, say so and delete it (doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)).","status":"closed","priority":1,"issue_type":"task","assignee":"wave2-h-frontend","owner":"el-github@elangley.org","created_at":"2026-09-20T04:38:43Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:05:08Z","started_at":"2026-09-20T18:05:07Z","closed_at":"2026-09-20T18:05:08Z","close_reason":"Closed","labels":["area:shared-frontend"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-oai9","title":"[bug] The worker's errors reach the console with their cause stripped","description":"FOUND BY THE FIRST REAL BROWSER RUN of the deployed page (2026-09-20), and it is the reason that run produced no actionable information.\n\nThe console said:\n\n improvise worker: failed to start\n Error: Cannot open demo.improv\n\n\"Cannot open demo.improv\" is the CONTEXT vfs::open attaches with with_context. anyhow Display prints ONLY THE OUTERMOST CONTEXT, so bindings::start mapping through e.to_string() kept the label and threw away the cause -- the Storage failure or the parse failure that actually happened.\n\nWHY THIS IS THE WORST POSSIBLE PLACE FOR IT: bindings.rs is cfg(target_arch = wasm32) and is the one module in the crate that NO TEST IN THE REPOSITORY CAN REACH. A devtools console is the only instrument pointed at it. So the single code path with no test coverage was also the one discarding its own diagnostics.\n\nFIXED: a report() helper rendering with the alternate form {:#}, which walks the chain joining with \": \". It lives in lib.rs rather than bindings.rs SO IT CAN BE TESTED AT ALL -- bindings is wasm-gated. Five call sites (start, on_message, flush, documents, refresh).\n\nTEST WRITTEN AGAINST THE OLD RENDERING FIRST: a_reported_error_carries_its_cause_not_just_its_label asserts that anyhow to_string() IS just the label (pinning the trap, so the helper stops being needed if that ever changes) and that report() contains BOTH the label and the cause.\n\nTHIS DOES NOT FIX THE UNDERLYING FAILURE -- it makes the next reload name it. Redeploy and read the console again.","status":"closed","priority":1,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T03:55:27Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:22:07Z","started_at":"2026-09-20T05:32:34Z","closed_at":"2026-09-20T06:22:07Z","close_reason":"Closed by PR #21 (WU-I) ON EVIDENCE, with NO CODE WRITTEN -- the fix described in this ticket was already in the tree.\n\nVerified rather than assumed: report() renders with the alternate form {:#}, which walks the anyhow chain joining with \": \"; it lives in lib.rs rather than the wasm-gated bindings.rs so it can be tested at all; five call sites (start, on_message, flush, documents, refresh); and a_reported_error_carries_its_cause_not_just_its_label pins both halves -- that anyhow's to_string() IS just the outermost label, and that report() carries the cause too.\n\nFAULT INJECTED BEFORE CLOSING: changed {e:#} back to {e} and watched the test fail on contains(\"no such document\"). So the test is discriminating and the claim is evidence rather than description.\n\nThe ticket's own closing line still stands and is not discharged by this: it does not fix the underlying failure, it makes the next reload name it.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-anr9","title":"Project the five grid-frame facts: unblocks H, z, gz and fixes Food/Alice at once","description":"Two tickets turn out to be ONE piece of work, noticed by improvise-bpq2 while walking the bindings.\n\nimprovise-s1xw (multi-level grid labels -- the browser shows Food/Alice where the TUI shows two columns) and the bindings H / z / gz need THE SAME FIVE FACTS, which gotcha 44 already enumerates as the grid frame the cache does not carry:\n row_cats / col_cats -- how many header levels\n row_items -- multi-level label TUPLES and inline group headers\n col_group_for -- column group spans\n is_group_collapsed -- the collapse indicator\n(record_col is the sixth and is CORRECT to keep off: session-local, keys pending drill edits.)\n\nSo:\n * hide-selected-row-item (H) needs to know WHICH CATEGORY a row label belongs to -- impossible while row_labels slash-joins a tuple.\n * toggle-group / gz need group membership and collapsed state.\n * And the browser renders the slash-joined form, which the TUI own test asserts must not appear.\n\nDOING THEM SEPARATELY MEANS TOUCHING row_labels TWICE. It is a co-ordinated type change either way -- improvise-wasm-client/src/render.rs consumes both row_labels and col_labels -- so the cheap moment is once.\n\nDO NOT add row_levels beside row_labels. Two answers to \"what are the labels\" is the disease (improvise-edp), and bpq2 has just spent a wave demonstrating what one shared derivation buys.\n\nSupersedes/absorbs improvise-s1xw -- decide which id survives.","notes":"USER RULING 2026-09-20: 'Most of the view functionality should be a wasm blob that's shared between the TUI and the web UI' -- clarified as ONE SHARED CRATE, not the TUI loading wasm at runtime. The view derivations live in improvise-protocol; the browser gets them as wasm, the TUI links them natively, and ONLY THE PAINTING differs (ratatui vs DOM).\n\nTHIS TICKET IS NOW THE LAST STEP OF THAT, which is why it is P1.\n\nMeasured 2026-09-20: every other widget already takes a shared cache and no \u0026Model -- FormulaContent(\u0026FormulaCache), ViewContent(\u0026ViewListCache), TileBar(\u0026TileBarCache), CategoryContent(\u0026CategoryTreeCache), WizardWidget(\u0026WizardCache), HelpWidget(usize), WhichKeyWidget(\u0026[..]). THE REMAINING DUPLICATION IS ONE FILE: src/ui/grid.rs still derives 14 things straight off layout./view., and those are exactly the six frame facts in gotcha 44.\n\nSo closing this ticket is what makes 'most of the view functionality shared' TRUE rather than nearly true, and it fixes the Food/Alice divergence and unblocks H/z/gz as the same change.\n\nSEQUENCING NOTE: this touches improvise-protocol/src/render.rs, src/ui/grid.rs AND improvise-wasm-client/src/render.rs together -- it is a co-ordinated type change on row_labels/col_labels, not an additive one. Do not start it while another agent is in improvise-protocol; the client-side keymap work (improvise-bpq2 successor) is there now.","status":"closed","priority":1,"issue_type":"task","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T02:36:42Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:41:32Z","started_at":"2026-09-20T05:32:26Z","closed_at":"2026-09-20T07:41:32Z","close_reason":"Closed by PR #23 (WU-A). The coordinated type change gotcha 44 had been waiting for.\n\nGridCache::row_labels/col_labels were Vec\u003cString\u003e from GridLayout::row_label, which SLASH-JOINS a multi-level tuple -- the exact rendering src/ui/grid.rs::two_row_categories_produce_cross_product_labels asserts must not appear. They are now ONE AxisFrame PER AXIS carrying all five facts: cats (hence levels()), FrameEntry::Item(Vec\u003cString\u003e) tuples, FrameEntry::Group(FrameGroup) inline headers, group_of(idx) spans and FrameGroup::collapsed.\n\nIt is ONE TYPE, NOT A row_levels BESIDE A row_labels -- the ticket's explicit constraint, honoured. The rules that were written twice are now methods both ends call: shows_level/header_labels (repeat suppression), group_of/groups, shares_ancestry, body_from. GridWidget lost its \u0026View; nothing in it reads the view any more. RenderModel gained a RenderRow ENUM so a group header cannot carry cells.\n\nEVIDENCE: the_whole_frame_is_unchanged passes byte for byte, which is the proof the TUI did not move -- and its doc comment now states honestly what it does NOT cover (both its axes are single-level, so it exercises no multi-level suppression), with the multi-level half pinned by the new tests/grid_frame_agreement.rs. That test drives the ratatui widget into a buffer and the browser client through a Snapshot OF THE SAME CACHE and compares WHAT EACH DRAWS -- comparing caches would be vacuous, since it is one cache. Six faults injected, and an independent reviewer re-ran all six and confirmed each fails on the assertion it is named after.\n\nTHE USER'S P1, found on the PR and larger than it first looked: a cell's identity is cats zipped with its item tuple, and invalidate_relabelled compared items alone. The pre-anr9 code could not distinguish a category change either -- but it never HAD cats to compare, so this migration did not break a check, IT OUTGREW ONE. The sharp case is an AXIS SWAP rather than a rename: two categories may legally share an item name, so the preserved CellId::Record stays VALID and addresses the OTHER record, a genuine misdirected write. And it was wrong end to end, not only in apply: grid_updates diffs cells against the FRAMED mirror, so an apply that kept them made the producer agree they matched and SEND NOTHING AT ALL, leaving the client re-identified with nothing in the stream that could ever correct it -- wrong forever, the direction Dirty::of is deliberately sloppy to avoid. Fixed with frame-only regression tests (no Cells or Totals behind them) and a load-bearing negative arm.\n\nWHAT THE ACCESSOR MIGRATION COST, which no test could see: replacing GridLayout access with AxisFrame accessors put an O(n) scan behind every item(idx), so a draw loop calling it per position per level became quadratic AND proportional to the absolute scroll offset. The old widget's one materializing line was load-bearing. Cured with bulk forms (header_labels, groups, shares_ancestry) and by deleting the per-position siblings nothing needed; the four that survive now carry \"not for a draw loop\" on each.\n\nFOLLOW-UPS FILED: improvise-gd83 (group_of is nearest-preceding, not membership -- with THE_MEMBERSHIP_CORRECT_SPANS and an assert_ne! that starts failing the day it is fixed), improvise-jq8l (no scrollIntoView), improvise-plcx (the harness needs a grouped multi-level fixture), improvise-0o6x (a group header first on its axis is never drawn).","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-vvl3","title":"A headless browser harness: run storage_contract and the shell assertions under Playwright","description":"THE CONDITION THAT DEFERRED improvise-xf1 HAS BEEN MET. The user deferred browser testing on 2026-09-20 with: \"I am happy just putting off the browser test for now: WE HAVE TO HAVE SOMETHING TO TEST.\" After improvise-9x6, djm, hwpi and the demo model, we do -- a complete self-contained application whose ENTIRE untested surface is the part a harness would cover.\n\nTHE EVIDENCE THAT IT HAS FLIPPED, not just an argument that it would be nice:\n * Three of improvise-hwpi four fixes are ONE-ASSERTION tests (status text after a bad frame, status text with no peer, one repaint on commit). That ticket itself said \"consider whether the right first move is a headless DOM -- otherwise the fixes are as unverified as the bugs.\" The fixes are now shipped, so that sentence is true of them.\n * The first-frame watchdog was found BY READING, and only because someone went looking for it. The next one will not be.\n * OPFS is the specific thing MemoryFs cannot stand in for, and it is now on the critical path of the DEFAULT deployment rather than a future one. storage_contract in storage.rs exists and is written down precisely so it can be run against OpfsFs in a browser; NOTHING BUT A BROWSER CAN RUN IT.\n * Persistence across reload is the one improvise-bck acceptance criterion no test in this repo can structurally reach.\n\nSCOPE, deliberately small: python3 -m http.server --directory site, plus one Playwright spec, reusing the assemble step CI already performs. Two parts -- (a) storage_contract against OpfsFs, which is the thing improvise-xf1 is actually about; (b) three shell assertions from hwpi.\n\nShould block improvise-bck sign-off. Un-defer or close improvise-xf1 into this, whichever the user prefers -- xf1 predates the worker and is narrower than what is now needed.","notes":"ASSERTIONS OWED BY improvise-taxx's FIX (from WU-I / PR #21, so vvl3 need not re-derive them). The close-listener now distinguishes never-connected from was-connected-and-dropped via an `opened` flag, because readyState is already CLOSED in any close listener and both cases are code 1006 / wasClean:false. Four assertions:\n\n 1. close-before-open -\u003e status stays 'cannot reach \u003curl\u003e ...'\n 2. open-then-close -\u003e status reads 'disconnected'\n 3. ASSERTION 1 MUST FAIL against the previous unconditional close handler. That arm IS the regression guard; without it, 'always say cannot reach' passes.\n 4. after either arm, the handshake watchdog must NOT fire 8s later and overwrite the message.\n\nAlso note WU-I DECLINED Copilot's request to add JS regression tests in PR #21, on the grounds that there is no JavaScript test infrastructure in this repo at all (no package.json, no runner, nothing in CI executing a line of web/*.js) and that a parallel ad-hoc JS surface beside this ticket would be two answers to one question. So this bead now owns that infrastructure decision, not just the OPFS half.","status":"closed","priority":1,"issue_type":"task","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T02:29:52Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:44:42Z","started_at":"2026-09-20T05:32:35Z","closed_at":"2026-09-20T06:44:42Z","close_reason":"Closed by PR #24 (WU-J). The harness exists, it runs in CI, and the browser job can fail the build.\n\nWHAT IT RUNS: harness/ at the repo root serves the ASSEMBLED site (web/assemble.sh, extracted from ci.yml so there is ONE answer to how the site is arranged) plus its own /opfs/ page. Under Playwright it runs (a) improvise_io::storage::contract::storage_contract against OpfsFs in a real dedicated Web Worker, on Chromium AND Firefox, at dir \"\" and at \"docs\", plus targeted checks on the two things improvise-xf1 named -- the hand-rolled keys() walk and FileSystemReadWriteOptions' `at` -- plus attach, refresh, both halves of commit_deletes, and PERSISTENCE ACROSS A RELOAD; and (b) improvise-hwpi's shell assertions plus the watchdog, which is the first execution of web/app.js and web/worker.js in this repository's history.\n\nstorage_contract moved OUT of cfg(test) to make that possible: it is now a public function, not a test helper.\n\nTHE FINDING, and it is bigger than the harness: A CONTRACT NOBODY CAN EXECUTE IS PROSE. storage_contract had been cited in gotcha 28 for months as \"the written-down spec OpfsFs must satisfy in a browser\". The first time OpfsFs was handed to it, IT FAILED ON ITS FIRST ASSERTION -- and not on a subtle browser quirk but on a semantic disagreement about what `list` MEANS, sitting in plain sight in a trait doc everyone had read.\n\nTHREE BUGS NOTHING ELSE COULD HAVE FOUND:\n 1. list named ENTRIES, not DOCUMENTS. attach must create a file to get a handle and indexed it, so a zero-byte non-document was listed. improvise-9x6 had worked around it in vfs::holds_a_document; the wart it could not reach is the DOCUMENT PICKER, which vfs::documents feeds straight from list. Now: attach indexes nothing, write indexes and un-queues a pending delete of the name, refresh skips zero-byte entries.\n 2. A second tab could break the first's picker -- found by reading the fix for (1). refresh now measures sizes, and a file under another context's exclusive sync handle is not readable by the promise API. An unmeasurable entry counts as a document: wrong in the cheap direction.\n 3. A directory is not an unmeasurable file (Copilot). keys() yields directories; getFileHandle rejects TypeMismatchError, which the fail-open in (2) would have listed. Tested rather than argued -- the harness creates a real OPFS directory.\n\nIT ALSO ABSORBED THE JS TEST-INFRASTRUCTURE DECISION that PR #21 declined. playwright.config.js states that this is the one place a line of web/*.js is executed and why Playwright rather than a headless-DOM runner: what goes wrong in those files is a real Worker, a real WebSocket, real OPFS and real event ordering, none of which a DOM shim has. That decline now holds something.\n\nWHAT IT STILL CANNOT SEE: Safari (Playwright's WebKit provides no OPFS -- getDirectory() itself rejects; kept as a runtime PROBE-skip so it starts running the day that changes, and an unexpected skip on any OTHER engine FAILS as a regression). bindings.rs and web/worker.js as code paths. The two-tab exclusive-handle path. The CSS and the ResizeObserver arithmetic. And two socket-timing bugs whose trigger is the host's network stack -- see improvise-xps8.","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -22,10 +22,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.","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-20T07:55:11Z","labels":["area:epic"],"dependencies":[{"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":"{}"}],"dependency_count":4,"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.","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-20T18:02:17Z","labels":["area:epic"],"dependencies":[{"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-zkkw","type":"blocks","created_at":"2026-09-20T18:02:34Z","created_by":"wave2-p-milestones","metadata":"{}"}],"dependency_count":5,"dependent_count":1,"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.","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-20T07:58:07Z","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-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-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} @@ -37,7 +37,9 @@ {"_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-864a","title":"Concurrent merges to main race on one Pages environment and the superseded deploys fail","description":"Three PRs merged within 20 seconds on 2026-09-20 (#37, #41, #42). Each CI run built the site and uploaded a github-pages artifact, and each queued a deploy behind 'concurrency: group: pages, cancel-in-progress: false'.\n\nOnly the newest run kept its artifact:\n\n run 35526758561 (04e4e3d, #37) conclusion=failure artifacts=0\n run 35526767814 (5f871f7, #41) conclusion=success artifacts=1\n run 35526774007 (52507a6, #42) conclusion=failure artifacts=0\n\nThe two superseded deploys failed with:\n\n Fetching artifact metadata for \"github-pages\" in this workflow run\n Found 0 artifact(s)\n Error: No artifacts named \"github-pages\" were found for this workflow run.\n\nThe upload step was GREEN in all three runs, so this is not a build failure —\nthe artifact is gone by the time the losing deploy runs. The successful deploy\n(17:47:28Z) preceded the failing one (17:47:44Z) by 16 seconds.\n\nDeploying a superseded commit is pointless anyway: the tip of main is what\nshould be published. Cancelling is deliberately NOT the answer — the existing\ncomment on the concurrency block records that a deploy-pages cancelled midway\ncan leave the Pages deployment half-applied.\n\n## Steps to Reproduce\n\n1. Merge three PRs to main within about a minute.\n2. Watch the CI runs: the newest deploy succeeds, the older ones fail on an\n empty artifact fetch and mark otherwise-green runs as failed.\n\nReported by the user from the Actions log.","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.","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-20T17:55:01Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-lltt","title":"[bug] A view-scoped ModelCommand names no view, so it lands on the host's","description":"Opened by improvise-jytm, which made `ViewState::active_view` authoritative per\nclient. The view-configuration half of `ModelCommand` — SetAxis, CycleAxis,\nTransposeAxes, SetPageSelection, HideItem, ShowItem, ToggleGroup, TogglePruneEmpty,\nSetNumberFormat — acts on \"the active view\", which is now an ambiguous phrase.\n\n`Sessions::apply_model` routes with no SessionId (gotcha 38, correctly: the change\nis addressed to no one session, because a `View` is document state shared by every\nclient sitting on it). But it must still name WHICH view, and today it names it\nimplicitly: `apply_command` runs the effect against the `App`, and the effect reads\n`App::view()`, which is the HOST's slice. So a connected client on another view\nmoves the host's view rather than its own.\n\nBefore jytm every client was on the same view, so the implicit answer was right.\nIt is now wrong exactly when two clients are on different views, which is the\ncapability jytm exists to add.\n\nThe honest fix is for those variants to name their view — a protocol change — so\nthe command carries the operand rather than borrowing an ambient one. Rejected\nduring jytm: retargeting `app.view_state.active_view` around `apply_command` at the\ndoor. It makes the host's own UI slice a parameter channel, and `reset_cursor`/\n`cursor_epoch` would then write the asker's key into the host's cursor map.\n\nNote the `_Drill`/`_Records` virtual views make this sharper than it looks: a host\nin records mode is on a view no client can name.\n\n## Steps to Reproduce\n\n1. Two clients, A and B, on one document with views \"Default\" and \"Second\".\n2. B sends `Command::SwitchView(\"Second\")`. Its mirror moves; the host's App stays\n on \"Default\" (correct, per improvise-jytm).\n3. B sends `Command::Model { cmd: ModelCommand::SetAxis { category: \"Region\",\n axis: Row } }`.\n4. The axis moves on \"Default\" — the view B is not on — and B's next projection\n shows nothing changed while A's shows a change A did not ask for.\n\nNot reachable from the TUI, which is the only client that drives the App's own\nslice, and not reachable from `web/app.js` today because it sends no SwitchView.\n","acceptance_criteria":"The view-configuration ModelCommand variants name the view they act on, so a connected client's SetAxis moves the view that client is on and not the host's. A test drives two sessions onto different views and asserts an axis change by one is invisible to the other. Deciding NOT to fix is also an acceptable close, but then gotcha 38 must say so.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:05:49Z","created_by":"wave2-e-active-view","updated_at":"2026-09-20T18:05:49Z","labels":["area:active-view"],"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":"CORRECTION, same session: this bead as first written read as though nobody had\ndecided the Chromium-only question. Somebody had.\n`invariant(opfs_is_run_but_bindings_is_not)` in prolog-sessions/kb.pl records\nit, and gives TWO reasons, not one:\n\n \"harness/tests/shell.spec.js runs web/app.js under CHROMIUM ONLY --\n engine-independent logic, and routeWebSocket is slower across three.\"\n\nThe description above only answers the second. The first — the logic is\nengine-independent — is a general claim covering all six specs, and against\nthe specs as a test of web/app.js it is correct: app.js is plain DOM and\nplain event wiring, and running the same assertions on a second engine buys\nvery little.\n\nWHAT IT DOES NOT COVER is the claim improvise-bck criterion (1) actually\nmakes. That criterion is not \"app.js's logic works in Firefox\", it is \"the\nstandalone deployment works in Firefox\" — and the standalone deployment is\nmodule workers, `wasm-pack` output, `createSyncAccessHandle` through\nbindings.rs and the seed fetch, none of which is app.js's logic and all of\nwhich is engine-specific. opfs.spec.js covers Firefox's OPFS through\n`OpfsFs` directly and, per the same invariant, NEVER INSTANTIATES\nbindings.rs. So the two standalone specs on Firefox would be the first\nexecution of that shim on a second engine, and near enough the first on any.\n\nSo the disagreement with the invariant is narrow and should be argued on\nthose terms: the engine-independence reason is a statement about app.js, and\nthe two standalone specs are not only a test of app.js. If that argument is\nrejected, the right outcome is to REWRITE improvise-bck criterion (1) to say\nChromium, not to leave a criterion standing that names an engine nothing\nruns on — a criterion nobody checks is the failure mode this whole pass\nexists to catch.\n\nEither way the invariant's wording is owed an update, since it currently\nreads as a blanket justification for a split that has two different reasons\nbehind its two halves.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:02:30Z","created_by":"wave2-p-milestones","updated_at":"2026-09-20T18:04:23Z","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":"Three PRs merged within 20 seconds on 2026-09-20 (#37, #41, #42). Each CI run built the site and uploaded a github-pages artifact, and each queued a deploy behind 'concurrency: group: pages, cancel-in-progress: false'.\n\nOnly the newest run kept its artifact:\n\n run 35526758561 (04e4e3d, #37) conclusion=failure artifacts=0\n run 35526767814 (5f871f7, #41) conclusion=success artifacts=1\n run 35526774007 (52507a6, #42) conclusion=failure artifacts=0\n\nThe two superseded deploys failed with:\n\n Fetching artifact metadata for \"github-pages\" in this workflow run\n Found 0 artifact(s)\n Error: No artifacts named \"github-pages\" were found for this workflow run.\n\nThe upload step was GREEN in all three runs, so this is not a build failure —\nthe artifact is gone by the time the losing deploy runs. The successful deploy\n(17:47:28Z) preceded the failing one (17:47:44Z) by 16 seconds.\n\nDeploying a superseded commit is pointless anyway: the tip of main is what\nshould be published. Cancelling is deliberately NOT the answer — the existing\ncomment on the concurrency block records that a deploy-pages cancelled midway\ncan leave the Pages deployment half-applied.\n\n## Steps to Reproduce\n\n1. Merge three PRs to main within about a minute.\n2. Watch the CI runs: the newest deploy succeeds, the older ones fail on an\n empty artifact fetch and mark otherwise-green runs as failed.\n\nReported by the user from the Actions log.","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-20T18:03:04Z","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":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T07:55:22Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:57:55Z","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":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T07:49:43Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:49:43Z","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":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T07:49:42Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:49:42Z","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -49,14 +51,14 @@ {"_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":"open","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-20T07:57:18Z","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":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T06:04:20Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:57:16Z","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-p1ym","title":"Project the page-axis item lists so [ and ] work in a browser","description":"The one key pair an independent smoke test found inert that the shared keymap did NOT fix, and it is a genuine projection gap rather than a resolution gap.\n\nGridCache::page_coords carries (category, SELECTED ITEM). PageNext/PagePrev need THE ITEM LIST TO CYCLE THROUGH, which nothing projects. So [ and ] resolve to a command name the client holds but cannot compose an argument for, and they reach Host::handle empty vec -- doctrine(an_upstream_key_is_a_black_hole), so they do nothing at all.\n\nCONFIRMED BY SMOKE TEST 2026-09-20 against the live page: \"[ / ] did not cycle Quarter (never saw Q2 ~17,100)\". The demo model puts Quarter on the page axis specifically so paging is a thing a visitor would try.\n\nTHE AGENT WAS TOLD NOT TO FORCE IT and did not -- a truthful \"these four keys work, [ / ] needs X\" is worth more than five half-working keys.\n\nSCOPE: extend page_coords (or add a sibling) to carry the item list per page-axis category. Note gotcha 27 -- \"what data is visible\" is View::filtering_cats() (Page UNION Filter) while \"what the user can page\" is categories_on(Axis::Page). This needs the SECOND one; projecting the first would let a client try to cycle a Filter axis, which by design it must not.\n\nWhile you are there: check whether the item list should be the EFFECTIVE items (hidden items excluded) rather than all of them, since [ and ] in the TUI cycle what the user can actually select.","acceptance_criteria":"GridCache carries the item list per page-axis category, so PageNext/PagePrev compose client-side and [ / ] cycle Quarter on the live page. It projects categories_on(Axis::Page), NOT filtering_cats() — projecting the latter would let a client try to cycle a Filter axis, which by design it must not (gotcha 27). Decide and state whether the list is the EFFECTIVE items (hidden excluded), since that is what the TUI cycles. Verified against the demo, which puts Quarter on the page axis precisely because paging is a thing a visitor tries.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T04:38:45Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:55:50Z","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-p1ym","title":"Project the page-axis item lists so [ and ] work in a browser","description":"The one key pair an independent smoke test found inert that the shared keymap did NOT fix, and it is a genuine projection gap rather than a resolution gap.\n\nGridCache::page_coords carries (category, SELECTED ITEM). PageNext/PagePrev need THE ITEM LIST TO CYCLE THROUGH, which nothing projects. So [ and ] resolve to a command name the client holds but cannot compose an argument for, and they reach Host::handle empty vec -- doctrine(an_upstream_key_is_a_black_hole), so they do nothing at all.\n\nCONFIRMED BY SMOKE TEST 2026-09-20 against the live page: \"[ / ] did not cycle Quarter (never saw Q2 ~17,100)\". The demo model puts Quarter on the page axis specifically so paging is a thing a visitor would try.\n\nTHE AGENT WAS TOLD NOT TO FORCE IT and did not -- a truthful \"these four keys work, [ / ] needs X\" is worth more than five half-working keys.\n\nSCOPE: extend page_coords (or add a sibling) to carry the item list per page-axis category. Note gotcha 27 -- \"what data is visible\" is View::filtering_cats() (Page UNION Filter) while \"what the user can page\" is categories_on(Axis::Page). This needs the SECOND one; projecting the first would let a client try to cycle a Filter axis, which by design it must not.\n\nWhile you are there: check whether the item list should be the EFFECTIVE items (hidden items excluded) rather than all of them, since [ and ] in the TUI cycle what the user can actually select.","acceptance_criteria":"GridCache carries the item list per page-axis category, so PageNext/PagePrev compose client-side and [ / ] cycle Quarter on the live page. It projects categories_on(Axis::Page), NOT filtering_cats() — projecting the latter would let a client try to cycle a Filter axis, which by design it must not (gotcha 27). Decide and state whether the list is the EFFECTIVE items (hidden excluded), since that is what the TUI cycles. Verified against the demo, which puts Quarter on the page axis precisely because paging is a thing a visitor tries.","status":"closed","priority":2,"issue_type":"task","assignee":"wave2-b-projection","owner":"el-github@elangley.org","created_at":"2026-09-20T04:38:45Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:05:59Z","started_at":"2026-09-20T18:00:04Z","closed_at":"2026-09-20T18:05:59Z","close_reason":"Closed","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-o8fa","title":"improvise-ws-server speaks ws:// only, so the deployed page cannot reach it","description":"Found by opening the deployed page (2026-09-20). The guidance the page and the README carried was WRONG in a way that sends someone down a dead end.\n\nFROM AN https ORIGIN A ws:// ADDRESS IS BLOCKED AS MIXED CONTENT, so the deployed GitHub Pages site needs wss://. improvise-ws-server has NO TLS AT ALL -- no rustls, no native-tls, nothing in the crate or its dependencies -- so wss:// is not obtainable from it. The text said \"note that from an https origin a ws:// address is blocked\", which correctly named the symptom and then offered an address that can never work.\n\nTEXT CORRECTED ALREADY: both web/index.html and README.md now say the address must be wss:// from the deployed page, that the server speaks plain ws:// only, and that the ws:// form works when the page is served over http://localhost. This ticket is the missing CAPABILITY.\n\nTHREE WAYS TO PROVIDE IT, and they are not equivalent:\n * TERMINATE TLS IN FRONT (nginx, caddy, cloudflared, ngrok). Zero code, works today, and is what the corrected text points at. But it means the documented path to sharing a TUI-hosted document involves a second piece of software, which is a poor answer for a tool whose selling point is that it is one binary.\n * NATIVE wss:// IN improvise-ws-server -- rustls plus a cert. Real work, and it raises the certificate question: a self-signed cert makes the browser refuse the socket with no usable error, so this is only pleasant with a real cert or an explicitly trusted local CA (mkcert).\n * DECIDE THE DEPLOYED PAGE DOES NOT PAIR WITH A LOCAL SERVER at all, and say so. Defensible: the standalone worker deployment is the point of the Pages site, and ws:// pairing is a LOCAL development convenience that works fine from the local page. Cheapest, and it makes the docs shorter rather than longer.\n\nThe third is probably right for now and the first is the escape hatch; the second only earns its place if sharing a TUI-hosted document to a remote browser becomes a real use case. Do not implement the second by default.","status":"closed","priority":2,"issue_type":"feature","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T03:53:54Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:22:10Z","started_at":"2026-09-20T05:32:35Z","closed_at":"2026-09-20T06:22:10Z","close_reason":"Closed by PR #21 (WU-I). DOCUMENTATION ONLY, per the user's ruling; native wss:// DECLINED.\n\nTHE DECLINE'S SUBSTANCE IS THE CERTIFICATE, NOT rustls. A self-signed cert makes the browser refuse the socket with no usable error, because a WebSocket handshake has no interstitial -- there is no place to click through. A real cert or mkcert is a second piece of software too, so the choice is two-either-way, with one option additionally shipping a TLS stack and a cert-loading surface in a pivot-table binary.\n\nWHAT LANDED: a worked Caddy example, the nginx difference that bites (proxy_http_version 1.1 plus the Upgrade/Connection headers), the honest cost (a second binary, a poor answer for a one-binary tool), and the framing that the standalone worker deployment is the point of the Pages site while ws:// pairing works fine from a local http://localhost page.\n\nTHIS BEAD COST SEVEN COPILOT ROUNDS AND NINE FINDINGS, EVERY ONE IN PROSE. The sequence is the value and is recorded in kb.pl:\n * the first recipe exposed an unauthenticated world-writable server -- documenting a proxy documents an exposure, because TLS termination REMOVES the loopback binding that was the only control;\n * the \"fix\" put Caddy basic_auth in front of a socket the page opens as new WebSocket(url), an API that can set no headers over a handshake that shows no prompt -- BROKEN reads as protection, which is worse than absent;\n * an ngrok --cidr-allow recipe was written without being run (deprecated-but-accepted, so the reviewer's own \"fails at startup\" was also wrong);\n * \"the browser cannot send a credential on a WebSocket handshake\" over-generalised the governing fact and contradicted the Cloudflare Access bullet three paragraphs up, which works BECAUSE the handshake carries a cookie;\n * an IP allow-list cannot see a cross-site socket at all, which is improvise-kc7n;\n * an absolute loopback claim denied a browser mitigation that exists but is mid-rollout;\n * and \"the auth above\" called an allow-list authentication in a section whose opening sentence is that the server has none.\n\nThe governing fact was ALREADY IN THE SAME FILE at every step, as the basis of this very decline. The lesson the repo did not have: a reviewed paragraph is not evidence.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-955n","title":"[bug] Host::commands applies a batch partially before rejecting","description":"Found by Copilot on PR #13 (the retro review of the unreviewed batch), DECLINED there with reasons on the thread, and filed rather than patched.\n\nADR 001 is emphatic that a stale batch is never applied, NOT EVEN PARTIALLY -- and improvise-ondj went to the trouble of a TYPE (Checked) to make the unchecked path unconstructible. But Checked is PER-COMMAND: Checked::against answers for one Command against the App AS IT STANDS. A ClientFrame::Commands is a SEQUENCE of them, and Host::commands applies in order and abandons at the first rejection. So a frame carrying SwitchView followed by a stale Command::Model has already applied the SwitchView.\n\nSO ADR 001 GUARANTEE IS CURRENTLY TRUE OF A SINGLE COMMAND AND FALSE OF A BATCH.\n\nA SECOND-ORDER EFFECT NOBODY HAD WRITTEN DOWN, found while declining it: Worker::on_frame skips persist() when ANY frame in the answer is Rejected. So a batch that DID apply a SwitchView before being refused leaves Workbook::active_view moved in memory and NOT WRITTEN BACK -- the document on disk and the document in the worker disagree, silently.\n\nWHY IT WAS DECLINED RATHER THAN PATCHED: closing it means the basis check moving from the COMMAND to the BATCH -- every command answered before any is applied. That is an ADR 001 design change with a real question inside it: WHAT DOES A BATCH BASIS MEAN WHEN ITS LATER COMMANDS WERE COMPOSED AGAINST ITS EARLIER ONES EFFECTS? A client resolving one keypress produces SetSelected plus offsets computed from the selection it just set. Answer that before writing code.\n\nREACHABILITY IS BOUNDED TODAY: no client in this workspace composes a batch containing more than one basis-carrying Command::Model -- the batches improvise-bpq2 produces are view commands, which carry no basis. Do this before the second tranche of client-side resolution creates a reason to batch two model commands.\n\nSupersedes the earlier ticket filed from bpq2 if there is one -- dedupe.","status":"closed","priority":2,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T03:35:27Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:29:48Z","started_at":"2026-09-20T05:32:29Z","closed_at":"2026-09-20T07:29:48Z","close_reason":"Closed by PR #26 (WU-C). improvise-08wb is closed as a duplicate of this one.\n\nADR 001 said a stale batch is never applied, NOT EVEN PARTIALLY, and improvise-ondj went to the trouble of a type (Checked) to make the unchecked path unconstructible -- but Checked is PER-COMMAND and a ClientFrame::Commands is a sequence of them, so Host::commands applied in order and abandoned at the first rejection. The guarantee was true of a single command and false of a batch.\n\nCheckedBatch::against now answers for EVERY command before any is applied.\n\nTHE DESIGN QUESTION THE TICKET DEMANDED WAS ANSWERED BY THE USER, not by the code, and it is larger than the ticket imagined. The ticket asked \"what does a batch's basis mean when its later commands were composed against its earlier ones' effects\". The user's ruling: \"a sequence of commands sent to the server should assume that it's played in order from start to end and that each command sees the state after the previous command runs\", as \"a single transaction from a given base\". Four clauses -- one basis per frame; checked once before anything; applied in order with data dependence EXPECTED; all or nothing. Clauses 1-3 landed here; clause 4 (rollback) is improvise-yqpd.\n\nAN EARLIER \"at most one document mutation per frame\" RULE WAS SHIPPED AND THEN REJECTED BY THE USER as forbidding the capability. It was wrong twice over: it forbids data-dependent chains, and arity is not a property of a variant anyway -- a future Command::RunScript is one variant performing unboundedly many mutations.\n\nTHE FINDING THAT MATTERED MOST came from the independent adversarial review that substituted for Copilot (quota exhausted), and it was a REGRESSION THIS WORK INTRODUCED: hoisting the basis check to the frame hoisted ALL THREE TIERS. The document and layout tiers are legitimately frame-level facts; THE RECORD TIER IS NOT AND CANNOT BE, because which records are at stake is a property of the command. So every mutation after the first had its records checked against nothing -- a silent lost update, confirmed by executing a real Host: client A's stale [SetCell(North), SetCell(South)] clobbered client B's write to South with no rejection, where the base commit refused it. Fixed: every mutation answers for its own records against the FRAME'S basis, with an exemption stated over a property -- a record that neither resolves nor is claimed is one the frame is creating. The reviewer attacked that exemption at all four corners and could not break it: the predicate is a conjunction and the client controls only one conjunct.\n\nTHE SECOND-ORDER WORKER EFFECT the ticket named is closed twice over; its test fails only when BOTH old behaviours are restored.\n\nA CAPABILITY NOBODY HAD NAMED, now guarded: a frame may write THE SAME RECORD TWICE -- two measures on one record IS one record -- and that works only because every check happens before any apply. Moving the checks into the loop breaks it, which is what stops someone closing clause 4 by taking that trade unnoticed.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-08wb","title":"[bug] A refused ClientFrame::Commands keeps the commands before the refusal","description":"Found by improvise-bpq2 (2026-09-20), stated on the variant, and currently UNREACHABLE from any client in this workspace -- filed so it is a decision rather than a discovery.\n\nClientFrame::Commands(Vec\u003cCommand\u003e) batches, because one cursor key resolved client-side is SetSelected plus the offsets viewport_effects returns, and a host that folds the selection but not the offset projects the wrong window. Host::commands applies IN ORDER and ABANDONS AT THE FIRST REJECTION.\n\nTHAT LEAVES A HOLE ADR 001 DOES NOT HAVE. ADR 001 is emphatic that a stale batch is never applied, NOT EVEN PARTIALLY -- and improvise-ondj went to the trouble of a type (Checked) to make the unchecked path unconstructible. But Checked is per-COMMAND, and a ClientFrame::Commands is a sequence of them, so a frame whose third command is refused has already applied the first two.\n\nWHY IT IS NOT LIVE: no client in this workspace composes a Commands frame containing more than one basis-carrying Command::Model -- the batches bpq2 produces are view commands (SetSelected + offsets), which carry no basis.\n\nTHE FIX IS TO MOVE THE BASIS CHECK TO THE BATCH: check every Command::Model in the frame before applying any of them. That is the shape ADR 001 already describes for an effect batch, one level up, and it would make Host::commands match the guarantee the ADR states.\n\nDo this before a client has reason to batch two model commands -- which the second tranche of client-side resolution (the ~15 bindings bpq2 left) may well create.","status":"closed","priority":2,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T02:36:42Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:29:28Z","started_at":"2026-09-20T05:32:29Z","closed_at":"2026-09-20T07:29:28Z","close_reason":"Closed as a DUPLICATE of improvise-955n, which PR #26 fixed. Both beads describe the same defect -- Host::commands applied a ClientFrame::Commands in order and abandoned at the first rejection, so a frame whose later command was refused had already applied the earlier ones, leaving ADR 001's 'never applied, not even partially' true of a single command and false of a batch. 08wb was filed from improvise-bpq2's walk and 955n from Copilot's retro review of PR #13; 955n carries the fuller reasoning and the second-order worker effect, so it is the survivor.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-ir7l","title":"[bug] A formula cell is offered as editable and silently reverts","description":"Pre-existing and consistent with the TUI, but improvise-bck just put it on the DEMO FRONT PAGE, which changes who meets it: the first thing a stranger sees is Profit beside Revenue and Cost.\n\nA formula target cell projects CellId::Record, so render.rs::editable says true, so the browser offers an editor. Type into Profit and the write lands -- then recompute_formulas overwrites it on the next pass and the value REVERTS with no explanation.\n\nWHY IT IS NOT SIMPLY A BUG IN editable: a formula target IS a stored coordinate; the cell is a record. What makes it unwritable is not its identity but that a FORMULA OWNS IT. So the fix is a fact the projection does not currently carry -- something like \"this cell is derived\" -- and it belongs beside numeric/error on CellDisplay, which already exists precisely because the SERVER is the side that knows.\n\nCHECK THE TUI BEFORE CHANGING ANYTHING: it has the same behaviour, so whatever is decided should land in both, or the divergence becomes the new bug (gotcha 44 is what that looks like). And note ADR 002 deliberately made settability a fact about the CellId VARIANT -- adding a second, independent \"but not really\" flag is exactly the two-answers-to-one-question shape this codebase keeps curing. Consider instead whether a formula-owned cell should project a different CellId at all.\n\n## Steps to Reproduce\n\n1. Open `web/demo.improv` in the browser — it ships `Profit = Revenue - Cost` beside Revenue and Cost on the front page.\n2. Click the Profit cell. It is offered as editable, because a formula target projects `CellId::Record` and `render.rs::editable` reads the variant.\n3. Type a number and commit. The write lands.\n4. `recompute_formulas` overwrites it on the next pass and the value reverts, with no explanation.\n\nThe TUI behaves the same way, so whatever is decided must land in both or the divergence becomes the new bug.\n","acceptance_criteria":"A formula-owned cell projects a non-Record CellId, so the write is unconstructible rather than flagged — the discipline ADR 002 used for aggregates and ADR 003 reaches for again for read-only sources. It lands in the TUI in the same change, or the two front ends diverge (gotcha 44 is what that looks like). Do NOT add a second 'but not really' flag beside CellId: two answers to 'can I write here' is the disease. Demonstrated on web/demo.improv, where Profit sits beside Revenue and Cost on the front page.","notes":"VERIFIED CONCRETELY during the PR #13 review (2026-09-20), and the diagnosis is sharper than when this was filed.\n\nTHE ROOT CAUSE: project::cell_id decides Record vs Aggregate purely on whether a regular category sits on Axis::None. IT NEVER ASKS WHETHER THE _Measure COORDINATE NAMES A FORMULA TARGET. So a formula cell is a Record, render.rs::editable says true, and the browser offers an editor.\n\nMEASURED AGAINST THE SHIPPED DEMO:\n col 2 label=\"Profit\" is_record=true text=\"12,200\"\n after applying SetCell(key, Number(999.0)), Profit reads \"12,200\"\n\nThe write is ACCEPTED AND STORED. Model::evaluate_aggregated checks formula_cache BEFORE raw data (model/types.rs:409), so the typed value is shadowed forever with nothing telling the user. Not a revert -- a permanent silent shadow.\n\nAND THE TEST THAT COVERS THIS AREA IS LYING BY ITS NAME. every_visible_cell_in_the_web_demo_is_settable PASSES while its name is FALSE for the Profit column -- a third of the landing-page grid. It was proved discriminating against the one thing it CAN see (the aggregate at (0,0) in examples/demo.improv) and is structurally blind to this. That is exactly the shape AGENTS.md calls this repo's characteristic find, arriving in the test written to prevent it.\n\nTHE DECISION IS STILL THE DECISION, and ADR 002 is where it goes:\n * TEACH cell_id ABOUT FORMULA TARGETS -- changes what EVERY client calls a record, and record identity is also ADR 001's unit of atomicity, so this reaches the basis check too.\n * OR GIVE editable A SECOND INPUT -- but that is the conflation gotcha 42 exists to forbid, and ADR 002 deliberately made settability a fact about the CellId VARIANT precisely so there could not be a second answer.\n\nThe first is probably right and is not small. Until it lands, the demo's front page teaches a visitor that typing into Profit does nothing.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-20T02:29:51Z","created_by":"Edward Langley","updated_at":"2026-09-20T17:53:19Z","started_at":"2026-09-20T17:53:19Z","lease_expires_at":"2026-09-20T17:58:19Z","heartbeat_at":"2026-09-20T17:53:19Z","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-vhcz","title":"[bug] OpfsFs::list disagrees with the other backends on path shape","description":"Found by improvise-9x6 (2026-09-20) and currently PAPERED OVER by always passing the flat root.\n\nOpfsFs::list returns BARE NAMES; MemoryFs and PhysicalFs return dir-JOINED ones. So Path::new(\"\") is the ONE dir at which all three agree, and the only one at which storage_contract actually describes OpfsFs.\n\nTHE TRAP: a worker that organised documents under docs/ would get an EMPTY PICKER in a browser and a FULL ONE in every test. The harness cannot see the disagreement because it never runs OpfsFs -- improvise-xf1 is deferred -- so this is precisely the class of defect the missing browser harness exists to catch, discovered by reading instead.\n\nTWO FIXES:\n * Make OpfsFs::list join dir, matching the other two. Straightforward, and makes storage_contract describe all three at every dir.\n * Or document the flat root as PART OF THE CONTRACT and assert it in storage_contract, so the constraint is stated rather than discovered.\n\nThe first is better unless there is a reason OPFS cannot -- note gotcha 28 says backends that are not trees treat dir as a NAME PREFIX, which is already the trait doc contemplating this, so check whether the doc or the impl is the thing that is wrong.","status":"closed","priority":2,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T02:10:14Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:44:42Z","started_at":"2026-09-20T05:32:36Z","closed_at":"2026-09-20T06:44:42Z","close_reason":"Closed by PR #24 (WU-J). THE TICKET ASKED WHICH OF THE DOC OR THE IMPL WAS WRONG: IT WAS THE IMPL.\n\nThe Storage trait doc already said that backends which are not trees treat `dir` as a NAME PREFIX. OpfsFs::list ignored `dir` outbound as well as inbound, returning bare names where MemoryFs and PhysicalFs return dir-joined ones -- so Path::new(\"\") was the one dir at which all three agreed, and the only one at which storage_contract actually described OpfsFs.\n\nNow it joins. The three backends agree at EVERY dir, and storage_contract runs against OpfsFs at both \"\" and \"docs\" in a real browser. `dir` stays DECORATION rather than a filter, because entry_name reduces every path to its file name and an OPFS entry name may not contain a separator.\n\nFAULT INJECTED: reverting to bare names fails storage_contract_at_\"docs\" on `lists the one document`.\n\nTHE TRAP THE TICKET NAMED IS CLOSED: a worker that organised documents under docs/ would have got an empty picker in a browser and a full one in every test. That divergence was invisible to the harness because nothing ran OpfsFs -- which is exactly why improvise-vvl3 had to land alongside this, and why this bead could only ever have been found by reading until it did.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-hmws","title":"Storage cannot expose an async seam through Box\u003cdyn Storage\u003e","description":"Found by improvise-9x6 (2026-09-20). Every worker host will hit this, so it is worth solving once rather than in each.\n\nOpfsFs::open / attach / refresh / commit_deletes are on the STRUCT, not on the Storage trait -- deliberately, per gotcha 28: Storage stays synchronous because createSyncAccessHandle yields a blocking handle, and only four operations are genuinely async. But App::storage is a Box\u003cdyn Storage\u003e, so HANDING THE BACKEND TO THE App PUTS ALL FOUR PERMANENTLY OUT OF REACH. The App owns the only copy and the trait object cannot name them.\n\nAND THE OBVIOUS FIX IS REFUSED BY THE COMPILER: impl Storage for Rc\u003cOpfsFs\u003e is two foreign types, so the orphan rule forbids it. improvise-9x6 worked around it with a local newtype (bindings::Shared) holding an Rc\u003cOpfsFs\u003e and forwarding the four sync methods. That works but every worker must write it again.\n\nTWO CANDIDATE FIXES:\n * Ship the shared wrapper FROM improvise-io, so the newtype exists once where OpfsFs does and the orphan rule is satisfied.\n * Or have OpfsFs hand out an attachable handle -- separate the thing the App holds from the thing the message loop awaits, which is arguably the more honest modelling since they have different lifetimes and different call sites.\n\nPrefer whichever keeps Storage synchronous; that part is not negotiable (gotcha 28 is the argument).","status":"closed","priority":2,"issue_type":"task","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T02:10:13Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:44:43Z","started_at":"2026-09-20T05:32:37Z","closed_at":"2026-09-20T06:44:43Z","close_reason":"Closed by PR #24 (WU-J). NEITHER OF THE TICKET'S TWO OPTIONS WAS NEEDED, and the reason is worth more than the fix.\n\nThe ticket said `impl Storage for Rc\u003cOpfsFs\u003e` is refused by the orphan rule. That is true IN improvise-worker-server, where both the trait and Rc are foreign -- which is where improvise-9x6 hit it and wrote bindings::Shared. It is NOT true in improvise-io, where the trait is LOCAL and the impl is simply allowed.\n\nSo: improvise-io ships a blanket `impl\u003cT: Storage + ?Sized\u003e Storage for Rc\u003cT\u003e`. bindings::Shared and its _shim_is_a_storage canary are DELETED, not moved, and no future worker writes one. Storage stays synchronous, which gotcha 28 says is not negotiable.\n\nTHE GUARD IS THE SIGNATURE: a_shared_backend_is_a_backend_and_a_clone_is_the_same_one does not compile without the impl. The body asserts what a hand-written newtype could still get wrong -- that a clone addresses the same backend -- and a fault injected into the forwarded `write` (made a no-op) fails it.\n\nTHE GENERALISABLE PART: an orphan-rule refusal is a fact about WHERE YOU ARE STANDING, not about the pair of types. Before working around one, check whether the impl belongs in the crate that owns the trait.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-9mo9","title":"[bug] Geometry effects that renumber the grid but never reset the cursor","description":"Found by the code review of improvise-gvnb (2026-09-19). A PRE-EXISTING gap that gvnb inherited rather than introduced -- but gvnb is what makes it matter, because the epoch is now what tells every client their cursor is invalid.\n\nApp::reset_cursor bumps cursor_epoch; Sessions::adopt_cursor_epoch watches the COUNTER and broadcasts Command::ResetCursor to all. Nothing declares which effects renumber the grid -- deliberately, per gotcha 31 -- which means a geometry effect that forgets reset_cursor is INVISIBLE IN BOTH DIRECTIONS: the host own cursor stays stale AND no client is told.\n\nToday only TransposeAxes and CycleAxis call it. These also renumber rows or columns and do not:\n SetAxis, HideItem, ShowItem, ToggleGroup, TogglePruneEmpty, AddCategory, RemoveCategory\n (plus AddItem/RemoveItem, which are CellData rather than LayoutGeometry)\n\nSetAxis IS THE SHARPEST CASE: CycleAxis is literally SetAxis to Axis::next() and resets; SetAxis does not.\n\nWHY THE REVIEW DID NOT JUST PATCH IT: SetAxis is used by DrillIntoCell (cmd/grid.rs:365-471) to build the _Drill view, and resetting there could discard the user position in the view they drilled FROM, depending on effect order. That is a decision about effect ordering and intended UX, not a patch.\n\nKEEP THE NEGATIVE ARM. a_model_command_that_does_not_reshape_the_grid_leaves_cursors_alone uses SetNumberFormat -- a LayoutGeometry effect that must NOT reset. Without it, \"reset on every model command\" would pass the positive test. Whatever you do here, do not lose it.\n\n## Steps to Reproduce\n\n1. In a view with a cursor somewhere other than the origin, apply any of `SetAxis`, `HideItem`, `ShowItem`, `ToggleGroup`, `TogglePruneEmpty`, `AddCategory`, `RemoveCategory`, `AddItem` or `RemoveItem`.\n2. The grid renumbers and the cursor is not reset, so it addresses a position that has moved.\n3. Worse for other clients: `reset_cursor` is what bumps `cursor_epoch`, and `Sessions::adopt_cursor_epoch` watches that counter to broadcast `Command::ResetCursor`. No reset means **no client is told either**.\n\n`SetAxis` is the sharpest case: `CycleAxis` *is* `SetAxis` to `Axis::next()` and does reset.\n","acceptance_criteria":"Each of SetAxis, HideItem, ShowItem, ToggleGroup, TogglePruneEmpty, AddCategory, RemoveCategory, AddItem and RemoveItem either calls reset_cursor or carries a one-line reason why it does not. SetAxis is the sharp case — CycleAxis IS SetAxis to Axis::next() and resets, while SetAxis does not — and DrillIntoCell uses SetAxis to build the _Drill view, where resetting could discard the position in the view drilled FROM, so that is a decision about effect ordering and intended UX, not a patch. KEEP THE NEGATIVE ARM: a_model_command_that_does_not_reshape_the_grid_leaves_cursors_alone uses SetNumberFormat, and without it 'reset on every model command' passes the positive test. Note improvise-2zq changed which axis a new category lands on, so re-read on_category_added before assuming AddCategory renumbers the way it used to.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"wave2-n-cleanups","owner":"el-github@elangley.org","created_at":"2026-09-20T01:05:15Z","created_by":"Edward Langley","updated_at":"2026-09-20T17:53:45Z","started_at":"2026-09-20T17:53:45Z","lease_expires_at":"2026-09-20T17:58:45Z","heartbeat_at":"2026-09-20T17:53:45Z","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-9mo9","title":"[bug] Geometry effects that renumber the grid but never reset the cursor","description":"Found by the code review of improvise-gvnb (2026-09-19). A PRE-EXISTING gap that gvnb inherited rather than introduced -- but gvnb is what makes it matter, because the epoch is now what tells every client their cursor is invalid.\n\nApp::reset_cursor bumps cursor_epoch; Sessions::adopt_cursor_epoch watches the COUNTER and broadcasts Command::ResetCursor to all. Nothing declares which effects renumber the grid -- deliberately, per gotcha 31 -- which means a geometry effect that forgets reset_cursor is INVISIBLE IN BOTH DIRECTIONS: the host own cursor stays stale AND no client is told.\n\nToday only TransposeAxes and CycleAxis call it. These also renumber rows or columns and do not:\n SetAxis, HideItem, ShowItem, ToggleGroup, TogglePruneEmpty, AddCategory, RemoveCategory\n (plus AddItem/RemoveItem, which are CellData rather than LayoutGeometry)\n\nSetAxis IS THE SHARPEST CASE: CycleAxis is literally SetAxis to Axis::next() and resets; SetAxis does not.\n\nWHY THE REVIEW DID NOT JUST PATCH IT: SetAxis is used by DrillIntoCell (cmd/grid.rs:365-471) to build the _Drill view, and resetting there could discard the user position in the view they drilled FROM, depending on effect order. That is a decision about effect ordering and intended UX, not a patch.\n\nKEEP THE NEGATIVE ARM. a_model_command_that_does_not_reshape_the_grid_leaves_cursors_alone uses SetNumberFormat -- a LayoutGeometry effect that must NOT reset. Without it, \"reset on every model command\" would pass the positive test. Whatever you do here, do not lose it.\n\n## Steps to Reproduce\n\n1. In a view with a cursor somewhere other than the origin, apply any of `SetAxis`, `HideItem`, `ShowItem`, `ToggleGroup`, `TogglePruneEmpty`, `AddCategory`, `RemoveCategory`, `AddItem` or `RemoveItem`.\n2. The grid renumbers and the cursor is not reset, so it addresses a position that has moved.\n3. Worse for other clients: `reset_cursor` is what bumps `cursor_epoch`, and `Sessions::adopt_cursor_epoch` watches that counter to broadcast `Command::ResetCursor`. No reset means **no client is told either**.\n\n`SetAxis` is the sharpest case: `CycleAxis` *is* `SetAxis` to `Axis::next()` and does reset.\n","acceptance_criteria":"Each of SetAxis, HideItem, ShowItem, ToggleGroup, TogglePruneEmpty, AddCategory, RemoveCategory, AddItem and RemoveItem either calls reset_cursor or carries a one-line reason why it does not. SetAxis is the sharp case — CycleAxis IS SetAxis to Axis::next() and resets, while SetAxis does not — and DrillIntoCell uses SetAxis to build the _Drill view, where resetting could discard the position in the view drilled FROM, so that is a decision about effect ordering and intended UX, not a patch. KEEP THE NEGATIVE ARM: a_model_command_that_does_not_reshape_the_grid_leaves_cursors_alone uses SetNumberFormat, and without it 'reset on every model command' passes the positive test. Note improvise-2zq changed which axis a new category lands on, so re-read on_category_added before assuming AddCategory renumbers the way it used to.","status":"closed","priority":2,"issue_type":"bug","assignee":"wave2-n-cleanups","owner":"el-github@elangley.org","created_at":"2026-09-20T01:05:15Z","created_by":"Edward Langley","updated_at":"2026-09-20T17:57:44Z","started_at":"2026-09-20T17:53:45Z","closed_at":"2026-09-20T17:57:44Z","close_reason":"Closed","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-s1xw","title":"Multi-level grid labels on the wire -- the browser shows Food/Alice where the TUI shows two columns","description":"THE LAST THING KEEPING GridLayout INSIDE GridWidget, and a LIVE DIVERGENCE today, not a hypothetical. Found closing improvise-n10.\n\nGridCache::row_labels / col_labels are Vec\u003cString\u003e produced by GridLayout::row_label, which JOINS A MULTI-LEVEL TUPLE WITH A SLASH. That is exactly the rendering src/ui/grid.rs::two_row_categories_produce_cross_product_labels asserts must NOT appear (\"slash-joined labels should be gone\"). So the protocol carries a form of the labels THE TUI ITSELF CONSIDERS WRONG, and crates/improvise-wasm-client/src/render.rs renders precisely that form.\n\nConsequence: the browser grid has no multi-level headers, no repeat suppression, no groups and no collapse indicators, and shows Food/Alice where the TUI shows two header columns.\n\nSIX FACTS THE CACHE DOES NOT CARRY (exhaustive as of improvise-n10, by grep over the non-test body of src/ui/grid.rs):\n 1. number of header levels -- layout.row_cats / col_cats\n 2. multi-level label TUPLES -- layout.row_items (AxisEntry::DataItem(Vec\u003cString\u003e))\n 3. inline GROUP HEADER rows -- layout.row_items (AxisEntry::GroupHeader)\n 4. column GROUP SPANS -- layout.col_group_for(ci)\n 5. collapse indicator (down/right triangle) -- view.is_group_collapsed(cat, group)\n 6. records-mode column identity -- layout.record_col(ci). THIS ONE IS CORRECT TO KEEP OFF: it keys pending drill edits and is session-local by design.\n\nSo 1-5 are the gap.\n\nTWO CONSTRAINTS ON THE FIX:\n * IT IS A CO-ORDINATED CHANGE, NOT AN ADDITIVE ONE. row_labels/col_labels change TYPE, and improvise-wasm-client/src/render.rs consumes both fields, so the client updates in the same change.\n * DO NOT ADD row_levels BESIDE row_labels. Two answers to \"what are the labels\" is the disease this whole projection architecture exists to cure -- see improvise-edp, where the category panel was walked twice, two different ways.","status":"closed","priority":2,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T00:58:33Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:41:34Z","started_at":"2026-09-20T05:32:27Z","closed_at":"2026-09-20T07:41:34Z","close_reason":"Closed as ABSORBED BY improvise-anr9, verified explicitly rather than assumed. s1xw enumerated six facts the cache did not carry; PR #23 landed 1-5 (header levels, multi-level label tuples, inline group headers, column group spans, the collapse indicator) and 6 is record_col, which s1xw itself says is CORRECT to keep off -- session-local, keys pending drill edits. Both of its stated constraints were honoured: it was a co-ordinated type change (row_labels/col_labels changed TYPE and improvise-wasm-client/src/render.rs updated in the same change) and no row_levels was added beside row_labels. The live divergence it reported -- the browser drawing Food/Alice where the TUI draws two header columns -- is closed, and tests/grid_frame_agreement.rs now drives both front ends over one cache and compares what each draws.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-4zl7","title":"A fitted tree must route per record and aggregate the results, not route on the aggregate","description":"Successor to improvise-8xhy, which shipped remedy 1 (refuse at emit) as an explicit interim. This is the remedy that produces a formula A HUMAN WOULD HAVE WRITTEN, and it is the right end state -- but it was costed during 8xhy and it is much larger than it looks.\n\nANSWER THIS FIRST, BEFORE TOUCHING THE GRAMMAR. A fitted measure cell is evaluated at a CellKey context. In any view that aggregates over the split category -- Region on Page or None -- context.get(\"Region\") is None, and today Formula::filter falls straight through to aggregate_raw. So a categorical fit would SILENTLY DEGRADE to something else in exactly the views a pivot-table user spends most of their time in. That is a product decision about what a fit MEANS for an aggregated cell, not an emitter detail.\n\nSCOPE, once that is answered:\n * Expr::Is(Filter) evaluating to 1.0/0.0. This keeps the f64-only evaluator and avoids introducing string comparison into eval_expr. The primitive already exists in the right shape -- types.rs reads context.get(\u0026filter.category), the current cell own coordinate.\n * A grammar production plus tokenizer handling in the 1109-line parser.rs, with quoting rules for the category and item inside it (gotcha 34 -- one home, improvise_formula::quote).\n * Arms in BOTH evaluators: eval_expr and eval_expr_cached.\n * Coverage in the grammar-walking generator.\n * Thread Dataset::columns (the ColumnSpec/ColumnKind plan) into the emitter, so feature index -\u003e role is read off preprocess own plan. Do NOT reconstruct it: Spec::names is \u0026[\u0026str], and reconstructing index-\u003erole is a second implementation of preprocess expansion order, exactly the drift risk gotcha 41 names in another key.\n * .improv is free -- the writer emits Formula::raw.\n\nIT CLOSES ONLY A THIRD OF 8xhy EITHER WAY. A WHERE predicate covers the one-hots. col.missing means \"this record has no value for measure X\", a different vocabulary. The date sub-features (when.year, when.month) need date arithmetic improvise does not have at all. Those stay refused, or get their own tickets.\n\nPAIRS WITH improvise-vx3 (the fit-measure command), which is where a user would actually learn about the restriction.","acceptance_criteria":"A fitted measure evaluated at an aggregating cell routes PER RECORD and aggregates the results, rather than routing on the aggregate. fit-measure on web/demo.improv succeeds and the resulting formula means the same thing on a record row and one row up. The seed-dependence is gone: whether the command succeeds no longer depends on which features the search happened to pick. Note what this costs — evaluate_aggregated deliberately does not iterate the records behind a formula cell — and state how it interacts with gotcha 9's fixed point and Model::formula_cache. col.missing and the date sub-features remain out of scope.","notes":"USER RULING 2026-09-20: AGGREGATE THE RULE OVER THE MEMBERS. Route-then-aggregate, per record. The other three options -- Error(\"aggregated\"), refuse at fit time, and fall through to aggregate_raw -- are all rejected.\n\nTHE BEAD'S SCOPE HAS CHANGED AND THE TITLE NOW SAYS SO. It asked what a CATEGORICAL split means in a cell that aggregates over the split category. PR #25's review established the question has nothing to do with the split being categorical:\n\nA tree is fitted on individual RECORDS. A formula is evaluated at a CellKey, and in any aggregating cell evaluate_aggregated aggregates each Expr::Ref FIRST and then evaluates the IF. A linear formula survives that -- SUM(Revenue) - SUM(Cost) is SUM(Revenue - Cost) -- and a tree does not, because AGGREGATE-THEN-ROUTE IS NOT ROUTE-THEN-AGGREGATE. So EVERY fitted tree, numeric splits included, means one thing on a record row and something else one row up.\n\nIT IS NOT THEORETICAL: fit-measure Revenue on the repo's own shipped web/demo.improv is refused, naming Product=Espresso/Product=Latte. And because which features a champion picks is a property of the SEARCH, on a document with several categories the same command refuses or succeeds DEPENDING ON THE SEED. That is not a contract.\n\nWHAT THE RULING COSTS, stated plainly because it is the expensive answer and was chosen anyway: evaluate_aggregated deliberately does NOT iterate the underlying records for a formula cell -- it aggregates the refs and evaluates once. Routing per record means a formula cell must be able to ask for the records behind it and fold over them. That touches the evaluator, and it interacts with gotcha 9 (fixed-point recomputation) and with Model::formula_cache, which is where derived values live precisely so raw data and derived data stay separate.\n\nNOTE WHAT IT BUYS BESIDES CORRECTNESS: it is what a user means. A fitted rule applied to a group should say what the rule says about the members, summed -- not what the rule says about a fictional average member. The second is the thing that looks right and is wrong, which is the failure mode vx3's own ticket warned about ('a rule the user will believe').\n\nALSO NOTE: this ruling supersedes improvise-8xhy's remedy 1 (refuse at emit) as the END STATE, but not as the interim -- the refusal stays until this lands, or fit-measure silently produces the wrong number in aggregating views.\n\nTHE TWO THIRDS THIS STILL DOES NOT COVER, unchanged from the original text: col.missing means 'this record has no value for measure X', a different vocabulary; and the date sub-features (when.year, when.month) need date arithmetic improvise does not have at all. Those stay refused or get their own beads.","status":"open","priority":2,"issue_type":"feature","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T00:45:22Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:45:26Z","started_at":"2026-09-20T05:32:41Z","labels":["area:gp"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-hwpi","title":"The browser shell fails quietly, and never reads a key","description":"Found by the improvise-cr3 code review (2026-09-19) by READING web/app.js. There is no browser and no headless DOM in this environment, so none of this is observed -- it is what the code says will happen, and it is filed rather than fixed because each item is a judgement call about the shell rather than a defect in the Rust.\n\nFOUR THINGS, in descending order of how much they would cost a person at a demo:\n\n1. NO KEYBOARD AT ALL. #grid carries tabindex=\"0\" but the page registers no keydown handler, so Client::on_key_event -- and the whole keys.rs classifier and the lockstep machinery behind it -- is UNREACHED from the browser. Consistent with improvise-bpq2 (ClientMsg has no Key variant server-side) but larger than it: even a LOCAL key, one that needs no round trip, does nothing. improvise-1ey acceptance (3) is \"arrow keys move the cursor locally with no round-trip\", and nothing in the page can do it.\n\n2. A MALFORMED FRAME IS QUIET. client.on_frame(event.data) returns Result\u003c_, JsError\u003e, which wasm-bindgen turns into a THROWN EXCEPTION. It is thrown inside the message listener with no try, so it surfaces as an uncaught error in the devtools console and the following repaint() is skipped. Nothing in the UI says so: the grid simply stops updating. A try/catch writing to el.status would make it visible.\n\n3. NO error LISTENER ON THE SOCKET. A connection that never opens leaves #status blank -- only close writes \"disconnected\". So \"server not running\" and \"still connecting\" look the same.\n\n4. close() RUNS TWICE ON COMMIT, benignly. Enter -\u003e close(true) -\u003e td.replaceChildren(original) removes the focused \u003cinput\u003e -\u003e blur fires -\u003e close(false). editing is already null so it is harmless (one extra repaint), but there is no re-entry guard and only a real browser would show it.\n\nNOTE WHAT MAKES THIS TICKET DIFFERENT: items 2-4 are the kind of thing a test would catch instantly and we have no way to run one. Before fixing them, consider whether the right first move is a headless DOM in CI (jsdom or playwright) -- otherwise the fixes are as unverified as the bugs. improvise-xf1 (verify OpfsFs against a real browser) wants the same harness.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T00:40:36Z","created_by":"Edward Langley","updated_at":"2026-09-20T02:29:50Z","closed_at":"2026-09-20T02:29:50Z","close_reason":"All four items done, plus a fifth the ticket could not have predicted.\n\n1. KEYDOWN WIRED -- a window listener feeding Client::on_key_event. preventDefault fires only when a frame was produced and no Ctrl/Alt is held, so browser shortcuts stay the browser's; metaKey is skipped entirely, matching BrowserKey::to_key_event dropping Meta so Cmd-R cannot arrive as a bare r. The inline editor stopPropagation keeps it out of the way, with an if (editing) return as belt and braces.\n2. MALFORMED FRAME -- try/catch around on_frame writing to #status, plus console.error with the offending frame. render_model() is guarded the same way; it also returns a Result.\n3. SOCKET error LISTENER -- 'cannot reach ... -- is improvise-ws-server running?', so that no longer reads like 'still connecting'.\n4. close() RE-ENTRY GUARD -- a closed flag in edit().\n\nTHE FIFTH, AND IT IS THE INTERESTING ONE: a worker whose start() REJECTS is caught INSIDE worker.js, so it fires NO error event on the main thread. The page would have sat on 'in-page worker - demo.improv' over an empty grid forever -- THIS TICKET'S EXACT DISEASE, REBUILT IN THE NEW DEPLOYMENT ON DAY ONE. The guard is a first-frame watchdog (8s, cleared by the first frame), and it deliberately covers BOTH transports rather than each peer's own failure modes, because 'the peer never answered the handshake' is the one condition that means the same thing over a socket and over a port -- a check aimed per-peer would have missed exactly this one.\n\nKEYS ARE INERT AND THAT IS STATED, NOT HIDDEN. Host::handle answers ClientFrame::Key with an empty vec; improvise-bpq2 is the other half.\n\nSTILL UNRUN -- and this ticket predicted that would be the problem: 'consider whether the right first move is a headless DOM, otherwise the fixes are as unverified as the bugs.' That sentence is now true OF THESE FIXES. Three of the four are one-assertion Playwright tests. See the harness ticket.","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -85,7 +87,7 @@ {"_type":"issue","id":"improvise-rkeq","title":"Decide RenderCache's shape: grid-only wire type vs per-widget sub-caches","description":"improvise-cqi landed RenderCache in improvise-protocol as a FLAT, grid-only type: row_labels, col_labels, col_widths, cells: Vec\u003cVec\u003cCellDisplay\u003e\u003e, documented as 'the client's copy of the visible grid', indexed by viewport position and rebuilt by folding projection commands. Six open tickets were written against a different object of the same name: a fat cache holding one sub-cache per widget, selected by a SubscriptionSet — improvise-35e (RenderCache::from_app, 'grid: Option\u003cGridCache\u003e, category_tree: Option\u003cCategoryTreeCache\u003e, formulas, views, tile_bar, wizard, help'), improvise-n10 ('takes \u0026GridCache'), improvise-pca ('its relevant sub-cache slice'), improvise-764 ('a WizardCache'), improvise-jb3 and improvise-edp. Neither is wrong; they are two objects with one name, and nothing in the tree will complain until someone writes from_app.","design":"Three shapes, none picked. (A) Grow the protocol type: RenderCache gains Option\u003cSubCache\u003e fields. Cost: the wire payload carries widget-shaped data a browser DOM renderer does not want, and Snapshot grows. (B) Keep the protocol RenderCache grid-only and give the native migration its own container — say WidgetCaches { grid: RenderCache, category_tree: ..., ... } — so the wire type is one field of it. The six migration tickets then say WidgetCaches where they say RenderCache. (C) Per-widget projection commands instead of a container: each widget subscribes and folds its own cache, no aggregate type at all. Whichever wins, improvise-cqq (projection emission) has to agree with it, because that is what produces the updates a cache folds.","acceptance_criteria":"(1) One of the three shapes is chosen and recorded here. (2) improvise-35e, n10, pca, 764, jb3 and edp are updated to name the chosen type. (3) If the protocol type changes shape, improvise-cqq's description is updated to emit for it.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T19:49:24Z","created_by":"Edward Langley","updated_at":"2026-09-19T20:29:33Z","closed_at":"2026-09-19T20:29:33Z","close_reason":"DECIDED (user, 2026-09-19): shape (C) with per-widget caches. RenderCache is per-widget -- each widget owns its own cache type and folds its own projection-command stream; there is no flat grid-only wire type and no fat aggregate container. State synchronizes by events, not snapshots; the single exception is the initial state transfer, where a new client gets a Snapshot to seed from and then follows the event stream. All six migration tickets (35e, n10, pca, 764, jb3, edp) and the projection-emission ticket (cqq) were annotated with the decision.","dependency_count":0,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"improvise-2ou","title":"test","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T19:43:00Z","created_by":"Edward Langley","updated_at":"2026-09-19T19:43:06Z","closed_at":"2026-09-19T19:43:06Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-g0f","title":"Decide whether two clients may view different views","description":"improvise-cqi added ViewState::active_view alongside Workbook::active_view. Today the host keeps them identical (App::switch_to_view, App::install_workbook), so behaviour is unchanged for a single client.\n\nThe open question is whether they are ALLOWED to diverge. If yes, 'which view am I looking at' becomes per-client the same way the cursor did in improvise-8uz, and Workbook::active_view degrades to the document's saved starting view. If no, SwitchView must be a broadcast command every client applies.\n\nThis matters for improvise-cqq (projection emission): the server computes each client's viewport from that client's view, and if views can differ then the projections differ per client too — which is a materially bigger server.\n\nNot urgent: the single-client TUI is correct either way, and the guard test the_two_active_view_names_never_diverge pins the current answer (they may not).","acceptance_criteria":"A decision recorded on the ticket, and either the divergence is permitted with a test showing two ViewStates on different views, or SwitchView is documented as a broadcast command.","notes":"GROOMED 2026-09-19: raised P3 -\u003e P2. It blocks improvise-cqq (P2), and a lower-priority blocker makes bd ready unusable in priority order (the priority-non-decreasing rule from the 2026-08-30 groom).","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-01T09:02:42Z","created_by":"Edward Langley","updated_at":"2026-09-19T21:00:50Z","closed_at":"2026-09-19T21:00:50Z","close_reason":"DECIDED (user, 2026-09-19): YES -- two clients may view different views. Workbook::active_view becomes the DOCUMENT DEFAULT (what a fresh client opens); ViewState::active_view becomes authoritative PER CLIENT. Decided at the point it acquired teeth: improvise-cqq's per-client ClientSession makes divergence expressible for the first time. ViewState::cursors is already keyed by view name, so the cursor half needs no change. Implementation work filed separately; gotcha 29's lockstep invariant and its guard test the_two_active_view_names_never_diverge must be replaced by a deliberate split rather than deleted.","dependency_count":0,"dependent_count":1,"comment_count":0} -{"_type":"issue","id":"improvise-xf1","title":"Verify OpfsFs against Safari — Chromium and Firefox are covered","description":"improvise-i34 landed OpfsFs (crates/improvise-io/src/opfs.rs) compile-checked for wasm32 only — there is no browser in the dev environment.\n\nWhat to verify, in a dedicated Web Worker:\n- OpfsFs::open() gets the origin root\n- attach() acquires a sync access handle, creating the file\n- the storage_contract sequence in storage.rs passes: write/read/replace/list-sorted/delete/read-fails/delete-twice-errors/shorter-write-truncates\n- refresh() lists what attach created\n- commit_deletes() actually removes entries (reload the page and confirm)\n\nKnown API constraints already handled: handles are exclusive per file (attach closes what it replaces, detach releases), and sync access handles are worker-only. Safari 16.4+ has synchronous handle methods, so no IndexedDB fallback.\n\nMost likely failure points: the keys() async-iterator walk in refresh() is hand-rolled through js_sys::Reflect and is the least certain code in the file; and FileSystemReadWriteOptions 'at' handling on read/write.","acceptance_criteria":"The storage_contract sequence passes against OpfsFs in Chrome, Firefox and Safari, run from a dedicated Web Worker.","notes":"OPFS IS NO LONGER UNVERIFIED -- the happy path ran, in a real browser, on 2026-09-20.\n\nAn independent smoke test of https://fiddlerwoaroof.github.io/improvise/ (Safari) confirmed: the worker booted, the document seeded from demo.improv, an edit to Espresso Revenue (18,400 -\u003e 19,000) COMMITTED AND RECALCULATED Profit to 12,800, and THE EDIT SURVIVED A RELOAD. That is create, attach, write, read and persist through OpfsFs -- the first real execution of a backend that had only ever been compile-checked.\n\nSo the headline risk this ticket carried is retired: OpfsFs is not fundamentally broken, and gotcha 28's sync-access-handle design holds in practice.\n\nWHAT THIS DOES NOT COVER, and why the ticket stays open at P2 rather than closing:\n * storage_contract has still never been RUN against OpfsFs. The happy path working is not the contract passing -- list semantics, delete/commit_deletes, the flat-root disagreement (improvise-2f4y), and re-attach after close are all untested.\n * One real OpfsFs-related bug was already found and fixed by reading rather than testing (attach creates and indexes the entry, so vfs::open skipped the seed -- see improvise-9x6 and the vfs test that stands in for attach). A harness would have caught it in seconds and it cost a deploy cycle instead.\n * Everything found in the browser so far was found BY A PERSON PRESSING KEYS. That is not a repeatable check.\n\nSo the argument shifts: this is no longer 'does OPFS work at all', it is 'run storage_contract against the one backend that owes it'. Fold into the Playwright harness ticket rather than treating it as a separate browser investigation.\nNARROWED 2026-09-20 by PR #24 (improvise-vvl3), not closed. Two engines of three now satisfy this ticket's acceptance criteria.\n\nWHAT IS NOW COVERED, by harness/ under Playwright on BOTH Chromium and Firefox: OpfsFs::open gets the origin root; attach acquires a sync access handle and creates the file; the full storage_contract sequence passes (write/read/replace/list-sorted/delete/read-fails/delete-twice-errors/shorter-write-truncates) at dir \"\" AND at \"docs\"; refresh lists what attach created; commit_deletes actually removes entries, verified ACROSS A RELOAD. Both of the failure points this ticket named are covered by targeted checks -- the hand-rolled keys() async-iterator walk through js_sys::Reflect (three entries, out of order) and FileSystemReadWriteOptions' 'at' handling (long write then short).\n\nIt found three real bugs doing it; see improvise-vvl3's close reason.\n\nWHAT REMAINS, and it is the whole of this bead now: SAFARI. Playwright's WebKit build provides NO OPFS AT ALL -- navigator.storage.getDirectory() itself rejects with UnknownError -- so the sync-access-handle path cannot be exercised there by this harness. The ticket's own note that 'Safari 16.4+ has synchronous handle methods, so no IndexedDB fallback' is a claim about Safari that remains UNTESTED.\n\nHOW IT IS PARKED, deliberately, rather than skipped-and-forgotten: the spec keeps the WebKit project and skips on a RUNTIME PROBE, so it starts running the day that build gains OPFS. An unexpected skip on any OTHER engine FAILS with 'that is a regression, not a known gap'.\n\nTO CLOSE THIS: either run the battery against a real Safari (a WebKit build with OPFS, or a manual run against Safari 16.4+ with the result recorded), or rule that Safari is out of scope for the browser deployment and say so -- which is a product decision, not a testing one.\nCORRECTION 2026-09-20, from the user (MDN browser-compatibility table): THE REASON RECORDED ABOVE WAS WRONG IN A WAY THAT MATTERS, even though the conclusion was right.\n\nnavigator.storage.getDirectory() has had FULL SUPPORT IN SAFARI SINCE 15.2 (released 2021-12-13) -- desktop Safari, Safari on iOS, and WebView on iOS. So 'WebKit provides no OPFS' is FALSE AS A CLAIM ABOUT SAFARI. What is true is narrower: PLAYWRIGHT'S BUNDLED WEBKIT BUILD does not expose it, and getDirectory() rejects there with UnknownError. Playwright's WebKit is not Safari, and a capability absent from that build says nothing about the browser it is derived from.\n\nKEEP THE TWO CAPABILITIES APART, because this ticket needs the second and not the first:\n * navigator.storage.getDirectory() -- the origin root. Safari 15.2+.\n * FileSystemFileHandle.createSyncAccessHandle() -- the BLOCKING read/write/truncate/flush handle that gotcha 28's whole design rests on, worker-only. That is the Safari 16.4 claim in this ticket's original text, and IT IS STILL THE UNVERIFIED ONE.\n\nSO THIS IS A TOOLING GAP, NOT A PLATFORM GAP, and that changes what closing it costs. The routes, cheapest first: a newer Playwright whose WebKit exposes OPFS; driving real Safari via safaridriver (Playwright cannot, WebDriver can); or a recorded manual run against Safari 16.4+ with the result written down. None of those needs a decision about whether Safari is in scope -- which is what the previous note implied and should not have.\n\nTHE GENERAL POINT, worth more than the correction: a capability missing from a TEST BROWSER was written down as a fact about the BROWSER. That is the same failure as doctrine(a_verified_spelling_is_not_a_verified_behaviour) pointed the other way -- an unverified ABSENCE asserted as a platform limit. The harness observed 'getDirectory() rejects here'; everything past that was inference.","status":"open","priority":2,"issue_type":"task","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-01T06:10:42Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:52:15Z","started_at":"2026-09-20T05:32:36Z","labels":["area:browser"],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"improvise-xf1","title":"Verify OpfsFs against Safari — Chromium and Firefox are covered","description":"improvise-i34 landed OpfsFs (crates/improvise-io/src/opfs.rs) compile-checked for wasm32 only — there is no browser in the dev environment.\n\nWhat to verify, in a dedicated Web Worker:\n- OpfsFs::open() gets the origin root\n- attach() acquires a sync access handle, creating the file\n- the storage_contract sequence in storage.rs passes: write/read/replace/list-sorted/delete/read-fails/delete-twice-errors/shorter-write-truncates\n- refresh() lists what attach created\n- commit_deletes() actually removes entries (reload the page and confirm)\n\nKnown API constraints already handled: handles are exclusive per file (attach closes what it replaces, detach releases), and sync access handles are worker-only. Safari 16.4+ has synchronous handle methods, so no IndexedDB fallback.\n\nMost likely failure points: the keys() async-iterator walk in refresh() is hand-rolled through js_sys::Reflect and is the least certain code in the file; and FileSystemReadWriteOptions 'at' handling on read/write.","acceptance_criteria":"The storage_contract sequence passes against OpfsFs in Chrome, Firefox and Safari, run from a dedicated Web Worker.","notes":"OPFS IS NO LONGER UNVERIFIED -- the happy path ran, in a real browser, on 2026-09-20.\n\nAn independent smoke test of https://fiddlerwoaroof.github.io/improvise/ (Safari) confirmed: the worker booted, the document seeded from demo.improv, an edit to Espresso Revenue (18,400 -\u003e 19,000) COMMITTED AND RECALCULATED Profit to 12,800, and THE EDIT SURVIVED A RELOAD. That is create, attach, write, read and persist through OpfsFs -- the first real execution of a backend that had only ever been compile-checked.\n\nSo the headline risk this ticket carried is retired: OpfsFs is not fundamentally broken, and gotcha 28's sync-access-handle design holds in practice.\n\nWHAT THIS DOES NOT COVER, and why the ticket stays open at P2 rather than closing:\n * storage_contract has still never been RUN against OpfsFs. The happy path working is not the contract passing -- list semantics, delete/commit_deletes, the flat-root disagreement (improvise-2f4y), and re-attach after close are all untested.\n * One real OpfsFs-related bug was already found and fixed by reading rather than testing (attach creates and indexes the entry, so vfs::open skipped the seed -- see improvise-9x6 and the vfs test that stands in for attach). A harness would have caught it in seconds and it cost a deploy cycle instead.\n * Everything found in the browser so far was found BY A PERSON PRESSING KEYS. That is not a repeatable check.\n\nSo the argument shifts: this is no longer 'does OPFS work at all', it is 'run storage_contract against the one backend that owes it'. Fold into the Playwright harness ticket rather than treating it as a separate browser investigation.\nNARROWED 2026-09-20 by PR #24 (improvise-vvl3), not closed. Two engines of three now satisfy this ticket's acceptance criteria.\n\nWHAT IS NOW COVERED, by harness/ under Playwright on BOTH Chromium and Firefox: OpfsFs::open gets the origin root; attach acquires a sync access handle and creates the file; the full storage_contract sequence passes (write/read/replace/list-sorted/delete/read-fails/delete-twice-errors/shorter-write-truncates) at dir \"\" AND at \"docs\"; refresh lists what attach created; commit_deletes actually removes entries, verified ACROSS A RELOAD. Both of the failure points this ticket named are covered by targeted checks -- the hand-rolled keys() async-iterator walk through js_sys::Reflect (three entries, out of order) and FileSystemReadWriteOptions' 'at' handling (long write then short).\n\nIt found three real bugs doing it; see improvise-vvl3's close reason.\n\nWHAT REMAINS, and it is the whole of this bead now: SAFARI. Playwright's WebKit build provides NO OPFS AT ALL -- navigator.storage.getDirectory() itself rejects with UnknownError -- so the sync-access-handle path cannot be exercised there by this harness. The ticket's own note that 'Safari 16.4+ has synchronous handle methods, so no IndexedDB fallback' is a claim about Safari that remains UNTESTED.\n\nHOW IT IS PARKED, deliberately, rather than skipped-and-forgotten: the spec keeps the WebKit project and skips on a RUNTIME PROBE, so it starts running the day that build gains OPFS. An unexpected skip on any OTHER engine FAILS with 'that is a regression, not a known gap'.\n\nTO CLOSE THIS: either run the battery against a real Safari (a WebKit build with OPFS, or a manual run against Safari 16.4+ with the result recorded), or rule that Safari is out of scope for the browser deployment and say so -- which is a product decision, not a testing one.\nCORRECTION 2026-09-20, from the user (MDN browser-compatibility table): THE REASON RECORDED ABOVE WAS WRONG IN A WAY THAT MATTERS, even though the conclusion was right.\n\nnavigator.storage.getDirectory() has had FULL SUPPORT IN SAFARI SINCE 15.2 (released 2021-12-13) -- desktop Safari, Safari on iOS, and WebView on iOS. So 'WebKit provides no OPFS' is FALSE AS A CLAIM ABOUT SAFARI. What is true is narrower: PLAYWRIGHT'S BUNDLED WEBKIT BUILD does not expose it, and getDirectory() rejects there with UnknownError. Playwright's WebKit is not Safari, and a capability absent from that build says nothing about the browser it is derived from.\n\nKEEP THE TWO CAPABILITIES APART, because this ticket needs the second and not the first:\n * navigator.storage.getDirectory() -- the origin root. Safari 15.2+.\n * FileSystemFileHandle.createSyncAccessHandle() -- the BLOCKING read/write/truncate/flush handle that gotcha 28's whole design rests on, worker-only. That is the Safari 16.4 claim in this ticket's original text, and IT IS STILL THE UNVERIFIED ONE.\n\nSO THIS IS A TOOLING GAP, NOT A PLATFORM GAP, and that changes what closing it costs. The routes, cheapest first: a newer Playwright whose WebKit exposes OPFS; driving real Safari via safaridriver (Playwright cannot, WebDriver can); or a recorded manual run against Safari 16.4+ with the result written down. None of those needs a decision about whether Safari is in scope -- which is what the previous note implied and should not have.\n\nTHE GENERAL POINT, worth more than the correction: a capability missing from a TEST BROWSER was written down as a fact about the BROWSER. That is the same failure as doctrine(a_verified_spelling_is_not_a_verified_behaviour) pointed the other way -- an unverified ABSENCE asserted as a platform limit. The harness observed 'getDirectory() rejects here'; everything past that was inference.\n\nSTATUS CHANGED TO DEFERRED, 2026-09-20 (orchestrator, wave 2). The reasoning above is unchanged and is the reason: this is a TOOLING gap, so there is no work here anyone can do today. vvl3 landed the harness; it installs webkit and the OPFS spec skips on a RUNTIME PROBE rather than by configuration, so the day a Playwright WebKit build exposes createSyncAccessHandle the check begins running with no CI change and no edit here. Deferred rather than closed because the criterion is genuinely unverified and closing it would assert Safari coverage the harness cannot currently produce.\n\nPROCESS NOTE, recorded because it nearly cost this history: this addendum was first written with `bd update --notes`, which REPLACED all 5352 characters above rather than appending. They were recovered from the previous .beads/issues.jsonl export in git. Use `--append-notes` on a ticket that already carries reasoning -- `--notes` is a destructive overwrite and warns only after the fact.","status":"deferred","priority":2,"issue_type":"task","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-01T06:10:42Z","created_by":"Edward Langley","updated_at":"2026-09-20T17:56:26Z","started_at":"2026-09-20T05:32:36Z","labels":["area:browser"],"dependency_count":0,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"improvise-a2x","title":"Derive Serialize/Deserialize for the ViewState field types","description":"improvise-h0b evicted the two fields that could never be serialized (wizard: an improvise-io type; drill_state: holds an Rc). What remains is that ViewState's own field types still derive no Serialize, so it cannot actually be put on the wire.\n\nNeeded: AppMode, MinibufferConfig, ViewFrame (crates/improvise-command/src/ui/app.rs) and KeyCode, KeyModifiers (crates/improvise-command/src/command/key.rs). All five are plain data. CellValue already derives it.\n\nAlso requires adding serde with the derive feature to improvise-command's [dependencies] — today it depends only on serde_json.\n\nDeliberately NOT done in h0b: nothing serializes ViewState yet, so the derives would have no consumer. They land when improvise-cqi extracts the protocol crate and there is a Snapshot to build.","acceptance_criteria":"ViewState (or its successor in improvise-protocol) derives Serialize + Deserialize and a roundtrip test passes.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-08-30T22:56:09Z","created_by":"Edward Langley","updated_at":"2026-09-01T04:05:38Z","started_at":"2026-09-01T04:01:54Z","closed_at":"2026-09-01T04:05:38Z","close_reason":"ViewState + AppMode, MinibufferConfig, MinibufColor, ViewFrame, KeyCode, KeyModifiers all derive Serialize/Deserialize; serde added to improvise-command. Real blocker was unanticipated: MinibufferConfig.buffer_key was \u0026'static str, which Deserialize cannot produce, and rustc reported it on the five enclosing AppMode variants rather than the field. buffer_key and TextEntry::buffer are now Strings; rejected Cow (public-API plumbing) and a BufferKey enum (drags the buffers map key, script-parser args, and the search buffer). Guarded by view_state_survives_a_json_round_trip. Unblocks improvise-cqi.","dependency_count":0,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"improvise-h0b","title":"Evict wizard and drill_state from ViewState (bootstrap-snapshot hygiene)","description":"Follow-up to improvise-jc8, which removed the hard cycle (Arc\u003cKeymap\u003e). Two fields remain in ViewState that should not be there:\n\n wizard: Option\u003cImportWizard\u003e -- improvise-io type; acyclic, but outside improvise-cqi AC 2's allowlist (core + formula + serde)\n drill_state: Option\u003cDrillState\u003e -- staged, uncommitted edits\n\nNeither is a dependency cycle, so this is not urgent the way jc8 was. The argument is bootstrap hygiene: per improvise-q08 the ONLY point ViewState is serialized is the Snapshot sent when a client connects. A fresh client should not inherit a half-open import wizard, and must not inherit another session's staged drill edits — those are uncommitted writes belonging to whoever staged them.\n\nShape (mirroring jc8): a host-side struct holds the session-local pieces alongside the portable ViewState.\n\n pub struct HostState {\n pub view: ViewState, // portable, serializable\n pub wizard: Option\u003cImportWizard\u003e,\n pub drill_state: Option\u003cDrillState\u003e,\n }\n\nAlso still needed before ViewState can be serialized at all: AppMode and DrillState derive no Serialize today (DrillState only matters if it stays).\n\nDo this before improvise-cqi extracts the protocol crate, or cqi inherits the problem.","notes":"PRIORITY RAISED P3-\u003eP2 (groomed 2026-08-30): this gates P2 work (improvise-cqi improvise-pro ), 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":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-08-30T03:48:29Z","created_by":"Edward Langley","updated_at":"2026-08-30T22:58:17Z","started_at":"2026-08-30T22:53:35Z","closed_at":"2026-08-30T22:58:17Z","close_reason":"wizard and drill_state moved from ViewState to App under a documented session-local residue tag. Deviated from the ticket's HostState sketch: App already is the host struct with a residue taxonomy, so this adds a fifth tag rather than a wrapper layer (jc8 set the same precedent). Demonstrated with a ViewState: Send assertion that failed to compile first. The ticket's note about Serialize derives is split out as improvise-a2x, now blocking cqi.","dependency_count":0,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"improvise-jc8","title":"Evict host-only state from ViewState: flip the transient-keymap Arc","description":"Prerequisite for improvise-cqi. ViewState (crates/improvise-command/src/ui/app.rs:381) holds transient_keymap: Option\u003cArc\u003cKeymap\u003e\u003e. Keymap lives in improvise-command, which will depend on improvise-protocol once improvise-gxi puts reduce_full there taking \u0026Command — so a ViewState in the protocol crate carrying a Keymap is a hard dependency cycle, independent of serde.\n\nFLIP THE ARC. The transient keymap is never arbitrary: it is always the sub-keymap of a Binding::Prefix at a specific (mode, key, mods), set during dispatch and consumed on the very next keypress. There are exactly three, all flat, all on the normal keymap (g, y, Z at keymap.rs:634-642); RecordsNormal reaches them through parent fallback. So ViewState can hold the ADDRESS rather than the keymap:\n\n - transient_keymap: Option\u003cArc\u003cKeymap\u003e\u003e\n + transient_prefix: Option\u003c(KeyCode, KeyModifiers)\u003e\n\nand let KeymapSet — which already owns every keymap — resolve it. Both consumers can: App::handle_key has keymap_set and the mode; src/draw.rs:238 only needs binding_hints() for the which-key popup. Nothing between set and consume changes the mode, because a Prefix binding emits SetTransientKeymap and nothing else.\n\nGuard the assumption: resolution looks the prefix up in the CURRENT MODE's keymap, which is only correct while prefixes are flat. If a nested prefix is ever added (a Prefix binding inside a sub-keymap), this breaks. Encode that as a test rather than a comment.\n\nSCOPE: this ticket is the Arc flip only. wizard: Option\u003cImportWizard\u003e and drill_state: Option\u003cDrillState\u003e also do not belong in a ViewState that gets serialized — improvise-q08 sends one bootstrap Snapshot on connect, and a fresh client should not inherit a half-open import wizard or another session's staged drill edits — but neither is a dependency cycle, so they are follow-ups.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-08-30T03:44:37Z","created_by":"Edward Langley","updated_at":"2026-08-30T03:48:16Z","started_at":"2026-08-30T03:44:42Z","closed_at":"2026-08-30T03:48:16Z","close_reason":"ViewState no longer holds Arc\u003cKeymap\u003e. It stores transient_prefix: Option\u003c(KeyCode, KeyModifiers)\u003e — the key that opened the prefix — and KeymapSet::prefix_keymap resolves it back to the sub-keymap. The Arc lives in KeymapSet, which already owned every keymap.\n\nThe motivation is the dependency graph, not serde: Keymap is in improvise-command, which will depend on improvise-protocol once improvise-gxi puts reduce_full there taking \u0026Command. A ViewState in protocol carrying a Keymap is a hard cycle. That blocker is now gone.\n\nCharacterization tests written BEFORE the change, all passing on the old code first, so the refactor had something to preserve — prefix keys had almost no coverage (one Keymap-level unit test):\n- prefix_g_then_g_jumps_to_first_row\n- prefix_z_then_z_reaches_save_and_quit (ZZ with no file path, which also confirms improvise-8tk's abort still fires)\n- unmatched_key_after_prefix_is_swallowed (a prefix is consumed whether or not the next key matches, so a stray key does not act as a fresh normal binding)\n- prefix_keys_reach_through_the_records_parent_keymap (RecordsNormal defines no prefixes; it inherits them)\n\nTwo new guards for the assumption the flip introduces:\n- prefix_keymaps_are_flat — resolution looks the prefix up in the CURRENT MODE's keymap, which is only correct while prefixes are flat. A nested Prefix would be resolved where it does not exist and the second half of the sequence would silently do nothing. Verified it bites by adding a nested prefix under y: 'Normal: prefix Key(Char('y')) contains nested prefixes [Key(Char('n'))]'.\n- prefix_resolves_through_parent_keymaps — pins that resolution walks parents, and that a plain command binding is not a prefix.\n\nSTILL IN ViewState, both out of scope here and neither a cycle: wizard: Option\u003cImportWizard\u003e (improvise-io is acyclic but outside cqi's AC 2 allowlist) and drill_state: Option\u003cDrillState\u003e. Both should still leave before cqi, for a different reason recorded on that ticket — improvise-q08 sends one bootstrap Snapshot on connect, and a fresh client should inherit neither a half-open import wizard nor another session's staged drill edits.","dependency_count":0,"dependent_count":1,"comment_count":0} @@ -134,7 +136,7 @@ {"_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.","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-20T07:55:12Z","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-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-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} @@ -142,7 +144,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.","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-20T07:58: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-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-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} @@ -163,6 +165,12 @@ {"_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-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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:06:03Z","created_by":"orchestrator","updated_at":"2026-09-20T18:06:03Z","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.","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-20T18:06:00Z","labels":["area:active-view"],"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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:05:51Z","created_by":"orchestrator","updated_at":"2026-09-20T18:05:51Z","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":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:04:14Z","created_by":"wave2-h-frontend","updated_at":"2026-09-20T18:04:14Z","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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:01:04Z","created_by":"wave2-p-milestones","updated_at":"2026-09-20T18:01:04Z","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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T17:57:25Z","created_by":"wave2-n-cleanups","updated_at":"2026-09-20T17:57:25Z","labels":["area:command"],"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":"open","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-20T17:49:30Z","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-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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T07:49:56Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:57:52Z","labels":["area:protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -182,12 +190,12 @@ {"_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":"deferred","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T05:31:32Z","created_by":"Edward Langley","updated_at":"2026-09-20T05:31:34Z","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":"deferred","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T05:31:31Z","created_by":"Edward Langley","updated_at":"2026-09-20T05:31:33Z","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} {"_type":"issue","id":"improvise-ktv1","title":"Move the help content into the shared layer so a browser can show help","description":"? and F1 now enter AppMode::Help in the browser -- the shared keymap delivered that for free -- BUT THE BROWSER HAS NO HELP OVERLAY, so the mode changes and nothing appears. src/ui/help.rs is ~600 lines of ratatui and nothing shared carries its content.\n\nTHE INTERIM, already shipped: the page draws AppMode::hint_text() in a #hint element, so each mode states its own keys. That is genuinely useful and is not a placeholder to rip out -- but it is one line, not five pages.\n\nWHY IT WAS DONE THAT WAY RATHER THAN SPECIAL-CASING: the alternative was to make the client refuse \"enter-mode help\", which is exactly the hand-written exception the keymap consolidation had just deleted. Declining to re-add one was right.\n\nTHE REAL FIX is the same shape as every other consolidation this week (design-principles 5): the help CONTENT is data -- pages of (key, description) -- and it belongs in improvise-protocol where both front ends read it. The TUI keeps its ratatui LAYOUT; the browser writes its own DOM; neither restates what the keys are.\n\nNOTE THE OPPORTUNITY: help content that lives beside the keymap can be DERIVED from it rather than written twice, which would close a drift nobody currently checks -- there is no test that the help text and the bindings agree, and doctrine(binding_dump_diff) exists because bindings have almost no direct tests. A help page generated from Keymaps::default_keymaps() cannot go stale.","acceptance_criteria":"A browser shows help. The content is data — pages of (key, description) — in improvise-protocol, with the TUI keeping its ratatui layout and the browser writing its own DOM; neither restates what the keys are. PREFER DERIVING IT FROM Keymaps::default_keymaps(), which closes a drift nothing currently checks: there is no test that the help text and the bindings agree. The AppMode::hint_text() interim in the browser is NOT a placeholder to rip out — it is one line per mode and genuinely useful; say what survives.","status":"in_progress","priority":3,"issue_type":"task","assignee":"wave2-h-frontend","owner":"el-github@elangley.org","created_at":"2026-09-20T04:38:45Z","created_by":"Edward Langley","updated_at":"2026-09-20T17:53:40Z","started_at":"2026-09-20T17:53:40Z","lease_expires_at":"2026-09-20T17:58:40Z","heartbeat_at":"2026-09-20T17:53:40Z","labels":["area:shared-frontend"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-e24v","title":"Second tranche of client-side key resolution: ~15 bindings needing no enrichment","description":"improvise-bpq2 closed the cursor half and DELIBERATELY STOPPED rather than half-doing a second tranche. These need NOTHING added to the protocol -- the operands are already in ViewState or a widget cache -- so they are the cheapest remaining keys, and each one currently does NOTHING in a browser (Host::handle answers ClientFrame::Key with an empty vec).\n\n * enter-mode \u003cx\u003e (: ? F1 C F V Esc Tab in most modes) -\u003e ChangeMode\n * toggle-panel-visibility / toggle-panel-and-focus -\u003e SetPanelOpen + ChangeMode\n * cycle-panel-focus\n * help-page-next / help-page-prev\n * transpose, toggle-prune-empty, view-back\n * switch-view-at-cursor / delete-view-at-cursor / create-and-switch-view (ViewListCache is flat)\n * commit-buffer category|formula|item (all operands in ViewState)\n * commit-cell-edit (ADR 002 CellId + the buffer)\n * clear-cell / paste (CellId + ViewState::yanked)\n * set-axis-for-tile / cycle-axis-for-tile\n\nALSO IN REACH, and bpq2 listed them under \"needs enrichment\" before establishing they do not: filter-to-item (Enter), open-item-add-at-cursor (a/o), delete-category-at-cursor (d), cycle-axis-at-cursor (Space) -- CatTreeEntry already carries the name and the axis. Each is a distinct Command shape, which is why they were left.\n\nMETHOD IS SETTLED, follow it: mirror each Cmd LINE FOR LINE including where siblings disagree, and add a lockstep test that drives the client and the real reduce_key over the same keypresses and compares ViewState. Do not tidy sibling commands into one rule -- that makes the client a second derivation, which is what lockstep testing exists to rule out.\n\nAnd update the_normal_mode_keys_that_still_need_the_host_are_these as you go: it is a named list precisely so closing one has to come there and say so.","acceptance_criteria":"Each listed binding resolves client-side with a lockstep test driving the client and the real reduce_key over the same keypresses. Mirror each Cmd LINE FOR LINE including where siblings disagree — Move and MoveTileCursor emit even when they clamp, MovePanelCursor returns nothing when the cursor does not move — and do NOT tidy them into one rule, which is what makes the client a second derivation. the_command_names_that_still_need_the_host_are_these shrinks by exactly the bindings closed, and closing one has to come to that list and say so. Note improvise-fdt6: commit-buffer formula is on this list and is also why a browser cannot add a formula at all.","status":"in_progress","priority":3,"issue_type":"task","assignee":"wave2-h-frontend","owner":"el-github@elangley.org","created_at":"2026-09-20T02:36:43Z","created_by":"Edward Langley","updated_at":"2026-09-20T17:53:38Z","started_at":"2026-09-20T17:53:38Z","lease_expires_at":"2026-09-20T17:58:38Z","heartbeat_at":"2026-09-20T17:53:38Z","labels":["area:shared-frontend"],"dependencies":[{"issue_id":"improvise-e24v","depends_on_id":"improvise-fdt6","type":"relates-to","created_at":"2026-09-20T07:55:25Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-s17o","title":"reduce_full and apply_command's view-slice arm have no production caller","description":"Found while fixing improvise-ondj (2026-09-20), and it is the reason that defect could sit unnoticed.\n\nui/reduce.rs module doc says: \"the one place a command is applied to the authoritative state\" and \"Every deployment mode -- the native TUI, a websocket server, a browser worker -- reaches the model through the functions here.\"\n\nTHAT IS ASPIRATIONAL. reduce_full has NO PRODUCTION CALLER ANYWHERE IN THE WORKSPACE. Every host reaches the model through Sessions::apply or reduce_key. Nothing exercised reduce_full Command::Model arm, so nothing could notice it skipped the basis check -- a door advertised by documentation and walked by nobody is exactly the shape that hides a defect until the next host arrives.\n\nSECOND HALF, same shape: apply_command other =\u003e reduce_view(\u0026mut app.view_state, other) folds a wire VIEW command into the AUTHORITATIVE App own slice. Nothing in production does that -- Sessions::apply folds view commands into the CLIENT MIRROR instead. So view_slice_commands_land_in_the_authoritative_view_state is testing a path no host walks, and passing tells you nothing about any deployment.\n\nTHE DECISION: are these a host API worth keeping, or test-only scaffolding wearing a module doc? If the former, HAVE A HOST USE ONE -- improvise-9x6 (the browser worker) is the natural candidate and is being built now, so this is decidable immediately rather than in principle. If the latter, delete them and correct the module doc, which is currently making a promise the code does not keep.\n\nDo not just delete the test: decide what the function is for first.","acceptance_criteria":"reduce_full and apply_command's view-slice arm either have a production caller or are deleted with the module doc corrected. The module doc currently promises 'every deployment mode reaches the model through the functions here', which is false and is what let improvise-ondj's defect sit unnoticed. NOTE: the post-wave review found Sessions::apply has ALSO lost its production caller (Host::command now calls apply_checked), and reduce.rs:48 still advertises it as 'the door that does it for you' — same question, same file, decide them together.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T01:54:30Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:56:09Z","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-e24v","title":"Second tranche of client-side key resolution: ~15 bindings needing no enrichment","description":"improvise-bpq2 closed the cursor half and DELIBERATELY STOPPED rather than half-doing a second tranche. These need NOTHING added to the protocol -- the operands are already in ViewState or a widget cache -- so they are the cheapest remaining keys, and each one currently does NOTHING in a browser (Host::handle answers ClientFrame::Key with an empty vec).\n\n * enter-mode \u003cx\u003e (: ? F1 C F V Esc Tab in most modes) -\u003e ChangeMode\n * toggle-panel-visibility / toggle-panel-and-focus -\u003e SetPanelOpen + ChangeMode\n * cycle-panel-focus\n * help-page-next / help-page-prev\n * transpose, toggle-prune-empty, view-back\n * switch-view-at-cursor / delete-view-at-cursor / create-and-switch-view (ViewListCache is flat)\n * commit-buffer category|formula|item (all operands in ViewState)\n * commit-cell-edit (ADR 002 CellId + the buffer)\n * clear-cell / paste (CellId + ViewState::yanked)\n * set-axis-for-tile / cycle-axis-for-tile\n\nALSO IN REACH, and bpq2 listed them under \"needs enrichment\" before establishing they do not: filter-to-item (Enter), open-item-add-at-cursor (a/o), delete-category-at-cursor (d), cycle-axis-at-cursor (Space) -- CatTreeEntry already carries the name and the axis. Each is a distinct Command shape, which is why they were left.\n\nMETHOD IS SETTLED, follow it: mirror each Cmd LINE FOR LINE including where siblings disagree, and add a lockstep test that drives the client and the real reduce_key over the same keypresses and compares ViewState. Do not tidy sibling commands into one rule -- that makes the client a second derivation, which is what lockstep testing exists to rule out.\n\nAnd update the_normal_mode_keys_that_still_need_the_host_are_these as you go: it is a named list precisely so closing one has to come there and say so.","acceptance_criteria":"Each listed binding resolves client-side with a lockstep test driving the client and the real reduce_key over the same keypresses. Mirror each Cmd LINE FOR LINE including where siblings disagree — Move and MoveTileCursor emit even when they clamp, MovePanelCursor returns nothing when the cursor does not move — and do NOT tidy them into one rule, which is what makes the client a second derivation. the_command_names_that_still_need_the_host_are_these shrinks by exactly the bindings closed, and closing one has to come to that list and say so. Note improvise-fdt6: commit-buffer formula is on this list and is also why a browser cannot add a formula at all.","status":"closed","priority":3,"issue_type":"task","assignee":"wave2-h-frontend","owner":"el-github@elangley.org","created_at":"2026-09-20T02:36:43Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:05:09Z","started_at":"2026-09-20T17:53:38Z","closed_at":"2026-09-20T18:05:09Z","close_reason":"Closed","labels":["area:shared-frontend"],"dependencies":[{"issue_id":"improvise-e24v","depends_on_id":"improvise-fdt6","type":"relates-to","created_at":"2026-09-20T07:55:25Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-s17o","title":"reduce_full and apply_command's view-slice arm have no production caller","description":"Found while fixing improvise-ondj (2026-09-20), and it is the reason that defect could sit unnoticed.\n\nui/reduce.rs module doc says: \"the one place a command is applied to the authoritative state\" and \"Every deployment mode -- the native TUI, a websocket server, a browser worker -- reaches the model through the functions here.\"\n\nTHAT IS ASPIRATIONAL. reduce_full has NO PRODUCTION CALLER ANYWHERE IN THE WORKSPACE. Every host reaches the model through Sessions::apply or reduce_key. Nothing exercised reduce_full Command::Model arm, so nothing could notice it skipped the basis check -- a door advertised by documentation and walked by nobody is exactly the shape that hides a defect until the next host arrives.\n\nSECOND HALF, same shape: apply_command other =\u003e reduce_view(\u0026mut app.view_state, other) folds a wire VIEW command into the AUTHORITATIVE App own slice. Nothing in production does that -- Sessions::apply folds view commands into the CLIENT MIRROR instead. So view_slice_commands_land_in_the_authoritative_view_state is testing a path no host walks, and passing tells you nothing about any deployment.\n\nTHE DECISION: are these a host API worth keeping, or test-only scaffolding wearing a module doc? If the former, HAVE A HOST USE ONE -- improvise-9x6 (the browser worker) is the natural candidate and is being built now, so this is decidable immediately rather than in principle. If the latter, delete them and correct the module doc, which is currently making a promise the code does not keep.\n\nDo not just delete the test: decide what the function is for first.","acceptance_criteria":"reduce_full and apply_command's view-slice arm either have a production caller or are deleted with the module doc corrected. The module doc currently promises 'every deployment mode reaches the model through the functions here', which is false and is what let improvise-ondj's defect sit unnoticed. NOTE: the post-wave review found Sessions::apply has ALSO lost its production caller (Host::command now calls apply_checked), and reduce.rs:48 still advertises it as 'the door that does it for you' — same question, same file, decide them together.","status":"in_progress","priority":3,"issue_type":"task","assignee":"wave2-n-cleanups","owner":"el-github@elangley.org","created_at":"2026-09-20T01:54:30Z","created_by":"Edward Langley","updated_at":"2026-09-20T17:57:47Z","started_at":"2026-09-20T17:57:47Z","lease_expires_at":"2026-09-20T18:02:47Z","heartbeat_at":"2026-09-20T17:57:47Z","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-r4lo","title":"Two answers to what a typed string means, and they are adjacent","description":"Noticed during improvise-xks6 (2026-09-20) and left alone, because the envelopes were that ticket. Searched first: nothing open covers it.\n\nTWO CONTRADICTORY DOC COMMENTS, now one level below the envelopes they used to hide behind:\n\n * Client::set_cell_at says classifying a typed string \"is the HOST decision, not one to re-implement here\", and takes a SERIALIZED CellValue.\n * socket.rs::typed says the OPPOSITE -- \"Putting it in Rust rather than in the page JavaScript is what makes it testable\" -- and typed_cell takes a PLAIN STRING.\n\nBoth now return the same ClientFrame, so this is the same disease improvise-xks6 just cured, one level down: TWO ANSWERS TO ONE QUESTION, kept apart only by the fact that nothing called both.\n\nWHICH ONE IS RIGHT: typed_cell is the one web/app.js calls and the one repo-map gotcha 42 blesses -- the browser has no App to ask, and ModelCommand::SetCell takes a CellValue, so somebody on the client side must decide; putting it in Rust rather than JS is what makes it testable and keeps one spelling per language boundary. set_cell_at is UNREFERENCED by the page.\n\nSo the likely answer is to delete set_cell_at and fix the doc, but check first whether anything else (a test, the lockstep harness, a future worker bootstrap) wants the pre-classified form -- a host that HAS an App genuinely can classify, and improvise-9x6 puts an App in the browser, which may make set_cell_at the right entry point for the WORKER deployment and typed_cell right for the socket one. If so the fix is to say that in both doc comments rather than to delete either. Decide it, do not leave two claims standing.","status":"closed","priority":3,"issue_type":"task","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T01:52:37Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:22:09Z","started_at":"2026-09-20T05:32:35Z","closed_at":"2026-09-20T06:22:09Z","close_reason":"Closed by PR #21 (WU-I). DECIDED: DELETE, do not re-document.\n\nThe ticket offered an escape hatch -- that improvise-9x6's browser App might want the pre-classified form, in which case the fix would be to say so in both doc comments rather than delete either. THAT ESCAPE HATCH DOES NOT EXIST. ModelCommand::SetCell carries a CellValue, so nothing that crosses a frame is ever a string, and the worker's App sits BEHIND the frame boundary and is handed a value. So set_cell_at's claim -- that classifying a typed string is \"the HOST's decision\" -- is false on every deployment, not just on the socket one.\n\nBoth set_cell_at AND set_cell are gone. set_cell cannot express ClearCell, which makes it a STRICTLY WEAKER DUPLICATE of typed_cell_frame rather than a peer -- and its the_json_wrapper_composes_the_same_message was exactly the compatibility test doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished) says a finished consolidation deletes.\n\nTEST COUNT DROPPED 5 AND IS FULLY ACCOUNTED: 2 aimed at deleted code; 3 were None-shape tests already asserted against typed_cell_frame in socket.rs. The 4th None shape -- ADR 001's absent basis -- had no twin, so a_client_with_no_basis_composes_nothing was RETARGETED rather than dropped, and proved discriminating by seeding a Projection::Basis into its fixture and watching it flip to Some.\n\nThree doc comments elsewhere named the deleted fn (keys.rs, state.rs, socket.rs); all re-pointed, per the grep rule.\n\nWHAT REMAINS, filed as improvise-j7v6: socket.rs::typed still duplicates commit_regular_cell_value on the host key path. Both are legitimate -- the browser has no App to ask -- but the RULE is written twice, and a_typed_string_is_classified_as_the_host_classifies_it states the obligation in its name while asserting three outcomes by hand rather than comparing against the host's function, so the two can drift and stay green.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-i5tk","title":"Narrow the TUI's GridCache to its viewport -- benchmark first","description":"Disclosed as a caveat of PR #12 rather than discovered by it: it is in the PR body, in the aa7a6d3 commit message, and in repo-map gotcha 44. Copilot raised it independently on round 2.\n\nproject::grid_of evaluates EVERY CELL PER FRAME, where GridWidget used to evaluate only the visible ones.\n\nBE PRECISE ABOUT WHAT CHANGED: this is a WIDENING, not an introduction. compute_col_widths was already whole-grid and the totals loop was already O(rows x visible cols). And it is UNBENCHMARKED IN BOTH DIRECTIONS -- \"this can dominate TUI rendering\" is exactly as unevidenced as \"it is fine\" would be. Do not fix it on the strength of the reasoning.\n\nSO ACCEPTANCE CRITERION #1 IS A BENCHMARK. The fix and the evidence that it was needed are the same work.\n\nWHAT THE FIX COSTS, if the benchmark says do it: teaching GridWidget that a cache position is RELATIVE, which is the browser client arrangement (gotcha 44 -- the TUI builds at offsets zero deliberately, and that is what let the scrolling arithmetic stay untouched through improvise-n10). So this is not a narrowing of a loop, it is a change to what a cache index MEANS in the TUI, and every scroll site is in scope.","acceptance_criteria":"ACCEPTANCE CRITERION 1 IS A BENCHMARK, and the fix happens only if it says so — 'this can dominate TUI rendering' is exactly as unevidenced as 'it is fine'. If it does: GridWidget learns that a cache position is RELATIVE, which is the browser's arrangement, and every scroll site is in scope — this is a change to what a cache index MEANS in the TUI, not a narrowed loop. Note two things the wave added: project::grid_of now evaluates every cell per frame (a widening of an existing whole-grid cost, flagged in gotcha 44), and improvise-rucf.3 may force this anyway, because a live-backed source turns the viewport-bounded projection from an optimisation into a correctness requirement.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T01:26:47Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:56:20Z","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-i5tk","title":"Narrow the TUI's GridCache to its viewport -- benchmark first","description":"Disclosed as a caveat of PR #12 rather than discovered by it: it is in the PR body, in the aa7a6d3 commit message, and in repo-map gotcha 44. Copilot raised it independently on round 2.\n\nproject::grid_of evaluates EVERY CELL PER FRAME, where GridWidget used to evaluate only the visible ones.\n\nBE PRECISE ABOUT WHAT CHANGED: this is a WIDENING, not an introduction. compute_col_widths was already whole-grid and the totals loop was already O(rows x visible cols). And it is UNBENCHMARKED IN BOTH DIRECTIONS -- \"this can dominate TUI rendering\" is exactly as unevidenced as \"it is fine\" would be. Do not fix it on the strength of the reasoning.\n\nSO ACCEPTANCE CRITERION #1 IS A BENCHMARK. The fix and the evidence that it was needed are the same work.\n\nWHAT THE FIX COSTS, if the benchmark says do it: teaching GridWidget that a cache position is RELATIVE, which is the browser client arrangement (gotcha 44 -- the TUI builds at offsets zero deliberately, and that is what let the scrolling arithmetic stay untouched through improvise-n10). So this is not a narrowing of a loop, it is a change to what a cache index MEANS in the TUI, and every scroll site is in scope.","acceptance_criteria":"ACCEPTANCE CRITERION 1 IS A BENCHMARK, and the fix happens only if it says so — 'this can dominate TUI rendering' is exactly as unevidenced as 'it is fine'. If it does: GridWidget learns that a cache position is RELATIVE, which is the browser's arrangement, and every scroll site is in scope — this is a change to what a cache index MEANS in the TUI, not a narrowed loop. Note two things the wave added: project::grid_of now evaluates every cell per frame (a widening of an existing whole-grid cost, flagged in gotcha 44), and improvise-rucf.3 may force this anyway, because a live-backed source turns the viewport-bounded projection from an optimisation into a correctness requirement.","status":"in_progress","priority":3,"issue_type":"task","assignee":"wave2-n-cleanups","owner":"el-github@elangley.org","created_at":"2026-09-20T01:26:47Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:00:35Z","started_at":"2026-09-20T18:00:35Z","lease_expires_at":"2026-09-20T18:05:35Z","heartbeat_at":"2026-09-20T18:00:35Z","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-6c6q","title":"[bug] apply_switch relays a refused switch to everyone but the asker","description":"Found by the code review of ADR 001 layer 1 (2026-09-19). Pre-existing -- the pre-wave broadcast: Option\u003cCommand\u003e was explicitly \"every OTHER client\" -- and the doc overclaims.\n\napply_switch doc says: \"a client that optimistically folded a name the document turned down is corrected by the same message that tells the others to follow.\" But it relays Broadcast::ToOthers, so THE ASKER IS NOT SENT THE CORRECTION. If the workbook refuses the switch (the view does not exist), the asker slice keeps the asked-for name while its mirror holds the accepted one -- gotcha 24 mismatch, and gotcha 29 says the two copies must move together.\n\nUNLIKE the apply_model case the review fixed, a blanket ToAll is WRONG here: the asker really did fold optimistically on the accepted path, so ToAll would double-apply on the common path.\n\nTHE HONEST FIX IS CONDITIONAL: ToAll when accepted != asked, ToOthers otherwise. Which is to say the audience is a function of whether the asker \"folded the right thing\", not of the command variant -- a slightly sharper version of doctrine(the_audience_follows_the_optimistic_fold).\n\nCheap. The reason it is P3 and not higher is that switching to a view that does not exist is not a thing a well-behaved client does; it is reachable through the : command line.\n\n## Steps to Reproduce\n\n1. Two clients on one document.\n2. Client A asks to switch to a view that does NOT exist, reachable through the `:` command line.\n3. The workbook refuses; `apply_switch` relays `Broadcast::ToOthers`, so every OTHER client is told the accepted name and **the asker is not**.\n4. A's own slice keeps the asked-for name while its server-side mirror holds the accepted one — gotcha 24's mismatch, and gotcha 29 says the two copies must move together.\n\n`apply_switch`'s own doc claims \"a client that optimistically folded a name the document turned down is corrected by the same message that tells the others to follow\". It is not.\n","acceptance_criteria":"apply_switch's audience is conditional: ToAll when the accepted view differs from the asked-for one, ToOthers otherwise. A blanket ToAll is WRONG here — the asker really did fold optimistically on the accepted path, so it would double-apply on the common path, which is what distinguishes this from the apply_model case. A test drives a switch to a view that does not exist and asserts the ASKER is corrected; note that arm is reachable only through the : command line.","notes":"ALSO FIX THE DOC COMMENT, and treat it as the more dangerous half. apply_switch's doc (project.rs:~714, verbatim on main) actively claims 'a client that optimistically folded a name the document turned down is corrected by the same message that tells the others to follow.' It is not -- the relay is ToOthers. A comment that says the hole is closed is worse than the hole, because the next reader stops looking.\n\nCopilot surfaced this independently on PR #12 and its suggested shape is right and cheap: apply_switch ALREADY RECEIVES the asked-for name -- it takes the whole cmd, which IS Command::SwitchView(asked) -- it simply never destructures it. So the conditional audience (ToAll when accepted != asked, ToOthers otherwise) needs no new parameter.\n\nTHE REAL COST OF THIS TICKET IS THE REGRESSION TEST, NOT THE CHANGE. It must drive THE ASKER'S OWN SLICE, not the to_others helper -- that helper looks away from the asker by construction, and that is exactly how the sibling bug in apply_model (fixed in 9cb16ce) shipped green.","status":"in_progress","priority":3,"issue_type":"bug","assignee":"wave2-e-active-view","owner":"el-github@elangley.org","created_at":"2026-09-20T01:05:15Z","created_by":"Edward Langley","updated_at":"2026-09-20T17:53:51Z","started_at":"2026-09-20T17:53:51Z","lease_expires_at":"2026-09-20T17:58:51Z","heartbeat_at":"2026-09-20T17:53:51Z","labels":["area:active-view"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-z754","title":"Document name, dirty flag and file path have no home a client can read","description":"draw_title reads workbook.model.name, model_state.dirty and model_state.file_path. NONE IS PROJECTED. improvise-wasm-client RenderModel carries mode, status and view_name but not these, so A BROWSER TITLE BAR CANNOT SHOW THE DOCUMENT NAME OR THE DIRTY MARKER.\n\nFound closing improvise-n10, alongside the grid-frame gaps.\n\nWORTH DECIDING RATHER THAN JUST ADDING: dirty is a fact about the document that every client shares, and name/path likewise -- so these want a projection or a ViewState field, not a per-widget cache. But note file_path is also the thing Save uses and a browser has no filesystem path in the same sense (gotcha 2, gotcha 28), so \"what a remote client should be told about where the document lives\" is a real question and not obviously \"the path\".","acceptance_criteria":"A client can render a title bar: document name, dirty marker and whatever the answer to 'where does this document live' turns out to be. These are facts about the DOCUMENT that every client shares, so they want a projection or a ViewState field rather than a per-widget cache — say which and why. Note file_path is the thing Save uses and a browser has no filesystem path in the same sense (gotchas 2, 28, and now improvise-368h's ClientFrame::Save which deliberately carries none), so 'what a remote client should be told about where the document lives' is a real question and the answer is probably not the path.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T00:58:35Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:55:51Z","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-z754","title":"Document name, dirty flag and file path have no home a client can read","description":"draw_title reads workbook.model.name, model_state.dirty and model_state.file_path. NONE IS PROJECTED. improvise-wasm-client RenderModel carries mode, status and view_name but not these, so A BROWSER TITLE BAR CANNOT SHOW THE DOCUMENT NAME OR THE DIRTY MARKER.\n\nFound closing improvise-n10, alongside the grid-frame gaps.\n\nWORTH DECIDING RATHER THAN JUST ADDING: dirty is a fact about the document that every client shares, and name/path likewise -- so these want a projection or a ViewState field, not a per-widget cache. But note file_path is also the thing Save uses and a browser has no filesystem path in the same sense (gotcha 2, gotcha 28), so \"what a remote client should be told about where the document lives\" is a real question and not obviously \"the path\".","acceptance_criteria":"A client can render a title bar: document name, dirty marker and whatever the answer to 'where does this document live' turns out to be. These are facts about the DOCUMENT that every client shares, so they want a projection or a ViewState field rather than a per-widget cache — say which and why. Note file_path is the thing Save uses and a browser has no filesystem path in the same sense (gotchas 2, 28, and now improvise-368h's ClientFrame::Save which deliberately carries none), so 'what a remote client should be told about where the document lives' is a real question and the answer is probably not the path.","status":"in_progress","priority":3,"issue_type":"task","assignee":"wave2-b-projection","owner":"el-github@elangley.org","created_at":"2026-09-20T00:58:35Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:05:59Z","started_at":"2026-09-20T18:05:59Z","lease_expires_at":"2026-09-20T18:10:59Z","heartbeat_at":"2026-09-20T18:05:59Z","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-1125","title":"Row-header width is computed per frame in the widget","description":"src/ui/grid.rs::render_grid computes sub_widths / row_header_width by walking layout.row_items per level. GridCache carries col_widths but NO row-header width.\n\nIts sibling compute_col_widths already moved into improvise-command::ui::viewport; this one did not. So a browser client sizing its row header will invent a second rule -- the exact shape of improvise-edp (the category panel walked twice) and of the multi-level-labels divergence.\n\nSmall, and it pairs naturally with the multi-level labels ticket, because both are facts about the grid FRAME rather than its contents, and the frame is what improvise-n10 deliberately left behind.","status":"closed","priority":3,"issue_type":"task","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T00:58:34Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:41:34Z","started_at":"2026-09-20T05:32:27Z","closed_at":"2026-09-20T07:41:34Z","close_reason":"Closed by PR #23 (WU-A), and the ticket's premise was understated: the row-header width rule existed TWICE ALREADY -- inline in src/ui/grid.rs::render_grid and in viewport::compute_row_header_width -- rather than once-and-unshared.\n\nIt is now improvise_protocol::header_level_widths, with both callers reading it.\n\nTHE TICKET SAID TO PUT IT BESIDE compute_col_widths IN improvise-command::ui::viewport. THAT HOME IS UNREACHABLE and taking it would have guaranteed the second derivation this ticket exists to prevent: improvise-wasm-client cannot depend on improvise-command, so a browser sizing its own row header would have had to invent a third copy. improvise-protocol is the only shared home.\n\nIT TAKES AN ITERATOR OF BORROWED TUPLES rather than an AxisFrame, and that is load-bearing: its other caller is compute_row_header_width, reached from App::cmd_context ON EVERY KEYPRESS. Building a frame there would clone every row tuple per key. An independent reviewer read that path line by line and confirmed no allocation on the keypress path.\n\nimprovise-protocol gained unicode-width to do it, judged acceptable: forbidden(improvise_protocol, ui) is about UI FRAMEWORK types (the worked examples are AxisColor -\u003e ratatui::Color and MinibufColor), text measurement is not one, and GridCache::col_widths was already a Vec\u003cu16\u003e of display cells on the wire -- the unit was already there. Noted honestly: header_widths() has exactly one production consumer today, the TUI widget, so the \"a browser would have been a third copy\" justification is anticipatory rather than actual.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_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":"in_progress","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-20T17:53:39Z","started_at":"2026-09-20T17:53:39Z","lease_expires_at":"2026-09-20T17:58:39Z","heartbeat_at":"2026-09-20T17:53:39Z","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} @@ -268,10 +276,12 @@ {"_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.","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-20T07:58:08Z","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-l36","type":"blocks","created_at":"2026-04-08T21:09:30Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"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.","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-20T07:58:08Z","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-11a","type":"blocks","created_at":"2026-04-08T21:09:30Z","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":2,"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-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.","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-20T07:58:09Z","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-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-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-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/.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:01:16Z","created_by":"wave2-p-milestones","updated_at":"2026-09-20T18:01:16Z","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":"open","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-20T16:53:36Z","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":"open","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T07:50:12Z","created_by":"Edward Langley","updated_at":"2026-09-20T16:53:34Z","labels":["area:core-io"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-zx8t","title":"draw.rs::mode_style matches AppMode with a wildcard, so the repo map's exhaustiveness promise is false there","description":"Found by WU-G on PR #27 while adding AppMode::SavePrompt, and it is a correction to something context/repo-map.md currently promises.\n\nThe repo map says of AppMode: \"Per-variant UI facts live on the enum as exhaustive-match methods -- label(), hint_text(), panel(), minibuffer(), is_editing(), is_records() (improvise-2hi); ADD A VARIANT AND THE COMPILER NAMES EVERY ONE YOU MUST FILL IN.\"\n\nThat is TRUE of the five enum methods and FALSE of src/draw.rs::mode_style, which matches AppMode with a `_ =\u003e` WILDCARD. So a new variant silently inherits whatever the wildcard arm gives -- SavePrompt currently takes ExportPrompt's DarkGray by accident rather than by decision, which happens to be defensible and was nobody's choice.\n\nWHY IT IS P4 AND NOT HIGHER: the consequence is a colour, and the wrong colour is visible rather than silent. But the reason to fix it is that the repo map states a PROPERTY, and a property that holds in five places and not the sixth is worse than one nobody claimed -- the next person adding a variant will trust the sentence.\n\nTHE FIX IS ONE OF TWO, and they are not equivalent:\n * Make mode_style exhaustive, so the compiler does name it. Note mode_style stays in src/draw.rs deliberately, because Style is a ratatui type and improvise-protocol must not name one (gotcha 26, the AxisColor/MinibufColor pattern).\n * Or give AppMode a semantic colour method in the protocol crate -- the AxisColor treatment -- and let draw.rs do the one mapping. That is the established shape here and it would also give a browser something to use, which draw.rs's Style cannot.\n\nThe second is probably right and is the same question improvise-k099 asks about hint_text/label. Decide them together.\n","acceptance_criteria":"mode_style either matches AppMode exhaustively, or AppMode gains a semantic colour method in improvise-protocol that draw.rs maps to a ratatui Style (the AxisColor/MinibufColor shape). Either way, adding an AppMode variant fails the build until someone chooses its colour. The repo-map sentence promising compiler-named sites is true of every listed method, or it is corrected to say which one it is not true of.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T06:04:22Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:44:19Z","labels":["area:tui"],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -296,5 +306,5 @@ {"_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-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.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-09T04:08:07Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:58:09Z","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.","status":"open","priority":4,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-09T04:05:53Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:58:10Z","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} +{"_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.","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:04:48Z","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.","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:04:51Z","started_at":"2026-09-20T18:02:40Z","closed_at":"2026-09-20T18:04:51Z","close_reason":"Both children terminal: improvise-3gy was already closed, improvise-e61 closes\nwith this wave.\n\nTHE COLLISION THIS TICKET ASKED TO RESOLVE FIRST, resolved: Pages serves the\nstandalone web app at the root (improvise-d31/bck), and the landing page now\nsits BESIDE it at /landing/ rather than replacing it. The reasoning is this\nticket's own — the live app is the better front door — and the third option,\ndropping the landing page in favour of the app, was declined only because the\nrecorded terminal casts show the TUI, which the browser app does not.\n\nweb/assemble.sh copies docs/index.html and docs/casts/ into the deployed tree,\nso the page is reachable and the browser harness serves the same tree the\ndeploy publishes. A landing page the deploy never copies would have satisfied\nthis ticket's letter and been a file nobody can open.\n\nREADME links both.","labels":["area:release"],"dependencies":[{"issue_id":"improvise-kh8","depends_on_id":"improvise-3gy","type":"blocks","created_at":"2026-04-08T23:37:42Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-kh8","depends_on_id":"improvise-e61","type":"blocks","created_at":"2026-04-08T23:37:41Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0} diff --git a/context/code/command.md b/context/code/command.md index 30c5a2a2..eb776516 100644 --- a/context/code/command.md +++ b/context/code/command.md @@ -59,8 +59,6 @@ authoritative `App`. A host that wants to apply a client's batch must face the `Sessions::apply` (which answers one command and then dispatches) has **no production caller** and is kept for the single-command case its tests drive. Verified at this commit: the only `Sessions::apply` call sites are tests. -Note `context/design-principles.md` §5 still names `Sessions::apply` as -"dispatch — the one door"; `apply_checked` is the one production hosts use. ### `Checked` — a wire `Command` the authoritative state has answered for diff --git a/context/design-principles.md b/context/design-principles.md index 9c64748b..c6cb163e 100644 --- a/context/design-principles.md +++ b/context/design-principles.md @@ -226,7 +226,7 @@ The pieces already exist and are worth naming as what they are: | action | `Command` / `ModelCommand` (`improvise-protocol`) | | reducer | `reduce_view`, and `WidgetCache::apply` per widget | | store | `ViewState` + the per-widget caches | -| dispatch | `Sessions::apply` / `apply_checked` — the one door (gotcha 36) | +| dispatch | `Sessions::apply_checked` — the one door (gotcha 36) | So the question to ask of any code in a front end is: **would a third front end have to write this again?** If yes, it is behaviour and it belongs in the shared diff --git a/context/repo-map.md b/context/repo-map.md index 50749930..cfacfdfc 100644 --- a/context/repo-map.md +++ b/context/repo-map.md @@ -175,7 +175,7 @@ Region=West, Measure=Revenue = |pending| # pipe-quoted text 14. **Keybindings are per-mode.** `ModeKey::from_app_mode()` resolves the mode; Normal + `search_mode=true` maps to `SearchMode`. Minibuffer modes bind Enter/Esc as `Binding::Sequence` so `clear-buffer` fires alongside commit/cancel. Hints are checked against the keymaps: `every_hint_key_resolves_in_its_mode` (`command/keymap.rs`) splits each `AppMode::hint_text()` and requires every key it names to resolve in that mode's map, because `only_quit_has_no_hint` asserts only that a hint is *present*. It checks which keys are named, not what they do. Its tokenizer knows the hint dialect explicitly (named keys, `Ctrl-`, `F/C/V` alternatives, character runs like `hjkl`/`[:]`, the em-dash prose tail, CommandMode's `:q` ex-commands) and panics on a token it cannot classify rather than skipping it. 15. **Registry names come from prototypes, not strings.** Every `register*` call reads the name off a prototype `Cmd` instance, so a typo is impossible (improvise-9cn) — there is no name literal in `registry.rs`. What types can't reach: four commands name themselves off a field (`ViewNavigate`, `TileAxisOp`, `ToggleGroupAtCursor`, `CommitAndAdvance`), so a prototype can name the wrong *variant* and still compile. That shows up as a **duplicate** registration, not a mismatch — the wrong-variant entry collides with its sibling and the sibling's name vanishes. `registered_names_are_unique` catches it; `every_bound_command_name_is_registered` catches the keymap side, and checks **both** halves of `dispatch`'s `registry.interactive(name, args, ctx).ok()?` — the name resolves *and* the args parse — because improvise-s13 moved `enter-mode ["export-prompt"]`/`["formula-edit"]` out of the nullary class, where a typo'd mode name compiles into a dead key. It builds the `CmdContext` in each keymap's own `AppMode`, since `commit-buffer item` reads `ctx.mode`. 16. **`effect_cmd!` macro** wraps a pre-built effect as a parseable command. Use it for simple wrappers; reach for a hand-written `Cmd` once there's real decision logic. Its counterpart on the effect side is `simple_effect!` in `ui/effect.rs`, which declares a one-field newtype that writes one place on the `App`. Its **kind argument is mandatory** — `simple_effect!(Name(T) => |app| place, EffectKind::ViewOnly)` — even though every call site so far answers `ViewOnly`: hard-coding it reintroduced exactly the default gotcha 3 does without, since `$place` is an unconstrained expression and a one-line `active_view_mut().number_format` setter would have reported `ViewOnly` with the suite green (improvise-8fy). Likewise, mode entry has no per-mode shim commands — bind `enter-mode ` (improvise-s13); the name is decoded by `parse_mode_name` in `registry.rs`, and `EnterMode` clears the mode's minibuffer on the way in. -17. **Float equality inside formula eval has one epsilon, and the div/0 guard shares it.** `FLOAT_EQ_EPSILON = 1e-10` in `model/types.rs` is used by `=`/`!=` **and** by the division-by-zero guard (`rv.abs() < FLOAT_EQ_EPSILON`), in both `eval_expr` and its aggregated twin — four call sites, one constant. This gotcha previously recorded the opposite (`=`/`!=` epsilon, div/0 exact) and was stale; the mix is gone. Why an epsilon at all and why this one: `context/code/core.md` §4. +17. **Float equality inside formula eval has one epsilon, and the div/0 guard shares it.** `FLOAT_EQ_EPSILON = 1e-10` in `model/types.rs` is used by `=`/`!=` **and** by the division-by-zero guard (`rv.abs() < FLOAT_EQ_EPSILON`), in both `eval_expr` and its aggregated twin — six uses across the two evaluators (a div/0 guard plus the `Eq` and `Ne` arms in each), one constant. This gotcha previously recorded the opposite (`=`/`!=` epsilon, div/0 exact) and was stale; the mix is gone. Why an epsilon at all and why this one: `context/code/core.md` §4. 18. **`IndexMap`** preserves insertion order for categories, views, and items. Persistence relies on this — and so, since improvise-shyt, do `DataStore::matching_cells`/`matching_values`: the secondary index **narrows** the candidates, then `matching_positions` resolves each to its position in the primary `IndexMap`, sorts, and reads back. **An index whose values are `HashSet`s is for O(1) lookup, never for iteration.** Walking it gave a different order in every process, which was not tidiness. Records-mode rows reordered between sessions; `SUM`/`AVG` drifted in the last bits because `f64` addition is not associative (`Min`/`Max`/`Count` and the text-unanimity rule of gotcha 7 are order-free, so they were never affected); and worst, the **drill snapshot** froze its record list in hash order while `drill_state.pending_edits` are keyed by *row index* (gotcha 1), so the same drill opened in a second process addressed **different records** — silent misdirected writes, and nothing in the tracker knew. Measured at 18,000 cells: +20% worst case on `matching_values`, and `matching_cells` 7% *faster*, sorted positions resolving `CellKey`s with better locality. `DataStore::live_records` was the open choice and is now decided (improvise-2yc2): it walks `cells` and projects insertion order onto records, deliberately paying O(cells) rather than O(records), because `cells`' order is already the answer `matching_positions` gives and a second answer to "what order is this store in" is the disease. It is derived per call rather than memoised on `RecordEntry`, since a stored position is a copy and a record deleted and re-created would hold its old one. Note what is *not* on offer: an unordered return — every iterator has an order, and a type that merely disclaims one still hands the caller hash order, which is the guard living one level away from the hazard and the shape this whole class of bug returns through. **The second real instance was `import::analyzer::analyze_records`** (improvise-7yx), which built its distinct values through a `HashSet`: `build_model` adds items in exactly that order and `IndexMap` preserves it, so the *same CSV imported twice* produced two different `.improv` files and two different row orders. Now first-seen order, pinned by a five-iteration test because each `HashSet` gets its own `RandomState`. Also answered here since: an **empty `partial`** matches every cell vacuously (improvise-hkt6) — it used to index `sets[0]` and panic, unreachable only because both callers guarded. 19. **Commit paths for cell edits** split across `commit_cell_value` → `commit_regular_cell_value` / `commit_plain_records_edit` in `command/cmd/commit.rs`. Keep the synthetic-records branch in sync with the plain branch when changing behavior. 20. **Minibuffer commits go through one command.** `commit-buffer ` (`CommitBuffer { kind: CommitKind }`) owns buffer→trim→bail-on-empty→dirty→status for all three; the varying parts are methods on `CommitKind`. The add effect is emitted **last** so its failure message wins over the command's optimistic status (improvise-6t3). `CommitKind::Item` carries its target category, resolved from `AppMode::ItemAdd` by `parse_commit_kind` at registry time — so `commit-buffer item` is not constructible from a script line. @@ -205,7 +205,7 @@ Region=West, Measure=Revenue = |pending| # pipe-quoted text 34. **A formula target is unquoted, and pipe quoting has one home.** `parse_formula` strips the pipes off a target exactly as it does off an `Expr::Ref` — the target names a `_Measure` item, so a target that kept its delimiters made `add_formula` create an item literally named `|Total Revenue|`: a ghost column the user's real item never matched, and `Expr::Ref("Total Revenue")` never resolved to. Both the TUI formula minibuffer (`AddFormula`) and `.improv` load go through that one call, so both were affected. The writer emits `Formula::raw`, not `target`, so file text is unaffected either way — which is exactly why the round-trip tests never saw it, and why the fix repairs existing files on next load with no migration. Pipe quoting has exactly **one home**: `improvise_formula::quote`, owning both halves — `quote_name`/`is_bare_name`, `pipe_quote` (unconditional, for a `.improv` text cell that must not read back as a number) and `unescape_pipe_body`. `improvise-gp` re-exports it, `improvise-io` calls it from `persistence/{format,parse}.rs`, `parser.rs::unquote_pipe` is a shim over it, and io's own copy is **deleted** (improvise-i2m5). **The keyword guard is canonical — user-confirmed, so do not revert it as over-caution**: an item literally named `SUM` is written `|SUM|`. Bare keywords do parse, but only because `agg_call` needs a `(` and PEG backtracking falls through to `ref_expr` — rule ordering, not a stated rule — and a `## Formulas` line carries formula text verbatim, so a name `format_md` writes is read back by `parse_formula`: one namespace, one spelling rule. One thing the guard does not cover, and one that used to be an exception and no longer is. **A section header takes its name as the whole line, totally** (improvise-pczv): `header_name = { pipe_quoted ~ &NEWLINE | line_name }` — the pipe-quoted arm needs the lookahead, or `|A|B` would parse as `A` and silently drop `B`, and `line_name` catches everything else, so a header name can no longer fail to parse. Four rules share it (`model_name`, `initial_view`, `category_section`, `view_section`), which is why it is a rule and not four copies. Note a header is still *written* raw where a reference is quoted — the grammar accepts both arms, so the quoting asymmetry between a header and a reference is a writer convention, not a format one. And the two former *unquoting* copies differed only on a trailing lone backslash, unreachable from the grammar, where io returned it and `parser.rs` panicked — the surviving `unescape_pipe_body` is total. -35. **`Dirty` over-reports on purpose, and the asymmetry is the reason.** `Dirty::of` maps an `EffectKind` to the widgets that kind can reach, and the two directions do not cost the same: over-reporting pays one redundant `Projection`, under-reporting leaves a client's cache wrong **forever**, with nothing in the stream to correct it. `LayoutGeometry` therefore includes `formulas`, because `RemoveCategory` wears it and `Model::remove_category` ends by dropping every formula targeting the category — a client's formula panel otherwise kept showing a formula the document no longer had. It now coincides with `Dirty::EVERYTHING` and is still spelled out, because the two are derived from different effects and need not stay equal. The derivation now lives in `context/code/command.md` (the doc comment on `Dirty::of` keeps only the one-line reason `LayoutGeometry` is spelled out rather than written as `EVERYTHING`). It went stale once already — claiming `AddCategory` was `CellData` after it was reclassified — so reclassifying an effect means re-reading the prose, not just the comment. +35. **`Dirty` over-reports on purpose, and the asymmetry is the reason.** `Dirty::of` maps an `EffectKind` to the widgets that kind can reach, and the two directions do not cost the same: over-reporting pays one redundant `Projection`, under-reporting leaves a client's cache wrong **forever**, with nothing in the stream to correct it. `LayoutGeometry` therefore includes `formulas`, because `RemoveCategory` wears it and `Model::remove_category` ends by dropping every formula targeting the category — a client's formula panel otherwise kept showing a formula the document no longer had. It now coincides with `Dirty::EVERYTHING` and is still spelled out, because the two are derived from different effects and need not stay equal. The derivation now lives in `context/code/command.md`, and the doc comment on `Dirty::of` points there rather than restating it; the reason `LayoutGeometry` is spelled out rather than written as `EVERYTHING` is a two-line comment inside the match body. It went stale once already — claiming `AddCategory` was `CellData` after it was reclassified — so reclassifying an effect means re-reading the prose, not just the comment. 36. **A client's command has one door, and the door takes the `App`.** `Sessions::apply(app, id, cmd)` is the only way a connected client's `Command` reaches the server; `ClientSession::fold` and the mirror's `ViewState` are private, so a transport cannot reach the mirror at all. The door takes the authoritative `App` because some commands have a **document half**: `SwitchView` routes through `apply_command`/`App::switch_to_view` (gotcha 29), and **every** mirror then takes what the document *accepted*, not what was asked — so a switch to a view that does not exist leaves the mirrors put. Not just the asker's: `grid_of` builds every client's grid from `workbook.active_view()`, so a bystander left naming the outgoing view has its cursor read under a stale key and is projected the new view's rows positioned by the old view's scroll — gotcha 24's mismatch arriving through somebody else's keystroke. `apply_switch` therefore takes **no `SessionId`**; the switch is addressed to no one session and that absence is the statement. A client's own slice lives on the client and stays in step only by folding the same command, so `Sessions::apply` returns `Applied { dirty, broadcast }`, where `broadcast` is a **`Vec`** and each item names its own audience (improvise-gvnb). **The audience follows from whether the asker folded.** `Broadcast::ToOthers` is a *correction* of something the asker already applied optimistically; `Broadcast::ToAll` is a *consequence* the asker is owed like everybody else. A `Command::Model` is the one kind a client **cannot** fold optimistically — it cannot predict what the document will accept — so **every** relay originating in `apply_model` is `ToAll`: the `ResetCursor` from `adopt_cursor_epoch`, and the `SwitchView` that `ViewBack`/`ViewForward` move. `apply_switch` is the exception, and only because its asker really did fold. **Copying `ToOthers` from `apply_switch` into `apply_model` is the trap, and it is invisible from the bystander's side** — `adopt_active_view` moves the asker's *mirror* either way, so the drift is between the server's picture of the asker and the asker's own slice, and a test asserting through a "what do the others hear" helper looks away from it by construction. (`apply_switch` has the mirror-image hole on its *refused* path, where the asker folded something the document turned down and is the one client not told: improvise-6c6q.) A `Vec` rather than an `Option` because one command can owe two relays with different audiences, and an `Option` would have had to drop one silently. Everything else is that client's alone and never touches the `App`'s own view slice: **the model is shared, the UI is not.** Folding `SwitchView` into the mirror alone was the bug — `viewport()` then read the cursor under the new view's key and the next `project()` built that client a grid out of the *other* view. From b30a0b6e70b78d9e9e43701318bf6a90e966eda7 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Sun, 20 Sep 2026 11:11:22 -0700 Subject: [PATCH 05/17] chore(bd): auto-export Co-Authored-By: Claude Opus 5 (1M context) --- .beads/issues.jsonl | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 236b0eb8..c3103867 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -37,6 +37,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-jmjz","title":"grid_of evaluates the whole grid three times per frame; compute_col_widths is 42-64% 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.","status":"open","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-20T18:06:57Z","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-lltt","title":"[bug] A view-scoped ModelCommand names no view, so it lands on the host's","description":"Opened by improvise-jytm, which made `ViewState::active_view` authoritative per\nclient. The view-configuration half of `ModelCommand` — SetAxis, CycleAxis,\nTransposeAxes, SetPageSelection, HideItem, ShowItem, ToggleGroup, TogglePruneEmpty,\nSetNumberFormat — acts on \"the active view\", which is now an ambiguous phrase.\n\n`Sessions::apply_model` routes with no SessionId (gotcha 38, correctly: the change\nis addressed to no one session, because a `View` is document state shared by every\nclient sitting on it). But it must still name WHICH view, and today it names it\nimplicitly: `apply_command` runs the effect against the `App`, and the effect reads\n`App::view()`, which is the HOST's slice. So a connected client on another view\nmoves the host's view rather than its own.\n\nBefore jytm every client was on the same view, so the implicit answer was right.\nIt is now wrong exactly when two clients are on different views, which is the\ncapability jytm exists to add.\n\nThe honest fix is for those variants to name their view — a protocol change — so\nthe command carries the operand rather than borrowing an ambient one. Rejected\nduring jytm: retargeting `app.view_state.active_view` around `apply_command` at the\ndoor. It makes the host's own UI slice a parameter channel, and `reset_cursor`/\n`cursor_epoch` would then write the asker's key into the host's cursor map.\n\nNote the `_Drill`/`_Records` virtual views make this sharper than it looks: a host\nin records mode is on a view no client can name.\n\n## Steps to Reproduce\n\n1. Two clients, A and B, on one document with views \"Default\" and \"Second\".\n2. B sends `Command::SwitchView(\"Second\")`. Its mirror moves; the host's App stays\n on \"Default\" (correct, per improvise-jytm).\n3. B sends `Command::Model { cmd: ModelCommand::SetAxis { category: \"Region\",\n axis: Row } }`.\n4. The axis moves on \"Default\" — the view B is not on — and B's next projection\n shows nothing changed while A's shows a change A did not ask for.\n\nNot reachable from the TUI, which is the only client that drives the App's own\nslice, and not reachable from `web/app.js` today because it sends no SwitchView.\n","acceptance_criteria":"The view-configuration ModelCommand variants name the view they act on, so a connected client's SetAxis moves the view that client is on and not the host's. A test drives two sessions onto different views and asserts an axis change by one is invisible to the other. Deciding NOT to fix is also an acceptable close, but then gotcha 38 must say so.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:05:49Z","created_by":"wave2-e-active-view","updated_at":"2026-09-20T18:05:49Z","labels":["area:active-view"],"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":"CORRECTION, same session: this bead as first written read as though nobody had\ndecided the Chromium-only question. Somebody had.\n`invariant(opfs_is_run_but_bindings_is_not)` in prolog-sessions/kb.pl records\nit, and gives TWO reasons, not one:\n\n \"harness/tests/shell.spec.js runs web/app.js under CHROMIUM ONLY --\n engine-independent logic, and routeWebSocket is slower across three.\"\n\nThe description above only answers the second. The first — the logic is\nengine-independent — is a general claim covering all six specs, and against\nthe specs as a test of web/app.js it is correct: app.js is plain DOM and\nplain event wiring, and running the same assertions on a second engine buys\nvery little.\n\nWHAT IT DOES NOT COVER is the claim improvise-bck criterion (1) actually\nmakes. That criterion is not \"app.js's logic works in Firefox\", it is \"the\nstandalone deployment works in Firefox\" — and the standalone deployment is\nmodule workers, `wasm-pack` output, `createSyncAccessHandle` through\nbindings.rs and the seed fetch, none of which is app.js's logic and all of\nwhich is engine-specific. opfs.spec.js covers Firefox's OPFS through\n`OpfsFs` directly and, per the same invariant, NEVER INSTANTIATES\nbindings.rs. So the two standalone specs on Firefox would be the first\nexecution of that shim on a second engine, and near enough the first on any.\n\nSo the disagreement with the invariant is narrow and should be argued on\nthose terms: the engine-independence reason is a statement about app.js, and\nthe two standalone specs are not only a test of app.js. If that argument is\nrejected, the right outcome is to REWRITE improvise-bck criterion (1) to say\nChromium, not to leave a criterion standing that names an engine nothing\nruns on — a criterion nobody checks is the failure mode this whole pass\nexists to catch.\n\nEither way the invariant's wording is owed an update, since it currently\nreads as a blanket justification for a split that has two different reasons\nbehind its two halves.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:02:30Z","created_by":"wave2-p-milestones","updated_at":"2026-09-20T18:04:23Z","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":"Three PRs merged within 20 seconds on 2026-09-20 (#37, #41, #42). Each CI run built the site and uploaded a github-pages artifact, and each queued a deploy behind 'concurrency: group: pages, cancel-in-progress: false'.\n\nOnly the newest run kept its artifact:\n\n run 35526758561 (04e4e3d, #37) conclusion=failure artifacts=0\n run 35526767814 (5f871f7, #41) conclusion=success artifacts=1\n run 35526774007 (52507a6, #42) conclusion=failure artifacts=0\n\nThe two superseded deploys failed with:\n\n Fetching artifact metadata for \"github-pages\" in this workflow run\n Found 0 artifact(s)\n Error: No artifacts named \"github-pages\" were found for this workflow run.\n\nThe upload step was GREEN in all three runs, so this is not a build failure —\nthe artifact is gone by the time the losing deploy runs. The successful deploy\n(17:47:28Z) preceded the failing one (17:47:44Z) by 16 seconds.\n\nDeploying a superseded commit is pointless anyway: the tip of main is what\nshould be published. Cancelling is deliberately NOT the answer — the existing\ncomment on the concurrency block records that a deploy-pages cancelled midway\ncan leave the Pages deployment half-applied.\n\n## Steps to Reproduce\n\n1. Merge three PRs to main within about a minute.\n2. Watch the CI runs: the newest deploy succeeds, the older ones fail on an\n empty artifact fetch and mark otherwise-green runs as failed.\n\nReported by the user from the Actions log.","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-20T18:03:04Z","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -165,6 +166,7 @@ {"_type":"issue","id":"improvise-pby","title":"2.2 Write the README","description":"Replace existing README.md with new one under 250 lines. Sections in order: Title, one-sentence pitch, inline demo GIF, why this exists (Lotus Improv reference), quick start, key bindings, installation (Nix/crates.io/prebuilt - no Homebrew), codebase overview, expectations disclaimer, license. No badges, TOC, or contributing guide.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:32Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-09T07:02:52Z","close_reason":"README written with all 10 required sections, committed. License updated to Apache-2.0 per user request.","dependency_count":0,"dependent_count":2,"comment_count":0} {"_type":"issue","id":"improvise-ihv","title":"2.1 Add Nix tooling for asciinema, VHS, and cargo-dist","description":"Modify flake.nix to add pkgs.asciinema and pkgs.vhs to dev shell nativeBuildInputs. Add cargo-dist if packaged in nixpkgs. Add runtime deps for VHS (ttyd, ffmpeg) if needed. Verify tools work via nix develop --command. Add nix run app or shell script for demo recording workflow.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:30Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-09T07:30:19Z","close_reason":"asciinema, vhs, cargo-dist added to flake.nix dev shell and verified. cargo-dist wrapper enables 'cargo dist' subcommand. scripts/record-demo.sh added.","dependency_count":0,"dependent_count":3,"comment_count":0} {"_type":"issue","id":"improvise-yk4","title":"Phase 2: README and demo artifacts","description":"The main launch work. README is 80% of the launch, demo artifacts are 20%. Covers Nix tooling for asciinema/VHS, writing the README, creating demo GIF and asciinema casts, and verifying all artifacts.","status":"closed","priority":2,"issue_type":"feature","assignee":"spot","owner":"el-github@elangley.org","created_at":"2026-04-09T04:05:49Z","created_by":"Edward Langley","updated_at":"2026-04-14T07:47:43Z","closed_at":"2026-04-14T07:47:43Z","close_reason":"All 5 subtasks closed; artifacts verified present and tracked in git (README.md, docs/demo.gif, docs/demo.tape, docs/casts/*.cast).","dependencies":[{"issue_id":"improvise-yk4","depends_on_id":"improvise-abz","type":"blocks","created_at":"2026-04-08T23:37:37Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-yk4","depends_on_id":"improvise-d4w","type":"blocks","created_at":"2026-04-08T23:37:36Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-yk4","depends_on_id":"improvise-ihv","type":"blocks","created_at":"2026-04-08T23:37:34Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-yk4","depends_on_id":"improvise-odx","type":"blocks","created_at":"2026-04-08T23:37:35Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-yk4","depends_on_id":"improvise-pby","type":"blocks","created_at":"2026-04-08T23:37:36Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":5,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-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":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:11:01Z","created_by":"wave2-b-projection","updated_at":"2026-09-20T18:11:01Z","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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:06:03Z","created_by":"orchestrator","updated_at":"2026-09-20T18:06:03Z","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.","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-20T18:06:00Z","labels":["area:active-view"],"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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:05:51Z","created_by":"orchestrator","updated_at":"2026-09-20T18:05:51Z","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -193,9 +195,9 @@ {"_type":"issue","id":"improvise-e24v","title":"Second tranche of client-side key resolution: ~15 bindings needing no enrichment","description":"improvise-bpq2 closed the cursor half and DELIBERATELY STOPPED rather than half-doing a second tranche. These need NOTHING added to the protocol -- the operands are already in ViewState or a widget cache -- so they are the cheapest remaining keys, and each one currently does NOTHING in a browser (Host::handle answers ClientFrame::Key with an empty vec).\n\n * enter-mode \u003cx\u003e (: ? F1 C F V Esc Tab in most modes) -\u003e ChangeMode\n * toggle-panel-visibility / toggle-panel-and-focus -\u003e SetPanelOpen + ChangeMode\n * cycle-panel-focus\n * help-page-next / help-page-prev\n * transpose, toggle-prune-empty, view-back\n * switch-view-at-cursor / delete-view-at-cursor / create-and-switch-view (ViewListCache is flat)\n * commit-buffer category|formula|item (all operands in ViewState)\n * commit-cell-edit (ADR 002 CellId + the buffer)\n * clear-cell / paste (CellId + ViewState::yanked)\n * set-axis-for-tile / cycle-axis-for-tile\n\nALSO IN REACH, and bpq2 listed them under \"needs enrichment\" before establishing they do not: filter-to-item (Enter), open-item-add-at-cursor (a/o), delete-category-at-cursor (d), cycle-axis-at-cursor (Space) -- CatTreeEntry already carries the name and the axis. Each is a distinct Command shape, which is why they were left.\n\nMETHOD IS SETTLED, follow it: mirror each Cmd LINE FOR LINE including where siblings disagree, and add a lockstep test that drives the client and the real reduce_key over the same keypresses and compares ViewState. Do not tidy sibling commands into one rule -- that makes the client a second derivation, which is what lockstep testing exists to rule out.\n\nAnd update the_normal_mode_keys_that_still_need_the_host_are_these as you go: it is a named list precisely so closing one has to come there and say so.","acceptance_criteria":"Each listed binding resolves client-side with a lockstep test driving the client and the real reduce_key over the same keypresses. Mirror each Cmd LINE FOR LINE including where siblings disagree — Move and MoveTileCursor emit even when they clamp, MovePanelCursor returns nothing when the cursor does not move — and do NOT tidy them into one rule, which is what makes the client a second derivation. the_command_names_that_still_need_the_host_are_these shrinks by exactly the bindings closed, and closing one has to come to that list and say so. Note improvise-fdt6: commit-buffer formula is on this list and is also why a browser cannot add a formula at all.","status":"closed","priority":3,"issue_type":"task","assignee":"wave2-h-frontend","owner":"el-github@elangley.org","created_at":"2026-09-20T02:36:43Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:05:09Z","started_at":"2026-09-20T17:53:38Z","closed_at":"2026-09-20T18:05:09Z","close_reason":"Closed","labels":["area:shared-frontend"],"dependencies":[{"issue_id":"improvise-e24v","depends_on_id":"improvise-fdt6","type":"relates-to","created_at":"2026-09-20T07:55:25Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-s17o","title":"reduce_full and apply_command's view-slice arm have no production caller","description":"Found while fixing improvise-ondj (2026-09-20), and it is the reason that defect could sit unnoticed.\n\nui/reduce.rs module doc says: \"the one place a command is applied to the authoritative state\" and \"Every deployment mode -- the native TUI, a websocket server, a browser worker -- reaches the model through the functions here.\"\n\nTHAT IS ASPIRATIONAL. reduce_full has NO PRODUCTION CALLER ANYWHERE IN THE WORKSPACE. Every host reaches the model through Sessions::apply or reduce_key. Nothing exercised reduce_full Command::Model arm, so nothing could notice it skipped the basis check -- a door advertised by documentation and walked by nobody is exactly the shape that hides a defect until the next host arrives.\n\nSECOND HALF, same shape: apply_command other =\u003e reduce_view(\u0026mut app.view_state, other) folds a wire VIEW command into the AUTHORITATIVE App own slice. Nothing in production does that -- Sessions::apply folds view commands into the CLIENT MIRROR instead. So view_slice_commands_land_in_the_authoritative_view_state is testing a path no host walks, and passing tells you nothing about any deployment.\n\nTHE DECISION: are these a host API worth keeping, or test-only scaffolding wearing a module doc? If the former, HAVE A HOST USE ONE -- improvise-9x6 (the browser worker) is the natural candidate and is being built now, so this is decidable immediately rather than in principle. If the latter, delete them and correct the module doc, which is currently making a promise the code does not keep.\n\nDo not just delete the test: decide what the function is for first.","acceptance_criteria":"reduce_full and apply_command's view-slice arm either have a production caller or are deleted with the module doc corrected. The module doc currently promises 'every deployment mode reaches the model through the functions here', which is false and is what let improvise-ondj's defect sit unnoticed. NOTE: the post-wave review found Sessions::apply has ALSO lost its production caller (Host::command now calls apply_checked), and reduce.rs:48 still advertises it as 'the door that does it for you' — same question, same file, decide them together.","status":"in_progress","priority":3,"issue_type":"task","assignee":"wave2-n-cleanups","owner":"el-github@elangley.org","created_at":"2026-09-20T01:54:30Z","created_by":"Edward Langley","updated_at":"2026-09-20T17:57:47Z","started_at":"2026-09-20T17:57:47Z","lease_expires_at":"2026-09-20T18:02:47Z","heartbeat_at":"2026-09-20T17:57:47Z","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-r4lo","title":"Two answers to what a typed string means, and they are adjacent","description":"Noticed during improvise-xks6 (2026-09-20) and left alone, because the envelopes were that ticket. Searched first: nothing open covers it.\n\nTWO CONTRADICTORY DOC COMMENTS, now one level below the envelopes they used to hide behind:\n\n * Client::set_cell_at says classifying a typed string \"is the HOST decision, not one to re-implement here\", and takes a SERIALIZED CellValue.\n * socket.rs::typed says the OPPOSITE -- \"Putting it in Rust rather than in the page JavaScript is what makes it testable\" -- and typed_cell takes a PLAIN STRING.\n\nBoth now return the same ClientFrame, so this is the same disease improvise-xks6 just cured, one level down: TWO ANSWERS TO ONE QUESTION, kept apart only by the fact that nothing called both.\n\nWHICH ONE IS RIGHT: typed_cell is the one web/app.js calls and the one repo-map gotcha 42 blesses -- the browser has no App to ask, and ModelCommand::SetCell takes a CellValue, so somebody on the client side must decide; putting it in Rust rather than JS is what makes it testable and keeps one spelling per language boundary. set_cell_at is UNREFERENCED by the page.\n\nSo the likely answer is to delete set_cell_at and fix the doc, but check first whether anything else (a test, the lockstep harness, a future worker bootstrap) wants the pre-classified form -- a host that HAS an App genuinely can classify, and improvise-9x6 puts an App in the browser, which may make set_cell_at the right entry point for the WORKER deployment and typed_cell right for the socket one. If so the fix is to say that in both doc comments rather than to delete either. Decide it, do not leave two claims standing.","status":"closed","priority":3,"issue_type":"task","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T01:52:37Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:22:09Z","started_at":"2026-09-20T05:32:35Z","closed_at":"2026-09-20T06:22:09Z","close_reason":"Closed by PR #21 (WU-I). DECIDED: DELETE, do not re-document.\n\nThe ticket offered an escape hatch -- that improvise-9x6's browser App might want the pre-classified form, in which case the fix would be to say so in both doc comments rather than delete either. THAT ESCAPE HATCH DOES NOT EXIST. ModelCommand::SetCell carries a CellValue, so nothing that crosses a frame is ever a string, and the worker's App sits BEHIND the frame boundary and is handed a value. So set_cell_at's claim -- that classifying a typed string is \"the HOST's decision\" -- is false on every deployment, not just on the socket one.\n\nBoth set_cell_at AND set_cell are gone. set_cell cannot express ClearCell, which makes it a STRICTLY WEAKER DUPLICATE of typed_cell_frame rather than a peer -- and its the_json_wrapper_composes_the_same_message was exactly the compatibility test doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished) says a finished consolidation deletes.\n\nTEST COUNT DROPPED 5 AND IS FULLY ACCOUNTED: 2 aimed at deleted code; 3 were None-shape tests already asserted against typed_cell_frame in socket.rs. The 4th None shape -- ADR 001's absent basis -- had no twin, so a_client_with_no_basis_composes_nothing was RETARGETED rather than dropped, and proved discriminating by seeding a Projection::Basis into its fixture and watching it flip to Some.\n\nThree doc comments elsewhere named the deleted fn (keys.rs, state.rs, socket.rs); all re-pointed, per the grep rule.\n\nWHAT REMAINS, filed as improvise-j7v6: socket.rs::typed still duplicates commit_regular_cell_value on the host key path. Both are legitimate -- the browser has no App to ask -- but the RULE is written twice, and a_typed_string_is_classified_as_the_host_classifies_it states the obligation in its name while asserting three outcomes by hand rather than comparing against the host's function, so the two can drift and stay green.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-i5tk","title":"Narrow the TUI's GridCache to its viewport -- benchmark first","description":"Disclosed as a caveat of PR #12 rather than discovered by it: it is in the PR body, in the aa7a6d3 commit message, and in repo-map gotcha 44. Copilot raised it independently on round 2.\n\nproject::grid_of evaluates EVERY CELL PER FRAME, where GridWidget used to evaluate only the visible ones.\n\nBE PRECISE ABOUT WHAT CHANGED: this is a WIDENING, not an introduction. compute_col_widths was already whole-grid and the totals loop was already O(rows x visible cols). And it is UNBENCHMARKED IN BOTH DIRECTIONS -- \"this can dominate TUI rendering\" is exactly as unevidenced as \"it is fine\" would be. Do not fix it on the strength of the reasoning.\n\nSO ACCEPTANCE CRITERION #1 IS A BENCHMARK. The fix and the evidence that it was needed are the same work.\n\nWHAT THE FIX COSTS, if the benchmark says do it: teaching GridWidget that a cache position is RELATIVE, which is the browser client arrangement (gotcha 44 -- the TUI builds at offsets zero deliberately, and that is what let the scrolling arithmetic stay untouched through improvise-n10). So this is not a narrowing of a loop, it is a change to what a cache index MEANS in the TUI, and every scroll site is in scope.","acceptance_criteria":"ACCEPTANCE CRITERION 1 IS A BENCHMARK, and the fix happens only if it says so — 'this can dominate TUI rendering' is exactly as unevidenced as 'it is fine'. If it does: GridWidget learns that a cache position is RELATIVE, which is the browser's arrangement, and every scroll site is in scope — this is a change to what a cache index MEANS in the TUI, not a narrowed loop. Note two things the wave added: project::grid_of now evaluates every cell per frame (a widening of an existing whole-grid cost, flagged in gotcha 44), and improvise-rucf.3 may force this anyway, because a live-backed source turns the viewport-bounded projection from an optimisation into a correctness requirement.","status":"in_progress","priority":3,"issue_type":"task","assignee":"wave2-n-cleanups","owner":"el-github@elangley.org","created_at":"2026-09-20T01:26:47Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:00:35Z","started_at":"2026-09-20T18:00:35Z","lease_expires_at":"2026-09-20T18:05:35Z","heartbeat_at":"2026-09-20T18:00:35Z","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-i5tk","title":"Narrow the TUI's GridCache to its viewport -- benchmark first","description":"Disclosed as a caveat of PR #12 rather than discovered by it: it is in the PR body, in the aa7a6d3 commit message, and in repo-map gotcha 44. Copilot raised it independently on round 2.\n\nproject::grid_of evaluates EVERY CELL PER FRAME, where GridWidget used to evaluate only the visible ones.\n\nBE PRECISE ABOUT WHAT CHANGED: this is a WIDENING, not an introduction. compute_col_widths was already whole-grid and the totals loop was already O(rows x visible cols). And it is UNBENCHMARKED IN BOTH DIRECTIONS -- \"this can dominate TUI rendering\" is exactly as unevidenced as \"it is fine\" would be. Do not fix it on the strength of the reasoning.\n\nSO ACCEPTANCE CRITERION #1 IS A BENCHMARK. The fix and the evidence that it was needed are the same work.\n\nWHAT THE FIX COSTS, if the benchmark says do it: teaching GridWidget that a cache position is RELATIVE, which is the browser client arrangement (gotcha 44 -- the TUI builds at offsets zero deliberately, and that is what let the scrolling arithmetic stay untouched through improvise-n10). So this is not a narrowing of a loop, it is a change to what a cache index MEANS in the TUI, and every scroll site is in scope.","acceptance_criteria":"ACCEPTANCE CRITERION 1 IS A BENCHMARK, and the fix happens only if it says so — 'this can dominate TUI rendering' is exactly as unevidenced as 'it is fine'. If it does: GridWidget learns that a cache position is RELATIVE, which is the browser's arrangement, and every scroll site is in scope — this is a change to what a cache index MEANS in the TUI, not a narrowed loop. Note two things the wave added: project::grid_of now evaluates every cell per frame (a widening of an existing whole-grid cost, flagged in gotcha 44), and improvise-rucf.3 may force this anyway, because a live-backed source turns the viewport-bounded projection from an optimisation into a correctness requirement.","status":"closed","priority":3,"issue_type":"task","assignee":"wave2-n-cleanups","owner":"el-github@elangley.org","created_at":"2026-09-20T01:26:47Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:08:05Z","started_at":"2026-09-20T18:00:35Z","closed_at":"2026-09-20T18:08:05Z","close_reason":"Closed","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-6c6q","title":"[bug] apply_switch relays a refused switch to everyone but the asker","description":"Found by the code review of ADR 001 layer 1 (2026-09-19). Pre-existing -- the pre-wave broadcast: Option\u003cCommand\u003e was explicitly \"every OTHER client\" -- and the doc overclaims.\n\napply_switch doc says: \"a client that optimistically folded a name the document turned down is corrected by the same message that tells the others to follow.\" But it relays Broadcast::ToOthers, so THE ASKER IS NOT SENT THE CORRECTION. If the workbook refuses the switch (the view does not exist), the asker slice keeps the asked-for name while its mirror holds the accepted one -- gotcha 24 mismatch, and gotcha 29 says the two copies must move together.\n\nUNLIKE the apply_model case the review fixed, a blanket ToAll is WRONG here: the asker really did fold optimistically on the accepted path, so ToAll would double-apply on the common path.\n\nTHE HONEST FIX IS CONDITIONAL: ToAll when accepted != asked, ToOthers otherwise. Which is to say the audience is a function of whether the asker \"folded the right thing\", not of the command variant -- a slightly sharper version of doctrine(the_audience_follows_the_optimistic_fold).\n\nCheap. The reason it is P3 and not higher is that switching to a view that does not exist is not a thing a well-behaved client does; it is reachable through the : command line.\n\n## Steps to Reproduce\n\n1. Two clients on one document.\n2. Client A asks to switch to a view that does NOT exist, reachable through the `:` command line.\n3. The workbook refuses; `apply_switch` relays `Broadcast::ToOthers`, so every OTHER client is told the accepted name and **the asker is not**.\n4. A's own slice keeps the asked-for name while its server-side mirror holds the accepted one — gotcha 24's mismatch, and gotcha 29 says the two copies must move together.\n\n`apply_switch`'s own doc claims \"a client that optimistically folded a name the document turned down is corrected by the same message that tells the others to follow\". It is not.\n","acceptance_criteria":"apply_switch's audience is conditional: ToAll when the accepted view differs from the asked-for one, ToOthers otherwise. A blanket ToAll is WRONG here — the asker really did fold optimistically on the accepted path, so it would double-apply on the common path, which is what distinguishes this from the apply_model case. A test drives a switch to a view that does not exist and asserts the ASKER is corrected; note that arm is reachable only through the : command line.","notes":"ALSO FIX THE DOC COMMENT, and treat it as the more dangerous half. apply_switch's doc (project.rs:~714, verbatim on main) actively claims 'a client that optimistically folded a name the document turned down is corrected by the same message that tells the others to follow.' It is not -- the relay is ToOthers. A comment that says the hole is closed is worse than the hole, because the next reader stops looking.\n\nCopilot surfaced this independently on PR #12 and its suggested shape is right and cheap: apply_switch ALREADY RECEIVES the asked-for name -- it takes the whole cmd, which IS Command::SwitchView(asked) -- it simply never destructures it. So the conditional audience (ToAll when accepted != asked, ToOthers otherwise) needs no new parameter.\n\nTHE REAL COST OF THIS TICKET IS THE REGRESSION TEST, NOT THE CHANGE. It must drive THE ASKER'S OWN SLICE, not the to_others helper -- that helper looks away from the asker by construction, and that is exactly how the sibling bug in apply_model (fixed in 9cb16ce) shipped green.","status":"in_progress","priority":3,"issue_type":"bug","assignee":"wave2-e-active-view","owner":"el-github@elangley.org","created_at":"2026-09-20T01:05:15Z","created_by":"Edward Langley","updated_at":"2026-09-20T17:53:51Z","started_at":"2026-09-20T17:53:51Z","lease_expires_at":"2026-09-20T17:58:51Z","heartbeat_at":"2026-09-20T17:53:51Z","labels":["area:active-view"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-z754","title":"Document name, dirty flag and file path have no home a client can read","description":"draw_title reads workbook.model.name, model_state.dirty and model_state.file_path. NONE IS PROJECTED. improvise-wasm-client RenderModel carries mode, status and view_name but not these, so A BROWSER TITLE BAR CANNOT SHOW THE DOCUMENT NAME OR THE DIRTY MARKER.\n\nFound closing improvise-n10, alongside the grid-frame gaps.\n\nWORTH DECIDING RATHER THAN JUST ADDING: dirty is a fact about the document that every client shares, and name/path likewise -- so these want a projection or a ViewState field, not a per-widget cache. But note file_path is also the thing Save uses and a browser has no filesystem path in the same sense (gotcha 2, gotcha 28), so \"what a remote client should be told about where the document lives\" is a real question and not obviously \"the path\".","acceptance_criteria":"A client can render a title bar: document name, dirty marker and whatever the answer to 'where does this document live' turns out to be. These are facts about the DOCUMENT that every client shares, so they want a projection or a ViewState field rather than a per-widget cache — say which and why. Note file_path is the thing Save uses and a browser has no filesystem path in the same sense (gotchas 2, 28, and now improvise-368h's ClientFrame::Save which deliberately carries none), so 'what a remote client should be told about where the document lives' is a real question and the answer is probably not the path.","status":"in_progress","priority":3,"issue_type":"task","assignee":"wave2-b-projection","owner":"el-github@elangley.org","created_at":"2026-09-20T00:58:35Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:05:59Z","started_at":"2026-09-20T18:05:59Z","lease_expires_at":"2026-09-20T18:10:59Z","heartbeat_at":"2026-09-20T18:05:59Z","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-z754","title":"Document name, dirty flag and file path have no home a client can read","description":"draw_title reads workbook.model.name, model_state.dirty and model_state.file_path. NONE IS PROJECTED. improvise-wasm-client RenderModel carries mode, status and view_name but not these, so A BROWSER TITLE BAR CANNOT SHOW THE DOCUMENT NAME OR THE DIRTY MARKER.\n\nFound closing improvise-n10, alongside the grid-frame gaps.\n\nWORTH DECIDING RATHER THAN JUST ADDING: dirty is a fact about the document that every client shares, and name/path likewise -- so these want a projection or a ViewState field, not a per-widget cache. But note file_path is also the thing Save uses and a browser has no filesystem path in the same sense (gotcha 2, gotcha 28), so \"what a remote client should be told about where the document lives\" is a real question and not obviously \"the path\".","acceptance_criteria":"A client can render a title bar: document name, dirty marker and whatever the answer to 'where does this document live' turns out to be. These are facts about the DOCUMENT that every client shares, so they want a projection or a ViewState field rather than a per-widget cache — say which and why. Note file_path is the thing Save uses and a browser has no filesystem path in the same sense (gotchas 2, 28, and now improvise-368h's ClientFrame::Save which deliberately carries none), so 'what a remote client should be told about where the document lives' is a real question and the answer is probably not the path.","status":"closed","priority":3,"issue_type":"task","assignee":"wave2-b-projection","owner":"el-github@elangley.org","created_at":"2026-09-20T00:58:35Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:11:14Z","started_at":"2026-09-20T18:05:59Z","closed_at":"2026-09-20T18:11:14Z","close_reason":"Closed","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-1125","title":"Row-header width is computed per frame in the widget","description":"src/ui/grid.rs::render_grid computes sub_widths / row_header_width by walking layout.row_items per level. GridCache carries col_widths but NO row-header width.\n\nIts sibling compute_col_widths already moved into improvise-command::ui::viewport; this one did not. So a browser client sizing its row header will invent a second rule -- the exact shape of improvise-edp (the category panel walked twice) and of the multi-level-labels divergence.\n\nSmall, and it pairs naturally with the multi-level labels ticket, because both are facts about the grid FRAME rather than its contents, and the frame is what improvise-n10 deliberately left behind.","status":"closed","priority":3,"issue_type":"task","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T00:58:34Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:41:34Z","started_at":"2026-09-20T05:32:27Z","closed_at":"2026-09-20T07:41:34Z","close_reason":"Closed by PR #23 (WU-A), and the ticket's premise was understated: the row-header width rule existed TWICE ALREADY -- inline in src/ui/grid.rs::render_grid and in viewport::compute_row_header_width -- rather than once-and-unshared.\n\nIt is now improvise_protocol::header_level_widths, with both callers reading it.\n\nTHE TICKET SAID TO PUT IT BESIDE compute_col_widths IN improvise-command::ui::viewport. THAT HOME IS UNREACHABLE and taking it would have guaranteed the second derivation this ticket exists to prevent: improvise-wasm-client cannot depend on improvise-command, so a browser sizing its own row header would have had to invent a third copy. improvise-protocol is the only shared home.\n\nIT TAKES AN ITERATOR OF BORROWED TUPLES rather than an AxisFrame, and that is load-bearing: its other caller is compute_row_header_width, reached from App::cmd_context ON EVERY KEYPRESS. Building a frame there would clone every row tuple per key. An independent reviewer read that path line by line and confirmed no allocation on the keypress path.\n\nimprovise-protocol gained unicode-width to do it, judged acceptable: forbidden(improvise_protocol, ui) is about UI FRAMEWORK types (the worked examples are AxisColor -\u003e ratatui::Color and MinibufColor), text measurement is not one, and GridCache::col_widths was already a Vec\u003cu16\u003e of display cells on the wire -- the unit was already there. Noted honestly: header_widths() has exactly one production consumer today, the TUI widget, so the \"a browser would have been a third copy\" justification is anticipatory rather than actual.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_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":"in_progress","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-20T17:53:39Z","started_at":"2026-09-20T17:53:39Z","lease_expires_at":"2026-09-20T17:58:39Z","heartbeat_at":"2026-09-20T17:53:39Z","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} @@ -246,7 +248,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":"GROOMED 2026-09-19: 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","owner":"el-github@elangley.org","created_at":"2026-06-10T03:59:05Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:56:08Z","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"],"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} @@ -254,7 +256,7 @@ {"_type":"issue","id":"improvise-jjx","title":"Scrolled grid suppresses first visible row-header label","description":"src/ui/grid.rs:338-350: repeat-suppression shows a row-header sub-label only when ri == 0 or the prefix differs from data_row_items[ri-1]. ri is the absolute data-row index (starts at row_offset, grid.rs:269), so when the view is scrolled the first visible row compares against the off-screen row above and its label is blanked — the user loses the group context at the top of the viewport. Fix: compare against the previous *visible* row, i.e. always show labels for the first rendered data row. 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:58:38Z","created_by":"Edward Langley","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-byz","title":"records_display returns Some(\"\") for out-of-bounds columns instead of None","description":"crates/improvise-core/src/view/layout.rs:186-202: records_display() calls col_label(col), which returns an empty string for out-of-bounds indices; the empty string then fails the == \"Value\" check, is looked up as a category name, and the function returns Some(\"\") via unwrap_or_default(). cell_key() (~line 396) explicitly returns None for an empty col_label — records_display should be symmetric so view/data mismatches are not masked. 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:58:35Z","created_by":"Edward Langley","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-zgb","title":".improv formula loader mis-splits category suffix via rfind(\" [\")","description":"crates/improvise-io/src/persistence/mod.rs:403-411: the formulas_section walker finds the [Category] suffix with raw.rfind(\" [\") + ends_with(']'). Two failure modes: (1) a pipe-quoted identifier at the end of the formula containing ' [' (e.g. |Revenue [USD]|) is mis-split, treating part of the quoted name as the category; (2) a suffix written without a leading space (X=Y[Cat]) is not recognized and the whole line is parsed as a _Measure formula, losing the target category. Fix: move the category suffix into the pest grammar instead of string scanning. Write failing tests first per AGENTS.md. 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:58:32Z","created_by":"Edward Langley","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-drg","title":"vb4 step 5: narrow Effect::apply signatures where one-slice","description":"Optional follow-up to vb4 structural split. For each impl Effect for Foo, audit which slice(s) it mutates. If apply only touches ModelState (or only ViewState), narrow its signature from fn apply(\u0026self, app: \u0026mut App) to fn apply(\u0026self, model: \u0026mut ModelState) (or ViewState). Cross-cutting effects (read layout, change mode that triggers layout rebuild, etc.) stay on \u0026mut App. Improves type-level documentation and enables finer-grained effect tests. Lower priority — vb4's acceptance criterion (4) explicitly allows leaving cross-cutting on \u0026mut App.","design":"Hidden trait-design decision surfaced during vb4 step 4 review: the issue's\nphrasing — \"narrow its signature from fn apply(\u0026self, app: \u0026mut App) to\nfn apply(\u0026self, model: \u0026mut ModelState)\" — is not directly possible. You\ncannot change one Effect impl's signature without changing the Effect\ntrait. Three approaches, none picked:\n\n(A) Split into multiple traits — ModelEffect / ViewEffect / AppEffect.\n Each impl becomes the narrower thing it actually is. Cost: ~50\n existing impls reorganize across three traits, and Vec\u003cBox\u003cdyn Effect\u003e\u003e\n has to become an enum wrapper (or three parallel queues) to keep\n apply_effects working. Highest fidelity to the issue's literal wording.\n\n(B) Keep one trait, rebind inside the impl body — e.g.\n fn apply(\u0026self, app: \u0026mut App) { let view = \u0026mut app.view_state; view.mode = ... }\n so the slice is visible at the call site without changing the\n signature. Lowest disruption. Doesn't strictly satisfy acceptance\n criterion (1) (a \"narrower borrow\") — only the body is narrower.\n\n(C) Default-method dispatch on a single trait — Effect grows\n apply_view(\u0026mut ViewState) and apply_model(\u0026mut ModelState) with\n default impls that fall through to apply(\u0026mut App). Each impl\n overrides only the method that matches its slice. Middle ground:\n type-level narrowing for impls that opt in, \u0026mut App stays available\n for cross-cutting effects. Wraps cleanly around the existing\n apply_effects loop.\n\nPicking (A) commits to the architecture for downstream effect tagging\n(improvise-mae) and the protocol extraction (improvise-cqi). (B) and (C)\nleave that decision unmade. Recommend deciding before claiming.","acceptance_criteria":"Each Effect whose apply touches only one slice takes \u0026mut ModelState or \u0026mut ViewState; cross-cutting effects stay on \u0026mut App, which vb4's own acceptance explicitly allows. The win is type-level documentation and finer-grained effect tests, not line count. CAUTION, new since this ticket was filed: App::apply_effects now reads dirtiness, layout_version and the record fingerprints off the EffectKind (improvise-gv9r, gotcha 40) — narrowing a signature must not put an effect somewhere that loop cannot see.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-26T18:16:52Z","created_by":"jeeves","updated_at":"2026-09-20T07:56:09Z","labels":["area:command","human"],"dependencies":[{"issue_id":"improvise-drg","depends_on_id":"improvise-99k","type":"blocks","created_at":"2026-04-26T11:17:16Z","created_by":"jeeves","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-drg","title":"vb4 step 5: narrow Effect::apply signatures where one-slice","description":"Optional follow-up to vb4 structural split. For each impl Effect for Foo, audit which slice(s) it mutates. If apply only touches ModelState (or only ViewState), narrow its signature from fn apply(\u0026self, app: \u0026mut App) to fn apply(\u0026self, model: \u0026mut ModelState) (or ViewState). Cross-cutting effects (read layout, change mode that triggers layout rebuild, etc.) stay on \u0026mut App. Improves type-level documentation and enables finer-grained effect tests. Lower priority — vb4's acceptance criterion (4) explicitly allows leaving cross-cutting on \u0026mut App.","design":"Hidden trait-design decision surfaced during vb4 step 4 review: the issue's\nphrasing — \"narrow its signature from fn apply(\u0026self, app: \u0026mut App) to\nfn apply(\u0026self, model: \u0026mut ModelState)\" — is not directly possible. You\ncannot change one Effect impl's signature without changing the Effect\ntrait. Three approaches, none picked:\n\n(A) Split into multiple traits — ModelEffect / ViewEffect / AppEffect.\n Each impl becomes the narrower thing it actually is. Cost: ~50\n existing impls reorganize across three traits, and Vec\u003cBox\u003cdyn Effect\u003e\u003e\n has to become an enum wrapper (or three parallel queues) to keep\n apply_effects working. Highest fidelity to the issue's literal wording.\n\n(B) Keep one trait, rebind inside the impl body — e.g.\n fn apply(\u0026self, app: \u0026mut App) { let view = \u0026mut app.view_state; view.mode = ... }\n so the slice is visible at the call site without changing the\n signature. Lowest disruption. Doesn't strictly satisfy acceptance\n criterion (1) (a \"narrower borrow\") — only the body is narrower.\n\n(C) Default-method dispatch on a single trait — Effect grows\n apply_view(\u0026mut ViewState) and apply_model(\u0026mut ModelState) with\n default impls that fall through to apply(\u0026mut App). Each impl\n overrides only the method that matches its slice. Middle ground:\n type-level narrowing for impls that opt in, \u0026mut App stays available\n for cross-cutting effects. Wraps cleanly around the existing\n apply_effects loop.\n\nPicking (A) commits to the architecture for downstream effect tagging\n(improvise-mae) and the protocol extraction (improvise-cqi). (B) and (C)\nleave that decision unmade. Recommend deciding before claiming.","acceptance_criteria":"Each Effect whose apply touches only one slice takes \u0026mut ModelState or \u0026mut ViewState; cross-cutting effects stay on \u0026mut App, which vb4's own acceptance explicitly allows. The win is type-level documentation and finer-grained effect tests, not line count. CAUTION, new since this ticket was filed: App::apply_effects now reads dirtiness, layout_version and the record fingerprints off the EffectKind (improvise-gv9r, gotcha 40) — narrowing a signature must not put an effect somewhere that loop cannot see.","notes":"DECLINED 2026-09-20 (wave 2), with counts. Reasoning in context/code/command.md,\n'Effect::apply stays on \u0026mut App'. NOTE: this bead carries the `human` label, so\nthe decline is a recommendation the user may overturn; it is recorded rather than\nsmuggled.\n\nCOUNTED over the 49 `impl Effect` blocks in ui/effect.rs:\n now (this branch) 8 view_state-only, 9 model_state-only, 32 cross-cutting\n base 52507a6 9 view_state-only, 16 model_state-only, 24 cross-cutting\nThe narrow set fell from 25 to 17 in one wave. The mover was improvise-9mo9,\nwhich made nine reshaping effects call app.reset_cursor(): a reshape lives in\nmodel_state.workbook and the cursor it invalidates lives in ViewState, so the\neffects that matter most are precisely the ones that reconcile the two slices.\nNarrowing bets against the direction the code moves in.\n\nFOUR GROUNDS\n1. The narrow set is small and shrinking, for that architectural reason.\n2. The type-level documentation already exists and is CHECKED: Effect::kind()\n has no default (gotcha 3) and apply_effects reads that one answer three times\n (gotchas 40, 45). A narrowed signature is a second statement of the same fact,\n free to disagree -- the defect, not the cure.\n3. Every offered mechanism weakens a guard. (A) three traits needs three queues\n (destroys batch order, which gotchas 21/23/43 lean on) or an enum wrapper (a\n fourth classification beside EffectKind). (C) default methods give `apply` a\n DEFAULT, exactly what kind() does without and for the same reason: overriding\n apply_view on an effect that needs the model compiles and writes the wrong\n slice silently; overriding neither compiles as a no-op. (B) does not meet the\n ticket's own acceptance criterion.\n4. \"Finer-grained effect tests\" is not blocked: effect.rs's 64 tests already call\n Effect.apply(\u0026mut app) against a one-line App::new(wb, None) fixture.\n\nWHAT IS NOT DECLINED: approach (B), rebinding the slice at the top of an apply\nbody, remains available as a per-function style choice. It is not mandated.","status":"in_progress","priority":3,"issue_type":"task","assignee":"wave2-n-cleanups","owner":"el-github@elangley.org","created_at":"2026-04-26T18:16:52Z","created_by":"jeeves","updated_at":"2026-09-20T18:11:23Z","started_at":"2026-09-20T18:08:16Z","lease_expires_at":"2026-09-20T18:13:16Z","heartbeat_at":"2026-09-20T18:08:16Z","labels":["area:command","human"],"dependencies":[{"issue_id":"improvise-drg","depends_on_id":"improvise-99k","type":"blocks","created_at":"2026-04-26T11:17:16Z","created_by":"jeeves","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-1ud","title":"Document RecordsEditing variant in repo-map.md","description":"context/repo-map.md currently lists 15 AppMode variants but does not include RecordsEditing, which is referenced at src/draw.rs:142 and src/ui/app.rs:442. Add the variant to the AppMode list in the 'Key Types' section so coding agents see it.\\n\\nTrivial doc fix.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:54:16Z","created_by":"Ed L","updated_at":"2026-06-10T04:05:13Z","closed_at":"2026-06-10T04:05:13Z","close_reason":"Done in commit 0fe04de (2026-06-09 repo-map refresh): AppMode list now reads 16 variants including RecordsNormal and RecordsEditing, and notes MinibufferConfig + the ModelState/ViewState split.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-8fy","title":"Introduce simple_effect! macro for single-field setter effects","description":"crates/improvise-command/src/ui/effect.rs has ~15 simple setter effects (SetSelected, SetRowOffset, SetColOffset, SetYanked, SetSearchQuery, SetSearchMode, SetStatus, etc.) that each implement the same 4-line apply: pub struct SetX(pub T); impl Effect { fn apply(\u0026self, app: \u0026mut App) { app.field = self.0.clone(); } }.\\n\\nAbstraction: simple_effect!(SetRowOffset, usize, row_offset) macro generates struct + Effect impl.\\n\\nCorrectness win: provides a single hook for cross-cutting concerns. If layout-affecting setters need to mark dirty or rebuild layout, one macro body covers all of them. Currently each setter independently decides whether to mark_dirty(), and silent omissions are plausible. ~50 LOC.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:54:14Z","created_by":"Ed L","updated_at":"2026-09-19T20:35:54Z","closed_at":"2026-09-19T20:35:54Z","close_reason":"simple_effect! macro added beside the Effect trait; eight single-field ViewOnly setters declared through it (-42 lines net). The macro HARD-CODES EffectKind::ViewOnly by design: an effect other clients must see is not 'simple' and keeps a hand-written impl, which is how gotcha 3's no-default-kind rule survives a macro at all. SetSelected (two fields) and SetNumberFormat (a Model change) stay hand-written. Existing effect tests already cover all eight, including view_only_effects_leave_shared_state_alone.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-1wu","title":"Extract axis label helper in view/layout.rs","description":"crates/improvise-core/crates/improvise-core/src/view/layout.rs:323-336 (row_label) and :338-351 (col_label) are character-identical except for which items list they read. Small syntactic duplication but trivial to consolidate.\\n\\nFix: fn axis_label(items: \u0026[AxisEntry], idx: usize) -\u003e String, call from both. ~14 LOC.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:54:13Z","created_by":"Ed L","updated_at":"2026-08-14T01:11:07Z","closed_at":"2026-08-14T01:11:07Z","dependencies":[{"issue_id":"improvise-1wu","depends_on_id":"improvise-ai7","type":"supersedes","created_at":"2026-08-13T18:11:07Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -291,7 +293,7 @@ {"_type":"issue","id":"improvise-hkt6","title":"Three small totality gaps found in review","description":"Three PLAUSIBLE/minor findings from the 2026-09-19 wave review, each unreachable today and each one refactor away from not being. Grouped because none is worth a ticket alone.\n\n1. DataStore::matching_positions panics on an empty partial -- it indexes sets[0]. Private, and both callers guard with `if partial.is_empty()`, so it is unreachable. But THE GUARD NOW LIVES ONE LEVEL AWAY FROM THE INDEXING, which is how this class of bug returns. A sets.first()?-shaped total function costs nothing.\n\n2. invalidate_relabelled does not blank `totals` the way Invalidated does. By gotcha 33(b) own argument -- preserve-by-position is only right where the label did not change -- a RENAMED column should drop its total. Unreachable in practice: grid_updates diffs mirror.totals != desired.totals and emits Totals after ColLabels in the same batch, so a changed total is always sent. Worth fixing anyway, because 33(b) exists PRECISELY to stop `apply` trusting producers.\n\n3. improvise-gp mark_features indexes used[*feat] without a bound, where expr_text uses .get().expect(...) for the same precondition. Same failure, less legible.","status":"closed","priority":4,"issue_type":"task","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T01:05:16Z","created_by":"Edward Langley","updated_at":"2026-09-20T05:54:44Z","started_at":"2026-09-20T05:32:38Z","closed_at":"2026-09-20T05:54:44Z","close_reason":"Closed by PR #20 (WU-K). All three gaps fixed, each fault-injected.\n\n(a) matching_positions total-ised via split_first. The fix is NOT vec![]: an empty partial constrains NOTHING, so EVERY cell matches -- answering \"none\" one level down would have made deleting a caller's fast path silently flip \"all\" to \"none\". The callers' partial.is_empty() branches are fast paths, not guards, and the test asserts the two doors agree.\n\n(b) invalidate_relabelled now blanks a relabelled COLUMN's total. Rows are the negative arm and stay untouched -- a total spans every row, so no one row frames it, and that arm is what stops \"blank on any relabel\" passing.\n\n(c) mark_features uses .get_mut().expect(...), matching expr_text.\n\nTHE THING THE TICKET DID NOT PREDICT, and the reason this bead was not independent of its own framing: (b) was filed as \"unreachable in practice, fix it anyway because gotcha 33(b) exists to stop apply trusting producers\". Right about the rule, wrong about the cost. Adding a blanking rule on the CONSUMER side changes what every PRODUCER owes, and grid_updates did not pay it: it diffed mirror.totals against desired.totals, so a column renamed while its total stayed the same NUMBER emitted ColLabels and no Totals -- the client blanked its own total and project_diff's self.grid = desired left the server believing it was still there. A client cache wrong forever, gotcha 35's exact failure direction. Found by Copilot. Fixed by diffing totals against the label-folded frame.\n\nWHEN YOU MAKE apply STOP TRUSTING A PRODUCER, GO READ THE PRODUCER.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_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":"in_progress","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-20T17:53:50Z","started_at":"2026-09-20T17:53:50Z","lease_expires_at":"2026-09-20T17:58:50Z","heartbeat_at":"2026-09-20T17:53:50Z","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":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T00:18:35Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:55:52Z","labels":["area:projection"],"dependency_count":0,"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":"in_progress","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:11:15Z","started_at":"2026-09-20T18:11:15Z","lease_expires_at":"2026-09-20T18:16:15Z","heartbeat_at":"2026-09-20T18:11:15Z","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-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} @@ -307,4 +309,4 @@ {"_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-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.","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:04:48Z","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.","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:04:51Z","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} +{"_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 86fd5dcb2ac2360269dd212670a27d18c2b5c0dc Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Sun, 20 Sep 2026 11:22:13 -0700 Subject: [PATCH 06/17] docs: bindings.rs and web/worker.js are run, and five places said otherwise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by adversarial review of PR #47; I verified the chain rather than taking it. shell.spec.js's "the standalone deployment" loads the app with no ?ws=, which spawns the worker (app.js:112), which imports the glue (worker.js:4), which calls bindings.rs::start (:30) and OpfsFs::open (:35) — and the spec then asserts a grid draws and a cell is editable. So the whole chain has executed under Chromium in CI since improvise-vvl3 landed the harness. Five places claimed it never runs: gotcha 45, the File Inventory line, two kb.pl facts, and improvise-bck's grooming note. Two of those are corrected here; kb.pl goes with the wave-end batch, and the bead belongs to the WU-P agent. Every copy was written before the harness landed and none was re-read after — gotcha 48's rule ("when a fact is stated in more than one place, changing it is a grep, not an edit") aimed at exactly this and did not fire, because nobody grepped for a claim they had no reason to doubt. The sharper lesson is in the gotcha now: a "never run" written in prose is unfalsifiable by any check this repo has. kb.pl's CI gate verifies the knowledge base is CONSISTENT, not that it is TRUE, so a false invariant passes it forever. And this one understated coverage, so nothing failed and nobody looked — the comfortable direction to be wrong in, and the reason it lasted. Co-Authored-By: Claude Opus 5 (1M context) --- .beads/issues.jsonl | 29 ++++++++++++++++------------- context/repo-map.md | 11 +++++++---- 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index c3103867..582d023a 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -22,7 +22,7 @@ {"_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.","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-20T18:02:17Z","labels":["area:epic"],"dependencies":[{"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-zkkw","type":"blocks","created_at":"2026-09-20T18:02:34Z","created_by":"wave2-p-milestones","metadata":"{}"}],"dependency_count":5,"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.","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-20T18:19:45Z","labels":["area:epic"],"dependencies":[{"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-zkkw","type":"blocks","created_at":"2026-09-20T18:02:34Z","created_by":"wave2-p-milestones","metadata":"{}"}],"dependency_count":5,"dependent_count":1,"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} @@ -37,9 +37,10 @@ {"_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-jmjz","title":"grid_of evaluates the whole grid three times per frame; compute_col_widths is 42-64% 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.","status":"open","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-20T18:06:57Z","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_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":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:22:32Z","created_by":"wave2-p-milestones","updated_at":"2026-09-20T18:22:32Z","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":"open","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-20T18:18:24Z","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-lltt","title":"[bug] A view-scoped ModelCommand names no view, so it lands on the host's","description":"Opened by improvise-jytm, which made `ViewState::active_view` authoritative per\nclient. The view-configuration half of `ModelCommand` — SetAxis, CycleAxis,\nTransposeAxes, SetPageSelection, HideItem, ShowItem, ToggleGroup, TogglePruneEmpty,\nSetNumberFormat — acts on \"the active view\", which is now an ambiguous phrase.\n\n`Sessions::apply_model` routes with no SessionId (gotcha 38, correctly: the change\nis addressed to no one session, because a `View` is document state shared by every\nclient sitting on it). But it must still name WHICH view, and today it names it\nimplicitly: `apply_command` runs the effect against the `App`, and the effect reads\n`App::view()`, which is the HOST's slice. So a connected client on another view\nmoves the host's view rather than its own.\n\nBefore jytm every client was on the same view, so the implicit answer was right.\nIt is now wrong exactly when two clients are on different views, which is the\ncapability jytm exists to add.\n\nThe honest fix is for those variants to name their view — a protocol change — so\nthe command carries the operand rather than borrowing an ambient one. Rejected\nduring jytm: retargeting `app.view_state.active_view` around `apply_command` at the\ndoor. It makes the host's own UI slice a parameter channel, and `reset_cursor`/\n`cursor_epoch` would then write the asker's key into the host's cursor map.\n\nNote the `_Drill`/`_Records` virtual views make this sharper than it looks: a host\nin records mode is on a view no client can name.\n\n## Steps to Reproduce\n\n1. Two clients, A and B, on one document with views \"Default\" and \"Second\".\n2. B sends `Command::SwitchView(\"Second\")`. Its mirror moves; the host's App stays\n on \"Default\" (correct, per improvise-jytm).\n3. B sends `Command::Model { cmd: ModelCommand::SetAxis { category: \"Region\",\n axis: Row } }`.\n4. The axis moves on \"Default\" — the view B is not on — and B's next projection\n shows nothing changed while A's shows a change A did not ask for.\n\nNot reachable from the TUI, which is the only client that drives the App's own\nslice, and not reachable from `web/app.js` today because it sends no SwitchView.\n","acceptance_criteria":"The view-configuration ModelCommand variants name the view they act on, so a connected client's SetAxis moves the view that client is on and not the host's. A test drives two sessions onto different views and asserts an axis change by one is invisible to the other. Deciding NOT to fix is also an acceptable close, but then gotcha 38 must say so.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:05:49Z","created_by":"wave2-e-active-view","updated_at":"2026-09-20T18:05:49Z","labels":["area:active-view"],"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":"CORRECTION, same session: this bead as first written read as though nobody had\ndecided the Chromium-only question. Somebody had.\n`invariant(opfs_is_run_but_bindings_is_not)` in prolog-sessions/kb.pl records\nit, and gives TWO reasons, not one:\n\n \"harness/tests/shell.spec.js runs web/app.js under CHROMIUM ONLY --\n engine-independent logic, and routeWebSocket is slower across three.\"\n\nThe description above only answers the second. The first — the logic is\nengine-independent — is a general claim covering all six specs, and against\nthe specs as a test of web/app.js it is correct: app.js is plain DOM and\nplain event wiring, and running the same assertions on a second engine buys\nvery little.\n\nWHAT IT DOES NOT COVER is the claim improvise-bck criterion (1) actually\nmakes. That criterion is not \"app.js's logic works in Firefox\", it is \"the\nstandalone deployment works in Firefox\" — and the standalone deployment is\nmodule workers, `wasm-pack` output, `createSyncAccessHandle` through\nbindings.rs and the seed fetch, none of which is app.js's logic and all of\nwhich is engine-specific. opfs.spec.js covers Firefox's OPFS through\n`OpfsFs` directly and, per the same invariant, NEVER INSTANTIATES\nbindings.rs. So the two standalone specs on Firefox would be the first\nexecution of that shim on a second engine, and near enough the first on any.\n\nSo the disagreement with the invariant is narrow and should be argued on\nthose terms: the engine-independence reason is a statement about app.js, and\nthe two standalone specs are not only a test of app.js. If that argument is\nrejected, the right outcome is to REWRITE improvise-bck criterion (1) to say\nChromium, not to leave a criterion standing that names an engine nothing\nruns on — a criterion nobody checks is the failure mode this whole pass\nexists to catch.\n\nEither way the invariant's wording is owed an update, since it currently\nreads as a blanket justification for a split that has two different reasons\nbehind its two halves.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:02:30Z","created_by":"wave2-p-milestones","updated_at":"2026-09-20T18:04:23Z","labels":["area:epic"],"dependency_count":0,"dependent_count":1,"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":"CORRECTION, same session: this bead as first written read as though nobody had\ndecided the Chromium-only question. Somebody had.\n`invariant(opfs_is_run_but_bindings_is_not)` in prolog-sessions/kb.pl records\nit, and gives TWO reasons, not one:\n\n \"harness/tests/shell.spec.js runs web/app.js under CHROMIUM ONLY --\n engine-independent logic, and routeWebSocket is slower across three.\"\n\nThe description above only answers the second. The first — the logic is\nengine-independent — is a general claim covering all six specs, and against\nthe specs as a test of web/app.js it is correct: app.js is plain DOM and\nplain event wiring, and running the same assertions on a second engine buys\nvery little.\n\nWHAT IT DOES NOT COVER is the claim improvise-bck criterion (1) actually\nmakes. That criterion is not \"app.js's logic works in Firefox\", it is \"the\nstandalone deployment works in Firefox\" — and the standalone deployment is\nmodule workers, `wasm-pack` output, `createSyncAccessHandle` through\nbindings.rs and the seed fetch, none of which is app.js's logic and all of\nwhich is engine-specific. opfs.spec.js covers Firefox's OPFS through\n`OpfsFs` directly and, per the same invariant, NEVER INSTANTIATES\nbindings.rs. So the two standalone specs on Firefox would be the first\nexecution of that shim on a second engine, and near enough the first on any.\n\nSo the disagreement with the invariant is narrow and should be argued on\nthose terms: the engine-independence reason is a statement about app.js, and\nthe two standalone specs are not only a test of app.js. If that argument is\nrejected, the right outcome is to REWRITE improvise-bck criterion (1) to say\nChromium, not to leave a criterion standing that names an engine nothing\nruns on — a criterion nobody checks is the failure mode this whole pass\nexists to catch.\n\nEither way the invariant's wording is owed an update, since it currently\nreads as a blanket justification for a split that has two different reasons\nbehind its two halves.\nCORRECTION — MY OWN PREMISE WAS WRONG, found in adversarial review of PR #47.\n\nThe note above says the two standalone specs on Firefox \"would be the first\nexecution of that shim on a second engine, and near enough the first on any\".\nThe second half is false. `bindings.rs` HAS been executed, on Chromium, since\nimprovise-vvl3 landed the harness. The chain is unbroken and each link is\ncheckable:\n\n /app/index.html with no ?ws= -\u003e app.js `new Worker(\"./worker.js\")`\n worker.js `import { start } from \"./worker-pkg/improvise_worker_server.js\"`\n bindings.rs `pub async fn start()` -\u003e `boot()` -\u003e `OpfsFs::open().await`\n\nand `harness/tests/shell.spec.js`'s own comment on the standalone test has said\nso all along: \"If this draws a grid, worker.js, bindings.rs, OpfsFs and the seed\nfetch all ran.\"\n\nI RAN THAT SPEC GREEN AT 2ccd21f while verifying the /app/ move, and watched the\nbrowser fetch /app/worker.js and /app/worker-pkg/improvise_worker_server_bg.wasm\nand draw the grid with #status reading \"in-page worker\". The evidence was in my\nhand and I still repeated the claim, because I took it from kb.pl and the repo\nmap instead of from the run.\n\nWHAT SURVIVES: the argument for running the two standalone specs on Firefox is\nUNAFFECTED and is still the point of this ticket. Firefox would be a genuine\nsecond engine for the shim, and improvise-bck's criterion (1) still names an\nengine nothing runs. Only the \"first on any engine\" rhetoric goes.\n\nFIVE PLACES STATE THE FALSE VERSION and want one grep, not five edits:\nkb.pl:11, kb.pl:105 (invariant opfs_is_run_but_bindings_is_not), repo-map.md:241,\nrepo-map.md:467, and improvise-bck's own NOT-DEMONSTRATED list. Those three files\nare orchestrator-owned; the bck half is corrected in bck's notes.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:02:30Z","created_by":"wave2-p-milestones","updated_at":"2026-09-20T18:19:44Z","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":"Three PRs merged within 20 seconds on 2026-09-20 (#37, #41, #42). Each CI run built the site and uploaded a github-pages artifact, and each queued a deploy behind 'concurrency: group: pages, cancel-in-progress: false'.\n\nOnly the newest run kept its artifact:\n\n run 35526758561 (04e4e3d, #37) conclusion=failure artifacts=0\n run 35526767814 (5f871f7, #41) conclusion=success artifacts=1\n run 35526774007 (52507a6, #42) conclusion=failure artifacts=0\n\nThe two superseded deploys failed with:\n\n Fetching artifact metadata for \"github-pages\" in this workflow run\n Found 0 artifact(s)\n Error: No artifacts named \"github-pages\" were found for this workflow run.\n\nThe upload step was GREEN in all three runs, so this is not a build failure —\nthe artifact is gone by the time the losing deploy runs. The successful deploy\n(17:47:28Z) preceded the failing one (17:47:44Z) by 16 seconds.\n\nDeploying a superseded commit is pointless anyway: the tip of main is what\nshould be published. Cancelling is deliberately NOT the answer — the existing\ncomment on the concurrency block records that a deploy-pages cancelled midway\ncan leave the Pages deployment half-applied.\n\n## Steps to Reproduce\n\n1. Merge three PRs to main within about a minute.\n2. Watch the CI runs: the newest deploy succeeds, the older ones fail on an\n empty artifact fetch and mark otherwise-green runs as failed.\n\nReported by the user from the Actions log.","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-20T18:03:04Z","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":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T07:55:22Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:57:55Z","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":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T07:49:43Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:49:43Z","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -76,7 +77,7 @@ {"_type":"issue","id":"improvise-xwi6","title":"Concurrent edits: basis check (ADR 001, layer 1 — the correctness floor)","description":"Implements layer 1 of context/adr/001-concurrent-edits.md. User decision 2026-09-19: record-level atomicity with optimistic concurrency per batch, no causal clock.\n\nTHE FRAMING THAT MATTERS: the server already serialises. Sessions::apply -\u003e apply_command -\u003e App::apply_effects against one App, so a total order EXISTS. This ticket is not about ordering; it is about noticing that a client composed a batch against a basis that has since moved -- the same stale-basis shape as gotcha 24 (cursor) and gotcha 39 (session mirror), one level up.\n\nSCOPE:\n1. Version tiers, living beside document_generation on the authoritative App (they are shared document state, NOT ViewState -- gotcha 30):\n * document_generation -- exists, bumped by WholeWorkbook\n * layout_version -- bumped by any LayoutGeometry\n * record_version -- per record, bumped by CellData on that record\n A 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.\n2. ModelCommand carries the basis it was composed against.\n3. Sessions::apply rejects a stale batch. Applied grows a third case beside Diff and Rebased -- an enum so matching FORCES a host to handle it, which is gotcha 39 reasoning and the reason a bool was rejected there.\n4. Rejection is a normal outcome, not an error: the client already has the new state via projections and re-composes.\n\nWHY THE 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. Reuses ModelChange so there is no second classification to drift from gotcha 35 table.\n\nTESTS: the arms, per doctrine(one_arm_of_a_symmetric_rule_is_evidence_about_that_arm_only) -- a stale CellData batch after a layout change is rejected; a fresh one is not; a batch touching an untouched record is not; 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.","notes":"\n--- REFINEMENT (user, 2026-09-19): a rejected edit must be forceable ---\nFORCE IS A RE-COMPOSITION, NOT A BYPASS. Never apply a batch whose basis is\nstale -- a mechanism whose only purpose is to violate the invariant is the\ninvariant deleted with extra steps. Force re-composes the same INTENT against\nthe current state and submits a fresh batch with a current basis.\n\nWhat force can mean differs by conflict class, and this is the part to get\nright:\n class 1 (two writers, same record) -- YES, trivially. Re-compose and apply;\n the user has seen the other value and chooses to overwrite. One keystroke.\n class 2 (shape change vs cell edit) -- NO. The intent no longer type-checks:\n the key names a coordinate that no longer exists. Applying anyway writes a\n CellKey naming a deleted category, which is the ghost-coordinate\n corruption this whole design exists to prevent.\n class 3 (formula shadows a value) -- yes, with a warning: the write lands,\n the displayed value is still derived.\n\nRULE: FORCE NEVER CREATES STRUCTURE. Re-instating a category someone deleted\nso an edit can land is a different and larger intent than the user expressed;\nit must be an explicit action, not a side effect of forcing a cell edit. When\nan intent cannot be re-composed, the UI owes the user what CHANGED and the\nconcrete alternatives -- re-target, or re-create the structure first -- rather\nthan a bare refusal.\n\nTWO THINGS THIS PULLS FORWARD FROM LAYER 2, into this ticket:\n 1. The rejected batch values STAY STAGED in the session, so rejection never\n costs the user their typing and force is cheap rather than a retype. This\n is the drill staging mechanism doing what it already does; only PUBLISHING\n the staged keys stays in layer 2.\n 2. A forced overwrite must be VISIBLE to the client it overwrote. Otherwise\n force is silent data loss with a confirmation step in front of it. The\n loser edit was already committed, so this is a notification rather than a\n conflict -- but without it, \"someone else won\" is indistinguishable from\n \"my edit never happened\".\n\n--- DECOMPOSED 2026-09-19; this is now the umbrella, do not implement it directly ---\nADR approved by the user. Layer 1 is split into five steps with real dependencies:\n improvise-5a5y a: version tiers on App (no wire change, testable alone)\n improvise-fkfg b: ModelCommand carries its basis; Applied gains a rejected case\n improvise-3al1 c: rejected batch keeps its values staged; class-1 force re-composes\n improvise-vb6l d: when an intent cannot be re-composed, show what changed (the expensive one)\n improvise-2wi8 e: a forced overwrite is visible to the client it overwrote\nPrerequisite: improvise-3x3u (the two unaddressed nullary wire variants) blocks b.\nLayer 2: improvise-7b8v (advisory reservations) is blocked by c, because c is where the staging buffer lands.\nThe design rationale stays here and in context/adr/001-concurrent-edits.md; the steps carry only what their implementer needs.","status":"closed","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-19T23:09:53Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:58:03Z","closed_at":"2026-09-20T00:58:03Z","close_reason":"ADR 001 layer 1 landed with improvise-fkfg -- see that ticket for the detail. Basis (three tiers) + Rejection + BasisCache in improvise-protocol/src/basis.rs, Command::Model{basis,cmd} as a struct variant so the basis cannot be forgotten, Applied::Rejected as the third case, and stale_basis/records_named as the check. No second counter was built: step (a) had already put the tiers on App and the RecordHash -\u003e RecordEntry{record,cells,version} index in DataStore. 10 tests, both arms per tier, each fixture asserting the OTHER tiers did not move so a rejection cannot be the wrong tier firing.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-gvnb","title":"[bug] A geometry command resets the App cursor but not the session mirrors","description":"Found during improvise-kmgs (2026-09-19). Pre-existing for the key path; the model half of the wire vocabulary makes it reachable remotely, which is what raises it to P2.\n\nCycleAxis and TransposeAxes call app.reset_cursor() because renumbering rows or columns invalidates the cursor (gotcha 24). But each ClientSession keeps its OWN cursor in its mirrored ViewState, and nothing resets those. So a bystander is projected renumbered rows at its old scroll -- gotcha 24 cursor-vs-geometry mismatch, arriving through somebody else command.\n\nThis is the same SHAPE as improvise-kvyt (a view switch moved only the asking session mirror) and should be fixed the same way: the geometry change is addressed to no one session, so every mirror owes the reset. See doctrine(lockstep_binds_every_session_not_the_asker) -- ask of any shared value, who else derives something from it. Here the derived thing is the cursor whose validity depends on the grid shape.\n\nTest it the way kvyt was tested: a bystander session, not the one that issued the command.","notes":"\n--- CONFIRMED AGAIN on PR #11 by Copilot, and deliberately NOT fixed there ---\nThe PR agent reproduced it and declined to fix it in that PR with three\nreasons, all of which stand:\n (a) Applied::Diff.broadcast is a single Option\u003cCommand\u003e and there is NO\n cursor-reset command -- a ViewCursor is selected plus both offsets, so\n this needs a new wire variant, derived not invented.\n (b) it read the policy question as improvise-g0f, \"deliberately unanswered\".\n (c) A PARTIAL FIX IS WORSE THAN NONE: resetting only the server-side mirrors\n would make the diff logic believe each client already holds origin-based\n content and send nothing -- leaving clients stale PERMANENTLY instead of\n until their next scroll. Note that carefully when implementing.\nObserved effect today: Viewport::window clips (start = offset.min(total)), so\na bystander gets a BLANK grid rather than garbage -- blank until it scrolls.\n\n--- THE POLICY QUESTION IS ANSWERED; (b) rests on a stale reading ---\nimprovise-g0f is decided (per-client active view), and the standing principle\nsettles this case directly: THE MODEL IS SHARED, THE UI IS NOT. Two things\nthat sound alike and are not:\n * Another client transposing must NOT move my cursor -- where I am looking\n is UI, and it is mine.\n * It MUST invalidate my cursor, because the grid my cursor indexes just\n changed shape. Validity is derived from shared geometry even though\n position is private.\nSo the answer is neither \"reset everyone to origin\" (that moves my UI on\nsomeone else behalf) nor \"leave it\" (that indexes a grid that no longer has\nthose rows). It is: the geometry change invalidates every client cursor, and\neach client re-establishes its own -- which is exactly what a cursor-reset\nwire variant should mean, and why it is a variant rather than a broadcast of\nsomebody else position.\n\nSame family as improvise-kvyt and doctrine(lockstep_binds_every_session_not_the_asker):\nask of any shared value, who else DERIVES something from it. Here the derived\nthing is the validity of a private cursor.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T22:11:31Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:58:01Z","closed_at":"2026-09-20T00:58:01Z","close_reason":"Fixed. reset_cursor owed the session mirrors BOTH halves, and doing only one is the trap the ticket named.\n\nTHREE PIECES:\n * Command::ResetCursor -- a new view-half variant, derived from the App::reset_cursor write the two geometry effects make on the portable slice. A variant rather than SetSelected{0,0} plus two offsets because the message is 'your cursor is invalid', not 'be here'.\n * App::cursor_epoch, bumped only by reset_cursor, OBSERVED by Sessions::adopt_cursor_epoch across apply_command. The trigger is the counter moving, not the command variant, for exactly the reason gotcha 39 watches document_generation instead of matching on LoadModel: which effects renumber the grid is a per-effect decision (gotcha 24 asks new ones to make it), so a variant table here would be a second description free to disagree.\n * It resets every mirror AND returns the broadcast. Resetting the mirrors alone is STRICTLY WORSE THAN DOING NOTHING: the diff then believes each client already holds origin-positioned content and sends nothing, so clients stay stale PERMANENTLY rather than until their next scroll.\n\nTHE AUDIENCE HAD TO BECOME PART OF THE MESSAGE. Applied::Diff::broadcast is now Vec\u003cBroadcast\u003e over Broadcast::{ToOthers, ToAll}. A SwitchView broadcast CORRECTS state the asker folded optimistically and must not go back to it; a cursor invalidation is a CONSEQUENCE the asker is owed on the same terms as everyone else, because a Command::Model is the one kind a client cannot fold optimistically. Vec not Option because one command can owe two relays with different audiences, and an Option would silently drop one.\n\nWHAT IS NOT BROADCAST IS A POSITION. Another client's transpose must not move where I am looking -- that is UI and it is mine (improvise-g0f). It may only declare that where I was looking stopped meaning anything.\n\nThe negative arm matters as much as the regression guard: SetNumberFormat moves nobody's cursor. Without it, 'reset on every model command' would pass.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-tym1","title":"[bug] folds(n, 0) panicked and mse divided by a length it had not scored","description":"Copilot finding on PR #10 (2026-09-19), partly real, fixed in 27a4cbd with both tests written first.\n\nfolds(n, 0) did not merely misbehave -- it PANICKED on out[pos % 0]. k arrives from a CLI flag, so zero is a value a host supplies, and gotcha 37 says gp-tree is a library a TUI or a wasm worker may embed, where a panic is not a usage error message. Now k.max(1), matching fit()s existing restarts.max(1).\n\nmse divided by x.len() rather than the number of rows it actually scored, so a length mismatch between the parallel slices silently skewed the result through zip truncation. Now it counts the rows it scored. Bit-identical where the slices match -- Sum for f32 is the same left fold -- so wave 3 nine-scenario byte-identical CLI evidence still holds.\n\nProven first: the folds test panicked; the mse test failed 16.912476 vs 17.802607.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T22:01:09Z","created_by":"Edward Langley","updated_at":"2026-09-19T22:01:10Z","closed_at":"2026-09-19T22:01:10Z","close_reason":"Fixed in 27a4cbd, merged to main in PR #10 as 336533b.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-tom1","title":"Drill pending edits are invisible to remote clients, and there are now three answers to 'what text is in this cell'","description":"Wave-2 review (2026-09-19), CONFIRMED by reading.\n\nproject.rs cell_display resolves records-mode text through layout.records_display(row,col). The TUI grid widget overlays staged drill edits (src/ui/grid.rs, drill_state.pending_edits) and so does App::display_value_at_cursor. SetDrillPendingEdit is ViewOnly, so Dirty is empty too. A remote client in drill mode sees neither the staged edit nor any update for it, until ApplyAndClearDrill (CellData) lands.\n\nThis is the 'second, silently divergent description' the producer's own module header warns against: three answers to what text is in a cell (GridLayout::display_text, src/ui/grid.rs, project::cell_display) and only two agree.\n\nNote the tension with the user's principle 'the model is shared, the UI isn't': pending drill edits are UNCOMMITTED writes belonging to whoever staged them (that is why gotcha 30 keeps drill_state off ViewState). So the answer may legitimately be 'a remote client SHOULD NOT see them' -- in which case the defect is only the three-way divergence, not the invisibility. Decide which before coding.\n\n## Steps to Reproduce\n\n1. Two clients on one document; client A drills into an aggregate cell and types an edit, which stages into `drill_state.pending_edits`.\n2. Client B sees neither the staged value nor any update for it — `SetDrillPendingEdit` is `ViewOnly`, so the `Dirty` is empty and nothing is projected — until `ApplyAndClearDrill` (`CellData`) lands.\n3. Meanwhile A's own TUI *does* show it, because `src/ui/grid.rs` overlays `drill_state.pending_edits` and so does `App::display_value_at_cursor`, while `project.rs::cell_display` resolves through `layout.records_display(row, col)`.\n\nSo there are three answers to \"what text is in this cell\" and only two agree. The invisibility is the *correct* half (the user has ruled staged edits are private); the divergence is the defect.\n","acceptance_criteria":"One answer to 'what text is in this cell'. The user has ruled (model-shared-ui-not-shared) that a remote client must NOT see another client's staged drill edits — pending edits are uncommitted writes belonging to whoever staged them, which is why gotcha 30 keeps drill_state off ViewState. So the invisibility is CORRECT and the defect is only the three-way divergence between GridLayout::display_text, src/ui/grid.rs and project::cell_display. Close that to one derivation without making staged edits visible.","notes":"\n--- DECIDED (user, 2026-09-19): NO ---\nA remote client must NOT see another client staged drill edits. This follows\nfrom the standing principle -- the model is shared, the UI is not -- and from\ngotcha 30, which keeps drill_state off ViewState precisely because pending\nedits are UNCOMMITTED WRITES BELONGING TO WHOEVER STAGED THEM. They become\neveryone business only at ApplyAndClearDrill, which is CellData and already\nreaches every client.\n\nSO THE INVISIBILITY IS CORRECT AND IS NOT THE BUG. Do not \"fix\" it by teaching\nthe producer to read drill_state; that would put one client uncommitted writes\non another client screen and contradict the decision.\n\nRE-SCOPED -- the defect that remains is the THREE-WAY DIVERGENCE in what text\nis in this cell:\n * App::display_value_of -- editable RAW text, resolves a staged drill\n edit, via layout.resolve_display\n * project::cell_display -- FORMATTED text + numeric/error flags; in\n records mode calls layout.records_display\n and then INFERS numeric-ness by\n text.parse::\u003cf64\u003e() -- the one place this\n module guesses rather than knows\n * src/ui/grid.rs -- formats for the terminal, also overlays\n pending edits\nThree answers, two of which agree. The cheap collapse identified during the\nreview is between the first two, on the records branch only: both want\n\"resolve a synthetic record key through the layout\", so cell_display records\narm can share the resolve_display path via a helper on GridLayout, keeping the\nPENDING-EDIT lookup out of it. That removes the duplicated resolution and,\nwith a real CellValue in hand, the parse::\u003cf64\u003e() inference -- while leaving\nthe staging map session-local and unread by the producer, as decided.\n\nIt is a behaviour change on the records projection, so it wants the failing\ntest first and a check that no existing records test changes meaning.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T21:04:41Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:57:42Z","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-tom1","title":"Drill pending edits are invisible to remote clients, and there are now three answers to 'what text is in this cell'","description":"Wave-2 review (2026-09-19), CONFIRMED by reading.\n\nproject.rs cell_display resolves records-mode text through layout.records_display(row,col). The TUI grid widget overlays staged drill edits (src/ui/grid.rs, drill_state.pending_edits) and so does App::display_value_at_cursor. SetDrillPendingEdit is ViewOnly, so Dirty is empty too. A remote client in drill mode sees neither the staged edit nor any update for it, until ApplyAndClearDrill (CellData) lands.\n\nThis is the 'second, silently divergent description' the producer's own module header warns against: three answers to what text is in a cell (GridLayout::display_text, src/ui/grid.rs, project::cell_display) and only two agree.\n\nNote the tension with the user's principle 'the model is shared, the UI isn't': pending drill edits are UNCOMMITTED writes belonging to whoever staged them (that is why gotcha 30 keeps drill_state off ViewState). So the answer may legitimately be 'a remote client SHOULD NOT see them' -- in which case the defect is only the three-way divergence, not the invisibility. Decide which before coding.\n\n## Steps to Reproduce\n\n1. Two clients on one document; client A drills into an aggregate cell and types an edit, which stages into `drill_state.pending_edits`.\n2. Client B sees neither the staged value nor any update for it — `SetDrillPendingEdit` is `ViewOnly`, so the `Dirty` is empty and nothing is projected — until `ApplyAndClearDrill` (`CellData`) lands.\n3. Meanwhile A's own TUI *does* show it, because `src/ui/grid.rs` overlays `drill_state.pending_edits` and so does `App::display_value_at_cursor`, while `project.rs::cell_display` resolves through `layout.records_display(row, col)`.\n\nSo there are three answers to \"what text is in this cell\" and only two agree. The invisibility is the *correct* half (the user has ruled staged edits are private); the divergence is the defect.\n","acceptance_criteria":"One answer to 'what text is in this cell'. The user has ruled (model-shared-ui-not-shared) that a remote client must NOT see another client's staged drill edits — pending edits are uncommitted writes belonging to whoever staged them, which is why gotcha 30 keeps drill_state off ViewState. So the invisibility is CORRECT and the defect is only the three-way divergence between GridLayout::display_text, src/ui/grid.rs and project::cell_display. Close that to one derivation without making staged edits visible.","notes":"\n--- DECIDED (user, 2026-09-19): NO ---\nA remote client must NOT see another client staged drill edits. This follows\nfrom the standing principle -- the model is shared, the UI is not -- and from\ngotcha 30, which keeps drill_state off ViewState precisely because pending\nedits are UNCOMMITTED WRITES BELONGING TO WHOEVER STAGED THEM. They become\neveryone business only at ApplyAndClearDrill, which is CellData and already\nreaches every client.\n\nSO THE INVISIBILITY IS CORRECT AND IS NOT THE BUG. Do not \"fix\" it by teaching\nthe producer to read drill_state; that would put one client uncommitted writes\non another client screen and contradict the decision.\n\nRE-SCOPED -- the defect that remains is the THREE-WAY DIVERGENCE in what text\nis in this cell:\n * App::display_value_of -- editable RAW text, resolves a staged drill\n edit, via layout.resolve_display\n * project::cell_display -- FORMATTED text + numeric/error flags; in\n records mode calls layout.records_display\n and then INFERS numeric-ness by\n text.parse::\u003cf64\u003e() -- the one place this\n module guesses rather than knows\n * src/ui/grid.rs -- formats for the terminal, also overlays\n pending edits\nThree answers, two of which agree. The cheap collapse identified during the\nreview is between the first two, on the records branch only: both want\n\"resolve a synthetic record key through the layout\", so cell_display records\narm can share the resolve_display path via a helper on GridLayout, keeping the\nPENDING-EDIT lookup out of it. That removes the duplicated resolution and,\nwith a real CellValue in hand, the parse::\u003cf64\u003e() inference -- while leaving\nthe staging map session-local and unread by the producer, as decided.\n\nIt is a behaviour change on the records projection, so it wants the failing\ntest first and a check that no existing records test changes meaning.","status":"closed","priority":2,"issue_type":"bug","assignee":"wave2-b-projection","owner":"el-github@elangley.org","created_at":"2026-09-19T21:04:41Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:19:01Z","started_at":"2026-09-20T18:16:32Z","closed_at":"2026-09-20T18:19:01Z","close_reason":"Closed","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-jytm","title":"Split active_view: document default vs per-client authoritative (improvise-g0f)","description":"Implements the user's improvise-g0f decision (2026-09-19): two clients MAY view different views.\n\nTODAY (gotcha 29): Workbook::active_view and ViewState::active_view are two copies of one fact, kept identical by App::switch_to_view and App::install_workbook, guarded by the_two_active_view_names_never_diverge. The second copy exists because reduce_view runs on a client holding no Workbook.\n\nTARGET: Workbook::active_view = the document's default, i.e. what a freshly connected client opens. ViewState::active_view = authoritative for that client. They may differ.\n\nWORK:\n1. Replace the_two_active_view_names_never_diverge with a test of the NEW contract -- do not simply delete it. A deliberate split with no guard is how this silently regresses. Assert that a fresh session opens the document default, and that one client switching does not move another.\n2. Re-examine SwitchView's EffectKind. Under lockstep it is a Model change; per-client it looks like ViewOnly (it must not reach other clients). Check what else SwitchView does before changing the kind -- gotcha 3, a mis-declared kind is silent.\n3. ClientSession::project must emit that client's whole widget set on a switch: every projection is view-dependent, and the grid mirror describes the OLD view.\n4. Persistence: Initial View in .improv is the document default, which is now exactly the right meaning -- confirm the writer still takes it from Workbook and not from any client.\n5. Update gotcha 29 in context/repo-map.md (orchestrator owns that file) and the kb decision recording the old lockstep rule.\n\nUnblocks improvise-cqq.","acceptance_criteria":"Workbook::active_view is the document default; ViewState::active_view is authoritative per client; two clients can view different views. the_two_active_view_names_never_diverge is REPLACED by a test of the new contract — a fresh session opens the document default, and one client switching does not move another — not deleted, because a deliberate split with no guard is how this silently regresses. SwitchView's EffectKind is re-examined and the reasoning recorded (gotcha 3: a mis-declared kind is silent). ClientSession::project emits that client's whole widget set on a switch, since every projection is view-dependent. The .improv writer still takes Initial View from the Workbook.","notes":"\n--- GUIDING PRINCIPLE (user, 2026-09-19) ---\n'The model is shared and the UI isn't.' g0f follows from this rather than\nbeing an independent choice: which view you are looking at is UI, not\ndocument. The principle is already embodied in EffectKind -- ViewOnly is UI\n(reaches nobody else), Model(...) is document (reaches everyone) -- so the\nsplit should make active_view agree with that existing rule rather than\nintroduce a new mechanism.\n\nDO NOT RULE OUT A FOLLOW MODE. The user explicitly wants the option of\nbinding two UIs together later (one client mirroring another's view, cursor\nand scroll) and explicitly does NOT want it implemented yet. Design\nconsequence for THIS ticket: keep per-client UI state addressable per\nsession -- ClientSession already does this -- so a follow relationship can\nlater be expressed as one session DERIVING its ViewState from another's.\nDo not 'simplify' by making any UI state globally shared to get follow\nbehaviour for free; that would trade the principle for the feature and is\nthe one shape that forecloses it.\n\n--- MUST REVISIT improvise-kvyt WHEN DOING THIS (2026-09-19) ---\nCopilot found on PR #10 that a view switch moved only the ASKING session mirror,\nstaling every bystander cursor: each client grid is built by grid_of from\nworkbook.active_view(), so a bystander left naming the outgoing view was\nprojected the NEW view rows positioned by the OLD view scroll. Fixed in 8475b2d\nby broadcasting the ACCEPTED view to every mirror, with Sessions::apply now\nreturning Applied { dirty, broadcast } so the host can forward the switch to the\nother clients, whose slices live on them.\n\nThat fix is correct ONLY for the current lockstep model. Under this ticket split\n-- active_view per-client, the model is shared and the UI is not -- a view switch\nbecomes the asker business alone and the broadcast either disappears or changes\nmeaning entirely. Do NOT keep it by inertia: re-derive whether anything still has\nto reach the other sessions when the asker switches, and if the answer is nothing,\ndelete the broadcast and the test that pins it rather than leaving a mechanism\nwhose reason has gone. If the answer is something (e.g. a follow-mode client\nbound to the switcher), that is the shape follow mode wants -- see the\nmodel-shared-ui-not-shared memory, which says explicitly not to foreclose it.","status":"in_progress","priority":2,"issue_type":"task","assignee":"wave2-e-active-view","owner":"el-github@elangley.org","created_at":"2026-09-19T21:00:51Z","created_by":"Edward Langley","updated_at":"2026-09-20T17:53:50Z","started_at":"2026-09-20T17:53:50Z","lease_expires_at":"2026-09-20T17:58:50Z","heartbeat_at":"2026-09-20T17:53:50Z","labels":["area:active-view"],"dependency_count":0,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"improvise-kmgs","title":"Derive the model half of the Command vocabulary from the EffectKind::Model effects","description":"From improvise-gxi (2026-09-19). Blocks that ticket's ACs 4 and 5, which the producer agent correctly declined to force.\n\ngxi asks for 'keymap lookup returns Command values rather than Box\u003cdyn Cmd\u003e' and 'CmdRegistry::dispatch_command(cmd: \u0026Command, ...)'. Neither is reachable today: the Command enum's client-\u003eserver half is exactly the ViewOnly portable-slice mutations, because decision(improvise-cqi, command_set_derived_not_invented) derived it from those effects and nothing else. There is no SetCell, AddCategory, SetAxis or Save in it. So a keymap cannot return a Command for most bindings, and reduce_full over a Command cannot drive a model mutation. The key path remains the only door the model has, and ui/reduce.rs's module doc says so.\n\nDO THIS THE SAME WAY cqi did the view half -- derive, do not invent. Enumerate the effects wearing EffectKind::Model(...) (29 of them; the agent's recipe: awk '/^pub struct /{n=$3} /ModelChange::/{print n}' over ui/effect.rs), let that set BE the enum, then finish gxi ACs 4-5 on top. Inventing variants ahead of the mutations produces a protocol nobody can implement against -- doctrine(derive_the_enum_from_the_effects).\n\nOnce this lands, reduce_full becomes the single door and the two-door note in ui/reduce.rs can go.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T20:56:08Z","created_by":"Edward Langley","updated_at":"2026-09-19T22:11:22Z","closed_at":"2026-09-19T22:11:22Z","close_reason":"ModelCommand = the 29 EffectKind::Model effects minus SwitchView = 28 variants, in improvise-protocol.\n\nEXTRACTION VERIFIED RATHER THAN TRUSTED, and the ticket own awk recipe was WRONG: it reports 30. The extra is HelpPageSet, which is ViewOnly -- it is the last pub struct before #[cfg(test)] and the test module mentions ModelChange::, so the running variable bleeds across the module boundary. Exactly the failure doctrine(derive_then_verify) records. The agent brace-matched every impl Effect block instead; the orchestrator independently confirmed 50 impls, 29 wearing Model(..).\n\nSwitchView rejected as a NEW variant, not as a mutation: Command::SwitchView already exists in the view half with its document half routed through apply_command/App::switch_to_view, and naming it twice would give two spellings for one mutation with only one wired to apply_switch. AcceptWizardModel and ApplyAndClearDrill were CONSIDERED for rejection on gotcha 30 grounds and kept: both commit session-local staged state, but the COMMAND carries none of it -- it is nullary and the protocol crate never names ImportWizard or DrillState. Rejecting them would have meant inventing a rule nothing in the codebase states.\n\nNo second mutation path: model_effects(\u0026ModelCommand) maps back to the effect and apply_command runs it through App::apply_effects, so effects stay the only mutation site and Dirty comes from the effect own EffectKind. Sessions::apply routes Command::Model to apply_model, which takes no SessionId and shares adopt_active_view -- ViewBack, ViewForward and a document-replacing LoadModel all end in App::switch_to_view, so every mirror takes the accepted name (doctrine(lockstep_binds_every_session_not_the_asker)). 7 tests, each proven by injection -- including one that did NOT fire at first because the shared fixture had a single category, so swapping axes and rotating Row-\u003eColumn landed on the same view; it got its own pivoted fixture and the doc comment records why.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-cu7s","title":"[bug] Formula target keeps its pipe delimiters, so a quoted target lands on the wrong _Measure item","description":"From the wave-1 gp-tree review (2026-09-19). CONFIRMED by probe. Likely PRE-EXISTING and user-facing, not introduced by the gp work.\n\nimprovise-formula/src/parser.rs:55 does target = Some(inner.as_str().trim().to_string()), taking the RAW span; formula.pest:20 documents this deliberately ('The target keeps its raw text (including pipes, if any)'). So parsing '|Total Revenue| = ...' yields Formula { target: \"|Total Revenue|\" } -- 15 characters including the pipes -- whereas an Expr::Ref for the same name is correctly unquoted.\n\nConsequence: Model::add_formula then targets a _Measure item whose literal name contains pipes, which never matches the user's item 'Total Revenue', and on save the writer quotes it a SECOND time. Probe confirmed targets 'Total Revenue', 'SUM', 'a|b' and '' all come back pipe-wrapped.\n\nINVESTIGATE FIRST -- the reviewer found this via improvise-gp, but the same parse path serves the formula minibuffer and the .improv '## Formulas' load path. Check whether a user typing a pipe-quoted target in the TUI, and a .improv file containing one, hit the same bug. If so this is a user-facing data bug, not a gp-only one, and its priority should rise.\n\nFIX LOCATION IS A DECISION: unquote in improvise-formula so target matches Expr::Ref's treatment (consistent, but changes a documented grammar behaviour and anything relying on the raw span), or unquote at each call site. Whichever, improvise-gp must stop asserting the broken shape: lib.rs:267-276 an_awkward_target_name_is_quoted_and_round_trips asserts parsed.target == \"|Total Revenue|\" and calls it correct.","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":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T20:43:11Z","created_by":"Edward Langley","updated_at":"2026-09-19T21:50:36Z","started_at":"2026-09-19T21:15:04Z","closed_at":"2026-09-19T21:50:36Z","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} @@ -173,7 +174,7 @@ {"_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":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:04:14Z","created_by":"wave2-h-frontend","updated_at":"2026-09-20T18:04:14Z","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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:01:04Z","created_by":"wave2-p-milestones","updated_at":"2026-09-20T18:01:04Z","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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T17:57:25Z","created_by":"wave2-n-cleanups","updated_at":"2026-09-20T17:57:25Z","labels":["area:command"],"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":"open","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-20T17:49:30Z","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-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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T07:49:56Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:57:52Z","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":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T07:49:56Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:49:56Z","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -191,7 +192,7 @@ {"_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":"deferred","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T05:31:33Z","created_by":"Edward Langley","updated_at":"2026-09-20T05:31:35Z","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":"deferred","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T05:31:32Z","created_by":"Edward Langley","updated_at":"2026-09-20T05:31:34Z","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":"deferred","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T05:31:31Z","created_by":"Edward Langley","updated_at":"2026-09-20T05:31:33Z","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} -{"_type":"issue","id":"improvise-ktv1","title":"Move the help content into the shared layer so a browser can show help","description":"? and F1 now enter AppMode::Help in the browser -- the shared keymap delivered that for free -- BUT THE BROWSER HAS NO HELP OVERLAY, so the mode changes and nothing appears. src/ui/help.rs is ~600 lines of ratatui and nothing shared carries its content.\n\nTHE INTERIM, already shipped: the page draws AppMode::hint_text() in a #hint element, so each mode states its own keys. That is genuinely useful and is not a placeholder to rip out -- but it is one line, not five pages.\n\nWHY IT WAS DONE THAT WAY RATHER THAN SPECIAL-CASING: the alternative was to make the client refuse \"enter-mode help\", which is exactly the hand-written exception the keymap consolidation had just deleted. Declining to re-add one was right.\n\nTHE REAL FIX is the same shape as every other consolidation this week (design-principles 5): the help CONTENT is data -- pages of (key, description) -- and it belongs in improvise-protocol where both front ends read it. The TUI keeps its ratatui LAYOUT; the browser writes its own DOM; neither restates what the keys are.\n\nNOTE THE OPPORTUNITY: help content that lives beside the keymap can be DERIVED from it rather than written twice, which would close a drift nobody currently checks -- there is no test that the help text and the bindings agree, and doctrine(binding_dump_diff) exists because bindings have almost no direct tests. A help page generated from Keymaps::default_keymaps() cannot go stale.","acceptance_criteria":"A browser shows help. The content is data — pages of (key, description) — in improvise-protocol, with the TUI keeping its ratatui layout and the browser writing its own DOM; neither restates what the keys are. PREFER DERIVING IT FROM Keymaps::default_keymaps(), which closes a drift nothing currently checks: there is no test that the help text and the bindings agree. The AppMode::hint_text() interim in the browser is NOT a placeholder to rip out — it is one line per mode and genuinely useful; say what survives.","status":"in_progress","priority":3,"issue_type":"task","assignee":"wave2-h-frontend","owner":"el-github@elangley.org","created_at":"2026-09-20T04:38:45Z","created_by":"Edward Langley","updated_at":"2026-09-20T17:53:40Z","started_at":"2026-09-20T17:53:40Z","lease_expires_at":"2026-09-20T17:58:40Z","heartbeat_at":"2026-09-20T17:53:40Z","labels":["area:shared-frontend"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-ktv1","title":"Move the help content into the shared layer so a browser can show help","description":"? and F1 now enter AppMode::Help in the browser -- the shared keymap delivered that for free -- BUT THE BROWSER HAS NO HELP OVERLAY, so the mode changes and nothing appears. src/ui/help.rs is ~600 lines of ratatui and nothing shared carries its content.\n\nTHE INTERIM, already shipped: the page draws AppMode::hint_text() in a #hint element, so each mode states its own keys. That is genuinely useful and is not a placeholder to rip out -- but it is one line, not five pages.\n\nWHY IT WAS DONE THAT WAY RATHER THAN SPECIAL-CASING: the alternative was to make the client refuse \"enter-mode help\", which is exactly the hand-written exception the keymap consolidation had just deleted. Declining to re-add one was right.\n\nTHE REAL FIX is the same shape as every other consolidation this week (design-principles 5): the help CONTENT is data -- pages of (key, description) -- and it belongs in improvise-protocol where both front ends read it. The TUI keeps its ratatui LAYOUT; the browser writes its own DOM; neither restates what the keys are.\n\nNOTE THE OPPORTUNITY: help content that lives beside the keymap can be DERIVED from it rather than written twice, which would close a drift nobody currently checks -- there is no test that the help text and the bindings agree, and doctrine(binding_dump_diff) exists because bindings have almost no direct tests. A help page generated from Keymaps::default_keymaps() cannot go stale.","acceptance_criteria":"A browser shows help. The content is data — pages of (key, description) — in improvise-protocol, with the TUI keeping its ratatui layout and the browser writing its own DOM; neither restates what the keys are. PREFER DERIVING IT FROM Keymaps::default_keymaps(), which closes a drift nothing currently checks: there is no test that the help text and the bindings agree. The AppMode::hint_text() interim in the browser is NOT a placeholder to rip out — it is one line per mode and genuinely useful; say what survives.","status":"closed","priority":3,"issue_type":"task","assignee":"wave2-h-frontend","owner":"el-github@elangley.org","created_at":"2026-09-20T04:38:45Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:18:06Z","started_at":"2026-09-20T17:53:40Z","closed_at":"2026-09-20T18:18:06Z","close_reason":"Closed","labels":["area:shared-frontend"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-e24v","title":"Second tranche of client-side key resolution: ~15 bindings needing no enrichment","description":"improvise-bpq2 closed the cursor half and DELIBERATELY STOPPED rather than half-doing a second tranche. These need NOTHING added to the protocol -- the operands are already in ViewState or a widget cache -- so they are the cheapest remaining keys, and each one currently does NOTHING in a browser (Host::handle answers ClientFrame::Key with an empty vec).\n\n * enter-mode \u003cx\u003e (: ? F1 C F V Esc Tab in most modes) -\u003e ChangeMode\n * toggle-panel-visibility / toggle-panel-and-focus -\u003e SetPanelOpen + ChangeMode\n * cycle-panel-focus\n * help-page-next / help-page-prev\n * transpose, toggle-prune-empty, view-back\n * switch-view-at-cursor / delete-view-at-cursor / create-and-switch-view (ViewListCache is flat)\n * commit-buffer category|formula|item (all operands in ViewState)\n * commit-cell-edit (ADR 002 CellId + the buffer)\n * clear-cell / paste (CellId + ViewState::yanked)\n * set-axis-for-tile / cycle-axis-for-tile\n\nALSO IN REACH, and bpq2 listed them under \"needs enrichment\" before establishing they do not: filter-to-item (Enter), open-item-add-at-cursor (a/o), delete-category-at-cursor (d), cycle-axis-at-cursor (Space) -- CatTreeEntry already carries the name and the axis. Each is a distinct Command shape, which is why they were left.\n\nMETHOD IS SETTLED, follow it: mirror each Cmd LINE FOR LINE including where siblings disagree, and add a lockstep test that drives the client and the real reduce_key over the same keypresses and compares ViewState. Do not tidy sibling commands into one rule -- that makes the client a second derivation, which is what lockstep testing exists to rule out.\n\nAnd update the_normal_mode_keys_that_still_need_the_host_are_these as you go: it is a named list precisely so closing one has to come there and say so.","acceptance_criteria":"Each listed binding resolves client-side with a lockstep test driving the client and the real reduce_key over the same keypresses. Mirror each Cmd LINE FOR LINE including where siblings disagree — Move and MoveTileCursor emit even when they clamp, MovePanelCursor returns nothing when the cursor does not move — and do NOT tidy them into one rule, which is what makes the client a second derivation. the_command_names_that_still_need_the_host_are_these shrinks by exactly the bindings closed, and closing one has to come to that list and say so. Note improvise-fdt6: commit-buffer formula is on this list and is also why a browser cannot add a formula at all.","status":"closed","priority":3,"issue_type":"task","assignee":"wave2-h-frontend","owner":"el-github@elangley.org","created_at":"2026-09-20T02:36:43Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:05:09Z","started_at":"2026-09-20T17:53:38Z","closed_at":"2026-09-20T18:05:09Z","close_reason":"Closed","labels":["area:shared-frontend"],"dependencies":[{"issue_id":"improvise-e24v","depends_on_id":"improvise-fdt6","type":"relates-to","created_at":"2026-09-20T07:55:25Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-s17o","title":"reduce_full and apply_command's view-slice arm have no production caller","description":"Found while fixing improvise-ondj (2026-09-20), and it is the reason that defect could sit unnoticed.\n\nui/reduce.rs module doc says: \"the one place a command is applied to the authoritative state\" and \"Every deployment mode -- the native TUI, a websocket server, a browser worker -- reaches the model through the functions here.\"\n\nTHAT IS ASPIRATIONAL. reduce_full has NO PRODUCTION CALLER ANYWHERE IN THE WORKSPACE. Every host reaches the model through Sessions::apply or reduce_key. Nothing exercised reduce_full Command::Model arm, so nothing could notice it skipped the basis check -- a door advertised by documentation and walked by nobody is exactly the shape that hides a defect until the next host arrives.\n\nSECOND HALF, same shape: apply_command other =\u003e reduce_view(\u0026mut app.view_state, other) folds a wire VIEW command into the AUTHORITATIVE App own slice. Nothing in production does that -- Sessions::apply folds view commands into the CLIENT MIRROR instead. So view_slice_commands_land_in_the_authoritative_view_state is testing a path no host walks, and passing tells you nothing about any deployment.\n\nTHE DECISION: are these a host API worth keeping, or test-only scaffolding wearing a module doc? If the former, HAVE A HOST USE ONE -- improvise-9x6 (the browser worker) is the natural candidate and is being built now, so this is decidable immediately rather than in principle. If the latter, delete them and correct the module doc, which is currently making a promise the code does not keep.\n\nDo not just delete the test: decide what the function is for first.","acceptance_criteria":"reduce_full and apply_command's view-slice arm either have a production caller or are deleted with the module doc corrected. The module doc currently promises 'every deployment mode reaches the model through the functions here', which is false and is what let improvise-ondj's defect sit unnoticed. NOTE: the post-wave review found Sessions::apply has ALSO lost its production caller (Host::command now calls apply_checked), and reduce.rs:48 still advertises it as 'the door that does it for you' — same question, same file, decide them together.","status":"in_progress","priority":3,"issue_type":"task","assignee":"wave2-n-cleanups","owner":"el-github@elangley.org","created_at":"2026-09-20T01:54:30Z","created_by":"Edward Langley","updated_at":"2026-09-20T17:57:47Z","started_at":"2026-09-20T17:57:47Z","lease_expires_at":"2026-09-20T18:02:47Z","heartbeat_at":"2026-09-20T17:57:47Z","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-r4lo","title":"Two answers to what a typed string means, and they are adjacent","description":"Noticed during improvise-xks6 (2026-09-20) and left alone, because the envelopes were that ticket. Searched first: nothing open covers it.\n\nTWO CONTRADICTORY DOC COMMENTS, now one level below the envelopes they used to hide behind:\n\n * Client::set_cell_at says classifying a typed string \"is the HOST decision, not one to re-implement here\", and takes a SERIALIZED CellValue.\n * socket.rs::typed says the OPPOSITE -- \"Putting it in Rust rather than in the page JavaScript is what makes it testable\" -- and typed_cell takes a PLAIN STRING.\n\nBoth now return the same ClientFrame, so this is the same disease improvise-xks6 just cured, one level down: TWO ANSWERS TO ONE QUESTION, kept apart only by the fact that nothing called both.\n\nWHICH ONE IS RIGHT: typed_cell is the one web/app.js calls and the one repo-map gotcha 42 blesses -- the browser has no App to ask, and ModelCommand::SetCell takes a CellValue, so somebody on the client side must decide; putting it in Rust rather than JS is what makes it testable and keeps one spelling per language boundary. set_cell_at is UNREFERENCED by the page.\n\nSo the likely answer is to delete set_cell_at and fix the doc, but check first whether anything else (a test, the lockstep harness, a future worker bootstrap) wants the pre-classified form -- a host that HAS an App genuinely can classify, and improvise-9x6 puts an App in the browser, which may make set_cell_at the right entry point for the WORKER deployment and typed_cell right for the socket one. If so the fix is to say that in both doc comments rather than to delete either. Decide it, do not leave two claims standing.","status":"closed","priority":3,"issue_type":"task","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T01:52:37Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:22:09Z","started_at":"2026-09-20T05:32:35Z","closed_at":"2026-09-20T06:22:09Z","close_reason":"Closed by PR #21 (WU-I). DECIDED: DELETE, do not re-document.\n\nThe ticket offered an escape hatch -- that improvise-9x6's browser App might want the pre-classified form, in which case the fix would be to say so in both doc comments rather than delete either. THAT ESCAPE HATCH DOES NOT EXIST. ModelCommand::SetCell carries a CellValue, so nothing that crosses a frame is ever a string, and the worker's App sits BEHIND the frame boundary and is handed a value. So set_cell_at's claim -- that classifying a typed string is \"the HOST's decision\" -- is false on every deployment, not just on the socket one.\n\nBoth set_cell_at AND set_cell are gone. set_cell cannot express ClearCell, which makes it a STRICTLY WEAKER DUPLICATE of typed_cell_frame rather than a peer -- and its the_json_wrapper_composes_the_same_message was exactly the compatibility test doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished) says a finished consolidation deletes.\n\nTEST COUNT DROPPED 5 AND IS FULLY ACCOUNTED: 2 aimed at deleted code; 3 were None-shape tests already asserted against typed_cell_frame in socket.rs. The 4th None shape -- ADR 001's absent basis -- had no twin, so a_client_with_no_basis_composes_nothing was RETARGETED rather than dropped, and proved discriminating by seeding a Projection::Basis into its fixture and watching it flip to Some.\n\nThree doc comments elsewhere named the deleted fn (keys.rs, state.rs, socket.rs); all re-pointed, per the grep rule.\n\nWHAT REMAINS, filed as improvise-j7v6: socket.rs::typed still duplicates commit_regular_cell_value on the host key path. Both are legitimate -- the browser has no App to ask -- but the RULE is written twice, and a_typed_string_is_classified_as_the_host_classifies_it states the obligation in its name while asserting three outcomes by hand rather than comparing against the host's function, so the two can drift and stay green.","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -199,7 +200,7 @@ {"_type":"issue","id":"improvise-6c6q","title":"[bug] apply_switch relays a refused switch to everyone but the asker","description":"Found by the code review of ADR 001 layer 1 (2026-09-19). Pre-existing -- the pre-wave broadcast: Option\u003cCommand\u003e was explicitly \"every OTHER client\" -- and the doc overclaims.\n\napply_switch doc says: \"a client that optimistically folded a name the document turned down is corrected by the same message that tells the others to follow.\" But it relays Broadcast::ToOthers, so THE ASKER IS NOT SENT THE CORRECTION. If the workbook refuses the switch (the view does not exist), the asker slice keeps the asked-for name while its mirror holds the accepted one -- gotcha 24 mismatch, and gotcha 29 says the two copies must move together.\n\nUNLIKE the apply_model case the review fixed, a blanket ToAll is WRONG here: the asker really did fold optimistically on the accepted path, so ToAll would double-apply on the common path.\n\nTHE HONEST FIX IS CONDITIONAL: ToAll when accepted != asked, ToOthers otherwise. Which is to say the audience is a function of whether the asker \"folded the right thing\", not of the command variant -- a slightly sharper version of doctrine(the_audience_follows_the_optimistic_fold).\n\nCheap. The reason it is P3 and not higher is that switching to a view that does not exist is not a thing a well-behaved client does; it is reachable through the : command line.\n\n## Steps to Reproduce\n\n1. Two clients on one document.\n2. Client A asks to switch to a view that does NOT exist, reachable through the `:` command line.\n3. The workbook refuses; `apply_switch` relays `Broadcast::ToOthers`, so every OTHER client is told the accepted name and **the asker is not**.\n4. A's own slice keeps the asked-for name while its server-side mirror holds the accepted one — gotcha 24's mismatch, and gotcha 29 says the two copies must move together.\n\n`apply_switch`'s own doc claims \"a client that optimistically folded a name the document turned down is corrected by the same message that tells the others to follow\". It is not.\n","acceptance_criteria":"apply_switch's audience is conditional: ToAll when the accepted view differs from the asked-for one, ToOthers otherwise. A blanket ToAll is WRONG here — the asker really did fold optimistically on the accepted path, so it would double-apply on the common path, which is what distinguishes this from the apply_model case. A test drives a switch to a view that does not exist and asserts the ASKER is corrected; note that arm is reachable only through the : command line.","notes":"ALSO FIX THE DOC COMMENT, and treat it as the more dangerous half. apply_switch's doc (project.rs:~714, verbatim on main) actively claims 'a client that optimistically folded a name the document turned down is corrected by the same message that tells the others to follow.' It is not -- the relay is ToOthers. A comment that says the hole is closed is worse than the hole, because the next reader stops looking.\n\nCopilot surfaced this independently on PR #12 and its suggested shape is right and cheap: apply_switch ALREADY RECEIVES the asked-for name -- it takes the whole cmd, which IS Command::SwitchView(asked) -- it simply never destructures it. So the conditional audience (ToAll when accepted != asked, ToOthers otherwise) needs no new parameter.\n\nTHE REAL COST OF THIS TICKET IS THE REGRESSION TEST, NOT THE CHANGE. It must drive THE ASKER'S OWN SLICE, not the to_others helper -- that helper looks away from the asker by construction, and that is exactly how the sibling bug in apply_model (fixed in 9cb16ce) shipped green.","status":"in_progress","priority":3,"issue_type":"bug","assignee":"wave2-e-active-view","owner":"el-github@elangley.org","created_at":"2026-09-20T01:05:15Z","created_by":"Edward Langley","updated_at":"2026-09-20T17:53:51Z","started_at":"2026-09-20T17:53:51Z","lease_expires_at":"2026-09-20T17:58:51Z","heartbeat_at":"2026-09-20T17:53:51Z","labels":["area:active-view"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-z754","title":"Document name, dirty flag and file path have no home a client can read","description":"draw_title reads workbook.model.name, model_state.dirty and model_state.file_path. NONE IS PROJECTED. improvise-wasm-client RenderModel carries mode, status and view_name but not these, so A BROWSER TITLE BAR CANNOT SHOW THE DOCUMENT NAME OR THE DIRTY MARKER.\n\nFound closing improvise-n10, alongside the grid-frame gaps.\n\nWORTH DECIDING RATHER THAN JUST ADDING: dirty is a fact about the document that every client shares, and name/path likewise -- so these want a projection or a ViewState field, not a per-widget cache. But note file_path is also the thing Save uses and a browser has no filesystem path in the same sense (gotcha 2, gotcha 28), so \"what a remote client should be told about where the document lives\" is a real question and not obviously \"the path\".","acceptance_criteria":"A client can render a title bar: document name, dirty marker and whatever the answer to 'where does this document live' turns out to be. These are facts about the DOCUMENT that every client shares, so they want a projection or a ViewState field rather than a per-widget cache — say which and why. Note file_path is the thing Save uses and a browser has no filesystem path in the same sense (gotchas 2, 28, and now improvise-368h's ClientFrame::Save which deliberately carries none), so 'what a remote client should be told about where the document lives' is a real question and the answer is probably not the path.","status":"closed","priority":3,"issue_type":"task","assignee":"wave2-b-projection","owner":"el-github@elangley.org","created_at":"2026-09-20T00:58:35Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:11:14Z","started_at":"2026-09-20T18:05:59Z","closed_at":"2026-09-20T18:11:14Z","close_reason":"Closed","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-1125","title":"Row-header width is computed per frame in the widget","description":"src/ui/grid.rs::render_grid computes sub_widths / row_header_width by walking layout.row_items per level. GridCache carries col_widths but NO row-header width.\n\nIts sibling compute_col_widths already moved into improvise-command::ui::viewport; this one did not. So a browser client sizing its row header will invent a second rule -- the exact shape of improvise-edp (the category panel walked twice) and of the multi-level-labels divergence.\n\nSmall, and it pairs naturally with the multi-level labels ticket, because both are facts about the grid FRAME rather than its contents, and the frame is what improvise-n10 deliberately left behind.","status":"closed","priority":3,"issue_type":"task","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T00:58:34Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:41:34Z","started_at":"2026-09-20T05:32:27Z","closed_at":"2026-09-20T07:41:34Z","close_reason":"Closed by PR #23 (WU-A), and the ticket's premise was understated: the row-header width rule existed TWICE ALREADY -- inline in src/ui/grid.rs::render_grid and in viewport::compute_row_header_width -- rather than once-and-unshared.\n\nIt is now improvise_protocol::header_level_widths, with both callers reading it.\n\nTHE TICKET SAID TO PUT IT BESIDE compute_col_widths IN improvise-command::ui::viewport. THAT HOME IS UNREACHABLE and taking it would have guaranteed the second derivation this ticket exists to prevent: improvise-wasm-client cannot depend on improvise-command, so a browser sizing its own row header would have had to invent a third copy. improvise-protocol is the only shared home.\n\nIT TAKES AN ITERATOR OF BORROWED TUPLES rather than an AxisFrame, and that is load-bearing: its other caller is compute_row_header_width, reached from App::cmd_context ON EVERY KEYPRESS. Building a frame there would clone every row tuple per key. An independent reviewer read that path line by line and confirmed no allocation on the keypress path.\n\nimprovise-protocol gained unicode-width to do it, judged acceptable: forbidden(improvise_protocol, ui) is about UI FRAMEWORK types (the worked examples are AxisColor -\u003e ratatui::Color and MinibufColor), text measurement is not one, and GridCache::col_widths was already a Vec\u003cu16\u003e of display cells on the wire -- the unit was already there. Noted honestly: header_widths() has exactly one production consumer today, the TUI widget, so the \"a browser would have been a third copy\" justification is anticipatory rather than actual.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_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":"in_progress","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-20T17:53:39Z","started_at":"2026-09-20T17:53:39Z","lease_expires_at":"2026-09-20T17:58:39Z","heartbeat_at":"2026-09-20T17:53:39Z","labels":["area:shared-frontend"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_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-gd28","title":"Drill over the wire needs the addressed command form","description":"From the ADR 002 spine landing (2026-09-19). The RESOLVER exists and is tested -- App::resolve_aggregate, layout-only by necessity since an aggregate has no existence outside the layout that derives it -- and CellId::Aggregate is emitted. What is missing is the command.\n\nModelCommand::Drill was deliberately NOT added. Drilling creates the _Drill view and writes drill_state, which gotchas 30 and 38 rule session-local and therefore unaddressable by a NULLARY Command -- exactly the reason ApplyAndClearDrill was removed from the wire in improvise-3x3u.\n\nSo a wire Drill needs the ADDRESSED form: the variant carries a SessionId and the staging map moves off the single App onto the session. That is ADR 001 layer 2 (context/adr/001-concurrent-edits.md), so this is blocked on it rather than being independent work.\n\nThe seam is ready: emit, resolve and the type-level guarantee (Drill takes an Aggregate and nothing else) are all in place and tested.","acceptance_criteria":"Drilling works over the wire. It needs the ADDRESSED command form — the variant carries a SessionId and the staging map moves off the single App onto the session — because drilling writes drill_state, which gotchas 30 and 38 rule session-local and therefore unaddressable by a nullary Command. That is ADR 001 layer 2, so this is blocked on improvise-7b8v rather than independent. The seam is ready: App::resolve_aggregate, CellId::Aggregate emission and the type-level guarantee that Drill takes an Aggregate and nothing else all exist and are tested.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T00:18:34Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:56:23Z","labels":["area:protocol"],"dependencies":[{"issue_id":"improvise-gd28","depends_on_id":"improvise-7b8v","type":"blocks","created_at":"2026-09-20T16:20:30Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} @@ -216,7 +217,7 @@ {"_type":"issue","id":"improvise-dkqn","title":"gp-tree::fit should take one slice of rows, not parallel x/y slices","description":"Filed from Copilot PR #10 round 2 (2026-09-19). The PR agent DECLINED the literal request and was right to; this is the real remedy.\n\nCopilot asked five public scoring functions (mse, cost, fit, cv_regress/cv_classify, permutation_importance) to enforce their basic preconditions -- i.e. validate that the parallel x and y slices have matching lengths. That was declined on the grounds that validating a parallel length is exactly the ASSERTED form improvise-n3wt removed from prune_axis_items in the very same PR (make the invariant unrepresentable, do not check it), and that Result on five functions is what AGENTS.md means by keeping Option/Result at the boundaries. The reasoning was posted on the PR thread citing both.\n\nTHE FIX IS A TYPE CHANGE: give these functions one slice of rows rather than parallel x/y. That removes the zip truncation Copilot identified -- the thing that made mse wrong -- and answers the empty-input NaN in standardize/misclass_rate/mse in the same move, because an empty row set is then one shape rather than four independent edge cases.\n\nCONSTRAINT: wave 3 established byte-identical CLI output across nine scenarios including predictions and the saved model. That evidence must be re-established after this change, not assumed -- the whole point of that harness is that a refactor of the search cannot be trusted on a green suite alone.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T22:01:11Z","created_by":"Edward Langley","updated_at":"2026-09-19T22:11:20Z","closed_at":"2026-09-19T22:11:20Z","close_reason":"Row\u003cT\u003e { feat: Vec\u003cf32\u003e, target: T } in fit.rs -- generic in the target because the two supervised shapes differ only there (Row\u003cf32\u003e regression, Row\u003cusize\u003e class code). mse/cost/fit/misclass_rate/standardize/cv_regress/cv_classify/permutation_importance all take one slice, so the parallel lengths zip used to truncate silently cannot be built. Rejected: a Rows newtype with a checked constructor (validation wearing a type -- the thing the doctrine declines), a bare tuple slice, and Result on five signatures. The pairing is made at the boundary that BUILDS the data: preprocess walks one row index and the rows live inside Target, because a Dataset carrying rows and target separately just moves the zip up one level into every caller. Empty input answered once in mean_over -- no rows scored is no error found, not the 0.0/0.0 NaN that propagated through cost and sorted unpredictably against real costs.\n\nBIT-IDENTITY ESTABLISHED, NOT ASSERTED: known-answer vectors were captured from the UNMODIFIED code first -- raw f32::to_bits for every scoring path plus rng.next_u64() after each stochastic run, which is what pins the generator call order and count (SplitMix64::seed() returns the original seed and witnesses nothing). Those literals are now tests/scoring_known_answers.rs (6 tests) and pass unchanged. 37 rows is prime, so no fold count divides evenly and the short last fold is exercised. Nine-scenario CLI byte-identity re-established including model.json sha256. Note the CLI prints 3-4 decimals, which is exactly why the bit-level tests exist: the diff alone could not see a moved accumulation.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-t3ux","title":"cv_regress and cv_classify are O(n-squared)","description":"Filed from Copilot PR #10 round 2 (2026-09-19). Confirmed, not speculative: both do test.contains(i) inside a filter over 0..y.len(), in both loops, so the inner membership test is linear in the fold size.\n\nA bool mask over the row indices fixes it and changes no results. Wants a benchmark rather than a bare assertion of improvement -- and note that results must be bit-identical afterwards, since gp-tree fits are reproducible from a seed and any change in accumulation order would invalidate every recorded seed (see the SplitMix64 known-answer work, improvise-8x9o).\n\nPairs naturally with the row-slice reshape ticket; doing them together avoids touching these functions twice.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T22:01:11Z","created_by":"Edward Langley","updated_at":"2026-09-19T22:11:21Z","closed_at":"2026-09-19T22:11:21Z","close_reason":"complement(n, test) marks the fold in a Vec\u003cbool\u003e and filters; both cross-validators call it. Measured on the split alone (release, 5 folds): n=1000 84us -\u003e 15us (5.8x), n=4000 1.01ms -\u003e 45us (22.7x), n=16000 16.2ms -\u003e 134us (120.6x), n=64000 258ms -\u003e 482us (535.5x). Quadrupling n multiplies the scan by ~16 and the mask by ~3.6 -- the shape changed, not just the constant.\n\nBit-identity: the_training_split_is_what_the_contains_scan_gave asserts complement(20, test) equals the old (0..20).filter(|i| !test.contains(i)) index-for-index over every fold of k in {1,2,3,5,7} -- the equivalence directly, not inferred from a fit that happened to agree. The known-answer vectors from improvise-dkqn also pass unchanged.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-pczv","title":".improv section headers are a second, unquoted naming rule","description":"Found during improvise-i2m5 (2026-09-19), pre-existing and deliberately left alone.\n\nPipe quoting now has one home and a category or item named SUM is written |SUM| in every REFERENCE -- axis lines, item lists, page selections, hidden:, data coords. But section headers never went through quote_name on either side and still do not: improv.pest has category_section = { \"## Category: \" ~ rest_of_line ~ ... } and view_section likewise, and parse.rs::section_title reads them with as_str().trim(). So a name is quoted everywhere it is referenced and raw in its own header.\n\nThat is coherent for a whole-line token, and it is why the round trip still works. But it means '# Model Name', 'Initial View:', '## Category:' and '## View:' are a second naming rule with different escaping. It matters if a name with leading/trailing whitespace, an embedded newline, or a line that looks like another section marker is ever reachable -- today the writer cannot produce one, but nothing enforces that.\n\nDECIDE: either route section headers through quote_name/extract_name like every other name position (grammar change first -- improv.pest is the source of truth), or state in the grammar that the whole-line token is the intended contract and add a test pinning what happens to an awkward name. The current answer is pinned only in a test comment.","notes":"\n--- CONTEXT (2026-09-19) ---\nThe user has confirmed the pipe-quoting canon itself is fine (\"Pipe quoting is\nfine\"), so this ticket is NOT about whether to quote -- it is only about the\none position that still does not: section headers. Decide it on its own merits\n(whole-line token vs. quoted name), not as a re-litigation of the guard.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T21:21:39Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:34:01Z","closed_at":"2026-09-20T00:34:01Z","close_reason":"IT WAS A CORRUPTION BUG, not a tidiness ticket -- reachability was verified rather than assumed, and it is reachable end to end with no hand-construction.\n\nCLASS 1, the file becomes unloadable: RFC 4180 permits a quoted CSV header field containing a newline; csv::Trim::All trims a field EDGES but preserves an embedded break, and build_model calls add_category on the field verbatim. So a header \"Region\\nName\" imports cleanly, format_md writes ## Category: Region / Name across two lines, and parse_md fails. The user imports, works, saves, and cannot reopen. A header containing \\n## Data wrote a second SECTION MARKER.\n\nCLASS 2, worse because it is silent: a name with leading/trailing whitespace round-tripped SUCCESSFULLY AND WRONGLY. The header was written raw and the reader trims, so the category came back as \"Region\" while every data coordinate still said |Region | -- references were already quoted. The cells survive in the file, attach to nothing, and render blank with no error anywhere. Reached via JSON import keys.\n\nCLASS 3, found while closing the others: a name literally spelled |Region| was written bare, consistent only while the reader never looked at pipes -- which stops being true the moment headers accept a quoted form.\n\nDECISION: keep the whole-line contract, STATE IT IN THE GRAMMAR, and quote only what the line cannot carry. improv.pest gains header_name = { pipe_quoted ~ \u0026NEWLINE | line_name }, used by model_name/initial_view/category_section/view_section; format.rs::quote_header is the writing half and names exactly four cases -- empty, edge whitespace, embedded \\n or \\r, and already spelled |...|. EACH CLAUSE IS ONE THING THE READER DOES, which is what makes the list a derivation rather than a grab-bag.\n\nREJECTED routing headers through quote_name, and not as over-caution: it quotes every name that is not a bare identifier, so \"# Q3 Budget\" becomes \"# |Q3 Budget|\" and every existing document header is rewritten on next save for zero correctness gain -- the DEFAULT model name \"Imported Model\" included. The whole-line contract is a genuine property of the position (a header is a token, a reference is not); the fix was to state it and make it TOTAL, not delete it.\n\n\u0026NEWLINE is load-bearing twice: it decides the one ambiguous line (## Category: |a| and |b| is a whole-line name) and it makes the change backward compatible, since a pre-pczv header has no closing pipe at end of line and falls to line_name. 7 tests including a proptest over awkward names on a POPULATED model; 5 faults injected, all caught by a check naming the specific thing broken -- and the pre-existing grammar-walking generated_file_roundtrips fired on two of them, having picked up header_name for free, which is the grammar-as-source-of-truth principle paying out.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-n89z","title":"Three Projection mutations have no producer","description":"Wave-2 review (2026-09-19), LOW/design note. document_projections only ever sends CategoryTreeUpdate::Categories(..). CategoryTreeUpdate::Items, CategoryTreeUpdate::Axes and GridUpdate::Invalidated have no emitter anywhere in the tree.\n\ndesign-principles 'nothing speculative': they are consumer-side machinery justified by prose rather than by a producer. Either emit them (Items and Axes are the fine-grained updates the producer would want once category-tree traffic matters) or record in the protocol why they exist ahead of a producer -- Invalidated has a stated meaning ('contents stale, frame unchanged') that the diff-based producer never needs, which is a legitimate reason to keep it, but it should be written down.","acceptance_criteria":"Each of CategoryTreeUpdate::Items, CategoryTreeUpdate::Axes and GridUpdate::Invalidated either has a production emitter, or carries a doc comment saying why it exists ahead of one, or is deleted. Whichever is chosen, gotcha 33 and the totals doc at render.rs:557-560 stop implying Invalidated is live when it is not — that is the half a reader is actually misled by.","notes":"CONFIRMED AND NARROWED by the post-wave code consistency review (2026-09-20), against main at 22d43b2.\n\nGridUpdate::Invalidated has NO PRODUCER in production: grid_updates never emits it and GridCache::seed never emits it. The only occurrences are render.rs:1211/1269/1939 and reduce.rs:518, ALL IN TESTS. So it is a wire variant that only tests construct.\n\nWHAT MAKES IT WORTH DECIDING RATHER THAN LEAVING: its semantics are documented as LOAD-BEARING in several places — gotcha 33 leans on 'Invalidated means contents stale, frame unchanged', and the totals doc at render.rs:557-560 cites it — so the docs read as if it were live. A reader reasoning about the fold's guarantees will assume something is producing it.\n\nThe two CategoryTreeUpdate arms (Items, Axes) are unchanged from the original report.\n\nSo the decision is unchanged in shape but sharper in evidence: emit them, or write down why a variant exists ahead of its producer. The third option the original text did not name — DELETE Invalidated and fold its meaning into the arms that do exist — is now on the table, because PR #23 gave invalidate_relabelled a real job (a cats change re-identifies every position) which is what Invalidated was describing in the abstract.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T21:04:42Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:50:11Z","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-n89z","title":"Three Projection mutations have no producer","description":"Wave-2 review (2026-09-19), LOW/design note. document_projections only ever sends CategoryTreeUpdate::Categories(..). CategoryTreeUpdate::Items, CategoryTreeUpdate::Axes and GridUpdate::Invalidated have no emitter anywhere in the tree.\n\ndesign-principles 'nothing speculative': they are consumer-side machinery justified by prose rather than by a producer. Either emit them (Items and Axes are the fine-grained updates the producer would want once category-tree traffic matters) or record in the protocol why they exist ahead of a producer -- Invalidated has a stated meaning ('contents stale, frame unchanged') that the diff-based producer never needs, which is a legitimate reason to keep it, but it should be written down.","acceptance_criteria":"Each of CategoryTreeUpdate::Items, CategoryTreeUpdate::Axes and GridUpdate::Invalidated either has a production emitter, or carries a doc comment saying why it exists ahead of one, or is deleted. Whichever is chosen, gotcha 33 and the totals doc at render.rs:557-560 stop implying Invalidated is live when it is not — that is the half a reader is actually misled by.","notes":"CONFIRMED AND NARROWED by the post-wave code consistency review (2026-09-20), against main at 22d43b2.\n\nGridUpdate::Invalidated has NO PRODUCER in production: grid_updates never emits it and GridCache::seed never emits it. The only occurrences are render.rs:1211/1269/1939 and reduce.rs:518, ALL IN TESTS. So it is a wire variant that only tests construct.\n\nWHAT MAKES IT WORTH DECIDING RATHER THAN LEAVING: its semantics are documented as LOAD-BEARING in several places — gotcha 33 leans on 'Invalidated means contents stale, frame unchanged', and the totals doc at render.rs:557-560 cites it — so the docs read as if it were live. A reader reasoning about the fold's guarantees will assume something is producing it.\n\nThe two CategoryTreeUpdate arms (Items, Axes) are unchanged from the original report.\n\nSo the decision is unchanged in shape but sharper in evidence: emit them, or write down why a variant exists ahead of its producer. The third option the original text did not name — DELETE Invalidated and fold its meaning into the arms that do exist — is now on the table, because PR #23 gave invalidate_relabelled a real job (a cats change re-identifies every position) which is what Invalidated was describing in the abstract.","status":"closed","priority":3,"issue_type":"task","assignee":"wave2-b-projection","owner":"el-github@elangley.org","created_at":"2026-09-19T21:04:42Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:16:31Z","started_at":"2026-09-20T18:14:06Z","closed_at":"2026-09-20T18:16:31Z","close_reason":"Closed","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-jxp3","title":"Sessions::connect rebuilds ViewState instead of cloning, so a reconnect snapshot is inexpressible","description":"From improvise-cqq (2026-09-19). The producer agent worked from a branch where ViewState was not Clone, so Sessions::connect CONSTRUCTS the Snapshot's view a second time rather than cloning the session's. At connect time the two are identical by construction, so there is no live defect -- but it means a snapshot of an ALREADY-RUNNING session cannot be expressed, which is exactly what a reconnect or a late-joining second client needs.\n\nViewState now derives Clone + PartialEq (added on the wave-1 review-fix branch so Snapshot could have a round-trip test), so the blocker is gone. Change connect to clone the session's view, and add a test that snapshotting a session which has already folded some commands reproduces that session's state -- not merely a default one. That test is the point: the current code would pass any test taken at connect time.\n\nRelates to invariant(bootstrap_is_a_compaction): the seed is already the diff-from-nothing via Dirty::EVERYTHING, so only the view half needs this.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T20:56:07Z","created_by":"Edward Langley","updated_at":"2026-09-19T21:53:05Z","closed_at":"2026-09-19T21:53:05Z","close_reason":"Fixed in the same commit (8475b2d, PR #10) as Copilot's second finding on that PR. The comment in Sessions::connect justified rebuilding the snapshot's view by claiming ViewState is not Clone -- which stopped being true when it gained the derive on the wave-1 branch so that Snapshot could have a round-trip test (gotcha 25). The snapshot now CLONES the mirror in both connect and rebase, which is also the correct shape: reseed is what decides what a freshly seeded mirror holds, and a client told anything else starts out disagreeing with the server's picture of it. That closes this ticket's actual point -- a snapshot of an already-running session is now expressible.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-i2m5","title":"improvise-io still carries its own pipe-quoting copy, without the keyword guard","description":"Follow-up from the wave-1 review fixes (2026-09-19). Pipe quoting now has one home -- improvise_formula::quote (quote_name/is_bare_name) -- and improvise-gp re-exports it. improvise-io/src/persistence/quoting.rs still has its own pub(super) is_bare_name/escape_pipe/quote_name/pipe_quote, and that copy lacks the keyword guard. It also holds a second copy of the UNQUOTING rule (unescape_pipe vs parser.rs::unquote_pipe).\n\nSwitching io to improvise_formula::quote_name is not purely mechanical: it would change .improv output for a category or item literally named SUM/WHERE/etc to |SUM|. That still parses (improv.pest accepts pipe_quoted), and the persistence round-trip tests will show the delta -- so the change is safe but visible, and someone should look at the diff rather than assume.\n\nDecide the keyword guard deliberately. It is NOT a correctness fix: probing showed the grammar accepts bare keywords in every position ('WHERE = A', 'X = SUM', 'SUM(WHERE)' all parse). It was kept in improvise-formula because that acceptance is an emergent property of PEG ordering rather than a stated rule, and a pipe-quoted name is an identifier everywhere with nothing to fall through. Cost is cosmetic.","notes":"\n--- CONFIRMED BY THE USER (2026-09-19) ---\n\"Pipe quoting is fine.\" The keyword guard as canonical behaviour is a USER\nDECISION, not an agent preference. So .improv writing |SUM| for a category or\nitem literally named SUM stays, and this is not to be revisited as\nover-caution by a later reader who notices that bare keywords happen to parse.\n\nThey do parse -- agg_call needs a ( and PEG backtracking falls through to\nref_expr -- but that is rule ordering rather than a stated rule, and a\n## Formulas line carries formula text verbatim, so a name format_md writes is\nhanded straight back to parse_formula. One namespace, one spelling rule.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T20:50:39Z","created_by":"Edward Langley","updated_at":"2026-09-19T22:10:02Z","closed_at":"2026-09-19T21:21:38Z","close_reason":"improvise-io/src/persistence/quoting.rs deleted entirely. improvise_formula::quote is now the ONE home and owns both halves: is_bare_name/quote_name (writing a name, keyword guard included), pipe_quote (unconditional, for .improv text cells that must not read back as numbers), and unescape_pipe_body (reading either back). parser.rs::unquote_pipe is now a 3-line shim over it, down from a 26-line duplicate.\n\nKEYWORD GUARD MADE CANONICAL, with a reason beyond taste: a ## Formulas line carries formula text verbatim, so a name format_md writes is later handed to parse_formula -- one namespace, so one spelling rule, and the one that does not lean on emergent PEG backtracking is the safer canon. USER-VISIBLE FORMAT DELTA, measured not assumed: a characterization test was written FIRST and confirmed passing on the old code, then was the only failure in the workspace after the switch. .improv now writes |SUM| for a category or item literally named SUM, on axis lines, item lists and data coords. Section headers are unchanged (see the follow-up ticket).\n\nUNQUOTING DIVERGENCE ANSWERED: the two old copies agreed on every input either grammar's pipe_inner can produce. They differed on exactly one unreachable input, a trailing lone backslash, where io returned it and parser.rs panicked via .expect(GRAMMAR_INVARIANT). The total (io) behaviour is canonical and pinned by a_trailing_lone_backslash_is_kept, on the grounds that a caller arriving by a non-grammar route should get its bytes back rather than a panic. 5 tests added.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-ipgl","title":"The pipe-quoting rule now has three copies, and two of them already differ","description":"From the wave-1 gp-tree review (2026-09-19). design-principles.md §3 calls this convention 'shared between the .improv persistence format and the formula parser's identifier syntax' -- one rule. It is now spelled three times:\n * improvise-io/src/persistence/quoting.rs:4-56 (is_bare_name/escape_pipe/quote_name), pub(super)\n * improvise-formula's tokenizer\n * improvise-gp/src/lib.rs:86-112 (new)\nThe gp copy was FORCED, not lazy -- the io one is pub(super) and genuinely unreachable. But the copies already diverge: gp guards the seven keywords (WHERE/SUM/AVG/MIN/MAX/COUNT/IF), io does not.\n\nNatural single home is improvise-formula, which both improvise-io and improvise-gp already depend on. Moving it there also settles which behaviour is canonical -- decide the keyword guard deliberately rather than by which copy you happened to read.","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":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T20:43:13Z","created_by":"Edward Langley","updated_at":"2026-09-19T21:50:37Z","started_at":"2026-09-19T21:15:05Z","closed_at":"2026-09-19T21:50:37Z","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} @@ -256,7 +257,7 @@ {"_type":"issue","id":"improvise-jjx","title":"Scrolled grid suppresses first visible row-header label","description":"src/ui/grid.rs:338-350: repeat-suppression shows a row-header sub-label only when ri == 0 or the prefix differs from data_row_items[ri-1]. ri is the absolute data-row index (starts at row_offset, grid.rs:269), so when the view is scrolled the first visible row compares against the off-screen row above and its label is blanked — the user loses the group context at the top of the viewport. Fix: compare against the previous *visible* row, i.e. always show labels for the first rendered data row. 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:58:38Z","created_by":"Edward Langley","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-byz","title":"records_display returns Some(\"\") for out-of-bounds columns instead of None","description":"crates/improvise-core/src/view/layout.rs:186-202: records_display() calls col_label(col), which returns an empty string for out-of-bounds indices; the empty string then fails the == \"Value\" check, is looked up as a category name, and the function returns Some(\"\") via unwrap_or_default(). cell_key() (~line 396) explicitly returns None for an empty col_label — records_display should be symmetric so view/data mismatches are not masked. 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:58:35Z","created_by":"Edward Langley","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-zgb","title":".improv formula loader mis-splits category suffix via rfind(\" [\")","description":"crates/improvise-io/src/persistence/mod.rs:403-411: the formulas_section walker finds the [Category] suffix with raw.rfind(\" [\") + ends_with(']'). Two failure modes: (1) a pipe-quoted identifier at the end of the formula containing ' [' (e.g. |Revenue [USD]|) is mis-split, treating part of the quoted name as the category; (2) a suffix written without a leading space (X=Y[Cat]) is not recognized and the whole line is parsed as a _Measure formula, losing the target category. Fix: move the category suffix into the pest grammar instead of string scanning. Write failing tests first per AGENTS.md. 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:58:32Z","created_by":"Edward Langley","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-drg","title":"vb4 step 5: narrow Effect::apply signatures where one-slice","description":"Optional follow-up to vb4 structural split. For each impl Effect for Foo, audit which slice(s) it mutates. If apply only touches ModelState (or only ViewState), narrow its signature from fn apply(\u0026self, app: \u0026mut App) to fn apply(\u0026self, model: \u0026mut ModelState) (or ViewState). Cross-cutting effects (read layout, change mode that triggers layout rebuild, etc.) stay on \u0026mut App. Improves type-level documentation and enables finer-grained effect tests. Lower priority — vb4's acceptance criterion (4) explicitly allows leaving cross-cutting on \u0026mut App.","design":"Hidden trait-design decision surfaced during vb4 step 4 review: the issue's\nphrasing — \"narrow its signature from fn apply(\u0026self, app: \u0026mut App) to\nfn apply(\u0026self, model: \u0026mut ModelState)\" — is not directly possible. You\ncannot change one Effect impl's signature without changing the Effect\ntrait. Three approaches, none picked:\n\n(A) Split into multiple traits — ModelEffect / ViewEffect / AppEffect.\n Each impl becomes the narrower thing it actually is. Cost: ~50\n existing impls reorganize across three traits, and Vec\u003cBox\u003cdyn Effect\u003e\u003e\n has to become an enum wrapper (or three parallel queues) to keep\n apply_effects working. Highest fidelity to the issue's literal wording.\n\n(B) Keep one trait, rebind inside the impl body — e.g.\n fn apply(\u0026self, app: \u0026mut App) { let view = \u0026mut app.view_state; view.mode = ... }\n so the slice is visible at the call site without changing the\n signature. Lowest disruption. Doesn't strictly satisfy acceptance\n criterion (1) (a \"narrower borrow\") — only the body is narrower.\n\n(C) Default-method dispatch on a single trait — Effect grows\n apply_view(\u0026mut ViewState) and apply_model(\u0026mut ModelState) with\n default impls that fall through to apply(\u0026mut App). Each impl\n overrides only the method that matches its slice. Middle ground:\n type-level narrowing for impls that opt in, \u0026mut App stays available\n for cross-cutting effects. Wraps cleanly around the existing\n apply_effects loop.\n\nPicking (A) commits to the architecture for downstream effect tagging\n(improvise-mae) and the protocol extraction (improvise-cqi). (B) and (C)\nleave that decision unmade. Recommend deciding before claiming.","acceptance_criteria":"Each Effect whose apply touches only one slice takes \u0026mut ModelState or \u0026mut ViewState; cross-cutting effects stay on \u0026mut App, which vb4's own acceptance explicitly allows. The win is type-level documentation and finer-grained effect tests, not line count. CAUTION, new since this ticket was filed: App::apply_effects now reads dirtiness, layout_version and the record fingerprints off the EffectKind (improvise-gv9r, gotcha 40) — narrowing a signature must not put an effect somewhere that loop cannot see.","notes":"DECLINED 2026-09-20 (wave 2), with counts. Reasoning in context/code/command.md,\n'Effect::apply stays on \u0026mut App'. NOTE: this bead carries the `human` label, so\nthe decline is a recommendation the user may overturn; it is recorded rather than\nsmuggled.\n\nCOUNTED over the 49 `impl Effect` blocks in ui/effect.rs:\n now (this branch) 8 view_state-only, 9 model_state-only, 32 cross-cutting\n base 52507a6 9 view_state-only, 16 model_state-only, 24 cross-cutting\nThe narrow set fell from 25 to 17 in one wave. The mover was improvise-9mo9,\nwhich made nine reshaping effects call app.reset_cursor(): a reshape lives in\nmodel_state.workbook and the cursor it invalidates lives in ViewState, so the\neffects that matter most are precisely the ones that reconcile the two slices.\nNarrowing bets against the direction the code moves in.\n\nFOUR GROUNDS\n1. The narrow set is small and shrinking, for that architectural reason.\n2. The type-level documentation already exists and is CHECKED: Effect::kind()\n has no default (gotcha 3) and apply_effects reads that one answer three times\n (gotchas 40, 45). A narrowed signature is a second statement of the same fact,\n free to disagree -- the defect, not the cure.\n3. Every offered mechanism weakens a guard. (A) three traits needs three queues\n (destroys batch order, which gotchas 21/23/43 lean on) or an enum wrapper (a\n fourth classification beside EffectKind). (C) default methods give `apply` a\n DEFAULT, exactly what kind() does without and for the same reason: overriding\n apply_view on an effect that needs the model compiles and writes the wrong\n slice silently; overriding neither compiles as a no-op. (B) does not meet the\n ticket's own acceptance criterion.\n4. \"Finer-grained effect tests\" is not blocked: effect.rs's 64 tests already call\n Effect.apply(\u0026mut app) against a one-line App::new(wb, None) fixture.\n\nWHAT IS NOT DECLINED: approach (B), rebinding the slice at the top of an apply\nbody, remains available as a per-function style choice. It is not mandated.","status":"in_progress","priority":3,"issue_type":"task","assignee":"wave2-n-cleanups","owner":"el-github@elangley.org","created_at":"2026-04-26T18:16:52Z","created_by":"jeeves","updated_at":"2026-09-20T18:11:23Z","started_at":"2026-09-20T18:08:16Z","lease_expires_at":"2026-09-20T18:13:16Z","heartbeat_at":"2026-09-20T18:08:16Z","labels":["area:command","human"],"dependencies":[{"issue_id":"improvise-drg","depends_on_id":"improvise-99k","type":"blocks","created_at":"2026-04-26T11:17:16Z","created_by":"jeeves","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-drg","title":"vb4 step 5: narrow Effect::apply signatures where one-slice","description":"Optional follow-up to vb4 structural split. For each impl Effect for Foo, audit which slice(s) it mutates. If apply only touches ModelState (or only ViewState), narrow its signature from fn apply(\u0026self, app: \u0026mut App) to fn apply(\u0026self, model: \u0026mut ModelState) (or ViewState). Cross-cutting effects (read layout, change mode that triggers layout rebuild, etc.) stay on \u0026mut App. Improves type-level documentation and enables finer-grained effect tests. Lower priority — vb4's acceptance criterion (4) explicitly allows leaving cross-cutting on \u0026mut App.","design":"Hidden trait-design decision surfaced during vb4 step 4 review: the issue's\nphrasing — \"narrow its signature from fn apply(\u0026self, app: \u0026mut App) to\nfn apply(\u0026self, model: \u0026mut ModelState)\" — is not directly possible. You\ncannot change one Effect impl's signature without changing the Effect\ntrait. Three approaches, none picked:\n\n(A) Split into multiple traits — ModelEffect / ViewEffect / AppEffect.\n Each impl becomes the narrower thing it actually is. Cost: ~50\n existing impls reorganize across three traits, and Vec\u003cBox\u003cdyn Effect\u003e\u003e\n has to become an enum wrapper (or three parallel queues) to keep\n apply_effects working. Highest fidelity to the issue's literal wording.\n\n(B) Keep one trait, rebind inside the impl body — e.g.\n fn apply(\u0026self, app: \u0026mut App) { let view = \u0026mut app.view_state; view.mode = ... }\n so the slice is visible at the call site without changing the\n signature. Lowest disruption. Doesn't strictly satisfy acceptance\n criterion (1) (a \"narrower borrow\") — only the body is narrower.\n\n(C) Default-method dispatch on a single trait — Effect grows\n apply_view(\u0026mut ViewState) and apply_model(\u0026mut ModelState) with\n default impls that fall through to apply(\u0026mut App). Each impl\n overrides only the method that matches its slice. Middle ground:\n type-level narrowing for impls that opt in, \u0026mut App stays available\n for cross-cutting effects. Wraps cleanly around the existing\n apply_effects loop.\n\nPicking (A) commits to the architecture for downstream effect tagging\n(improvise-mae) and the protocol extraction (improvise-cqi). (B) and (C)\nleave that decision unmade. Recommend deciding before claiming.","acceptance_criteria":"Each Effect whose apply touches only one slice takes \u0026mut ModelState or \u0026mut ViewState; cross-cutting effects stay on \u0026mut App, which vb4's own acceptance explicitly allows. The win is type-level documentation and finer-grained effect tests, not line count. CAUTION, new since this ticket was filed: App::apply_effects now reads dirtiness, layout_version and the record fingerprints off the EffectKind (improvise-gv9r, gotcha 40) — narrowing a signature must not put an effect somewhere that loop cannot see.","notes":"DECLINED 2026-09-20 (wave 2), with counts. Reasoning in context/code/command.md,\n'Effect::apply stays on \u0026mut App'. NOTE: this bead carries the `human` label, so\nthe decline is a recommendation the user may overturn; it is recorded rather than\nsmuggled.\n\nCOUNTED over the 49 `impl Effect` blocks in ui/effect.rs:\n now (this branch) 8 view_state-only, 9 model_state-only, 32 cross-cutting\n base 52507a6 9 view_state-only, 16 model_state-only, 24 cross-cutting\nThe narrow set fell from 25 to 17 in one wave. The mover was improvise-9mo9,\nwhich made nine reshaping effects call app.reset_cursor(): a reshape lives in\nmodel_state.workbook and the cursor it invalidates lives in ViewState, so the\neffects that matter most are precisely the ones that reconcile the two slices.\nNarrowing bets against the direction the code moves in.\n\nFOUR GROUNDS\n1. The narrow set is small and shrinking, for that architectural reason.\n2. The type-level documentation already exists and is CHECKED: Effect::kind()\n has no default (gotcha 3) and apply_effects reads that one answer three times\n (gotchas 40, 45). A narrowed signature is a second statement of the same fact,\n free to disagree -- the defect, not the cure.\n3. Every offered mechanism weakens a guard. (A) three traits needs three queues\n (destroys batch order, which gotchas 21/23/43 lean on) or an enum wrapper (a\n fourth classification beside EffectKind). (C) default methods give `apply` a\n DEFAULT, exactly what kind() does without and for the same reason: overriding\n apply_view on an effect that needs the model compiles and writes the wrong\n slice silently; overriding neither compiles as a no-op. (B) does not meet the\n ticket's own acceptance criterion.\n4. \"Finer-grained effect tests\" is not blocked: effect.rs's 64 tests already call\n Effect.apply(\u0026mut app) against a one-line App::new(wb, None) fixture.\n\nWHAT IS NOT DECLINED: approach (B), rebinding the slice at the top of an apply\nbody, remains available as a per-function style choice. It is not mandated.","status":"closed","priority":3,"issue_type":"task","assignee":"wave2-n-cleanups","owner":"el-github@elangley.org","created_at":"2026-04-26T18:16:52Z","created_by":"jeeves","updated_at":"2026-09-20T18:11:26Z","started_at":"2026-09-20T18:08:16Z","closed_at":"2026-09-20T18:11:26Z","close_reason":"Closed","labels":["area:command","human"],"dependencies":[{"issue_id":"improvise-drg","depends_on_id":"improvise-99k","type":"blocks","created_at":"2026-04-26T11:17:16Z","created_by":"jeeves","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-1ud","title":"Document RecordsEditing variant in repo-map.md","description":"context/repo-map.md currently lists 15 AppMode variants but does not include RecordsEditing, which is referenced at src/draw.rs:142 and src/ui/app.rs:442. Add the variant to the AppMode list in the 'Key Types' section so coding agents see it.\\n\\nTrivial doc fix.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:54:16Z","created_by":"Ed L","updated_at":"2026-06-10T04:05:13Z","closed_at":"2026-06-10T04:05:13Z","close_reason":"Done in commit 0fe04de (2026-06-09 repo-map refresh): AppMode list now reads 16 variants including RecordsNormal and RecordsEditing, and notes MinibufferConfig + the ModelState/ViewState split.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-8fy","title":"Introduce simple_effect! macro for single-field setter effects","description":"crates/improvise-command/src/ui/effect.rs has ~15 simple setter effects (SetSelected, SetRowOffset, SetColOffset, SetYanked, SetSearchQuery, SetSearchMode, SetStatus, etc.) that each implement the same 4-line apply: pub struct SetX(pub T); impl Effect { fn apply(\u0026self, app: \u0026mut App) { app.field = self.0.clone(); } }.\\n\\nAbstraction: simple_effect!(SetRowOffset, usize, row_offset) macro generates struct + Effect impl.\\n\\nCorrectness win: provides a single hook for cross-cutting concerns. If layout-affecting setters need to mark dirty or rebuild layout, one macro body covers all of them. Currently each setter independently decides whether to mark_dirty(), and silent omissions are plausible. ~50 LOC.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:54:14Z","created_by":"Ed L","updated_at":"2026-09-19T20:35:54Z","closed_at":"2026-09-19T20:35:54Z","close_reason":"simple_effect! macro added beside the Effect trait; eight single-field ViewOnly setters declared through it (-42 lines net). The macro HARD-CODES EffectKind::ViewOnly by design: an effect other clients must see is not 'simple' and keeps a hand-written impl, which is how gotcha 3's no-default-kind rule survives a macro at all. SetSelected (two fields) and SetNumberFormat (a Model change) stay hand-written. Existing effect tests already cover all eight, including view_only_effects_leave_shared_state_alone.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-1wu","title":"Extract axis label helper in view/layout.rs","description":"crates/improvise-core/crates/improvise-core/src/view/layout.rs:323-336 (row_label) and :338-351 (col_label) are character-identical except for which items list they read. Small syntactic duplication but trivial to consolidate.\\n\\nFix: fn axis_label(items: \u0026[AxisEntry], idx: usize) -\u003e String, call from both. ~14 LOC.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:54:13Z","created_by":"Ed L","updated_at":"2026-08-14T01:11:07Z","closed_at":"2026-08-14T01:11:07Z","dependencies":[{"issue_id":"improvise-1wu","depends_on_id":"improvise-ai7","type":"supersedes","created_at":"2026-08-13T18:11:07Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -282,8 +283,10 @@ {"_type":"issue","id":"improvise-l36","title":"3.2 Publish to crates.io","description":"After cargo publish --dry-run is clean and user confirms, run cargo publish. Verify crate appears at crates.io/crates/improvise and cargo install improvise works.","acceptance_criteria":"improvise is on crates.io and 'cargo install improvise' works from a clean machine. NEEDS THE USER: crates.io credentials, and their confirmation after 'cargo publish --dry-run' is clean. Note the workspace has grown to nine crates since this was filed — decide which are published and in what order, since a dependent cannot publish before its dependency, and improvise-io/protocol/command are all now real crates rather than internal modules.","notes":"DRY RUN ACTUALLY RUN, 2026-09-20 at 52507a6 (wave 2, WU-P). It is NOT clean,\nand the blocker is mechanical rather than a judgement call:\n\n error: failed to verify manifest at Cargo.toml\n all dependencies must have a version requirement specified when publishing.\n dependency `gp-tree` does not specify a version\n\ncargo stops at the FIRST offender, and gp-tree is not the only one. Every\ninter-crate dependency in this workspace is `{ path = \"...\" }` with no\n`version`, which crates.io forbids for normal dependencies:\n\n Cargo.toml (root) gp-tree, improvise-core, improvise-formula,\n improvise-io, improvise-command\n improvise-core improvise-formula\n improvise-formula (none)\n improvise-protocol improvise-core\n improvise-io improvise-core, improvise-formula\n improvise-gp gp-tree, improvise-core, improvise-formula\n improvise-command improvise-core, improvise-protocol,\n improvise-formula, improvise-io, improvise-gp\n\nDev-dependencies are exempt — cargo strips them on publish — so the root's\n`improvise-wasm-client`/`improvise-protocol` dev deps and improvise-wasm-\nclient's dev dep on the unpublishable improvise-ws-server are fine as they are.\n\nWHICH CRATES PUBLISH. `improvise-ws-server` and `improvise-worker-server` both\ncarry `publish = false` already, so the set is eight, and a dependent cannot go\nbefore its dependency. The only order that works:\n\n 1. improvise-formula (no intra-workspace deps)\n 2. improvise-core\n 3. gp-tree (no intra-workspace deps; can go any time before 4)\n 4. improvise-gp\n 5. improvise-protocol\n 6. improvise-io\n 7. improvise-command\n 8. improvise (the binary)\n\ncrates.io needs a minute or two to index each one before the next can resolve\nit, so this is eight sequential `cargo publish` calls, not one.\n\nTWO VERSION FACTS THE USER MUST DECIDE. `gp-tree` is at `0.1.0` while every\nother crate is at `0.1.0-rc2`; and a `-rc2` version requirement in a published\nmanifest pins consumers to a prerelease. If the intent is to publish 0.1.0\n(which improvise-3tj's tag implies), the version bump across ten manifests\nshould happen BEFORE the version requirements are written, or they will be\nwritten twice.\n\nNAME AVAILABILITY CHECKED: crates.io returns \"crate `improvise` does not exist\",\nso the name is free. Not checked: the other seven names.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:58Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:01:38Z","labels":["area:release","needs:user"],"dependencies":[{"issue_id":"improvise-l36","depends_on_id":"improvise-11a","type":"blocks","created_at":"2026-04-08T21:09:29Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-l36","depends_on_id":"improvise-2fr","type":"blocks","created_at":"2026-04-08T21:09:28Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":2,"comment_count":0} {"_type":"issue","id":"improvise-11a","title":"3.1 Configure cargo dist","description":"Run cargo dist init targeting x86_64-unknown-linux-gnu, aarch64-apple-darwin, x86_64-apple-darwin. Skip Windows and musl. Commit generated .github/workflows/release.yml and Cargo.toml additions. Test with v0.1.0-rc1 tag, verify builds, delete rc tag.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:55Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-11T07:49:57Z","close_reason":"Closed","dependencies":[{"issue_id":"improvise-11a","depends_on_id":"improvise-abz","type":"blocks","created_at":"2026-04-08T21:09:28Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":3,"comment_count":0} {"_type":"issue","id":"improvise-0s6","title":"Phase 3: Distribution","description":"Configure cargo dist, publish to crates.io, tag v0.1.0 release. Produces prebuilt binaries for Linux x86_64 and macOS (Intel + Apple Silicon).","acceptance_criteria":"Closes when improvise-l36 and improvise-3tj close. Both need the user; nothing here is agent work. improvise-11a (cargo dist) is already closed.","notes":"CHECKED, wave 2 WU-P: STAYS OPEN, correctly — both children need the user and\nneither was touched. Nothing was published and nothing was tagged.\n\nWhat this pass DID do is make them ready, and one of them is not as ready as\nit looked. `cargo publish --dry-run` was actually run and FAILS: every\ninter-crate dependency in the workspace is `{ path = ... }` with no `version`,\nwhich crates.io forbids. cargo reports only the first offender (gp-tree), so\nthe dry run understates it — the full list, the publish order for the eight\npublishable crates, and the gp-tree/rc2 version disagreement are on\nimprovise-l36.\n\nimprovise-3tj's premise is already partly false and its note now says so: the\nREADME prebuilt-binaries link is not a placeholder, v0.1.0-rc2 carries real\ncargo-dist artifacts for all four targets, and what is actually missing is the\nv0.1.0 tag and the manifest bump that must precede it.","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-09T04:05:51Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:05:07Z","labels":["area:release","needs:user"],"dependencies":[{"issue_id":"improvise-0s6","depends_on_id":"improvise-11a","type":"blocks","created_at":"2026-04-08T23:37:39Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-0s6","depends_on_id":"improvise-3tj","type":"blocks","created_at":"2026-04-08T23:37:41Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-0s6","depends_on_id":"improvise-l36","type":"blocks","created_at":"2026-04-08T23:37:40Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":3,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-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":"open","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:14:57Z","created_by":"orchestrator","updated_at":"2026-09-20T18:14:57Z","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":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:13:31Z","created_by":"wave2-b-projection","updated_at":"2026-09-20T18:13:31Z","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-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/.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:01:16Z","created_by":"wave2-p-milestones","updated_at":"2026-09-20T18:01:16Z","labels":["area:release"],"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":"TWO ADDITIONS, both instances of this ticket's one fact rather than new ones.\n\nFIRST, WHAT A CHECK MUST ACTUALLY DO — the part that gets lost, and the reason\nthis ticket is not a one-liner. Counting the four `.ap-player` elements PROVES\nNOTHING. `autoPlay` is false, so asciinema-player does not fetch a cast until\nthe viewer presses play; a page whose every cast path 404s constructs four\nplayers, throws no page error before the click, and is indistinguishable from a\nworking one. This was verified by injecting exactly that fault (`./casts/` -\u003e\n`./cats/`): the construction-only check passed on the broken page, and pressing\nplay returned 0 rendered lines and surfaced four 404s. So the spec must press\nplay on each of the four and assert a non-zero rendered line count, and its own\nfault injection must be recorded in `context/test-evidence/`.\n\nThe same applies to the page's one functional link. Since the user's ruling\n(improvise-kh8) the landing page IS the site root and its whole job is to reach\nthe app at `./app/`; a broken link there is a dead front door, and the page\nlooks entirely correct with one. Proved able to fail by pointing it at `./aap/`\n-- 404 while nothing on screen changes -- so the spec should resolve the\ncall-to-action href and assert the response status.\n\nSECOND, THE LINE BUDGET, which is an instance of \"asserted by nothing\" and not\na separate defect. improvise-e61's criterion is \"under 200 lines\" and\n`docs/index.html` is now 199 after the root/app restructure. A criterion met by\none line, with nothing checking it, is met by accident: the next person to touch\nthe file either trims something or silently breaks it, and no test, lint or CI\njob would say so. If this ticket's spec lands, the cheapest place for the budget\nis beside it -- a line count assertion is one statement and needs no browser.\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/` -- the landing page renders and the casts play,\n and no test in the repository would have noticed if they had not.\n4. Break a cast path in `docs/index.html`, repeat, and observe that the page is\n still structurally correct until a play button is pressed.\n5. `wc -l docs/index.html` -- 199, against a stated limit of 200, checked by\n nothing.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:01:16Z","created_by":"wave2-p-milestones","updated_at":"2026-09-20T18:14:01Z","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":"open","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-20T16:53:36Z","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":"open","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T07:50:12Z","created_by":"Edward Langley","updated_at":"2026-09-20T16:53:34Z","labels":["area:core-io"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-zx8t","title":"draw.rs::mode_style matches AppMode with a wildcard, so the repo map's exhaustiveness promise is false there","description":"Found by WU-G on PR #27 while adding AppMode::SavePrompt, and it is a correction to something context/repo-map.md currently promises.\n\nThe repo map says of AppMode: \"Per-variant UI facts live on the enum as exhaustive-match methods -- label(), hint_text(), panel(), minibuffer(), is_editing(), is_records() (improvise-2hi); ADD A VARIANT AND THE COMPILER NAMES EVERY ONE YOU MUST FILL IN.\"\n\nThat is TRUE of the five enum methods and FALSE of src/draw.rs::mode_style, which matches AppMode with a `_ =\u003e` WILDCARD. So a new variant silently inherits whatever the wildcard arm gives -- SavePrompt currently takes ExportPrompt's DarkGray by accident rather than by decision, which happens to be defensible and was nobody's choice.\n\nWHY IT IS P4 AND NOT HIGHER: the consequence is a colour, and the wrong colour is visible rather than silent. But the reason to fix it is that the repo map states a PROPERTY, and a property that holds in five places and not the sixth is worse than one nobody claimed -- the next person adding a variant will trust the sentence.\n\nTHE FIX IS ONE OF TWO, and they are not equivalent:\n * Make mode_style exhaustive, so the compiler does name it. Note mode_style stays in src/draw.rs deliberately, because Style is a ratatui type and improvise-protocol must not name one (gotcha 26, the AxisColor/MinibufColor pattern).\n * Or give AppMode a semantic colour method in the protocol crate -- the AxisColor treatment -- and let draw.rs do the one mapping. That is the established shape here and it would also give a browser something to use, which draw.rs's Style cannot.\n\nThe second is probably right and is the same question improvise-k099 asks about hint_text/label. Decide them together.\n","acceptance_criteria":"mode_style either matches AppMode exhaustively, or AppMode gains a semantic colour method in improvise-protocol that draw.rs maps to a ratatui Style (the AxisColor/MinibufColor shape). Either way, adding an AppMode variant fails the build until someone chooses its colour. The repo-map sentence promising compiler-named sites is true of every listed method, or it is corrected to say which one it is not true of.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T06:04:22Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:44:19Z","labels":["area:tui"],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -293,12 +296,12 @@ {"_type":"issue","id":"improvise-hkt6","title":"Three small totality gaps found in review","description":"Three PLAUSIBLE/minor findings from the 2026-09-19 wave review, each unreachable today and each one refactor away from not being. Grouped because none is worth a ticket alone.\n\n1. DataStore::matching_positions panics on an empty partial -- it indexes sets[0]. Private, and both callers guard with `if partial.is_empty()`, so it is unreachable. But THE GUARD NOW LIVES ONE LEVEL AWAY FROM THE INDEXING, which is how this class of bug returns. A sets.first()?-shaped total function costs nothing.\n\n2. invalidate_relabelled does not blank `totals` the way Invalidated does. By gotcha 33(b) own argument -- preserve-by-position is only right where the label did not change -- a RENAMED column should drop its total. Unreachable in practice: grid_updates diffs mirror.totals != desired.totals and emits Totals after ColLabels in the same batch, so a changed total is always sent. Worth fixing anyway, because 33(b) exists PRECISELY to stop `apply` trusting producers.\n\n3. improvise-gp mark_features indexes used[*feat] without a bound, where expr_text uses .get().expect(...) for the same precondition. Same failure, less legible.","status":"closed","priority":4,"issue_type":"task","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T01:05:16Z","created_by":"Edward Langley","updated_at":"2026-09-20T05:54:44Z","started_at":"2026-09-20T05:32:38Z","closed_at":"2026-09-20T05:54:44Z","close_reason":"Closed by PR #20 (WU-K). All three gaps fixed, each fault-injected.\n\n(a) matching_positions total-ised via split_first. The fix is NOT vec![]: an empty partial constrains NOTHING, so EVERY cell matches -- answering \"none\" one level down would have made deleting a caller's fast path silently flip \"all\" to \"none\". The callers' partial.is_empty() branches are fast paths, not guards, and the test asserts the two doors agree.\n\n(b) invalidate_relabelled now blanks a relabelled COLUMN's total. Rows are the negative arm and stay untouched -- a total spans every row, so no one row frames it, and that arm is what stops \"blank on any relabel\" passing.\n\n(c) mark_features uses .get_mut().expect(...), matching expr_text.\n\nTHE THING THE TICKET DID NOT PREDICT, and the reason this bead was not independent of its own framing: (b) was filed as \"unreachable in practice, fix it anyway because gotcha 33(b) exists to stop apply trusting producers\". Right about the rule, wrong about the cost. Adding a blanking rule on the CONSUMER side changes what every PRODUCER owes, and grid_updates did not pay it: it diffed mirror.totals against desired.totals, so a column renamed while its total stayed the same NUMBER emitted ColLabels and no Totals -- the client blanked its own total and project_diff's self.grid = desired left the server believing it was still there. A client cache wrong forever, gotcha 35's exact failure direction. Found by Copilot. Fixed by diffing totals against the label-folded frame.\n\nWHEN YOU MAKE apply STOP TRUSTING A PRODUCER, GO READ THE PRODUCER.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_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":"in_progress","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-20T17:53:50Z","started_at":"2026-09-20T17:53:50Z","lease_expires_at":"2026-09-20T17:58:50Z","heartbeat_at":"2026-09-20T17:53:50Z","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":"in_progress","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:11:15Z","started_at":"2026-09-20T18:11:15Z","lease_expires_at":"2026-09-20T18:16:15Z","heartbeat_at":"2026-09-20T18:11:15Z","labels":["area:projection"],"dependency_count":0,"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-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":"in_progress","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-20T17:53:40Z","started_at":"2026-09-20T17:53:40Z","lease_expires_at":"2026-09-20T17:58:40Z","heartbeat_at":"2026-09-20T17:53:40Z","labels":["area:shared-frontend"],"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} {"_type":"issue","id":"improvise-n3wt","title":"prune_axis_items: keep-slice length invariant is only a debug_assert","description":"From the wave-1 review (2026-09-19). NOT a defect today -- filed so the latent risk is recorded rather than rediscovered.\n\nimprovise-ai7 replaced the old guard 'data_idx \u003c rc \u0026\u0026 keep_row[data_idx]' with 'keep.get(data_idx).copied().unwrap_or(false)' plus a debug_assert_eq!(data_idx, keep.len()). The reviewer confirmed the lengths cannot diverge today: keep_row/keep_col are built as (0..rc)/(0..cc) collections, rc is row_count() which is itself data_items(\u0026row_items).count(), nothing mutates the axis vectors between building keep and consuming it, and the fn is private with exactly two call sites. The debug_assert can never fire, and cargo test --workspace --release (assert compiled out) is green.\n\nThe risk is what happens IF a third caller ever derives keep from something other than row_count()/col_count(), because debug_assert is compiled out in release and the two builds then differ in OPPOSITE directions from the old code:\n * keep too SHORT: old code panicked (loud, index out of bounds); new code silently drops the item in release -- a crash becomes silent data loss from the grid.\n * keep too LONG: old dropped every item at index \u003e= rc; new keeps them if flagged.\n\nFix by making the count unsupplied-able rather than asserted -- e.g. have prune_axis_items derive keep's length from items itself, or take a newtype that can only be built from the same vector. Failing that, a doc-comment on the fn naming the invariant and both failure directions.","status":"closed","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T20:41:44Z","created_by":"Edward Langley","updated_at":"2026-09-19T20:52:13Z","closed_at":"2026-09-19T20:52:13Z","close_reason":"Fixed structurally rather than by assertion: prune_axis_items now takes impl Fn(usize) -\u003e bool over the DATA index instead of a \u0026[bool] plus a debug_assert. The function no longer receives a length at all -- the index set is generated by walking items -- so there is no parallel quantity a caller can derive wrongly, and debug and release cannot diverge because both the assertion and the unwrap_or fallback are gone. Both failure directions the ticket named become unrepresentable rather than detected. prune_empty now consults the has_value grid directly and the two Vec\u003cbool\u003e allocations disappear; the eager-to-lazy change is safe because pruning row_items touches neither col_items nor has_value, so the column pass still reads the original grid with the original rc (reasoning left in a comment at the call site). Two tests added, including prune_axis_items_indexes_data_positions_not_visual_ones, which pins the contract the old debug_assert only gestured at -- a group header must not shift the numbering the predicate sees. Records mode untouched.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-th7m","title":"Dead F(1) binding: bound to bare 'enter-mode' then immediately rebound","description":"Noticed while working improvise-s13 (2026-09-19), not touched per AGENTS.md (mention dead code, do not delete unasked).\n\nIn crates/improvise-command/src/command/keymap.rs, F(1) is bound to a bare \"enter-mode\" with NO args -- which would fail require_args if it were ever dispatched -- and is then rebound on the very next line with [\"help\"]. The first line is dead: it can never take effect.\n\nTrivial deletion, but worth confirming the second binding is the intended one rather than deleting the wrong line.","status":"closed","priority":4,"issue_type":"chore","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-19T20:36:45Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:54:15Z","started_at":"2026-09-20T05:32:32Z","closed_at":"2026-09-20T07:54:15Z","close_reason":"Closed by PR #27 (WU-G). The dead F(1) line is deleted.\n\nF(1) was bound to a bare \"enter-mode\" with NO ARGS -- which would have failed require_args if it were ever dispatched -- and rebound on the very next line with [\"help\"]. Keymap::bind inserts, so the second won; had the first survived, F1 would have done nothing.\n\nTHE SURVIVOR WAS CONFIRMED TWO WAYS BEFORE DELETING, which is the whole reason this was filed rather than done blind: the binding dump resolves F1 to enter-mode [\"help\"], and `?` on the next line carries the identical binding.\n\nEVIDENCE: IMPROVISE_BINDING_DUMP diff BYTE-IDENTICAL across the deletion (4021 resolved lookups) -- the strongest form of \"this changed nothing\". Plus a new f1_and_question_mark_both_open_help, fault-injected by restoring the deleted line and watched to fail.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-2zq","title":"Records mode has no inline way to add a category, unlike the + column for measures","description":"improvise-c5v gave records mode a trailing '+' column: type a name, the measure is created, and it becomes a real column you can immediately type values into — without leaving the grid. There is no equivalent for categories, which is an asymmetry the wide-format work introduced.\n\nWHAT EXISTS TODAY (all leave the grid):\n- N in normal or records mode -\u003e search-or-category-add (command/cmd/search.rs:172). Emits SetPanelOpen{Category} + enter-mode category-add, so it opens the side panel and switches mode. Records-normal inherits it from the normal keymap (keymap.rs:645 builds rn with_parent(normal)).\n- n inside the category panel -\u003e the same CategoryAdd mode.\n- :add-cat Region (alias for add-category).\n\nNone of them set a coordinate on the row you were standing on, so after adding 'Region' you still have to navigate to that column and type the value.\n\nWHY IT LOOKS TRACTABLE:\n- GridLayout::records_columns filters on cat.kind.is_regular() and does NOT require the category to have items, so a freshly added empty category appears as a records column immediately.\n- CellKey::with(cat, item) ADDS a coordinate when absent, so the existing rename_record_coordinate path already handles a row that has no coordinate for the new category — it will move every measure cell in the row onto the new coordinate, which is the correct semantics.\n\nSo the shape could mirror '+': a second trailing column (say '+cat') whose commit runs add-category and then, optionally, sets that coordinate for the current row.\n\nOPEN QUESTIONS, decide before building:\n1. Two trailing columns ('+' for measure, '+cat' for category) may be more clutter than it is worth. An alternative is one '+' column that disambiguates by prefix, or a key rather than a column.\n2. Adding a category is more consequential than adding a measure: every existing record lacks a coordinate for it, so the column is blank everywhere and MAX_CATEGORIES (12) applies. Adding a measure only adds a blank column. Worth deciding whether inline creation is even desirable, or whether the panel is the right home for a schema change of that weight.\n3. Should creating the category also set its value on the current row in one commit, or just create the column?\n\nFiled after the user asked whether such a bead existed; it did not.","status":"closed","priority":4,"issue_type":"feature","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-08-29T19:32:40Z","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). Built in full on the user's ruling (2026-09-20), which chose the larger option over the agent's recommendation to decline.\n\nTHE PROBE CHANGED THE FIX, and this is the part worth keeping. Breaking on_category_added in two directions first:\n * removing the virtual bump entirely: ZERO tests fail\n * seating every category on None: TWELVE tests fail\nSo the auto-assignment was load-bearing and well covered, while THE BUMP WAS COMPLETELY UNGUARDED -- which is how it came to be wrong about two different virtuals with nothing noticing.\n\nA SECOND LIVE BUG NOBODY HAD REPORTED, found by reading the rule once the probe had located it: _Measure on Column is an ordinary configuration -- it is what web/demo.improv ships -- and adding a second regular category KICKED IT OFF, making every measure vanish from the grid. On the demo's own front page.\n\nTHE NEW RULE, stated for every view and not just records: A VIRTUAL ON AN AXIS IS A CONFIGURATION, NOT A VACANCY. It is never moved. A records view seats a new regular category on Axis::None (where records mode keeps every other regular category, and where records_columns picks it up anyway); a pivot view is unchanged except that a new category now JOINS a virtual already on the target axis, nesting a header, instead of evicting it. View::is_records_layout() is new and is the ONE definition -- GridLayout now asks it instead of re-deriving the _Index/_Dim test.\n\nTHE COLUMN: one `+` per block, positioned -- [coords, +cat, measures, +] -- so POSITION tells them apart rather than a label. A prefix was rejected because it puts a sigil into the name space where a category and a measure may legitimately share a name, the exact ambiguity _Dim carries an index to avoid (gotcha 1). Committing a name does NOT set the coordinate: that would move every measure cell in the row (gotcha 1) on the same Enter as a schema change, in an app with no undo.\n\nMAX_CATEGORIES refuses VISIBLY, which took a second fix: the refusal was written and never drawn, because CommitAndAdvance re-enters editing and draw_bottom_bar renders the minibuffer instead of the status line. AddCategory now aborts the batch and CommitAndAdvance emits its mode change FIRST. The limit stays checked in exactly one place, Model::add_category.\n\nFOUR characterization tests updated knowingly, each recording why.\n\nFOLLOW-UP FOUND BY THE POST-WAVE REVIEW: a refused or DUPLICATE AddCategory still bumps layout_version and dirty -- improvise-75ld.","dependencies":[{"issue_id":"improvise-2zq","depends_on_id":"improvise-c5v","type":"blocks","created_at":"2026-08-29T12:32:45Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} @@ -308,5 +311,5 @@ {"_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-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.","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:04:48Z","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-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.","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:19:45Z","started_at":"2026-09-20T18:02:40Z","closed_at":"2026-09-20T18:04:48Z","close_reason":"docs/index.html on wave/p-milestones: 194 lines, vanilla, single file, dark\nbackground, monospace headings, asciinema-player 3.10.0 pinned from jsdelivr\n(both URLs checked live, 200), all six sections, and the four improvise-d4w\ncasts. Verified by driving it in Chromium against both docs/ and the assembled\nsite/landing/: four players construct, each renders 37 terminal lines after\nplay, zero failed requests, zero page errors. That check was proved able to\nfail first — with autoPlay false the cast is not fetched until the click, so\ncounting players passes on a page whose every cast path 404s; breaking\n./casts/ to ./cats/ passes the construction check and fails the play check.\nNothing in CI asserts it yet: improvise-bgtf.\n\nTWO DEVIATIONS, both stated rather than papered over.\n\nPlayer geometry is cols 120 / rows 37, not the 100x30 this ticket and\nimprovise-d4w both name. Every cast header says 120x37, so the recordings\nnever complied with d4w's own \"Terminal: 100x30\"; a 30-row player clips the\nbottom seven rows of a 37-row cast, which is where the tile bar and the status\nline are.\n\nThe \"check they still reflect the current UI\" criterion was performed and the\nanswer is partly no. The tile bar's short_label vocabulary and the mode line\nstill match; the casts predate improvise-2zq's +cat column by five months and\nevery one opens on the recorder's shell prompt and home directory. Filed as\nimprovise-2oxt rather than embedded as if current.","labels":["area:release"],"dependencies":[{"issue_id":"improvise-e61","depends_on_id":"improvise-d4w","type":"blocks","created_at":"2026-04-08T21:09:31Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} {"_type":"issue","id":"improvise-kh8","title":"Phase 4: Landing page (optional)","description":"Create docs/index.html with embedded asciinema casts and enable GitHub Pages. Optional but recommended for launch.","acceptance_criteria":"Closes when improvise-e61 closes; improvise-3gy (enable GitHub Pages) is already closed. NOTE A COLLISION worth resolving before starting: Pages is already serving the standalone web app from the CI assemble step (improvise-d31/bck). Decide whether the landing page replaces that, sits beside it at a path, or is dropped in favour of the live app being the landing page — which is arguably the better demo and would close this as won't-fix.","notes":"CORRECTION TO THE CLOSE REASON ABOVE — THE USER OVERRULED IT, same session.\n\nThe close reason records the landing page sitting BESIDE the app at /landing/,\nwith the app keeping the root. The user ruled the other way:\n\n \"The landing page should be served at the root of the repository and link\n to the in-browser demo.\"\n\nSo the arrangement is inverted. `docs/index.html` is the site root and links\nprominently to the app, which moves to `/app/`. The argument for the other\narrangement is left standing above rather than deleted, because it is still the\nreason the alternative was considered and the collision this ticket asked to\nresolve was a genuine one — but it is OVERRULED, not merely superseded, and the\nroot is now the landing page.\n\nWHAT THE INVERSION COST, which is the part worth carrying:\n\n* The app moves under `app/` WHOLE — `index.html`, `app.js`, `style.css`,\n `worker.js`, `demo.improv` and BOTH bundle directories. Leaving `pkg/` and\n `worker-pkg/` at the root would have meant editing the wasm-pack generated\n glue; every path in app.js and worker.js is relative, so moving the bundle\n together keeps all of them correct and the glue untouched. `worker.js`'s\n `fetch(\"demo.improv\")` resolves against the WORKER's url, so the seed follows\n the move for the same reason.\n* `web/assemble.sh`'s four `stamped` guards had to follow the files. They are\n the only check that the cache-bust substitution worked, and a guard pointed at\n a path nothing writes fails in the direction that looks like success.\n* `harness/tests/shell.spec.js` navigates to `/index.html` six times, including\n the `?ws=` and `?doc=` variants. Every one of those now lands on the landing\n page, so all six were repointed at `/app/index.html`. The browser harness is a\n CI job that can fail the build, so this was not optional and not someone\n else's to discover.\n\nVERIFIED against the ASSEMBLED tree, not against docs/ directly, because a path\nmove is exactly what re-arms the trap this page already caught once: the four\ncasts play 37 lines each at the root, the primary call-to-action resolves to\n/app/ and returns 200, and there are no failed requests and no page errors. The\ncall-to-action check was proved able to fail by pointing the link at ./aap/,\nwhich returns 404 while the page still looks entirely correct.","status":"closed","priority":4,"issue_type":"feature","assignee":"wave2-p-milestones","owner":"el-github@elangley.org","created_at":"2026-04-09T04:05:53Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:10:48Z","started_at":"2026-09-20T18:02:40Z","closed_at":"2026-09-20T18:04:51Z","close_reason":"Both children terminal: improvise-3gy was already closed, improvise-e61 closes\nwith this wave.\n\nTHE COLLISION THIS TICKET ASKED TO RESOLVE FIRST, resolved: Pages serves the\nstandalone web app at the root (improvise-d31/bck), and the landing page now\nsits BESIDE it at /landing/ rather than replacing it. The reasoning is this\nticket's own — the live app is the better front door — and the third option,\ndropping the landing page in favour of the app, was declined only because the\nrecorded terminal casts show the TUI, which the browser app does not.\n\nweb/assemble.sh copies docs/index.html and docs/casts/ into the deployed tree,\nso the page is reachable and the browser harness serves the same tree the\ndeploy publishes. A landing page the deploy never copies would have satisfied\nthis ticket's letter and been a file nobody can open.\n\nREADME links both.","labels":["area:release"],"dependencies":[{"issue_id":"improvise-kh8","depends_on_id":"improvise-3gy","type":"blocks","created_at":"2026-04-08T23:37:42Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-kh8","depends_on_id":"improvise-e61","type":"blocks","created_at":"2026-04-08T23:37:41Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0} diff --git a/context/repo-map.md b/context/repo-map.md index cfacfdfc..f770230a 100644 --- a/context/repo-map.md +++ b/context/repo-map.md @@ -238,7 +238,7 @@ Region=West, Measure=Revenue = |pending| # pipe-quoted text **The async seam does not survive being boxed.** `OpfsFs::open`/`attach`/`refresh`/`commit_deletes` are on the struct, not on `Storage`. `App::storage` is a `Box` and a trait object can name none of the four, so **handing the backend to the `App` puts the async seam permanently out of reach**; the host must hold the concrete backend *and* give the `App` a `Storage`, which means sharing one. That is `impl Storage for Rc`, and it lives in **`improvise-io`** rather than in each worker (improvise-hmws): `impl Storage for Rc` is two foreign types in any other crate, so improvise-9x6 wrote a forwarding newtype that every future worker would have written again. `Rc` and not `Arc` because the one caller is a Web Worker and wasm32 has no other thread. **The flat root is no longer load-bearing**: `OpfsFs::list` joins `dir` like the other two backends, so `storage_contract` takes `dir` as a parameter and the three agree at *every* directory rather than only at `Path::new("")` where joining is the identity (improvise-vhcz). What the index holds is **documents, not entries**, and the split between its three writers is the rule: `attach` indexes nothing (what it creates is zero bytes), `Storage::write` indexes the name and un-queues any pending delete of it, and `refresh` rebuilds from OPFS skipping zero-byte entries — counting an entry it cannot measure as a document, since the one cause of that is another tab holding the exclusive handle (improvise-vvl3). - **What no test here can see** has shrunk, and precisely. `harness/` (improvise-vvl3/xf1) runs `improvise_io::storage::contract::storage_contract` against a real `OpfsFs` inside a real dedicated Web Worker under Chromium and Firefox, and the `browser` job in CI can fail the build — so **`OpfsFs` is no longer compile-checked-and-never-run**, and two bugs fell out of its first execution. That is why `storage_contract` stopped being a `#[cfg(test)]` helper and became a public function returning `Result`: a browser must run *that* sequence, not a second transcription of it, and a failure has to travel out of a worker as a string. Still unseen: **Safari** (Playwright's WebKit has no OPFS, so xf1's criterion is met for two engines of three; `opfs.spec.js` skips on a runtime probe and an unexpected skip on any other engine is a failure), and **`bindings.rs`**, which the harness does not instantiate — `createSyncAccessHandle` exists in no *host* harness, and that shim is now the dark corner where `OpfsFs` used to be. What was already verified stays: `tests/post_message.rs` drives a real `Worker` over a real `App` on nothing but the JSON a `MessagePort` carries. Build note unchanged: the worker bundle needs `--enable-nontrapping-float-to-int` **as well as** `--enable-bulk-memory`, because it carries the whole `App` and so chrono's date arithmetic. + **What no test here can see** has shrunk, and precisely. `harness/` (improvise-vvl3/xf1) runs `improvise_io::storage::contract::storage_contract` against a real `OpfsFs` inside a real dedicated Web Worker under Chromium and Firefox, and the `browser` job in CI can fail the build — so **`OpfsFs` is no longer compile-checked-and-never-run**, and two bugs fell out of its first execution. That is why `storage_contract` stopped being a `#[cfg(test)]` helper and became a public function returning `Result`: a browser must run *that* sequence, not a second transcription of it, and a failure has to travel out of a worker as a string. Still unseen: **Safari** (Playwright's WebKit has no OPFS, so xf1's criterion is met for two engines of three; `opfs.spec.js` skips on a runtime probe and an unexpected skip on any other engine is a failure). **`bindings.rs` is NOT in that list, and said it was for months.** `shell.spec.js`'s "the standalone deployment" loads the app with no `?ws=`, which spawns the worker, which imports the glue, which calls `bindings.rs::start` and `OpfsFs::open` — and the spec then asserts a grid draws, so the whole chain has run under Chromium in CI since improvise-vvl3. The claim survived in **five** places (this line, the File Inventory, two `kb.pl` facts and improvise-bck's grooming note) because each was written before the harness landed and none was re-read after; the spec's own comment says the chain plainly. It is the reason to distrust a "never run" written in prose: nothing checks it, and `kb.pl`'s CI gate checks consistency rather than truth. What was already verified stays: `tests/post_message.rs` drives a real `Worker` over a real `App` on nothing but the JSON a `MessagePort` carries. Build note unchanged: the worker bundle needs `--enable-nontrapping-float-to-int` **as well as** `--enable-bulk-memory`, because it carries the whole `App` and so chrono's date arithmetic. 46. **The page picks a peer, and the demo was built against an assertion.** `web/app.js` holds two transports and no protocol knowledge: `?ws=` selects `improvise-ws-server`, its absence spawns `new Worker('./worker.js?doc=&seed=', { type: "module" })` (improvise-djm). `type: "module"` is mandatory — `worker.js` `import`s the bundle — and there is **no `MessageChannel`**: a dedicated worker's own `postMessage`/`onmessage` *is* the port, so the ticket's channel wording was a date rather than a decision. The websocket path is **kept**, because the ws-server is how a TUI-hosted document is shared and nothing else in the browser can reach it; the cost is contained because the two differ only in *where strings come from* — handshake, fold, repaint and keyboard are all above the seam — and **the presence of the parameter is the whole selection mechanism**, a second knob being a second thing to get wrong. `#status` composes **two** facts, the transport's and the document's, because a single source let a repaint silently erase a connection error. **A worker that rejects in `start()` fires no `error` on the main thread** — `worker.js` catches its own — so the guard is a **first-frame watchdog**, deliberately *not* per-transport: "the peer never answered the handshake" is the one failure that means the same thing over a socket and over a port, and a check aimed at each peer's own failure modes would have missed exactly this one. @@ -449,7 +449,8 @@ wasm-client/tests/lockstep.rs 606 / 13t host and client agree on a ke web/app.js 519 string calls only, no protocol knowledge, no framework. Run under Chromium by harness/tests/shell.spec.js — see gotcha 48 for what that does and does not reach -web/worker.js 100 three string calls, no protocol knowledge. NEVER RUN +web/worker.js 100 three string calls, no protocol knowledge. RUN by + shell.spec.js's no-?ws path, with bindings.rs (gotcha 45) web/index.html, style.css the page; legibility only web/build.sh 38 wasm-pack build → web/pkg (gitignored). Needs wasm-opt = ["-O", "--enable-bulk-memory"] or the bundled @@ -464,8 +465,10 @@ web/demo.improv 57 the document the site ships and seeds from. improvise-worker-server/src/worker.rs 738 / 13t Worker: one port, one client; handshake, Addressed filter, persist trigger (model_state.dirty). Every decision, every test improvise-worker-server/src/vfs.rs 311 / 6t documents/open: seeding is a Storage::write -improvise-worker-server/src/bindings.rs 158 / 0t wasm32 shim: OpfsFs awaits + postMessage. NEVER RUN — - the one dark corner left now that harness/ runs OpfsFs +improvise-worker-server/src/bindings.rs 158 / 0t wasm32 shim: OpfsFs awaits + postMessage. No Rust test + reaches it, but harness/tests/shell.spec.js DOES: the + no-?ws path runs start()/OpfsFs::open under Chromium. + Called NEVER RUN here for months: see gotcha 45 improvise-worker-server/src/lib.rs 114 / 1t the module doc that states exactly what is and is not covered — the one correct copy of that fact improvise-worker-server/tests/post_message.rs 381 / 7t main-thread traffic, strings only From 519f0adf231e4bb1830d511e2138717d55bff28c Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Sun, 20 Sep 2026 11:30:14 -0700 Subject: [PATCH 07/17] kb: bindings.rs is run, and the invariant's NAME was the false claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by adversarial review of PR #47, which called this the one only the orchestrator can fix. Verified independently before acting: /app/index.html with no ?ws= spawns the worker, which imports the glue, which calls bindings.rs::start and OpfsFs::open — and shell.spec.js's "the standalone deployment" then asserts a grid draws. It has run under Chromium in CI since improvise-vvl3. invariant(opfs_is_run_but_bindings_is_not) is renamed to the_harness_runs_the_worker_chain_under_chromium_only, because the name itself asserted the falsehood and a reader who never opened the body would carry it. The crate/3 fact for improvise-worker-server said the same thing and is corrected. What remains genuinely unrun is now stated: Safari/WebKit, which has no OPFS in Playwright's build, and any non-Chromium engine for the shell spec. The body records how it survived, because that is the reusable part: a "never run" written in prose is unfalsifiable by anything this repo has. The Knowledge base consistency job was green throughout — it checks the KB for CONSISTENCY, not truth — and the claim UNDERSTATED coverage, so nothing failed and nobody looked. Five copies, every one written before the harness landed and none re-read after. Caught while making this edit, and worth recording: the first version dropped the clause-terminating period, so the invariant failed to parse and was SILENTLY LOST — invariants went 51 → 50 and the new name was absent. The findall/length count AGENTS.md prescribes is what caught it, on exactly the predicate that has been silently wiped before. Counting before and after is not ceremony. Verified: kb_consistent exits 0; 55 doctrines / 47 decisions / 51 invariants, the new name present and the old one gone. Co-Authored-By: Claude Opus 5 (1M context) --- prolog-sessions/kb.pl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/prolog-sessions/kb.pl b/prolog-sessions/kb.pl index ac0c0e72..0583cab8 100644 --- a/prolog-sessions/kb.pl +++ b/prolog-sessions/kb.pl @@ -8,7 +8,7 @@ crate(gp_tree,'crates/gp-tree','genetic-programming tree fitting; problem-agnost crate(improvise_gp,'crates/improvise-gp','bridge: emits a fitted gp-tree Tree as an improvise formula string and validates it through parse_formula. Exists because gp-tree must stay problem-agnostic and improvise-formula must not drag GP into the wasm bundle.'). crate(improvise_ws_server,'crates/improvise-ws-server','the socket host: tokio + tungstenite transport and a Registry of Hosts keyed by DocumentId. Re-exports Host and the frames; defines neither. Native only.'). crate(improvise_wasm_client,'crates/improvise-wasm-client','the browser client: the fold, the key classifier, and the render model the DOM paints. Non-dev deps are improvise-protocol + serde + wasm-bindgen and nothing else.'). -crate(improvise_worker_server,'crates/improvise-worker-server','the other host: the same App, Sessions and Host as the socket server, behind self.onmessage/self.postMessage with OPFS underneath. worker.rs and vfs.rs are ordinary Rust and hold every decision and every test; bindings.rs is the wasm32 shim and is never run.'). +crate(improvise_worker_server,'crates/improvise-worker-server','the other host: the same App, Sessions and Host as the socket server, behind self.onmessage/self.postMessage with OPFS underneath. worker.rs and vfs.rs are ordinary Rust and hold every decision and every test; bindings.rs is the wasm32 shim, run by harness/tests/shell.spec.js under Chromium and by no Rust test.'). layer(model,improvise_core). layer(view,improvise_core). layer(formula,improvise_formula). @@ -102,7 +102,7 @@ invariant(a_cats_change_reidentifies_every_position,'A cells identity is AxisFra invariant(the_header_width_rule_has_one_home,'improvise_protocol::header_level_widths: per level, the widest label plus a gap, clamped (improvise-1125). It lived twice -- inline in src/ui/grid.rs::render_grid and again in ui::viewport::compute_row_header_width -- and a browser sizing its own row header would have been a third. It takes a borrowed iterator rather than an AxisFrame because its other caller holds a GridLayout on the keypress path and building a frame there would clone every row tuple on every key.'). invariant(saving_is_a_frame_not_a_command,'ClientFrame::Save (improvise-368h) carries no path -- a client cannot see the hosts storage -- and is a FRAME because as a Command it put a runtime-fallible operation inside a batch ClientFrame::Commands promises is whole-or-nothing, so [Save, SetCell] reported a failed write and applied the edit anyway. It also fails the test every Command passes: it mutates no shared state and no clients slice, so reduce_view has nothing to fold and Checked has no basis to answer for. Refused as NoDocumentPath or SaveFailed to the asker alone; success is silent.'). invariant(the_persist_trigger_is_an_observation,'improvise-worker-server persists on model_state.dirty, not on a table of Command variants (touches_document is deleted) -- the same choice as watching cursor_epoch rather than matching CycleAxis, and document_generation rather than matching LoadModel. It is strictly tighter: a refused batch raises nothing, so the "was anything rejected" guard went with it.'). -invariant(opfs_is_run_but_bindings_is_not,'harness/ (improvise-vvl3/xf1) executes improvise_io::storage::contract::storage_contract against a real OpfsFs in a real dedicated Web Worker under Chromium and Firefox, gated by the browser job in CI; two bugs fell out of the first run. Playwrights WebKit provides no OPFS at all (getDirectory rejects with UnknownError), so xf1s three-engine criterion is met for two of three and the project is kept so it starts the day that build gains OPFS. harness/tests/shell.spec.js runs web/app.js under CHROMIUM ONLY -- engine-independent logic, and routeWebSocket is slower across three. Never instantiated by any harness: bindings.rs.'). +invariant(the_harness_runs_the_worker_chain_under_chromium_only,'harness/ (improvise-vvl3/xf1) executes improvise_io::storage::contract::storage_contract against a real OpfsFs in a real dedicated Web Worker under Chromium and Firefox, gated by the browser job in CI; two bugs fell out of the first run. Playwrights WebKit provides no OPFS at all (getDirectory rejects with UnknownError), so xf1s three-engine criterion is met for two of three and the project is kept so it starts the day that build gains OPFS. harness/tests/shell.spec.js runs web/app.js under CHROMIUM ONLY -- engine-independent logic, and routeWebSocket is slower across three. It also runs BINDINGS.RS: its no-?ws test loads the app, which spawns web/worker.js, which imports the glue, which calls bindings.rs start() and OpfsFs::open(), and the test then asserts a grid draws. This invariant was named opfs_is_run_but_bindings_is_not and asserted the opposite in FIVE places for months (here, the crate/3 fact above, two context/repo-map.md lines and improvise-bck) -- every copy written before the harness landed and none re-read after. A never-run claim in prose is unfalsifiable by anything in this repo: the CI job checks this KB for CONSISTENCY, not truth, and the error UNDERSTATED coverage so nothing failed and nobody looked. What is genuinely unrun: Safari/WebKit (no OPFS in Playwrights build) and any non-Chromium engine for the shell spec.'). doctrine(bug_first,'write a failing test demonstrating the bug before fixing it'). doctrine(unit_over_e2e,'prefer small unit tests over integration/e2e'). doctrine(suite_speed,'Two numbers, because the ambiguity between them is what let this drift: NO SINGLE TEST BINARY may exceed 2 seconds, and the WORKSPACE WALL CLOCK is the sum of them. Measured at 22d43b2: 1309 tests over 33 binaries, ~2.2s wall, longest single binary 1.42s -- so the workspace is already past 2s and the rule that bites is the per-binary one. Re-MEASURE rather than re-assert: cargo test --workspace prints a "finished in" per binary. Excludes compilation, and excludes harness/ (Playwright, minutes, its own CI job).'). From ddfc06ed66e08fb8b0babdcd9250da28cded1d77 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Sun, 20 Sep 2026 11:55:35 -0700 Subject: [PATCH 08/17] docs: follow the active_view split through repo-map and kb.pl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The batched instruction-file edits owed by improvise-jytm (PR #48), applied now that it has merged. Text supplied by the WU-E agent and audited before applying: 97 ticket IDs across both files, all resolve. repo-map gotcha 29 is replaced entirely — the two copies of "which view is active" are now ALLOWED to differ, and the gotcha said the opposite. Gotcha 36's `apply_switch` half is inverted: it now takes a `SessionId` and is addressed to exactly one session, where the paragraph said its taking no SessionId "is the statement". Gotcha 38's counts move 29 → 26 model effects and 28 → 24 ModelCommand variants, and its `ViewBack`/`ViewForward` sentence is deleted for being wrong on all three clauses. Gotchas 3 and 44 gain a clause each. One paragraph needed more than a correction. Gotcha 36's `ToOthers` trap describes a mechanism this PR deletes — but the lesson outlives the mechanism, so it is rewritten in the past tense with the two rules that survive: assert the `Broadcast` ITSELF, because the audience is the message rather than something to infer from folded state; and seat a bystander where a wrong relay can move it FROM, because a bystander already on the relayed value cannot see a wrongly WIDENED audience — the wrong relay folds to a no-op. Review demonstrated that vacuity twice on #48, in tests whose evidence file had stated the risk in one sentence and claimed the assertion covered it in the next. kb.pl: four clauses replaced, two added. `lockstep_binds_every_session_not_the_asker` changes shape rather than wording — active_view was the example that taught it and is now out of its scope, so what remains under it is the document half and the doctrine becomes a test for whether a value is shared AT ALL before it is a test for who hears about it. The addition worth reading is `an_injection_that_did_not_compile_reads_as_a_clean_run`, caught by #48's reviewer in its OWN tooling: a grep for `^error[` that misses a bare `error:` turns a failed compile into a green read — the defect injection exists to catch, one level up in the instrument. The rule is deliberately not "widen the grep", since a pattern can always be one case short: require a POSITIVE signal that the injected build ran. A count cannot be silently empty. Counted before and after, on the predicate that has been silently wiped before: doctrines 55 → 56, decisions 47 → 48, invariants 51 → 51. kb_consistent exits 0. Co-Authored-By: Claude Opus 5 (1M context) --- .beads/issues.jsonl | 26 ++++++++++++++++++-------- context/repo-map.md | 10 +++++----- prolog-sessions/kb.pl | 10 ++++++---- 3 files changed, 29 insertions(+), 17 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 0c30e6d4..2cbd9d70 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -22,7 +22,7 @@ {"_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.","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-20T18:19:45Z","labels":["area:epic"],"dependencies":[{"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-zkkw","type":"blocks","created_at":"2026-09-20T18:02:34Z","created_by":"wave2-p-milestones","metadata":"{}"}],"dependency_count":5,"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.","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-20T18:22:57Z","labels":["area:epic"],"dependencies":[{"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-zkkw","type":"blocks","created_at":"2026-09-20T18:02:34Z","created_by":"wave2-p-milestones","metadata":"{}"}],"dependency_count":5,"dependent_count":1,"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} @@ -37,8 +37,9 @@ {"_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-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":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:22:32Z","created_by":"wave2-p-milestones","updated_at":"2026-09-20T18:22:32Z","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":"open","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-20T18:18:24Z","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-lltt","title":"[bug] A view-scoped ModelCommand names no view, so it lands on the host's","description":"Opened by improvise-jytm, which made `ViewState::active_view` authoritative per\nclient. The view-configuration half of `ModelCommand` — SetAxis, CycleAxis,\nTransposeAxes, SetPageSelection, HideItem, ShowItem, ToggleGroup, TogglePruneEmpty,\nSetNumberFormat — acts on \"the active view\", which is now an ambiguous phrase.\n\n`Sessions::apply_model` routes with no SessionId (gotcha 38, correctly: the change\nis addressed to no one session, because a `View` is document state shared by every\nclient sitting on it). But it must still name WHICH view, and today it names it\nimplicitly: `apply_command` runs the effect against the `App`, and the effect reads\n`App::view()`, which is the HOST's slice. So a connected client on another view\nmoves the host's view rather than its own.\n\nBefore jytm every client was on the same view, so the implicit answer was right.\nIt is now wrong exactly when two clients are on different views, which is the\ncapability jytm exists to add.\n\nThe honest fix is for those variants to name their view — a protocol change — so\nthe command carries the operand rather than borrowing an ambient one. Rejected\nduring jytm: retargeting `app.view_state.active_view` around `apply_command` at the\ndoor. It makes the host's own UI slice a parameter channel, and `reset_cursor`/\n`cursor_epoch` would then write the asker's key into the host's cursor map.\n\nNote the `_Drill`/`_Records` virtual views make this sharper than it looks: a host\nin records mode is on a view no client can name.\n\n## Steps to Reproduce\n\n1. Two clients, A and B, on one document with views \"Default\" and \"Second\".\n2. B sends `Command::SwitchView(\"Second\")`. Its mirror moves; the host's App stays\n on \"Default\" (correct, per improvise-jytm).\n3. B sends `Command::Model { cmd: ModelCommand::SetAxis { category: \"Region\",\n axis: Row } }`.\n4. The axis moves on \"Default\" — the view B is not on — and B's next projection\n shows nothing changed while A's shows a change A did not ask for.\n\nNot reachable from the TUI, which is the only client that drives the App's own\nslice, and not reachable from `web/app.js` today because it sends no SwitchView.\n","acceptance_criteria":"The view-configuration ModelCommand variants name the view they act on, so a connected client's SetAxis moves the view that client is on and not the host's. A test drives two sessions onto different views and asserts an axis change by one is invisible to the other. Deciding NOT to fix is also an acceptable close, but then gotcha 38 must say so.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:05:49Z","created_by":"wave2-e-active-view","updated_at":"2026-09-20T18:05:49Z","labels":["area:active-view"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-lltt","title":"[bug] A view-scoped ModelCommand names no view, so it lands on the host's","description":"Opened by improvise-jytm, which made `ViewState::active_view` authoritative per\nclient. The view-configuration half of `ModelCommand` — SetAxis, CycleAxis,\nTransposeAxes, SetPageSelection, HideItem, ShowItem, ToggleGroup, TogglePruneEmpty,\nSetNumberFormat — acts on \"the active view\", which is now an ambiguous phrase.\n\n`Sessions::apply_model` routes with no SessionId (gotcha 38, correctly: the change\nis addressed to no one session, because a `View` is document state shared by every\nclient sitting on it). But it must still name WHICH view, and today it names it\nimplicitly: `apply_command` runs the effect against the `App`, and the effect reads\n`App::view()`, which is the HOST's slice. So a connected client on another view\nmoves the host's view rather than its own.\n\nBefore jytm every client was on the same view, so the implicit answer was right.\nIt is now wrong exactly when two clients are on different views, which is the\ncapability jytm exists to add.\n\nThe honest fix is for those variants to name their view — a protocol change — so\nthe command carries the operand rather than borrowing an ambient one. Rejected\nduring jytm: retargeting `app.view_state.active_view` around `apply_command` at the\ndoor. It makes the host's own UI slice a parameter channel, and `reset_cursor`/\n`cursor_epoch` would then write the asker's key into the host's cursor map.\n\nNote the `_Drill`/`_Records` virtual views make this sharper than it looks: a host\nin records mode is on a view no client can name.\n\n## Steps to Reproduce\n\n1. Two clients, A and B, on one document with views \"Default\" and \"Second\".\n2. B sends `Command::SwitchView(\"Second\")`. Its mirror moves; the host's App stays\n on \"Default\" (correct, per improvise-jytm).\n3. B sends `Command::Model { cmd: ModelCommand::SetAxis { category: \"Region\",\n axis: Row } }`.\n4. The axis moves on \"Default\" — the view B is not on — and B's next projection\n shows nothing changed while A's shows a change A did not ask for.\n\nNot reachable from the TUI, which is the only client that drives the App's own\nslice, and not reachable from `web/app.js` today because it sends no SwitchView.\n","acceptance_criteria":"The view-configuration ModelCommand variants name the view they act on, so a connected client's SetAxis moves the view that client is on and not the host's. A test drives two sessions onto different views and asserts an axis change by one is invisible to the other. Deciding NOT to fix is also an acceptable close, but then gotcha 38 must say so.","notes":"THE TRIGGER, added after the adversarial review of PR #48 (2026-09-20). This is deferrable, but it is deferred on a CONDITION rather than on a judgement, and the condition is one line of somebody else's future work away. It is unreachable today ONLY because no shipped client emits Command::SwitchView. That is the whole of the argument. The browser already emits view-configuration model commands -- keys.rs resolves a keypress to ModelCommand::TransposeAxes locally and sends it -- so the machinery on the other side of the ambiguity is live. THE DAY ANY WAVE WIRES A VIEW SWITCH INTO THE BROWSER, THIS GOES LIVE ON THE NEXT TRANSPOSE KEYSTROKE, AND SILENTLY: a client on Second presses transpose, the axes of Default move instead, that client's grid does not change and every other client's does. Two consequences. (1) Do not close this by arguing it is unreachable -- reachability is the thing that is about to change, and gotcha 47 already records that 'unreachable today' was the argument under which the lost update CheckedBatch later fixed had shipped. (2) There is a cheap interim guard worth landing before the real fix: assert that no shipped browser client emits Command::SwitchView, a test over improvise-wasm-client's key resolver. It turns the silent failure into a loud one at exactly the moment the premise stops holding, which is the only moment anybody would otherwise not be looking.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:05:49Z","created_by":"wave2-e-active-view","updated_at":"2026-09-20T18:34:24Z","labels":["area:active-view"],"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":"CORRECTION, same session: this bead as first written read as though nobody had\ndecided the Chromium-only question. Somebody had.\n`invariant(opfs_is_run_but_bindings_is_not)` in prolog-sessions/kb.pl records\nit, and gives TWO reasons, not one:\n\n \"harness/tests/shell.spec.js runs web/app.js under CHROMIUM ONLY --\n engine-independent logic, and routeWebSocket is slower across three.\"\n\nThe description above only answers the second. The first — the logic is\nengine-independent — is a general claim covering all six specs, and against\nthe specs as a test of web/app.js it is correct: app.js is plain DOM and\nplain event wiring, and running the same assertions on a second engine buys\nvery little.\n\nWHAT IT DOES NOT COVER is the claim improvise-bck criterion (1) actually\nmakes. That criterion is not \"app.js's logic works in Firefox\", it is \"the\nstandalone deployment works in Firefox\" — and the standalone deployment is\nmodule workers, `wasm-pack` output, `createSyncAccessHandle` through\nbindings.rs and the seed fetch, none of which is app.js's logic and all of\nwhich is engine-specific. opfs.spec.js covers Firefox's OPFS through\n`OpfsFs` directly and, per the same invariant, NEVER INSTANTIATES\nbindings.rs. So the two standalone specs on Firefox would be the first\nexecution of that shim on a second engine, and near enough the first on any.\n\nSo the disagreement with the invariant is narrow and should be argued on\nthose terms: the engine-independence reason is a statement about app.js, and\nthe two standalone specs are not only a test of app.js. If that argument is\nrejected, the right outcome is to REWRITE improvise-bck criterion (1) to say\nChromium, not to leave a criterion standing that names an engine nothing\nruns on — a criterion nobody checks is the failure mode this whole pass\nexists to catch.\n\nEither way the invariant's wording is owed an update, since it currently\nreads as a blanket justification for a split that has two different reasons\nbehind its two halves.\nCORRECTION — MY OWN PREMISE WAS WRONG, found in adversarial review of PR #47.\n\nThe note above says the two standalone specs on Firefox \"would be the first\nexecution of that shim on a second engine, and near enough the first on any\".\nThe second half is false. `bindings.rs` HAS been executed, on Chromium, since\nimprovise-vvl3 landed the harness. The chain is unbroken and each link is\ncheckable:\n\n /app/index.html with no ?ws= -\u003e app.js `new Worker(\"./worker.js\")`\n worker.js `import { start } from \"./worker-pkg/improvise_worker_server.js\"`\n bindings.rs `pub async fn start()` -\u003e `boot()` -\u003e `OpfsFs::open().await`\n\nand `harness/tests/shell.spec.js`'s own comment on the standalone test has said\nso all along: \"If this draws a grid, worker.js, bindings.rs, OpfsFs and the seed\nfetch all ran.\"\n\nI RAN THAT SPEC GREEN AT 2ccd21f while verifying the /app/ move, and watched the\nbrowser fetch /app/worker.js and /app/worker-pkg/improvise_worker_server_bg.wasm\nand draw the grid with #status reading \"in-page worker\". The evidence was in my\nhand and I still repeated the claim, because I took it from kb.pl and the repo\nmap instead of from the run.\n\nWHAT SURVIVES: the argument for running the two standalone specs on Firefox is\nUNAFFECTED and is still the point of this ticket. Firefox would be a genuine\nsecond engine for the shim, and improvise-bck's criterion (1) still names an\nengine nothing runs. Only the \"first on any engine\" rhetoric goes.\n\nFIVE PLACES STATE THE FALSE VERSION and want one grep, not five edits:\nkb.pl:11, kb.pl:105 (invariant opfs_is_run_but_bindings_is_not), repo-map.md:241,\nrepo-map.md:467, and improvise-bck's own NOT-DEMONSTRATED list. Those three files\nare orchestrator-owned; the bck half is corrected in bck's notes.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:02:30Z","created_by":"wave2-p-milestones","updated_at":"2026-09-20T18:19:44Z","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":"Three PRs merged within 20 seconds on 2026-09-20 (#37, #41, #42). Each CI run built the site and uploaded a github-pages artifact, and each queued a deploy behind 'concurrency: group: pages, cancel-in-progress: false'.\n\nOnly the newest run kept its artifact:\n\n run 35526758561 (04e4e3d, #37) conclusion=failure artifacts=0\n run 35526767814 (5f871f7, #41) conclusion=success artifacts=1\n run 35526774007 (52507a6, #42) conclusion=failure artifacts=0\n\nThe two superseded deploys failed with:\n\n Fetching artifact metadata for \"github-pages\" in this workflow run\n Found 0 artifact(s)\n Error: No artifacts named \"github-pages\" were found for this workflow run.\n\nThe upload step was GREEN in all three runs, so this is not a build failure —\nthe artifact is gone by the time the losing deploy runs. The successful deploy\n(17:47:28Z) preceded the failing one (17:47:44Z) by 16 seconds.\n\nDeploying a superseded commit is pointless anyway: the tip of main is what\nshould be published. Cancelling is deliberately NOT the answer — the existing\ncomment on the concurrency block records that a deploy-pages cancelled midway\ncan leave the Pages deployment half-applied.\n\n## Steps to Reproduce\n\n1. Merge three PRs to main within about a minute.\n2. Watch the CI runs: the newest deploy succeeds, the older ones fail on an\n empty artifact fetch and mark otherwise-green runs as failed.\n\nReported by the user from the Actions log.","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-20T18:03:04Z","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":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T07:55:22Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:57:55Z","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} @@ -56,12 +57,12 @@ {"_type":"issue","id":"improvise-o8fa","title":"improvise-ws-server speaks ws:// only, so the deployed page cannot reach it","description":"Found by opening the deployed page (2026-09-20). The guidance the page and the README carried was WRONG in a way that sends someone down a dead end.\n\nFROM AN https ORIGIN A ws:// ADDRESS IS BLOCKED AS MIXED CONTENT, so the deployed GitHub Pages site needs wss://. improvise-ws-server has NO TLS AT ALL -- no rustls, no native-tls, nothing in the crate or its dependencies -- so wss:// is not obtainable from it. The text said \"note that from an https origin a ws:// address is blocked\", which correctly named the symptom and then offered an address that can never work.\n\nTEXT CORRECTED ALREADY: both web/index.html and README.md now say the address must be wss:// from the deployed page, that the server speaks plain ws:// only, and that the ws:// form works when the page is served over http://localhost. This ticket is the missing CAPABILITY.\n\nTHREE WAYS TO PROVIDE IT, and they are not equivalent:\n * TERMINATE TLS IN FRONT (nginx, caddy, cloudflared, ngrok). Zero code, works today, and is what the corrected text points at. But it means the documented path to sharing a TUI-hosted document involves a second piece of software, which is a poor answer for a tool whose selling point is that it is one binary.\n * NATIVE wss:// IN improvise-ws-server -- rustls plus a cert. Real work, and it raises the certificate question: a self-signed cert makes the browser refuse the socket with no usable error, so this is only pleasant with a real cert or an explicitly trusted local CA (mkcert).\n * DECIDE THE DEPLOYED PAGE DOES NOT PAIR WITH A LOCAL SERVER at all, and say so. Defensible: the standalone worker deployment is the point of the Pages site, and ws:// pairing is a LOCAL development convenience that works fine from the local page. Cheapest, and it makes the docs shorter rather than longer.\n\nThe third is probably right for now and the first is the escape hatch; the second only earns its place if sharing a TUI-hosted document to a remote browser becomes a real use case. Do not implement the second by default.","status":"closed","priority":2,"issue_type":"feature","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T03:53:54Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:22:10Z","started_at":"2026-09-20T05:32:35Z","closed_at":"2026-09-20T06:22:10Z","close_reason":"Closed by PR #21 (WU-I). DOCUMENTATION ONLY, per the user's ruling; native wss:// DECLINED.\n\nTHE DECLINE'S SUBSTANCE IS THE CERTIFICATE, NOT rustls. A self-signed cert makes the browser refuse the socket with no usable error, because a WebSocket handshake has no interstitial -- there is no place to click through. A real cert or mkcert is a second piece of software too, so the choice is two-either-way, with one option additionally shipping a TLS stack and a cert-loading surface in a pivot-table binary.\n\nWHAT LANDED: a worked Caddy example, the nginx difference that bites (proxy_http_version 1.1 plus the Upgrade/Connection headers), the honest cost (a second binary, a poor answer for a one-binary tool), and the framing that the standalone worker deployment is the point of the Pages site while ws:// pairing works fine from a local http://localhost page.\n\nTHIS BEAD COST SEVEN COPILOT ROUNDS AND NINE FINDINGS, EVERY ONE IN PROSE. The sequence is the value and is recorded in kb.pl:\n * the first recipe exposed an unauthenticated world-writable server -- documenting a proxy documents an exposure, because TLS termination REMOVES the loopback binding that was the only control;\n * the \"fix\" put Caddy basic_auth in front of a socket the page opens as new WebSocket(url), an API that can set no headers over a handshake that shows no prompt -- BROKEN reads as protection, which is worse than absent;\n * an ngrok --cidr-allow recipe was written without being run (deprecated-but-accepted, so the reviewer's own \"fails at startup\" was also wrong);\n * \"the browser cannot send a credential on a WebSocket handshake\" over-generalised the governing fact and contradicted the Cloudflare Access bullet three paragraphs up, which works BECAUSE the handshake carries a cookie;\n * an IP allow-list cannot see a cross-site socket at all, which is improvise-kc7n;\n * an absolute loopback claim denied a browser mitigation that exists but is mid-rollout;\n * and \"the auth above\" called an allow-list authentication in a section whose opening sentence is that the server has none.\n\nThe governing fact was ALREADY IN THE SAME FILE at every step, as the basis of this very decline. The lesson the repo did not have: a reviewed paragraph is not evidence.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-955n","title":"[bug] Host::commands applies a batch partially before rejecting","description":"Found by Copilot on PR #13 (the retro review of the unreviewed batch), DECLINED there with reasons on the thread, and filed rather than patched.\n\nADR 001 is emphatic that a stale batch is never applied, NOT EVEN PARTIALLY -- and improvise-ondj went to the trouble of a TYPE (Checked) to make the unchecked path unconstructible. But Checked is PER-COMMAND: Checked::against answers for one Command against the App AS IT STANDS. A ClientFrame::Commands is a SEQUENCE of them, and Host::commands applies in order and abandons at the first rejection. So a frame carrying SwitchView followed by a stale Command::Model has already applied the SwitchView.\n\nSO ADR 001 GUARANTEE IS CURRENTLY TRUE OF A SINGLE COMMAND AND FALSE OF A BATCH.\n\nA SECOND-ORDER EFFECT NOBODY HAD WRITTEN DOWN, found while declining it: Worker::on_frame skips persist() when ANY frame in the answer is Rejected. So a batch that DID apply a SwitchView before being refused leaves Workbook::active_view moved in memory and NOT WRITTEN BACK -- the document on disk and the document in the worker disagree, silently.\n\nWHY IT WAS DECLINED RATHER THAN PATCHED: closing it means the basis check moving from the COMMAND to the BATCH -- every command answered before any is applied. That is an ADR 001 design change with a real question inside it: WHAT DOES A BATCH BASIS MEAN WHEN ITS LATER COMMANDS WERE COMPOSED AGAINST ITS EARLIER ONES EFFECTS? A client resolving one keypress produces SetSelected plus offsets computed from the selection it just set. Answer that before writing code.\n\nREACHABILITY IS BOUNDED TODAY: no client in this workspace composes a batch containing more than one basis-carrying Command::Model -- the batches improvise-bpq2 produces are view commands, which carry no basis. Do this before the second tranche of client-side resolution creates a reason to batch two model commands.\n\nSupersedes the earlier ticket filed from bpq2 if there is one -- dedupe.","status":"closed","priority":2,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T03:35:27Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:29:48Z","started_at":"2026-09-20T05:32:29Z","closed_at":"2026-09-20T07:29:48Z","close_reason":"Closed by PR #26 (WU-C). improvise-08wb is closed as a duplicate of this one.\n\nADR 001 said a stale batch is never applied, NOT EVEN PARTIALLY, and improvise-ondj went to the trouble of a type (Checked) to make the unchecked path unconstructible -- but Checked is PER-COMMAND and a ClientFrame::Commands is a sequence of them, so Host::commands applied in order and abandoned at the first rejection. The guarantee was true of a single command and false of a batch.\n\nCheckedBatch::against now answers for EVERY command before any is applied.\n\nTHE DESIGN QUESTION THE TICKET DEMANDED WAS ANSWERED BY THE USER, not by the code, and it is larger than the ticket imagined. The ticket asked \"what does a batch's basis mean when its later commands were composed against its earlier ones' effects\". The user's ruling: \"a sequence of commands sent to the server should assume that it's played in order from start to end and that each command sees the state after the previous command runs\", as \"a single transaction from a given base\". Four clauses -- one basis per frame; checked once before anything; applied in order with data dependence EXPECTED; all or nothing. Clauses 1-3 landed here; clause 4 (rollback) is improvise-yqpd.\n\nAN EARLIER \"at most one document mutation per frame\" RULE WAS SHIPPED AND THEN REJECTED BY THE USER as forbidding the capability. It was wrong twice over: it forbids data-dependent chains, and arity is not a property of a variant anyway -- a future Command::RunScript is one variant performing unboundedly many mutations.\n\nTHE FINDING THAT MATTERED MOST came from the independent adversarial review that substituted for Copilot (quota exhausted), and it was a REGRESSION THIS WORK INTRODUCED: hoisting the basis check to the frame hoisted ALL THREE TIERS. The document and layout tiers are legitimately frame-level facts; THE RECORD TIER IS NOT AND CANNOT BE, because which records are at stake is a property of the command. So every mutation after the first had its records checked against nothing -- a silent lost update, confirmed by executing a real Host: client A's stale [SetCell(North), SetCell(South)] clobbered client B's write to South with no rejection, where the base commit refused it. Fixed: every mutation answers for its own records against the FRAME'S basis, with an exemption stated over a property -- a record that neither resolves nor is claimed is one the frame is creating. The reviewer attacked that exemption at all four corners and could not break it: the predicate is a conjunction and the client controls only one conjunct.\n\nTHE SECOND-ORDER WORKER EFFECT the ticket named is closed twice over; its test fails only when BOTH old behaviours are restored.\n\nA CAPABILITY NOBODY HAD NAMED, now guarded: a frame may write THE SAME RECORD TWICE -- two measures on one record IS one record -- and that works only because every check happens before any apply. Moving the checks into the loop breaks it, which is what stops someone closing clause 4 by taking that trade unnoticed.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-08wb","title":"[bug] A refused ClientFrame::Commands keeps the commands before the refusal","description":"Found by improvise-bpq2 (2026-09-20), stated on the variant, and currently UNREACHABLE from any client in this workspace -- filed so it is a decision rather than a discovery.\n\nClientFrame::Commands(Vec\u003cCommand\u003e) batches, because one cursor key resolved client-side is SetSelected plus the offsets viewport_effects returns, and a host that folds the selection but not the offset projects the wrong window. Host::commands applies IN ORDER and ABANDONS AT THE FIRST REJECTION.\n\nTHAT LEAVES A HOLE ADR 001 DOES NOT HAVE. ADR 001 is emphatic that a stale batch is never applied, NOT EVEN PARTIALLY -- and improvise-ondj went to the trouble of a type (Checked) to make the unchecked path unconstructible. But Checked is per-COMMAND, and a ClientFrame::Commands is a sequence of them, so a frame whose third command is refused has already applied the first two.\n\nWHY IT IS NOT LIVE: no client in this workspace composes a Commands frame containing more than one basis-carrying Command::Model -- the batches bpq2 produces are view commands (SetSelected + offsets), which carry no basis.\n\nTHE FIX IS TO MOVE THE BASIS CHECK TO THE BATCH: check every Command::Model in the frame before applying any of them. That is the shape ADR 001 already describes for an effect batch, one level up, and it would make Host::commands match the guarantee the ADR states.\n\nDo this before a client has reason to batch two model commands -- which the second tranche of client-side resolution (the ~15 bindings bpq2 left) may well create.","status":"closed","priority":2,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T02:36:42Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:29:28Z","started_at":"2026-09-20T05:32:29Z","closed_at":"2026-09-20T07:29:28Z","close_reason":"Closed as a DUPLICATE of improvise-955n, which PR #26 fixed. Both beads describe the same defect -- Host::commands applied a ClientFrame::Commands in order and abandoned at the first rejection, so a frame whose later command was refused had already applied the earlier ones, leaving ADR 001's 'never applied, not even partially' true of a single command and false of a batch. 08wb was filed from improvise-bpq2's walk and 955n from Copilot's retro review of PR #13; 955n carries the fuller reasoning and the second-order worker effect, so it is the survivor.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-ir7l","title":"[bug] A formula cell is offered as editable and silently reverts","description":"Pre-existing and consistent with the TUI, but improvise-bck just put it on the DEMO FRONT PAGE, which changes who meets it: the first thing a stranger sees is Profit beside Revenue and Cost.\n\nA formula target cell projects CellId::Record, so render.rs::editable says true, so the browser offers an editor. Type into Profit and the write lands -- then recompute_formulas overwrites it on the next pass and the value REVERTS with no explanation.\n\nWHY IT IS NOT SIMPLY A BUG IN editable: a formula target IS a stored coordinate; the cell is a record. What makes it unwritable is not its identity but that a FORMULA OWNS IT. So the fix is a fact the projection does not currently carry -- something like \"this cell is derived\" -- and it belongs beside numeric/error on CellDisplay, which already exists precisely because the SERVER is the side that knows.\n\nCHECK THE TUI BEFORE CHANGING ANYTHING: it has the same behaviour, so whatever is decided should land in both, or the divergence becomes the new bug (gotcha 44 is what that looks like). And note ADR 002 deliberately made settability a fact about the CellId VARIANT -- adding a second, independent \"but not really\" flag is exactly the two-answers-to-one-question shape this codebase keeps curing. Consider instead whether a formula-owned cell should project a different CellId at all.\n\n## Steps to Reproduce\n\n1. Open `web/demo.improv` in the browser — it ships `Profit = Revenue - Cost` beside Revenue and Cost on the front page.\n2. Click the Profit cell. It is offered as editable, because a formula target projects `CellId::Record` and `render.rs::editable` reads the variant.\n3. Type a number and commit. The write lands.\n4. `recompute_formulas` overwrites it on the next pass and the value reverts, with no explanation.\n\nThe TUI behaves the same way, so whatever is decided must land in both or the divergence becomes the new bug.\n","acceptance_criteria":"A formula-owned cell projects a non-Record CellId, so the write is unconstructible rather than flagged — the discipline ADR 002 used for aggregates and ADR 003 reaches for again for read-only sources. It lands in the TUI in the same change, or the two front ends diverge (gotcha 44 is what that looks like). Do NOT add a second 'but not really' flag beside CellId: two answers to 'can I write here' is the disease. Demonstrated on web/demo.improv, where Profit sits beside Revenue and Cost on the front page.","notes":"VERIFIED CONCRETELY during the PR #13 review (2026-09-20), and the diagnosis is sharper than when this was filed.\n\nTHE ROOT CAUSE: project::cell_id decides Record vs Aggregate purely on whether a regular category sits on Axis::None. IT NEVER ASKS WHETHER THE _Measure COORDINATE NAMES A FORMULA TARGET. So a formula cell is a Record, render.rs::editable says true, and the browser offers an editor.\n\nMEASURED AGAINST THE SHIPPED DEMO:\n col 2 label=\"Profit\" is_record=true text=\"12,200\"\n after applying SetCell(key, Number(999.0)), Profit reads \"12,200\"\n\nThe write is ACCEPTED AND STORED. Model::evaluate_aggregated checks formula_cache BEFORE raw data (model/types.rs:409), so the typed value is shadowed forever with nothing telling the user. Not a revert -- a permanent silent shadow.\n\nAND THE TEST THAT COVERS THIS AREA IS LYING BY ITS NAME. every_visible_cell_in_the_web_demo_is_settable PASSES while its name is FALSE for the Profit column -- a third of the landing-page grid. It was proved discriminating against the one thing it CAN see (the aggregate at (0,0) in examples/demo.improv) and is structurally blind to this. That is exactly the shape AGENTS.md calls this repo's characteristic find, arriving in the test written to prevent it.\n\nTHE DECISION IS STILL THE DECISION, and ADR 002 is where it goes:\n * TEACH cell_id ABOUT FORMULA TARGETS -- changes what EVERY client calls a record, and record identity is also ADR 001's unit of atomicity, so this reaches the basis check too.\n * OR GIVE editable A SECOND INPUT -- but that is the conflation gotcha 42 exists to forbid, and ADR 002 deliberately made settability a fact about the CellId VARIANT precisely so there could not be a second answer.\n\nThe first is probably right and is not small. Until it lands, the demo's front page teaches a visitor that typing into Profit does nothing.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-20T02:29:51Z","created_by":"Edward Langley","updated_at":"2026-09-20T17:53:19Z","started_at":"2026-09-20T17:53:19Z","lease_expires_at":"2026-09-20T17:58:19Z","heartbeat_at":"2026-09-20T17:53:19Z","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-ir7l","title":"[bug] A formula cell is offered as editable and silently reverts","description":"Pre-existing and consistent with the TUI, but improvise-bck just put it on the DEMO FRONT PAGE, which changes who meets it: the first thing a stranger sees is Profit beside Revenue and Cost.\n\nA formula target cell projects CellId::Record, so render.rs::editable says true, so the browser offers an editor. Type into Profit and the write lands -- then recompute_formulas overwrites it on the next pass and the value REVERTS with no explanation.\n\nWHY IT IS NOT SIMPLY A BUG IN editable: a formula target IS a stored coordinate; the cell is a record. What makes it unwritable is not its identity but that a FORMULA OWNS IT. So the fix is a fact the projection does not currently carry -- something like \"this cell is derived\" -- and it belongs beside numeric/error on CellDisplay, which already exists precisely because the SERVER is the side that knows.\n\nCHECK THE TUI BEFORE CHANGING ANYTHING: it has the same behaviour, so whatever is decided should land in both, or the divergence becomes the new bug (gotcha 44 is what that looks like). And note ADR 002 deliberately made settability a fact about the CellId VARIANT -- adding a second, independent \"but not really\" flag is exactly the two-answers-to-one-question shape this codebase keeps curing. Consider instead whether a formula-owned cell should project a different CellId at all.\n\n## Steps to Reproduce\n\n1. Open `web/demo.improv` in the browser — it ships `Profit = Revenue - Cost` beside Revenue and Cost on the front page.\n2. Click the Profit cell. It is offered as editable, because a formula target projects `CellId::Record` and `render.rs::editable` reads the variant.\n3. Type a number and commit. The write lands.\n4. `recompute_formulas` overwrites it on the next pass and the value reverts, with no explanation.\n\nThe TUI behaves the same way, so whatever is decided must land in both or the divergence becomes the new bug.\n","acceptance_criteria":"A formula-owned cell projects a non-Record CellId, so the write is unconstructible rather than flagged — the discipline ADR 002 used for aggregates and ADR 003 reaches for again for read-only sources. It lands in the TUI in the same change, or the two front ends diverge (gotcha 44 is what that looks like). Do NOT add a second 'but not really' flag beside CellId: two answers to 'can I write here' is the disease. Demonstrated on web/demo.improv, where Profit sits beside Revenue and Cost on the front page.","notes":"VERIFIED CONCRETELY during the PR #13 review (2026-09-20), and the diagnosis is sharper than when this was filed.\n\nTHE ROOT CAUSE: project::cell_id decides Record vs Aggregate purely on whether a regular category sits on Axis::None. IT NEVER ASKS WHETHER THE _Measure COORDINATE NAMES A FORMULA TARGET. So a formula cell is a Record, render.rs::editable says true, and the browser offers an editor.\n\nMEASURED AGAINST THE SHIPPED DEMO:\n col 2 label=\"Profit\" is_record=true text=\"12,200\"\n after applying SetCell(key, Number(999.0)), Profit reads \"12,200\"\n\nThe write is ACCEPTED AND STORED. Model::evaluate_aggregated checks formula_cache BEFORE raw data (model/types.rs:409), so the typed value is shadowed forever with nothing telling the user. Not a revert -- a permanent silent shadow.\n\nAND THE TEST THAT COVERS THIS AREA IS LYING BY ITS NAME. every_visible_cell_in_the_web_demo_is_settable PASSES while its name is FALSE for the Profit column -- a third of the landing-page grid. It was proved discriminating against the one thing it CAN see (the aggregate at (0,0) in examples/demo.improv) and is structurally blind to this. That is exactly the shape AGENTS.md calls this repo's characteristic find, arriving in the test written to prevent it.\n\nTHE DECISION IS STILL THE DECISION, and ADR 002 is where it goes:\n * TEACH cell_id ABOUT FORMULA TARGETS -- changes what EVERY client calls a record, and record identity is also ADR 001's unit of atomicity, so this reaches the basis check too.\n * OR GIVE editable A SECOND INPUT -- but that is the conflation gotcha 42 exists to forbid, and ADR 002 deliberately made settability a fact about the CellId VARIANT precisely so there could not be a second answer.\n\nThe first is probably right and is not small. Until it lands, the demo's front page teaches a visitor that typing into Profit does nothing.","status":"closed","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-20T02:29:51Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:22:48Z","started_at":"2026-09-20T17:53:19Z","closed_at":"2026-09-20T18:22:48Z","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-vhcz","title":"[bug] OpfsFs::list disagrees with the other backends on path shape","description":"Found by improvise-9x6 (2026-09-20) and currently PAPERED OVER by always passing the flat root.\n\nOpfsFs::list returns BARE NAMES; MemoryFs and PhysicalFs return dir-JOINED ones. So Path::new(\"\") is the ONE dir at which all three agree, and the only one at which storage_contract actually describes OpfsFs.\n\nTHE TRAP: a worker that organised documents under docs/ would get an EMPTY PICKER in a browser and a FULL ONE in every test. The harness cannot see the disagreement because it never runs OpfsFs -- improvise-xf1 is deferred -- so this is precisely the class of defect the missing browser harness exists to catch, discovered by reading instead.\n\nTWO FIXES:\n * Make OpfsFs::list join dir, matching the other two. Straightforward, and makes storage_contract describe all three at every dir.\n * Or document the flat root as PART OF THE CONTRACT and assert it in storage_contract, so the constraint is stated rather than discovered.\n\nThe first is better unless there is a reason OPFS cannot -- note gotcha 28 says backends that are not trees treat dir as a NAME PREFIX, which is already the trait doc contemplating this, so check whether the doc or the impl is the thing that is wrong.","status":"closed","priority":2,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T02:10:14Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:44:42Z","started_at":"2026-09-20T05:32:36Z","closed_at":"2026-09-20T06:44:42Z","close_reason":"Closed by PR #24 (WU-J). THE TICKET ASKED WHICH OF THE DOC OR THE IMPL WAS WRONG: IT WAS THE IMPL.\n\nThe Storage trait doc already said that backends which are not trees treat `dir` as a NAME PREFIX. OpfsFs::list ignored `dir` outbound as well as inbound, returning bare names where MemoryFs and PhysicalFs return dir-joined ones -- so Path::new(\"\") was the one dir at which all three agreed, and the only one at which storage_contract actually described OpfsFs.\n\nNow it joins. The three backends agree at EVERY dir, and storage_contract runs against OpfsFs at both \"\" and \"docs\" in a real browser. `dir` stays DECORATION rather than a filter, because entry_name reduces every path to its file name and an OPFS entry name may not contain a separator.\n\nFAULT INJECTED: reverting to bare names fails storage_contract_at_\"docs\" on `lists the one document`.\n\nTHE TRAP THE TICKET NAMED IS CLOSED: a worker that organised documents under docs/ would have got an empty picker in a browser and a full one in every test. That divergence was invisible to the harness because nothing ran OpfsFs -- which is exactly why improvise-vvl3 had to land alongside this, and why this bead could only ever have been found by reading until it did.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-hmws","title":"Storage cannot expose an async seam through Box\u003cdyn Storage\u003e","description":"Found by improvise-9x6 (2026-09-20). Every worker host will hit this, so it is worth solving once rather than in each.\n\nOpfsFs::open / attach / refresh / commit_deletes are on the STRUCT, not on the Storage trait -- deliberately, per gotcha 28: Storage stays synchronous because createSyncAccessHandle yields a blocking handle, and only four operations are genuinely async. But App::storage is a Box\u003cdyn Storage\u003e, so HANDING THE BACKEND TO THE App PUTS ALL FOUR PERMANENTLY OUT OF REACH. The App owns the only copy and the trait object cannot name them.\n\nAND THE OBVIOUS FIX IS REFUSED BY THE COMPILER: impl Storage for Rc\u003cOpfsFs\u003e is two foreign types, so the orphan rule forbids it. improvise-9x6 worked around it with a local newtype (bindings::Shared) holding an Rc\u003cOpfsFs\u003e and forwarding the four sync methods. That works but every worker must write it again.\n\nTWO CANDIDATE FIXES:\n * Ship the shared wrapper FROM improvise-io, so the newtype exists once where OpfsFs does and the orphan rule is satisfied.\n * Or have OpfsFs hand out an attachable handle -- separate the thing the App holds from the thing the message loop awaits, which is arguably the more honest modelling since they have different lifetimes and different call sites.\n\nPrefer whichever keeps Storage synchronous; that part is not negotiable (gotcha 28 is the argument).","status":"closed","priority":2,"issue_type":"task","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T02:10:13Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:44:43Z","started_at":"2026-09-20T05:32:37Z","closed_at":"2026-09-20T06:44:43Z","close_reason":"Closed by PR #24 (WU-J). NEITHER OF THE TICKET'S TWO OPTIONS WAS NEEDED, and the reason is worth more than the fix.\n\nThe ticket said `impl Storage for Rc\u003cOpfsFs\u003e` is refused by the orphan rule. That is true IN improvise-worker-server, where both the trait and Rc are foreign -- which is where improvise-9x6 hit it and wrote bindings::Shared. It is NOT true in improvise-io, where the trait is LOCAL and the impl is simply allowed.\n\nSo: improvise-io ships a blanket `impl\u003cT: Storage + ?Sized\u003e Storage for Rc\u003cT\u003e`. bindings::Shared and its _shim_is_a_storage canary are DELETED, not moved, and no future worker writes one. Storage stays synchronous, which gotcha 28 says is not negotiable.\n\nTHE GUARD IS THE SIGNATURE: a_shared_backend_is_a_backend_and_a_clone_is_the_same_one does not compile without the impl. The body asserts what a hand-written newtype could still get wrong -- that a clone addresses the same backend -- and a fault injected into the forwarded `write` (made a no-op) fails it.\n\nTHE GENERALISABLE PART: an orphan-rule refusal is a fact about WHERE YOU ARE STANDING, not about the pair of types. Before working around one, check whether the impl belongs in the crate that owns the trait.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-9mo9","title":"[bug] Geometry effects that renumber the grid but never reset the cursor","description":"Found by the code review of improvise-gvnb (2026-09-19). A PRE-EXISTING gap that gvnb inherited rather than introduced -- but gvnb is what makes it matter, because the epoch is now what tells every client their cursor is invalid.\n\nApp::reset_cursor bumps cursor_epoch; Sessions::adopt_cursor_epoch watches the COUNTER and broadcasts Command::ResetCursor to all. Nothing declares which effects renumber the grid -- deliberately, per gotcha 31 -- which means a geometry effect that forgets reset_cursor is INVISIBLE IN BOTH DIRECTIONS: the host own cursor stays stale AND no client is told.\n\nToday only TransposeAxes and CycleAxis call it. These also renumber rows or columns and do not:\n SetAxis, HideItem, ShowItem, ToggleGroup, TogglePruneEmpty, AddCategory, RemoveCategory\n (plus AddItem/RemoveItem, which are CellData rather than LayoutGeometry)\n\nSetAxis IS THE SHARPEST CASE: CycleAxis is literally SetAxis to Axis::next() and resets; SetAxis does not.\n\nWHY THE REVIEW DID NOT JUST PATCH IT: SetAxis is used by DrillIntoCell (cmd/grid.rs:365-471) to build the _Drill view, and resetting there could discard the user position in the view they drilled FROM, depending on effect order. That is a decision about effect ordering and intended UX, not a patch.\n\nKEEP THE NEGATIVE ARM. a_model_command_that_does_not_reshape_the_grid_leaves_cursors_alone uses SetNumberFormat -- a LayoutGeometry effect that must NOT reset. Without it, \"reset on every model command\" would pass the positive test. Whatever you do here, do not lose it.\n\n## Steps to Reproduce\n\n1. In a view with a cursor somewhere other than the origin, apply any of `SetAxis`, `HideItem`, `ShowItem`, `ToggleGroup`, `TogglePruneEmpty`, `AddCategory`, `RemoveCategory`, `AddItem` or `RemoveItem`.\n2. The grid renumbers and the cursor is not reset, so it addresses a position that has moved.\n3. Worse for other clients: `reset_cursor` is what bumps `cursor_epoch`, and `Sessions::adopt_cursor_epoch` watches that counter to broadcast `Command::ResetCursor`. No reset means **no client is told either**.\n\n`SetAxis` is the sharpest case: `CycleAxis` *is* `SetAxis` to `Axis::next()` and does reset.\n","acceptance_criteria":"Each of SetAxis, HideItem, ShowItem, ToggleGroup, TogglePruneEmpty, AddCategory, RemoveCategory, AddItem and RemoveItem either calls reset_cursor or carries a one-line reason why it does not. SetAxis is the sharp case — CycleAxis IS SetAxis to Axis::next() and resets, while SetAxis does not — and DrillIntoCell uses SetAxis to build the _Drill view, where resetting could discard the position in the view drilled FROM, so that is a decision about effect ordering and intended UX, not a patch. KEEP THE NEGATIVE ARM: a_model_command_that_does_not_reshape_the_grid_leaves_cursors_alone uses SetNumberFormat, and without it 'reset on every model command' passes the positive test. Note improvise-2zq changed which axis a new category lands on, so re-read on_category_added before assuming AddCategory renumbers the way it used to.","status":"closed","priority":2,"issue_type":"bug","assignee":"wave2-n-cleanups","owner":"el-github@elangley.org","created_at":"2026-09-20T01:05:15Z","created_by":"Edward Langley","updated_at":"2026-09-20T17:57:44Z","started_at":"2026-09-20T17:53:45Z","closed_at":"2026-09-20T17:57:44Z","close_reason":"Closed","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-s1xw","title":"Multi-level grid labels on the wire -- the browser shows Food/Alice where the TUI shows two columns","description":"THE LAST THING KEEPING GridLayout INSIDE GridWidget, and a LIVE DIVERGENCE today, not a hypothetical. Found closing improvise-n10.\n\nGridCache::row_labels / col_labels are Vec\u003cString\u003e produced by GridLayout::row_label, which JOINS A MULTI-LEVEL TUPLE WITH A SLASH. That is exactly the rendering src/ui/grid.rs::two_row_categories_produce_cross_product_labels asserts must NOT appear (\"slash-joined labels should be gone\"). So the protocol carries a form of the labels THE TUI ITSELF CONSIDERS WRONG, and crates/improvise-wasm-client/src/render.rs renders precisely that form.\n\nConsequence: the browser grid has no multi-level headers, no repeat suppression, no groups and no collapse indicators, and shows Food/Alice where the TUI shows two header columns.\n\nSIX FACTS THE CACHE DOES NOT CARRY (exhaustive as of improvise-n10, by grep over the non-test body of src/ui/grid.rs):\n 1. number of header levels -- layout.row_cats / col_cats\n 2. multi-level label TUPLES -- layout.row_items (AxisEntry::DataItem(Vec\u003cString\u003e))\n 3. inline GROUP HEADER rows -- layout.row_items (AxisEntry::GroupHeader)\n 4. column GROUP SPANS -- layout.col_group_for(ci)\n 5. collapse indicator (down/right triangle) -- view.is_group_collapsed(cat, group)\n 6. records-mode column identity -- layout.record_col(ci). THIS ONE IS CORRECT TO KEEP OFF: it keys pending drill edits and is session-local by design.\n\nSo 1-5 are the gap.\n\nTWO CONSTRAINTS ON THE FIX:\n * IT IS A CO-ORDINATED CHANGE, NOT AN ADDITIVE ONE. row_labels/col_labels change TYPE, and improvise-wasm-client/src/render.rs consumes both fields, so the client updates in the same change.\n * DO NOT ADD row_levels BESIDE row_labels. Two answers to \"what are the labels\" is the disease this whole projection architecture exists to cure -- see improvise-edp, where the category panel was walked twice, two different ways.","status":"closed","priority":2,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T00:58:33Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:41:34Z","started_at":"2026-09-20T05:32:27Z","closed_at":"2026-09-20T07:41:34Z","close_reason":"Closed as ABSORBED BY improvise-anr9, verified explicitly rather than assumed. s1xw enumerated six facts the cache did not carry; PR #23 landed 1-5 (header levels, multi-level label tuples, inline group headers, column group spans, the collapse indicator) and 6 is record_col, which s1xw itself says is CORRECT to keep off -- session-local, keys pending drill edits. Both of its stated constraints were honoured: it was a co-ordinated type change (row_labels/col_labels changed TYPE and improvise-wasm-client/src/render.rs updated in the same change) and no row_levels was added beside row_labels. The live divergence it reported -- the browser drawing Food/Alice where the TUI draws two header columns -- is closed, and tests/grid_frame_agreement.rs now drives both front ends over one cache and compares what each draws.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-4zl7","title":"A fitted tree must route per record and aggregate the results, not route on the aggregate","description":"Successor to improvise-8xhy, which shipped remedy 1 (refuse at emit) as an explicit interim. This is the remedy that produces a formula A HUMAN WOULD HAVE WRITTEN, and it is the right end state -- but it was costed during 8xhy and it is much larger than it looks.\n\nANSWER THIS FIRST, BEFORE TOUCHING THE GRAMMAR. A fitted measure cell is evaluated at a CellKey context. In any view that aggregates over the split category -- Region on Page or None -- context.get(\"Region\") is None, and today Formula::filter falls straight through to aggregate_raw. So a categorical fit would SILENTLY DEGRADE to something else in exactly the views a pivot-table user spends most of their time in. That is a product decision about what a fit MEANS for an aggregated cell, not an emitter detail.\n\nSCOPE, once that is answered:\n * Expr::Is(Filter) evaluating to 1.0/0.0. This keeps the f64-only evaluator and avoids introducing string comparison into eval_expr. The primitive already exists in the right shape -- types.rs reads context.get(\u0026filter.category), the current cell own coordinate.\n * A grammar production plus tokenizer handling in the 1109-line parser.rs, with quoting rules for the category and item inside it (gotcha 34 -- one home, improvise_formula::quote).\n * Arms in BOTH evaluators: eval_expr and eval_expr_cached.\n * Coverage in the grammar-walking generator.\n * Thread Dataset::columns (the ColumnSpec/ColumnKind plan) into the emitter, so feature index -\u003e role is read off preprocess own plan. Do NOT reconstruct it: Spec::names is \u0026[\u0026str], and reconstructing index-\u003erole is a second implementation of preprocess expansion order, exactly the drift risk gotcha 41 names in another key.\n * .improv is free -- the writer emits Formula::raw.\n\nIT CLOSES ONLY A THIRD OF 8xhy EITHER WAY. A WHERE predicate covers the one-hots. col.missing means \"this record has no value for measure X\", a different vocabulary. The date sub-features (when.year, when.month) need date arithmetic improvise does not have at all. Those stay refused, or get their own tickets.\n\nPAIRS WITH improvise-vx3 (the fit-measure command), which is where a user would actually learn about the restriction.","acceptance_criteria":"A fitted measure evaluated at an aggregating cell routes PER RECORD and aggregates the results, rather than routing on the aggregate. fit-measure on web/demo.improv succeeds and the resulting formula means the same thing on a record row and one row up. The seed-dependence is gone: whether the command succeeds no longer depends on which features the search happened to pick. Note what this costs — evaluate_aggregated deliberately does not iterate the records behind a formula cell — and state how it interacts with gotcha 9's fixed point and Model::formula_cache. col.missing and the date sub-features remain out of scope.","notes":"USER RULING 2026-09-20: AGGREGATE THE RULE OVER THE MEMBERS. Route-then-aggregate, per record. The other three options -- Error(\"aggregated\"), refuse at fit time, and fall through to aggregate_raw -- are all rejected.\n\nTHE BEAD'S SCOPE HAS CHANGED AND THE TITLE NOW SAYS SO. It asked what a CATEGORICAL split means in a cell that aggregates over the split category. PR #25's review established the question has nothing to do with the split being categorical:\n\nA tree is fitted on individual RECORDS. A formula is evaluated at a CellKey, and in any aggregating cell evaluate_aggregated aggregates each Expr::Ref FIRST and then evaluates the IF. A linear formula survives that -- SUM(Revenue) - SUM(Cost) is SUM(Revenue - Cost) -- and a tree does not, because AGGREGATE-THEN-ROUTE IS NOT ROUTE-THEN-AGGREGATE. So EVERY fitted tree, numeric splits included, means one thing on a record row and something else one row up.\n\nIT IS NOT THEORETICAL: fit-measure Revenue on the repo's own shipped web/demo.improv is refused, naming Product=Espresso/Product=Latte. And because which features a champion picks is a property of the SEARCH, on a document with several categories the same command refuses or succeeds DEPENDING ON THE SEED. That is not a contract.\n\nWHAT THE RULING COSTS, stated plainly because it is the expensive answer and was chosen anyway: evaluate_aggregated deliberately does NOT iterate the underlying records for a formula cell -- it aggregates the refs and evaluates once. Routing per record means a formula cell must be able to ask for the records behind it and fold over them. That touches the evaluator, and it interacts with gotcha 9 (fixed-point recomputation) and with Model::formula_cache, which is where derived values live precisely so raw data and derived data stay separate.\n\nNOTE WHAT IT BUYS BESIDES CORRECTNESS: it is what a user means. A fitted rule applied to a group should say what the rule says about the members, summed -- not what the rule says about a fictional average member. The second is the thing that looks right and is wrong, which is the failure mode vx3's own ticket warned about ('a rule the user will believe').\n\nALSO NOTE: this ruling supersedes improvise-8xhy's remedy 1 (refuse at emit) as the END STATE, but not as the interim -- the refusal stays until this lands, or fit-measure silently produces the wrong number in aggregating views.\n\nTHE TWO THIRDS THIS STILL DOES NOT COVER, unchanged from the original text: col.missing means 'this record has no value for measure X', a different vocabulary; and the date sub-features (when.year, when.month) need date arithmetic improvise does not have at all. Those stay refused or get their own beads.","status":"open","priority":2,"issue_type":"feature","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T00:45:22Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:45:26Z","started_at":"2026-09-20T05:32:41Z","labels":["area:gp"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-4zl7","title":"A fitted tree must route per record and aggregate the results, not route on the aggregate","description":"Successor to improvise-8xhy, which shipped remedy 1 (refuse at emit) as an explicit interim. This is the remedy that produces a formula A HUMAN WOULD HAVE WRITTEN, and it is the right end state -- but it was costed during 8xhy and it is much larger than it looks.\n\nANSWER THIS FIRST, BEFORE TOUCHING THE GRAMMAR. A fitted measure cell is evaluated at a CellKey context. In any view that aggregates over the split category -- Region on Page or None -- context.get(\"Region\") is None, and today Formula::filter falls straight through to aggregate_raw. So a categorical fit would SILENTLY DEGRADE to something else in exactly the views a pivot-table user spends most of their time in. That is a product decision about what a fit MEANS for an aggregated cell, not an emitter detail.\n\nSCOPE, once that is answered:\n * Expr::Is(Filter) evaluating to 1.0/0.0. This keeps the f64-only evaluator and avoids introducing string comparison into eval_expr. The primitive already exists in the right shape -- types.rs reads context.get(\u0026filter.category), the current cell own coordinate.\n * A grammar production plus tokenizer handling in the 1109-line parser.rs, with quoting rules for the category and item inside it (gotcha 34 -- one home, improvise_formula::quote).\n * Arms in BOTH evaluators: eval_expr and eval_expr_cached.\n * Coverage in the grammar-walking generator.\n * Thread Dataset::columns (the ColumnSpec/ColumnKind plan) into the emitter, so feature index -\u003e role is read off preprocess own plan. Do NOT reconstruct it: Spec::names is \u0026[\u0026str], and reconstructing index-\u003erole is a second implementation of preprocess expansion order, exactly the drift risk gotcha 41 names in another key.\n * .improv is free -- the writer emits Formula::raw.\n\nIT CLOSES ONLY A THIRD OF 8xhy EITHER WAY. A WHERE predicate covers the one-hots. col.missing means \"this record has no value for measure X\", a different vocabulary. The date sub-features (when.year, when.month) need date arithmetic improvise does not have at all. Those stay refused, or get their own tickets.\n\nPAIRS WITH improvise-vx3 (the fit-measure command), which is where a user would actually learn about the restriction.","acceptance_criteria":"A fitted measure evaluated at an aggregating cell routes PER RECORD and aggregates the results, rather than routing on the aggregate. fit-measure on web/demo.improv succeeds and the resulting formula means the same thing on a record row and one row up. The seed-dependence is gone: whether the command succeeds no longer depends on which features the search happened to pick. Note what this costs — evaluate_aggregated deliberately does not iterate the records behind a formula cell — and state how it interacts with gotcha 9's fixed point and Model::formula_cache. col.missing and the date sub-features remain out of scope.","notes":"USER RULING 2026-09-20: AGGREGATE THE RULE OVER THE MEMBERS. Route-then-aggregate, per record. The other three options -- Error(\"aggregated\"), refuse at fit time, and fall through to aggregate_raw -- are all rejected.\n\nTHE BEAD'S SCOPE HAS CHANGED AND THE TITLE NOW SAYS SO. It asked what a CATEGORICAL split means in a cell that aggregates over the split category. PR #25's review established the question has nothing to do with the split being categorical:\n\nA tree is fitted on individual RECORDS. A formula is evaluated at a CellKey, and in any aggregating cell evaluate_aggregated aggregates each Expr::Ref FIRST and then evaluates the IF. A linear formula survives that -- SUM(Revenue) - SUM(Cost) is SUM(Revenue - Cost) -- and a tree does not, because AGGREGATE-THEN-ROUTE IS NOT ROUTE-THEN-AGGREGATE. So EVERY fitted tree, numeric splits included, means one thing on a record row and something else one row up.\n\nIT IS NOT THEORETICAL: fit-measure Revenue on the repo's own shipped web/demo.improv is refused, naming Product=Espresso/Product=Latte. And because which features a champion picks is a property of the SEARCH, on a document with several categories the same command refuses or succeeds DEPENDING ON THE SEED. That is not a contract.\n\nWHAT THE RULING COSTS, stated plainly because it is the expensive answer and was chosen anyway: evaluate_aggregated deliberately does NOT iterate the underlying records for a formula cell -- it aggregates the refs and evaluates once. Routing per record means a formula cell must be able to ask for the records behind it and fold over them. That touches the evaluator, and it interacts with gotcha 9 (fixed-point recomputation) and with Model::formula_cache, which is where derived values live precisely so raw data and derived data stay separate.\n\nNOTE WHAT IT BUYS BESIDES CORRECTNESS: it is what a user means. A fitted rule applied to a group should say what the rule says about the members, summed -- not what the rule says about a fictional average member. The second is the thing that looks right and is wrong, which is the failure mode vx3's own ticket warned about ('a rule the user will believe').\n\nALSO NOTE: this ruling supersedes improvise-8xhy's remedy 1 (refuse at emit) as the END STATE, but not as the interim -- the refusal stays until this lands, or fit-measure silently produces the wrong number in aggregating views.\n\nTHE TWO THIRDS THIS STILL DOES NOT COVER, unchanged from the original text: col.missing means 'this record has no value for measure X', a different vocabulary; and the date sub-features (when.year, when.month) need date arithmetic improvise does not have at all. Those stay refused or get their own beads.","status":"in_progress","priority":2,"issue_type":"feature","assignee":"wave3-4zl7","owner":"el-github@elangley.org","created_at":"2026-09-20T00:45:22Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:37:13Z","started_at":"2026-09-20T05:32:41Z","labels":["area:gp"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-hwpi","title":"The browser shell fails quietly, and never reads a key","description":"Found by the improvise-cr3 code review (2026-09-19) by READING web/app.js. There is no browser and no headless DOM in this environment, so none of this is observed -- it is what the code says will happen, and it is filed rather than fixed because each item is a judgement call about the shell rather than a defect in the Rust.\n\nFOUR THINGS, in descending order of how much they would cost a person at a demo:\n\n1. NO KEYBOARD AT ALL. #grid carries tabindex=\"0\" but the page registers no keydown handler, so Client::on_key_event -- and the whole keys.rs classifier and the lockstep machinery behind it -- is UNREACHED from the browser. Consistent with improvise-bpq2 (ClientMsg has no Key variant server-side) but larger than it: even a LOCAL key, one that needs no round trip, does nothing. improvise-1ey acceptance (3) is \"arrow keys move the cursor locally with no round-trip\", and nothing in the page can do it.\n\n2. A MALFORMED FRAME IS QUIET. client.on_frame(event.data) returns Result\u003c_, JsError\u003e, which wasm-bindgen turns into a THROWN EXCEPTION. It is thrown inside the message listener with no try, so it surfaces as an uncaught error in the devtools console and the following repaint() is skipped. Nothing in the UI says so: the grid simply stops updating. A try/catch writing to el.status would make it visible.\n\n3. NO error LISTENER ON THE SOCKET. A connection that never opens leaves #status blank -- only close writes \"disconnected\". So \"server not running\" and \"still connecting\" look the same.\n\n4. close() RUNS TWICE ON COMMIT, benignly. Enter -\u003e close(true) -\u003e td.replaceChildren(original) removes the focused \u003cinput\u003e -\u003e blur fires -\u003e close(false). editing is already null so it is harmless (one extra repaint), but there is no re-entry guard and only a real browser would show it.\n\nNOTE WHAT MAKES THIS TICKET DIFFERENT: items 2-4 are the kind of thing a test would catch instantly and we have no way to run one. Before fixing them, consider whether the right first move is a headless DOM in CI (jsdom or playwright) -- otherwise the fixes are as unverified as the bugs. improvise-xf1 (verify OpfsFs against a real browser) wants the same harness.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T00:40:36Z","created_by":"Edward Langley","updated_at":"2026-09-20T02:29:50Z","closed_at":"2026-09-20T02:29:50Z","close_reason":"All four items done, plus a fifth the ticket could not have predicted.\n\n1. KEYDOWN WIRED -- a window listener feeding Client::on_key_event. preventDefault fires only when a frame was produced and no Ctrl/Alt is held, so browser shortcuts stay the browser's; metaKey is skipped entirely, matching BrowserKey::to_key_event dropping Meta so Cmd-R cannot arrive as a bare r. The inline editor stopPropagation keeps it out of the way, with an if (editing) return as belt and braces.\n2. MALFORMED FRAME -- try/catch around on_frame writing to #status, plus console.error with the offending frame. render_model() is guarded the same way; it also returns a Result.\n3. SOCKET error LISTENER -- 'cannot reach ... -- is improvise-ws-server running?', so that no longer reads like 'still connecting'.\n4. close() RE-ENTRY GUARD -- a closed flag in edit().\n\nTHE FIFTH, AND IT IS THE INTERESTING ONE: a worker whose start() REJECTS is caught INSIDE worker.js, so it fires NO error event on the main thread. The page would have sat on 'in-page worker - demo.improv' over an empty grid forever -- THIS TICKET'S EXACT DISEASE, REBUILT IN THE NEW DEPLOYMENT ON DAY ONE. The guard is a first-frame watchdog (8s, cleared by the first frame), and it deliberately covers BOTH transports rather than each peer's own failure modes, because 'the peer never answered the handshake' is the one condition that means the same thing over a socket and over a port -- a check aimed per-peer would have missed exactly this one.\n\nKEYS ARE INERT AND THAT IS STATED, NOT HIDDEN. Host::handle answers ClientFrame::Key with an empty vec; improvise-bpq2 is the other half.\n\nSTILL UNRUN -- and this ticket predicted that would be the problem: 'consider whether the right first move is a headless DOM, otherwise the fixes are as unverified as the bugs.' That sentence is now true OF THESE FIXES. Three of the four are one-assertion Playwright tests. See the harness ticket.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-zv4m","title":"improvise-protocol should own the websocket framing, so both ends stop mirroring it","description":"Raised by improvise-cr3 (2026-09-19) as the thing to push back on, and filed rather than fixed there.\n\nTODAY: improvise-wasm-client/src/socket.rs declares ClientFrame/ServerFrame, which MIRROR improvise-ws-server/src/wire.rs ClientMsg/ServerMsg field for field. The client cannot depend on the server crate -- tokio, tungstenite, Box\u003cdyn Storage\u003e, all native -- so the duplication was the only way to speak the protocol from wasm.\n\nWHAT HOLDS THE LINE MEANWHILE: improvise-ws-server is a DEV-dependency of the client (the same argument the crate already makes for improvise-command), and the_socket_framing_matches_the_server round-trips every variant THROUGH THE SERVERS OWN TYPES in both directions; a_frame_named_the_other_envelopes_way_does_not_parse is its check-the-checker. So a drift is caught -- but only by a test whose job is to notice, rather than by a shared definition that cannot drift.\n\nTHE FIX: move the framing into improvise-protocol, which both ends already depend on and which is the crate whose charter is exactly this -- \"the wire vocabulary and the portable session slice\". Then the mirror, the dev-dependency and both compatibility tests all delete.\n\nNOTE THE THIRD ENVELOPE while you are in here: improvise-wasm-client/src/wire.rs has ClientMessage/ServerMessage framing the WORKER deployment (improvise-tm6). Three envelopes for two deployments is worth resolving in one pass -- decide whether the worker and the socket want the same vocabulary before moving either.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T00:37:29Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:37:50Z","closed_at":"2026-09-20T00:37:50Z","close_reason":"Duplicate of improvise-xks6 ('One home for the client/server frame envelopes'), filed minutes apart from the improvise-cr3 hand-back without spotting the existing ticket. Closing this one; xks6 is the survivor and its description should absorb two facts this one carried:\n\n * WHAT HOLDS THE LINE MEANWHILE: improvise-ws-server is a DEV-dependency of improvise-wasm-client, and the_socket_framing_matches_the_server round-trips every variant THROUGH THE SERVERS OWN TYPES in both directions, with a_frame_named_the_other_envelopes_way_does_not_parse as its check-the-checker. The drift is caught by a test whose job is to notice, rather than prevented by a shared definition.\n * THERE ARE THREE ENVELOPES, NOT TWO: improvise-wasm-client/src/wire.rs frames the WORKER deployment (improvise-tm6) separately again. Decide whether the worker and the socket want the same vocabulary before moving either, or the move produces two homes instead of one.\n\n(See also memory agent-issue-drift-pattern -- search before filing.)","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-8xhy","title":"[bug] A fit over a categorical feature emits an unevaluable formula","description":"Found during improvise-7na (2026-09-19), verified by probe, and pinned as a characterization test rather than fixed.\n\ngp_tree::data::preprocess one-hots a categorical column into a feature named Region=North. That is an ENCODING ARTEFACT, not an item of the document, so the emitted formula Expr::Ref fails Model::find_item_category and EVERY CELL of the fitted measure evaluates to Error(\"ref:Region=North\"). col.missing and the col.year / col.month date sub-features are in exactly the same position.\n\nSo \"fit a tree and get a working formula\" holds ONLY for a fit over numeric measures.\n\nTHE TEST THAT SHOULD HAVE CAUGHT IT WAS LYING: improvise_evaluation_matches_gp_tree_eval cannot see this, because its improvise_eval helper FABRICATES a _Measure item for every feature name, including outlook=rainy. It asserts the emitter and gp_tree::eval agree, which they do -- against a model that could not exist. Worth remembering as a shape: a helper that builds the world the code wants is not a test of whether that world exists.\n\nPinned as a_split_on_a_one_hot_feature_does_not_evaluate -- characterization, flip it when fixed.\n\nTHREE REMEDIES, pick deliberately: restrict fits to numeric features (smallest, and honest about what works); materialise one-hot columns as real _Measure items (makes the formula evaluable but pollutes the document with encoding artefacts); or teach the emitter improvise WHERE vocabulary so a categorical split becomes a real predicate over the category (largest, and the only one that produces a formula a human would have written). The third is probably right and should be costed before the first is shipped as permanent.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T00:35:05Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:45:21Z","closed_at":"2026-09-20T00:45:21Z","close_reason":"Fixed as REMEDY 1, explicitly labelled interim. fitted_formula now takes the \u0026Model the formula is to be stored in and refuses a tree whose splits it cannot resolve, listing them. formula_text is untouched -- it is the tree's own text and there is nothing else it could write. Nothing outside the crate called fitted_formula.\n\nWHY THE EMITTER AND NOT THE DATASET LAYER: dropping categorical columns before the fit is smaller, but it throws away real signal (permutation importance, and any future remedy-3 emitter) and makes the crate's halves disagree -- one-hot encoding is gp-tree's whole job. The defect is that the EMITTER spells a formula the document cannot evaluate.\n\nWHY THE CHECK ASKS THE DOCUMENT, NOT THE NAME'S SHAPE: refusing names containing '=' or matching .missing would be a syntactic proxy and a wrong one, because a measure LITERALLY NAMED Region=North is a legal document and must be accepted. The two are indistinguishable as strings; only the document can tell them apart. So it calls Model::find_item_category -- the exact precondition Expr::Ref has -- which covers .missing, the date parts, and any future sub-feature naming scheme with no second copy of preprocess's expansion rules to drift from. Both directions pinned.\n\nTHE LYING TEST IS KEPT, NOT DELETED. Quoting and threshold routing are real things improvise_evaluation_matches_gp_tree_eval checks, and outlook=rainy earns its place there as an awkward NAME TO SPELL. What changed is that it and its helper now state their scope -- a test of arithmetic and quoting against a STIPULATED model, unable to see whether such a model could exist. The property it could not state is asserted separately against a real document: a_formula_the_emitter_accepted_evaluates_everywhere -- if fitted_formula says Ok, no cell of the target is a ref: error. That test and the refusal test are each other's check; without the accepting case the refusal could pass by refusing everything. The characterization is flipped to a_split_on_a_one_hot_feature_is_refused.\n\nREMEDY 3 IS STILL RIGHT AND IS NOT THIS TICKET -- successor filed. It is much larger than it looked: improvise has NO SCALAR COORDINATE PREDICATE (Filter gates a formula or an aggregate, it is not an Expr), it would still leave col.missing and the date parts uncovered, and it must first answer what such a predicate means where context.get(cat) is None.","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -166,13 +167,20 @@ {"_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-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":"open","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-20T18:53:08Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-9iyv","title":"The route-then-aggregate fold is unbenchmarked","description":"improvise-4zl7 made `SUM(\u003cexpr\u003e)` fold over the member records rather than over\nthe raw matching cells. The fold is not cached and not memoised: for one\naggregated formula cell it walks `matching_cells(partial)`, dedupes the record\ntuples through an `IndexSet`, and evaluates the inner expression once per\nrecord. `recompute_formulas` runs that for every stem, every formula target, and\nup to `MAX_EVAL_DEPTH` (16) passes.\n\nNothing here is known to be slow. Nothing here has been measured either, and the\nwork is O(cells + members × expression size) per cell per pass where the\nprevious code was one `matching_values` scan. A routed formula over a document\nwith thousands of records is the shape to measure.\n\nNote what must NOT be \"fixed\" by memoising carelessly: `formula_cache` holds\nderived values and the fold deliberately reads *raw* data through the same\n`evaluate_depth` / `aggregate_raw` path the non-folding evaluator uses, which is\nwhy the fixed point still converges (gotcha 9, context/code/core.md 3 and 13). A\nper-record memo keyed by anything less than the whole record key would be a\nsecond answer to \"what does this cell depend on\".\n\n## Steps to Reproduce\n\n1. Build a document with several thousand records — `examples/gen-grammar.rs`\n or a CSV import will do.\n2. Add a routing formula over them, e.g.\n `Pred = SUM(IF(Cost \u003c 50, 1, 10))`, or run `fit-measure` and store the\n result.\n3. Time `Model::recompute_formulas`, and compare against the same document with\n a linear formula (`Profit = Revenue - Cost`), which takes the `Expr::Ref`\n fast path and does not fold.\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.","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-20T18:51:21Z","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":"open","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-20T18:50:51Z","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.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:45:39Z","created_by":"wave2-h-frontend","updated_at":"2026-09-20T18:45:39Z","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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:33:24Z","created_by":"wave2-e-active-view","updated_at":"2026-09-20T18:33:24Z","labels":["area:active-view"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-w3bf","title":"A session mirror's active_view is written in four places, none through reduce_view","description":"Found by the adversarial review of PR #48 (improvise-jytm). Not a defect today: the\nfour writers agree. It is the SHAPE the divergence bugs in this area have taken every\ntime, filed before it becomes one.\n\n`ClientSession::view.active_view` — the server's mirror of a client's view — is\nwritten in four places, and none of them goes through `reduce_view`:\n\n1. `Sessions::apply_switch` — sets it directly from the accepted name.\n2. `Sessions::adopt_deleted_views` — sets it directly to the document default.\n3. `ClientSession::reseed` — replaces the whole slice via `fresh_view`.\n4. `Sessions::connect` — same, through `reseed`.\n\nEverywhere else in this codebase, the mirror stays in step with the client by folding\nthe SAME command the client folds (`ClientSession::fold` → `reduce_view`, the\n\"same function runs on both ends\" rule of improvise-cqq/m91). These four bypass it.\nThey are correct today only because `reduce_view`'s `SwitchView` arm is one line\n(`view.active_view = name.clone()`) and each of these reproduces it.\n\nThe day that arm grows a second statement — clearing a per-view buffer, resetting a\ncursor, anything — the mirror and the client diverge and nothing fails. That is\nexactly gotcha 36's \"folding SwitchView into the mirror alone was the bug\", arriving\nfrom the other direction.\n\nTwo shapes worth weighing:\n\n- Route all four through `reduce_view(\u0026mut session.view, \u0026Command::SwitchView(name))`.\n Cheap, and makes the bypass unrepresentable for this command.\n- A test that the two agree: fold a `SwitchView` into a bare `ViewState` and into a\n `ClientSession` and compare. Weaker (it is a compatibility test, and\n `doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)` says\n those are a definition waiting to be made) but it costs nothing.\n\n## Steps to Reproduce\n\nNot reproducible today; the four copies agree. To make it reproduce: add any second\nstatement to `reduce_view`'s `Command::SwitchView` arm in\n`crates/improvise-protocol/src/reduce.rs` — for instance clearing `status_msg` — then\nrun the suite. It stays green, and a client's own slice now carries something its\nserver-side mirror does not.\n","acceptance_criteria":"Either the four writers route through reduce_view, or a test pins that a ClientSession's mirror and a bare ViewState fold a SwitchView identically. Closing it as 'the copies agree' is not acceptable — that is the state it is filed in.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:32:43Z","created_by":"wave2-e-active-view","updated_at":"2026-09-20T18:32:43Z","labels":["area:active-view"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_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.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:23:51Z","created_by":"orchestrator","updated_at":"2026-09-20T18:23:51Z","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":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:11:01Z","created_by":"wave2-b-projection","updated_at":"2026-09-20T18:11:01Z","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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:06:03Z","created_by":"orchestrator","updated_at":"2026-09-20T18:06:03Z","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.","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-20T18:06:00Z","labels":["area:active-view"],"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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:05:51Z","created_by":"orchestrator","updated_at":"2026-09-20T18:05:51Z","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":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:04:14Z","created_by":"wave2-h-frontend","updated_at":"2026-09-20T18:04:14Z","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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:01:04Z","created_by":"wave2-p-milestones","updated_at":"2026-09-20T18:01:04Z","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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T17:57:25Z","created_by":"wave2-n-cleanups","updated_at":"2026-09-20T17:57:25Z","labels":["area:command"],"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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T17:57:25Z","created_by":"wave2-n-cleanups","updated_at":"2026-09-20T17:57:25Z","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-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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T07:49:56Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:57:52Z","labels":["area:protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -256,7 +264,7 @@ {"_type":"issue","id":"improvise-jjx","title":"Scrolled grid suppresses first visible row-header label","description":"src/ui/grid.rs:338-350: repeat-suppression shows a row-header sub-label only when ri == 0 or the prefix differs from data_row_items[ri-1]. ri is the absolute data-row index (starts at row_offset, grid.rs:269), so when the view is scrolled the first visible row compares against the off-screen row above and its label is blanked — the user loses the group context at the top of the viewport. Fix: compare against the previous *visible* row, i.e. always show labels for the first rendered data row. 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:58:38Z","created_by":"Edward Langley","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-byz","title":"records_display returns Some(\"\") for out-of-bounds columns instead of None","description":"crates/improvise-core/src/view/layout.rs:186-202: records_display() calls col_label(col), which returns an empty string for out-of-bounds indices; the empty string then fails the == \"Value\" check, is looked up as a category name, and the function returns Some(\"\") via unwrap_or_default(). cell_key() (~line 396) explicitly returns None for an empty col_label — records_display should be symmetric so view/data mismatches are not masked. 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:58:35Z","created_by":"Edward Langley","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-zgb","title":".improv formula loader mis-splits category suffix via rfind(\" [\")","description":"crates/improvise-io/src/persistence/mod.rs:403-411: the formulas_section walker finds the [Category] suffix with raw.rfind(\" [\") + ends_with(']'). Two failure modes: (1) a pipe-quoted identifier at the end of the formula containing ' [' (e.g. |Revenue [USD]|) is mis-split, treating part of the quoted name as the category; (2) a suffix written without a leading space (X=Y[Cat]) is not recognized and the whole line is parsed as a _Measure formula, losing the target category. Fix: move the category suffix into the pest grammar instead of string scanning. Write failing tests first per AGENTS.md. 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:58:32Z","created_by":"Edward Langley","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-drg","title":"vb4 step 5: narrow Effect::apply signatures where one-slice","description":"Optional follow-up to vb4 structural split. For each impl Effect for Foo, audit which slice(s) it mutates. If apply only touches ModelState (or only ViewState), narrow its signature from fn apply(\u0026self, app: \u0026mut App) to fn apply(\u0026self, model: \u0026mut ModelState) (or ViewState). Cross-cutting effects (read layout, change mode that triggers layout rebuild, etc.) stay on \u0026mut App. Improves type-level documentation and enables finer-grained effect tests. Lower priority — vb4's acceptance criterion (4) explicitly allows leaving cross-cutting on \u0026mut App.","design":"Hidden trait-design decision surfaced during vb4 step 4 review: the issue's\nphrasing — \"narrow its signature from fn apply(\u0026self, app: \u0026mut App) to\nfn apply(\u0026self, model: \u0026mut ModelState)\" — is not directly possible. You\ncannot change one Effect impl's signature without changing the Effect\ntrait. Three approaches, none picked:\n\n(A) Split into multiple traits — ModelEffect / ViewEffect / AppEffect.\n Each impl becomes the narrower thing it actually is. Cost: ~50\n existing impls reorganize across three traits, and Vec\u003cBox\u003cdyn Effect\u003e\u003e\n has to become an enum wrapper (or three parallel queues) to keep\n apply_effects working. Highest fidelity to the issue's literal wording.\n\n(B) Keep one trait, rebind inside the impl body — e.g.\n fn apply(\u0026self, app: \u0026mut App) { let view = \u0026mut app.view_state; view.mode = ... }\n so the slice is visible at the call site without changing the\n signature. Lowest disruption. Doesn't strictly satisfy acceptance\n criterion (1) (a \"narrower borrow\") — only the body is narrower.\n\n(C) Default-method dispatch on a single trait — Effect grows\n apply_view(\u0026mut ViewState) and apply_model(\u0026mut ModelState) with\n default impls that fall through to apply(\u0026mut App). Each impl\n overrides only the method that matches its slice. Middle ground:\n type-level narrowing for impls that opt in, \u0026mut App stays available\n for cross-cutting effects. Wraps cleanly around the existing\n apply_effects loop.\n\nPicking (A) commits to the architecture for downstream effect tagging\n(improvise-mae) and the protocol extraction (improvise-cqi). (B) and (C)\nleave that decision unmade. Recommend deciding before claiming.","acceptance_criteria":"Each Effect whose apply touches only one slice takes \u0026mut ModelState or \u0026mut ViewState; cross-cutting effects stay on \u0026mut App, which vb4's own acceptance explicitly allows. The win is type-level documentation and finer-grained effect tests, not line count. CAUTION, new since this ticket was filed: App::apply_effects now reads dirtiness, layout_version and the record fingerprints off the EffectKind (improvise-gv9r, gotcha 40) — narrowing a signature must not put an effect somewhere that loop cannot see.","notes":"DECLINED 2026-09-20 (wave 2), with counts. Reasoning in context/code/command.md,\n'Effect::apply stays on \u0026mut App'. NOTE: this bead carries the `human` label, so\nthe decline is a recommendation the user may overturn; it is recorded rather than\nsmuggled.\n\nCOUNTED over the 49 `impl Effect` blocks in ui/effect.rs:\n now (this branch) 8 view_state-only, 9 model_state-only, 32 cross-cutting\n base 52507a6 9 view_state-only, 16 model_state-only, 24 cross-cutting\nThe narrow set fell from 25 to 17 in one wave. The mover was improvise-9mo9,\nwhich made nine reshaping effects call app.reset_cursor(): a reshape lives in\nmodel_state.workbook and the cursor it invalidates lives in ViewState, so the\neffects that matter most are precisely the ones that reconcile the two slices.\nNarrowing bets against the direction the code moves in.\n\nFOUR GROUNDS\n1. The narrow set is small and shrinking, for that architectural reason.\n2. The type-level documentation already exists and is CHECKED: Effect::kind()\n has no default (gotcha 3) and apply_effects reads that one answer three times\n (gotchas 40, 45). A narrowed signature is a second statement of the same fact,\n free to disagree -- the defect, not the cure.\n3. Every offered mechanism weakens a guard. (A) three traits needs three queues\n (destroys batch order, which gotchas 21/23/43 lean on) or an enum wrapper (a\n fourth classification beside EffectKind). (C) default methods give `apply` a\n DEFAULT, exactly what kind() does without and for the same reason: overriding\n apply_view on an effect that needs the model compiles and writes the wrong\n slice silently; overriding neither compiles as a no-op. (B) does not meet the\n ticket's own acceptance criterion.\n4. \"Finer-grained effect tests\" is not blocked: effect.rs's 64 tests already call\n Effect.apply(\u0026mut app) against a one-line App::new(wb, None) fixture.\n\nWHAT IS NOT DECLINED: approach (B), rebinding the slice at the top of an apply\nbody, remains available as a per-function style choice. It is not mandated.","status":"closed","priority":3,"issue_type":"task","assignee":"wave2-n-cleanups","owner":"el-github@elangley.org","created_at":"2026-04-26T18:16:52Z","created_by":"jeeves","updated_at":"2026-09-20T18:11:26Z","started_at":"2026-09-20T18:08:16Z","closed_at":"2026-09-20T18:11:26Z","close_reason":"Closed","labels":["area:command","human"],"dependencies":[{"issue_id":"improvise-drg","depends_on_id":"improvise-99k","type":"blocks","created_at":"2026-04-26T11:17:16Z","created_by":"jeeves","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-drg","title":"vb4 step 5: narrow Effect::apply signatures where one-slice","description":"Optional follow-up to vb4 structural split. For each impl Effect for Foo, audit which slice(s) it mutates. If apply only touches ModelState (or only ViewState), narrow its signature from fn apply(\u0026self, app: \u0026mut App) to fn apply(\u0026self, model: \u0026mut ModelState) (or ViewState). Cross-cutting effects (read layout, change mode that triggers layout rebuild, etc.) stay on \u0026mut App. Improves type-level documentation and enables finer-grained effect tests. Lower priority — vb4's acceptance criterion (4) explicitly allows leaving cross-cutting on \u0026mut App.","design":"Hidden trait-design decision surfaced during vb4 step 4 review: the issue's\nphrasing — \"narrow its signature from fn apply(\u0026self, app: \u0026mut App) to\nfn apply(\u0026self, model: \u0026mut ModelState)\" — is not directly possible. You\ncannot change one Effect impl's signature without changing the Effect\ntrait. Three approaches, none picked:\n\n(A) Split into multiple traits — ModelEffect / ViewEffect / AppEffect.\n Each impl becomes the narrower thing it actually is. Cost: ~50\n existing impls reorganize across three traits, and Vec\u003cBox\u003cdyn Effect\u003e\u003e\n has to become an enum wrapper (or three parallel queues) to keep\n apply_effects working. Highest fidelity to the issue's literal wording.\n\n(B) Keep one trait, rebind inside the impl body — e.g.\n fn apply(\u0026self, app: \u0026mut App) { let view = \u0026mut app.view_state; view.mode = ... }\n so the slice is visible at the call site without changing the\n signature. Lowest disruption. Doesn't strictly satisfy acceptance\n criterion (1) (a \"narrower borrow\") — only the body is narrower.\n\n(C) Default-method dispatch on a single trait — Effect grows\n apply_view(\u0026mut ViewState) and apply_model(\u0026mut ModelState) with\n default impls that fall through to apply(\u0026mut App). Each impl\n overrides only the method that matches its slice. Middle ground:\n type-level narrowing for impls that opt in, \u0026mut App stays available\n for cross-cutting effects. Wraps cleanly around the existing\n apply_effects loop.\n\nPicking (A) commits to the architecture for downstream effect tagging\n(improvise-mae) and the protocol extraction (improvise-cqi). (B) and (C)\nleave that decision unmade. Recommend deciding before claiming.","acceptance_criteria":"Each Effect whose apply touches only one slice takes \u0026mut ModelState or \u0026mut ViewState; cross-cutting effects stay on \u0026mut App, which vb4's own acceptance explicitly allows. The win is type-level documentation and finer-grained effect tests, not line count. CAUTION, new since this ticket was filed: App::apply_effects now reads dirtiness, layout_version and the record fingerprints off the EffectKind (improvise-gv9r, gotcha 40) — narrowing a signature must not put an effect somewhere that loop cannot see.","notes":"DECLINED 2026-09-20 (wave 2), with counts. Reasoning in context/code/command.md,\n'Effect::apply stays on \u0026mut App'. NOTE: this bead carries the `human` label, so\nthe decline is a recommendation the user may overturn; it is recorded rather than\nsmuggled.\n\nCOUNTED over the 49 `impl Effect` blocks in ui/effect.rs:\n now (this branch) 8 view_state-only, 9 model_state-only, 32 cross-cutting\n base 52507a6 9 view_state-only, 16 model_state-only, 24 cross-cutting\nThe narrow set fell from 25 to 17 in one wave. The mover was improvise-9mo9,\nwhich made nine reshaping effects call app.reset_cursor(): a reshape lives in\nmodel_state.workbook and the cursor it invalidates lives in ViewState, so the\neffects that matter most are precisely the ones that reconcile the two slices.\nNarrowing bets against the direction the code moves in.\n\nFOUR GROUNDS\n1. The narrow set is small and shrinking, for that architectural reason.\n2. The type-level documentation already exists and is CHECKED: Effect::kind()\n has no default (gotcha 3) and apply_effects reads that one answer three times\n (gotchas 40, 45). A narrowed signature is a second statement of the same fact,\n free to disagree -- the defect, not the cure.\n3. Every offered mechanism weakens a guard. (A) three traits needs three queues\n (destroys batch order, which gotchas 21/23/43 lean on) or an enum wrapper (a\n fourth classification beside EffectKind). (C) default methods give `apply` a\n DEFAULT, exactly what kind() does without and for the same reason: overriding\n apply_view on an effect that needs the model compiles and writes the wrong\n slice silently; overriding neither compiles as a no-op. (B) does not meet the\n ticket's own acceptance criterion.\n4. \"Finer-grained effect tests\" is not blocked: effect.rs's 64 tests already call\n Effect.apply(\u0026mut app) against a one-line App::new(wb, None) fixture.\n\nWHAT IS NOT DECLINED: approach (B), rebinding the slice at the top of an apply\nbody, remains available as a per-function style choice. It is not mandated.\nUSER CONFIRMATION 2026-09-20: the decline is CONFIRMED and the bead stays closed. This bead carries the 'human' label, so the WU-N agent correctly recorded its decline as a recommendation rather than acting on it unilaterally; the user was shown the counts (8 view-only / 9 model-only / 32 cross-cutting of 49, down from 9/16/24 at the wave base) and all three approaches (A: three traits; B: rebind in the body; C: default-method dispatch), and chose to confirm the decline.\n\nThe reasoning the user accepted: the classification ALREADY EXISTS and is checked — Effect::kind has no default and apply_effects reads that one answer three times — so a narrowed signature is a second statement free to disagree with it. Three traits need three queues (destroying batch order) or an enum wrapper (a fourth classification). Default methods would give apply a DEFAULT whose wrong answer is silent, which is precisely what kind()'s no-default rule exists to prevent.\n\nThe fact worth carrying past this bead: improvise-9mo9 moved nine effects from 'touches one slice' to 'cross-cuts' INSIDE THE SAME BRANCH that declined this, and that is not a coincidence to route around. The App exists because the grid's shape lives in model_state.workbook and the cursor into it lives in ViewState, so the effects that matter most are exactly the ones that RECONCILE the two slices. A design that rewards single-slice effects would have been pulling against the repo's actual centre of gravity.","status":"closed","priority":3,"issue_type":"task","assignee":"wave2-n-cleanups","owner":"el-github@elangley.org","created_at":"2026-04-26T18:16:52Z","created_by":"jeeves","updated_at":"2026-09-20T18:25:53Z","started_at":"2026-09-20T18:08:16Z","closed_at":"2026-09-20T18:11:26Z","close_reason":"Closed","labels":["area:command","human"],"dependencies":[{"issue_id":"improvise-drg","depends_on_id":"improvise-99k","type":"blocks","created_at":"2026-04-26T11:17:16Z","created_by":"jeeves","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-1ud","title":"Document RecordsEditing variant in repo-map.md","description":"context/repo-map.md currently lists 15 AppMode variants but does not include RecordsEditing, which is referenced at src/draw.rs:142 and src/ui/app.rs:442. Add the variant to the AppMode list in the 'Key Types' section so coding agents see it.\\n\\nTrivial doc fix.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:54:16Z","created_by":"Ed L","updated_at":"2026-06-10T04:05:13Z","closed_at":"2026-06-10T04:05:13Z","close_reason":"Done in commit 0fe04de (2026-06-09 repo-map refresh): AppMode list now reads 16 variants including RecordsNormal and RecordsEditing, and notes MinibufferConfig + the ModelState/ViewState split.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-8fy","title":"Introduce simple_effect! macro for single-field setter effects","description":"crates/improvise-command/src/ui/effect.rs has ~15 simple setter effects (SetSelected, SetRowOffset, SetColOffset, SetYanked, SetSearchQuery, SetSearchMode, SetStatus, etc.) that each implement the same 4-line apply: pub struct SetX(pub T); impl Effect { fn apply(\u0026self, app: \u0026mut App) { app.field = self.0.clone(); } }.\\n\\nAbstraction: simple_effect!(SetRowOffset, usize, row_offset) macro generates struct + Effect impl.\\n\\nCorrectness win: provides a single hook for cross-cutting concerns. If layout-affecting setters need to mark dirty or rebuild layout, one macro body covers all of them. Currently each setter independently decides whether to mark_dirty(), and silent omissions are plausible. ~50 LOC.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:54:14Z","created_by":"Ed L","updated_at":"2026-09-19T20:35:54Z","closed_at":"2026-09-19T20:35:54Z","close_reason":"simple_effect! macro added beside the Effect trait; eight single-field ViewOnly setters declared through it (-42 lines net). The macro HARD-CODES EffectKind::ViewOnly by design: an effect other clients must see is not 'simple' and keeps a hand-written impl, which is how gotcha 3's no-default-kind rule survives a macro at all. SetSelected (two fields) and SetNumberFormat (a Model change) stay hand-written. Existing effect tests already cover all eight, including view_only_effects_leave_shared_state_alone.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-1wu","title":"Extract axis label helper in view/layout.rs","description":"crates/improvise-core/crates/improvise-core/src/view/layout.rs:323-336 (row_label) and :338-351 (col_label) are character-identical except for which items list they read. Small syntactic duplication but trivial to consolidate.\\n\\nFix: fn axis_label(items: \u0026[AxisEntry], idx: usize) -\u003e String, call from both. ~14 LOC.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:54:13Z","created_by":"Ed L","updated_at":"2026-08-14T01:11:07Z","closed_at":"2026-08-14T01:11:07Z","dependencies":[{"issue_id":"improvise-1wu","depends_on_id":"improvise-ai7","type":"supersedes","created_at":"2026-08-13T18:11:07Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -282,10 +290,12 @@ {"_type":"issue","id":"improvise-l36","title":"3.2 Publish to crates.io","description":"After cargo publish --dry-run is clean and user confirms, run cargo publish. Verify crate appears at crates.io/crates/improvise and cargo install improvise works.","acceptance_criteria":"improvise is on crates.io and 'cargo install improvise' works from a clean machine. NEEDS THE USER: crates.io credentials, and their confirmation after 'cargo publish --dry-run' is clean. Note the workspace has grown to nine crates since this was filed — decide which are published and in what order, since a dependent cannot publish before its dependency, and improvise-io/protocol/command are all now real crates rather than internal modules.","notes":"DRY RUN ACTUALLY RUN, 2026-09-20 at 52507a6 (wave 2, WU-P). It is NOT clean,\nand the blocker is mechanical rather than a judgement call:\n\n error: failed to verify manifest at Cargo.toml\n all dependencies must have a version requirement specified when publishing.\n dependency `gp-tree` does not specify a version\n\ncargo stops at the FIRST offender, and gp-tree is not the only one. Every\ninter-crate dependency in this workspace is `{ path = \"...\" }` with no\n`version`, which crates.io forbids for normal dependencies:\n\n Cargo.toml (root) gp-tree, improvise-core, improvise-formula,\n improvise-io, improvise-command\n improvise-core improvise-formula\n improvise-formula (none)\n improvise-protocol improvise-core\n improvise-io improvise-core, improvise-formula\n improvise-gp gp-tree, improvise-core, improvise-formula\n improvise-command improvise-core, improvise-protocol,\n improvise-formula, improvise-io, improvise-gp\n\nDev-dependencies are exempt — cargo strips them on publish — so the root's\n`improvise-wasm-client`/`improvise-protocol` dev deps and improvise-wasm-\nclient's dev dep on the unpublishable improvise-ws-server are fine as they are.\n\nWHICH CRATES PUBLISH. `improvise-ws-server` and `improvise-worker-server` both\ncarry `publish = false` already, so the set is eight, and a dependent cannot go\nbefore its dependency. The only order that works:\n\n 1. improvise-formula (no intra-workspace deps)\n 2. improvise-core\n 3. gp-tree (no intra-workspace deps; can go any time before 4)\n 4. improvise-gp\n 5. improvise-protocol\n 6. improvise-io\n 7. improvise-command\n 8. improvise (the binary)\n\ncrates.io needs a minute or two to index each one before the next can resolve\nit, so this is eight sequential `cargo publish` calls, not one.\n\nTWO VERSION FACTS THE USER MUST DECIDE. `gp-tree` is at `0.1.0` while every\nother crate is at `0.1.0-rc2`; and a `-rc2` version requirement in a published\nmanifest pins consumers to a prerelease. If the intent is to publish 0.1.0\n(which improvise-3tj's tag implies), the version bump across ten manifests\nshould happen BEFORE the version requirements are written, or they will be\nwritten twice.\n\nNAME AVAILABILITY CHECKED: crates.io returns \"crate `improvise` does not exist\",\nso the name is free. Not checked: the other seven names.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:58Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:01:38Z","labels":["area:release","needs:user"],"dependencies":[{"issue_id":"improvise-l36","depends_on_id":"improvise-11a","type":"blocks","created_at":"2026-04-08T21:09:29Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-l36","depends_on_id":"improvise-2fr","type":"blocks","created_at":"2026-04-08T21:09:28Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":2,"comment_count":0} {"_type":"issue","id":"improvise-11a","title":"3.1 Configure cargo dist","description":"Run cargo dist init targeting x86_64-unknown-linux-gnu, aarch64-apple-darwin, x86_64-apple-darwin. Skip Windows and musl. Commit generated .github/workflows/release.yml and Cargo.toml additions. Test with v0.1.0-rc1 tag, verify builds, delete rc tag.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:55Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-11T07:49:57Z","close_reason":"Closed","dependencies":[{"issue_id":"improvise-11a","depends_on_id":"improvise-abz","type":"blocks","created_at":"2026-04-08T21:09:28Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":3,"comment_count":0} {"_type":"issue","id":"improvise-0s6","title":"Phase 3: Distribution","description":"Configure cargo dist, publish to crates.io, tag v0.1.0 release. Produces prebuilt binaries for Linux x86_64 and macOS (Intel + Apple Silicon).","acceptance_criteria":"Closes when improvise-l36 and improvise-3tj close. Both need the user; nothing here is agent work. improvise-11a (cargo dist) is already closed.","notes":"CHECKED, wave 2 WU-P: STAYS OPEN, correctly — both children need the user and\nneither was touched. Nothing was published and nothing was tagged.\n\nWhat this pass DID do is make them ready, and one of them is not as ready as\nit looked. `cargo publish --dry-run` was actually run and FAILS: every\ninter-crate dependency in the workspace is `{ path = ... }` with no `version`,\nwhich crates.io forbids. cargo reports only the first offender (gp-tree), so\nthe dry run understates it — the full list, the publish order for the eight\npublishable crates, and the gp-tree/rc2 version disagreement are on\nimprovise-l36.\n\nimprovise-3tj's premise is already partly false and its note now says so: the\nREADME prebuilt-binaries link is not a placeholder, v0.1.0-rc2 carries real\ncargo-dist artifacts for all four targets, and what is actually missing is the\nv0.1.0 tag and the manifest bump that must precede it.","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-09T04:05:51Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:05:07Z","labels":["area:release","needs:user"],"dependencies":[{"issue_id":"improvise-0s6","depends_on_id":"improvise-11a","type":"blocks","created_at":"2026-04-08T23:37:39Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-0s6","depends_on_id":"improvise-3tj","type":"blocks","created_at":"2026-04-08T23:37:41Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-0s6","depends_on_id":"improvise-l36","type":"blocks","created_at":"2026-04-08T23:37:40Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":3,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-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":"open","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-20T18:45:36Z","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.","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-20T18:48:10Z","labels":["area:active-view"],"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":"open","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:14:57Z","created_by":"orchestrator","updated_at":"2026-09-20T18:14:57Z","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":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:13:31Z","created_by":"wave2-b-projection","updated_at":"2026-09-20T18:13:31Z","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-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":"TWO ADDITIONS, both instances of this ticket's one fact rather than new ones.\n\nFIRST, WHAT A CHECK MUST ACTUALLY DO — the part that gets lost, and the reason\nthis ticket is not a one-liner. Counting the four `.ap-player` elements PROVES\nNOTHING. `autoPlay` is false, so asciinema-player does not fetch a cast until\nthe viewer presses play; a page whose every cast path 404s constructs four\nplayers, throws no page error before the click, and is indistinguishable from a\nworking one. This was verified by injecting exactly that fault (`./casts/` -\u003e\n`./cats/`): the construction-only check passed on the broken page, and pressing\nplay returned 0 rendered lines and surfaced four 404s. So the spec must press\nplay on each of the four and assert a non-zero rendered line count, and its own\nfault injection must be recorded in `context/test-evidence/`.\n\nThe same applies to the page's one functional link. Since the user's ruling\n(improvise-kh8) the landing page IS the site root and its whole job is to reach\nthe app at `./app/`; a broken link there is a dead front door, and the page\nlooks entirely correct with one. Proved able to fail by pointing it at `./aap/`\n-- 404 while nothing on screen changes -- so the spec should resolve the\ncall-to-action href and assert the response status.\n\nSECOND, THE LINE BUDGET, which is an instance of \"asserted by nothing\" and not\na separate defect. improvise-e61's criterion is \"under 200 lines\" and\n`docs/index.html` is now 199 after the root/app restructure. A criterion met by\none line, with nothing checking it, is met by accident: the next person to touch\nthe file either trims something or silently breaks it, and no test, lint or CI\njob would say so. If this ticket's spec lands, the cheapest place for the budget\nis beside it -- a line count assertion is one statement and needs no browser.\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/` -- the landing page renders and the casts play,\n and no test in the repository would have noticed if they had not.\n4. Break a cast path in `docs/index.html`, repeat, and observe that the page is\n still structurally correct until a play button is pressed.\n5. `wc -l docs/index.html` -- 199, against a stated limit of 200, checked by\n nothing.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:01:16Z","created_by":"wave2-p-milestones","updated_at":"2026-09-20T18:14:01Z","labels":["area:release"],"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":"TWO ADDITIONS, both instances of this ticket's one fact rather than new ones.\n\nFIRST, WHAT A CHECK MUST ACTUALLY DO — the part that gets lost, and the reason\nthis ticket is not a one-liner. Counting the four `.ap-player` elements PROVES\nNOTHING. `autoPlay` is false, so asciinema-player does not fetch a cast until\nthe viewer presses play; a page whose every cast path 404s constructs four\nplayers, throws no page error before the click, and is indistinguishable from a\nworking one. This was verified by injecting exactly that fault (`./casts/` -\u003e\n`./cats/`): the construction-only check passed on the broken page, and pressing\nplay returned 0 rendered lines and surfaced four 404s. So the spec must press\nplay on each of the four and assert a non-zero rendered line count, and its own\nfault injection must be recorded in `context/test-evidence/`.\n\nThe same applies to the page's one functional link. Since the user's ruling\n(improvise-kh8) the landing page IS the site root and its whole job is to reach\nthe app at `./app/`; a broken link there is a dead front door, and the page\nlooks entirely correct with one. Proved able to fail by pointing it at `./aap/`\n-- 404 while nothing on screen changes -- so the spec should resolve the\ncall-to-action href and assert the response status.\n\nSECOND, THE LINE BUDGET, which is an instance of \"asserted by nothing\" and not\na separate defect. improvise-e61's criterion is \"under 200 lines\" and\n`docs/index.html` is now 199 after the root/app restructure. A criterion met by\none line, with nothing checking it, is met by accident: the next person to touch\nthe file either trims something or silently breaks it, and no test, lint or CI\njob would say so. If this ticket's spec lands, the cheapest place for the budget\nis beside it -- a line count assertion is one statement and needs no browser.\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/` -- the landing page renders and the casts play,\n and no test in the repository would have noticed if they had not.\n4. Break a cast path in `docs/index.html`, repeat, and observe that the page is\n still structurally correct until a play button is pressed.\n5. `wc -l docs/index.html` -- 199, against a stated limit of 200, checked by\n nothing.\nWITHDRAWN FROM THIS TICKET: the 199-line budget paragraph above.\n\nIt was added here on the reasoning that \"a 200-line limit met by one line with\nnothing checking it\" is an instance of this ticket's one fact. On review that is\nthe wrong home: the better answer is to rewrite improvise-e61's criterion, since\nthe line count was only ever a proxy for single file / vanilla / no build step /\nno framework — properties nothing threatens and which a line count measures\nbadly. That has been done on improvise-e61.\n\nThis ticket is about the landing page being SERVED and ASSERTED BY NOTHING, and\nit should stay that. Read the paragraph above as superseded; the rest of the\nnotes — press play, count rendered lines, resolve the call-to-action href — is\nwhat this ticket is for.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:01:16Z","created_by":"wave2-p-milestones","updated_at":"2026-09-20T18:22:56Z","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":"open","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-20T16:53:36Z","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":"open","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T07:50:12Z","created_by":"Edward Langley","updated_at":"2026-09-20T16:53:34Z","labels":["area:core-io"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-zx8t","title":"draw.rs::mode_style matches AppMode with a wildcard, so the repo map's exhaustiveness promise is false there","description":"Found by WU-G on PR #27 while adding AppMode::SavePrompt, and it is a correction to something context/repo-map.md currently promises.\n\nThe repo map says of AppMode: \"Per-variant UI facts live on the enum as exhaustive-match methods -- label(), hint_text(), panel(), minibuffer(), is_editing(), is_records() (improvise-2hi); ADD A VARIANT AND THE COMPILER NAMES EVERY ONE YOU MUST FILL IN.\"\n\nThat is TRUE of the five enum methods and FALSE of src/draw.rs::mode_style, which matches AppMode with a `_ =\u003e` WILDCARD. So a new variant silently inherits whatever the wildcard arm gives -- SavePrompt currently takes ExportPrompt's DarkGray by accident rather than by decision, which happens to be defensible and was nobody's choice.\n\nWHY IT IS P4 AND NOT HIGHER: the consequence is a colour, and the wrong colour is visible rather than silent. But the reason to fix it is that the repo map states a PROPERTY, and a property that holds in five places and not the sixth is worse than one nobody claimed -- the next person adding a variant will trust the sentence.\n\nTHE FIX IS ONE OF TWO, and they are not equivalent:\n * Make mode_style exhaustive, so the compiler does name it. Note mode_style stays in src/draw.rs deliberately, because Style is a ratatui type and improvise-protocol must not name one (gotcha 26, the AxisColor/MinibufColor pattern).\n * Or give AppMode a semantic colour method in the protocol crate -- the AxisColor treatment -- and let draw.rs do the one mapping. That is the established shape here and it would also give a browser something to use, which draw.rs's Style cannot.\n\nThe second is probably right and is the same question improvise-k099 asks about hint_text/label. Decide them together.\n","acceptance_criteria":"mode_style either matches AppMode exhaustively, or AppMode gains a semantic colour method in improvise-protocol that draw.rs maps to a ratatui Style (the AxisColor/MinibufColor shape). Either way, adding an AppMode variant fails the build until someone chooses its colour. The repo-map sentence promising compiler-named sites is true of every listed method, or it is corrected to say which one it is not true of.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T06:04:22Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:44:19Z","labels":["area:tui"],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -310,5 +320,5 @@ {"_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-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.","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:19:45Z","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-e61","title":"4.1 Create docs/index.html landing page","description":"Vanilla HTML, single file, under 200 lines. Dark background, monospace headings. Embed asciinema-player from jsdelivr CDN. Sections: title/tagline, pivot cast, drill cast, formulas cast, import cast, install commands + GitHub link. Player config: rows 30, cols 100, theme monokai, autoPlay false, loop true.","acceptance_criteria":"docs/index.html exists: vanilla HTML, single file, under 200 lines, dark background and monospace headings, asciinema-player embedded from jsdelivr, sections for title/tagline, the pivot/drill/formulas/import casts, install commands and a GitHub link. Player config rows 30, cols 100, theme monokai, autoPlay false, loop true. The four casts already exist (improvise-d4w is closed) — check they still reflect the current UI before embedding them, since the wave changed the records columns and the status line.","notes":"CRITERION REINTERPRETED, on review of PR #47, rather than a second bead filed.\n\n\"Under 200 lines\" is a PROXY for what this ticket actually cared about — single\nfile, vanilla, no build step, no framework — and those are the properties worth\nholding. The line count is not: it is met or missed by an arbitrary margin, it is\nchecked by nothing, and at 199 lines it was \"met\" only by accident, with the next\nperson to touch the file silently breaking it.\n\n`docs/index.html` is now 209 lines. The properties the number stood for are all\nintact: one file, no build step, no framework, no dependency but the pinned\nasciinema-player from a CDN.\n\nWhat took it over was a fix, not padding. At a 390px viewport the four players\ncomputed a 4.83px terminal font — measured in Chromium against the assembled\ntree — because a 120-column cast shrunk into a phone is unreadable. That was\ntolerable when the landing page sat at /landing/; since the user's ruling it is\nthe first thing a phone visitor sees. A media query under 900px now scrolls each\nplayer sideways at a legible size instead: 11.95px, measured the same way, with\nthe PAGE's own scrollWidth still equal to its clientWidth, so the overflow is\ncontained inside the player and there is no horizontal page scroll.\nTWO CORRECTIONS TO THE NOTE ABOVE, and one thing it should not be read as saying.\n\nFIRST, THE NUMBERS IN THAT NOTE ARE NOW WRONG. It says the file is 209 lines and\nthat a media query fixes the phone rendering. A user ruling has since reverted\nthat fix — the mobile defect is filed as improvise-o31u and the page is\nunchanged — so `docs/index.html` is **213 lines**, and what took it past 200 is\nthe drill-cast caption below, not the mobile fix. The criterion reinterpretation\nstands on its own reasoning and does not depend on which change crossed the\nline: \"under 200 lines\" was a proxy for single file, vanilla, no build step, no\nframework, and all four hold.\n\nSECOND, AND MORE IMPORTANT: THE CAPTION IS A LABEL ON AN UNMET CRITERION, NOT A\nMET ONE. This ticket asked that the casts be checked against the current UI\nbefore embedding. They were, and `drill.cast` fails: it shows the narrow records\nformat with a single `Value` column, which improvise-c5v and improvise-2zq\nreplaced with a column per measure plus the `+cat` and `+` columns. The user\nruled to ship it with a visible caption rather than re-record or drop the\nsection, and to keep this ticket closed. That ruling is about what to SHIP; it\ndoes not make the criterion satisfied.\n\nSo the honest reading of this ticket's state is: delivered, with one embedded\ncast known to demo a UI the software no longer has, labelled as such on the page\nat body size and above the player rather than in a footnote, and improvise-2oxt\nopen to fix it properly. Anyone auditing \"do the casts reflect the current UI\"\nshould read improvise-2oxt, not this close reason, for the answer.","status":"closed","priority":4,"issue_type":"task","assignee":"wave2-p-milestones","owner":"el-github@elangley.org","created_at":"2026-04-09T04:08:07Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:22:56Z","started_at":"2026-09-20T18:02:40Z","closed_at":"2026-09-20T18:04:48Z","close_reason":"docs/index.html on wave/p-milestones: 194 lines, vanilla, single file, dark\nbackground, monospace headings, asciinema-player 3.10.0 pinned from jsdelivr\n(both URLs checked live, 200), all six sections, and the four improvise-d4w\ncasts. Verified by driving it in Chromium against both docs/ and the assembled\nsite/landing/: four players construct, each renders 37 terminal lines after\nplay, zero failed requests, zero page errors. That check was proved able to\nfail first — with autoPlay false the cast is not fetched until the click, so\ncounting players passes on a page whose every cast path 404s; breaking\n./casts/ to ./cats/ passes the construction check and fails the play check.\nNothing in CI asserts it yet: improvise-bgtf.\n\nTWO DEVIATIONS, both stated rather than papered over.\n\nPlayer geometry is cols 120 / rows 37, not the 100x30 this ticket and\nimprovise-d4w both name. Every cast header says 120x37, so the recordings\nnever complied with d4w's own \"Terminal: 100x30\"; a 30-row player clips the\nbottom seven rows of a 37-row cast, which is where the tile bar and the status\nline are.\n\nThe \"check they still reflect the current UI\" criterion was performed and the\nanswer is partly no. The tile bar's short_label vocabulary and the mode line\nstill match; the casts predate improvise-2zq's +cat column by five months and\nevery one opens on the recorder's shell prompt and home directory. Filed as\nimprovise-2oxt rather than embedded as if current.","labels":["area:release"],"dependencies":[{"issue_id":"improvise-e61","depends_on_id":"improvise-d4w","type":"blocks","created_at":"2026-04-08T21:09:31Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} {"_type":"issue","id":"improvise-kh8","title":"Phase 4: Landing page (optional)","description":"Create docs/index.html with embedded asciinema casts and enable GitHub Pages. Optional but recommended for launch.","acceptance_criteria":"Closes when improvise-e61 closes; improvise-3gy (enable GitHub Pages) is already closed. NOTE A COLLISION worth resolving before starting: Pages is already serving the standalone web app from the CI assemble step (improvise-d31/bck). Decide whether the landing page replaces that, sits beside it at a path, or is dropped in favour of the live app being the landing page — which is arguably the better demo and would close this as won't-fix.","notes":"CORRECTION TO THE CLOSE REASON ABOVE — THE USER OVERRULED IT, same session.\n\nThe close reason records the landing page sitting BESIDE the app at /landing/,\nwith the app keeping the root. The user ruled the other way:\n\n \"The landing page should be served at the root of the repository and link\n to the in-browser demo.\"\n\nSo the arrangement is inverted. `docs/index.html` is the site root and links\nprominently to the app, which moves to `/app/`. The argument for the other\narrangement is left standing above rather than deleted, because it is still the\nreason the alternative was considered and the collision this ticket asked to\nresolve was a genuine one — but it is OVERRULED, not merely superseded, and the\nroot is now the landing page.\n\nWHAT THE INVERSION COST, which is the part worth carrying:\n\n* The app moves under `app/` WHOLE — `index.html`, `app.js`, `style.css`,\n `worker.js`, `demo.improv` and BOTH bundle directories. Leaving `pkg/` and\n `worker-pkg/` at the root would have meant editing the wasm-pack generated\n glue; every path in app.js and worker.js is relative, so moving the bundle\n together keeps all of them correct and the glue untouched. `worker.js`'s\n `fetch(\"demo.improv\")` resolves against the WORKER's url, so the seed follows\n the move for the same reason.\n* `web/assemble.sh`'s four `stamped` guards had to follow the files. They are\n the only check that the cache-bust substitution worked, and a guard pointed at\n a path nothing writes fails in the direction that looks like success.\n* `harness/tests/shell.spec.js` navigates to `/index.html` six times, including\n the `?ws=` and `?doc=` variants. Every one of those now lands on the landing\n page, so all six were repointed at `/app/index.html`. The browser harness is a\n CI job that can fail the build, so this was not optional and not someone\n else's to discover.\n\nVERIFIED against the ASSEMBLED tree, not against docs/ directly, because a path\nmove is exactly what re-arms the trap this page already caught once: the four\ncasts play 37 lines each at the root, the primary call-to-action resolves to\n/app/ and returns 200, and there are no failed requests and no page errors. The\ncall-to-action check was proved able to fail by pointing the link at ./aap/,\nwhich returns 404 while the page still looks entirely correct.","status":"closed","priority":4,"issue_type":"feature","assignee":"wave2-p-milestones","owner":"el-github@elangley.org","created_at":"2026-04-09T04:05:53Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:10:48Z","started_at":"2026-09-20T18:02:40Z","closed_at":"2026-09-20T18:04:51Z","close_reason":"Both children terminal: improvise-3gy was already closed, improvise-e61 closes\nwith this wave.\n\nTHE COLLISION THIS TICKET ASKED TO RESOLVE FIRST, resolved: Pages serves the\nstandalone web app at the root (improvise-d31/bck), and the landing page now\nsits BESIDE it at /landing/ rather than replacing it. The reasoning is this\nticket's own — the live app is the better front door — and the third option,\ndropping the landing page in favour of the app, was declined only because the\nrecorded terminal casts show the TUI, which the browser app does not.\n\nweb/assemble.sh copies docs/index.html and docs/casts/ into the deployed tree,\nso the page is reachable and the browser harness serves the same tree the\ndeploy publishes. A landing page the deploy never copies would have satisfied\nthis ticket's letter and been a file nobody can open.\n\nREADME links both.","labels":["area:release"],"dependencies":[{"issue_id":"improvise-kh8","depends_on_id":"improvise-3gy","type":"blocks","created_at":"2026-04-08T23:37:42Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-kh8","depends_on_id":"improvise-e61","type":"blocks","created_at":"2026-04-08T23:37:41Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0} diff --git a/context/repo-map.md b/context/repo-map.md index 4ed7fbbe..557e5e2c 100644 --- a/context/repo-map.md +++ b/context/repo-map.md @@ -161,7 +161,7 @@ Region=West, Measure=Revenue = |pending| # pipe-quoted text **`prune_axis_items` takes a predicate, not a flag vector.** `prune_empty` hands it `impl Fn(usize) -> bool` over the *data* index (group headers consume no index). The domain is derived from `items` itself, so there is no parallel length a caller could get wrong — the earlier `&[bool]` + `debug_assert` pair could disagree between debug and release if a third caller ever sized `keep` from something other than `row_count()`/`col_count()` (improvise-n3wt). 2. **Effects never touch `std::fs`.** Every document read or write goes through `App::storage: Box` (improvise-fuj). The host picks the backend: `App::new` supplies `PhysicalFs`, `App::with_storage` takes any other — a browser worker will supply OPFS (improvise-i34), and tests use `MemoryFs`. `improvise-io` keeps path-taking wrappers (`save`, `load`, `export_csv`, `parse_csv_with_warnings`) for the native CLI, each delegating to a `_to`/`_from`/`_bytes` variant that takes the backend or the bytes. -3. **Every effect declares an `EffectKind`, and there is no default.** `ViewOnly` never reaches other clients; `Model(CellData | LayoutGeometry | WholeWorkbook)` does (improvise-mae). A new effect that forgets to classify itself fails to compile — that is deliberate, since the failure mode is silent: a mis-declared `ViewOnly` edit would simply never reach connected clients. Watch two traps: the *View* lives inside `model_state.workbook`, so "touches model_state" does NOT mean "is a model change" (axis/page/format are `LayoutGeometry`, cursor and scroll are `ViewOnly` — see improvise-8uz); and `Save`/`SaveAs`/`MarkDirty` touch `model_state` for bookkeeping only and are `ViewOnly`. `AddCategory`/`RemoveCategory` are the third trap and go the other way: they look like data edits, but both go through `Workbook`, whose `on_category_added`/`on_category_removed` rewrite **every** view's axis map, so they are `LayoutGeometry`. The two "an effect declaring X actually changes X" tests cannot separate them — a category change moves model data *and* view config, so it clears either bar; `structural_effects_declare_their_kind` pins the declaration itself. A third trap, now guarded: **`EffectKind` is too coarse to check a *mapping*.** `ui/reduce.rs::model_effects` sends each of 28 `ModelCommand` variants to one effect, and every inverse pair — `HideItem`/`ShowItem`, `AddItem`/`RemoveItem`, `CreateView`/`DeleteView`, `SetCell`/`ClearCell` — declares the *same* kind, so swapping three of them at once left the suite at 918 passing while three wire messages did the exact inverse of what they said. The mapping is name-for-name by construction, so `every_model_command_names_the_effect_of_the_same_name` asserts that, reading the type name off the effect's derived `Debug` (`Effect` is a trait object, so `type_name` is out of reach). A variant whose effect must be named something else is a variant that stopped being derived — it should fail there and be argued about. Their reach is wider than the name suggests: `Model::remove_category` also drops every formula targeting the category, which is why `Dirty::of(LayoutGeometry)` reports `formulas: true` — see gotcha 35. +3. **Every effect declares an `EffectKind`, and there is no default.** `ViewOnly` never reaches other clients; `Model(CellData | LayoutGeometry | WholeWorkbook)` does (improvise-mae). A new effect that forgets to classify itself fails to compile — that is deliberate, since the failure mode is silent: a mis-declared `ViewOnly` edit would simply never reach connected clients. Watch two traps: the *View* lives inside `model_state.workbook`, so "touches model_state" does NOT mean "is a model change" (axis/page/format are `LayoutGeometry`, cursor and scroll are `ViewOnly` — see improvise-8uz; and `SwitchView`, `ViewBack` and `ViewForward` moved from `LayoutGeometry` to `ViewOnly` with improvise-jytm, writing `view_state` and nothing else, which is what took the latter two off the wire — but a sound derivation is not a guard: only `SwitchView` had one, and review showed reclassifying either sibling back was silent across the whole suite); and `Save`/`SaveAs`/`MarkDirty` touch `model_state` for bookkeeping only and are `ViewOnly`. `AddCategory`/`RemoveCategory` are the third trap and go the other way: they look like data edits, but both go through `Workbook`, whose `on_category_added`/`on_category_removed` rewrite **every** view's axis map, so they are `LayoutGeometry`. The two "an effect declaring X actually changes X" tests cannot separate them — a category change moves model data *and* view config, so it clears either bar; `structural_effects_declare_their_kind` pins the declaration itself. A third trap, now guarded: **`EffectKind` is too coarse to check a *mapping*.** `ui/reduce.rs::model_effects` sends each of 28 `ModelCommand` variants to one effect, and every inverse pair — `HideItem`/`ShowItem`, `AddItem`/`RemoveItem`, `CreateView`/`DeleteView`, `SetCell`/`ClearCell` — declares the *same* kind, so swapping three of them at once left the suite at 918 passing while three wire messages did the exact inverse of what they said. The mapping is name-for-name by construction, so `every_model_command_names_the_effect_of_the_same_name` asserts that, reading the type name off the effect's derived `Debug` (`Effect` is a trait object, so `type_name` is out of reach). A variant whose effect must be named something else is a variant that stopped being derived — it should fail there and be argued about. Their reach is wider than the name suggests: `Model::remove_category` also drops every formula targeting the category, which is why `Dirty::of(LayoutGeometry)` reports `formulas: true` — see gotcha 35. 4. **Commands never mutate.** `&CmdContext` is read-only; return `Vec>`. If you want to touch `&mut App`, add or extend an Effect. 5. **`CellKey` is always sorted.** Use the constructors; equality and hashing rely on canonical form. 6. **No `Model::add_item`.** With a `Workbook` in hand use the facade: `wb.add_item("Region", "East")?` / `add_item_in_group` / `remove_item` (improvise-tvt). With only a `Model`, go through the category: `m.category_mut("Region").unwrap().add_item("East")`. @@ -193,7 +193,7 @@ Region=West, Measure=Revenue = |pending| # pipe-quoted text 28. **The browser backend is sync on purpose, with an async seam.** `OpfsFs` (`improvise-io/src/opfs.rs`, wasm32 only) implements the same synchronous `Storage` trait as `PhysicalFs`, because `FileSystemFileHandle.createSyncAccessHandle()` yields a handle whose read/write/truncate/flush **are** blocking — available only inside a dedicated Web Worker, which is where improvise-9x6 puts the app. So `Storage` never became async. What is still async splits out into four explicit methods the worker awaits from its message loop: `open()`, `attach(path)` (must precede any read/write of that document — acquiring a handle is async even though using it is not), `refresh()` (repopulates the index `list` answers from), and `commit_deletes()`. Consequence: **`delete` is visible immediately but lands in OPFS at the end of the batch** — it drops the name from the index and queues a `removeEntry`. Handles are exclusive per file, so `attach` closes any handle it replaces and `detach` releases the lock. `storage_contract` in `storage.rs` is the behaviour every backend owes; it runs against both host backends and is the written-down spec `OpfsFs` must satisfy in a browser, where the harness cannot reach. -29. **"Which view is active" is stored twice, and the copies must move together.** `Workbook::active_view` is the document's; `ViewState::active_view` is the client's, and it is what keys `ViewState::cursors`. The second copy exists because `reduce_view` runs on a client that holds no `Workbook` (improvise-cqi). Only two writers are allowed to touch either — `App::switch_to_view` and `App::install_workbook` — and `the_two_active_view_names_never_diverge` guards it. Calling `workbook.switch_view()` directly desyncs them and the cursor map is then read under the wrong key; that is exactly how the 8uz characterization test broke during the extraction. Whether the two are *allowed* to diverge — two clients on different views — is improvise-g0f, and the answer is **yes, eventually**: the model is shared, the UI is not, so `Workbook::active_view` becomes the document's *default* (what a fresh client opens) and `ViewState::active_view` is authoritative per client. improvise-jytm owns the split; a "follow mode" binding two UIs together is deliberately not ruled out. Until then `Sessions::apply` keeps them in lockstep by routing `SwitchView` through `App::switch_to_view` — see gotcha 36. +29. **"Which view is active" is stored twice, and the copies are now allowed to differ.** `Workbook::active_view` is the document's **default** — what a freshly connected client opens, and what `.improv` writes as `Initial View`. `ViewState::active_view` is **authoritative for that client**, and it is what keys `ViewState::cursors`. The second copy exists because `reduce_view` runs on a client that holds no `Workbook` (improvise-cqi); the *split* is improvise-jytm answering improvise-g0f, on the user's ruling that **the model is shared and the UI is not**. Two clients may sit on different views of one document. Three things follow. **`App::resolve_view` is the one answer** to what a view name names in this document, falling back to the default so a slice naming a deleted view cannot panic in `rebuild_layout`; `view()`, `view_mut()`, `view_name()`, `project::grid_in` and `ClientSession::view_in` all go through it, and a second copy of that fallback was the first thing review found. **The guard was replaced, not deleted** — `the_two_active_view_names_never_diverge` became `the_documents_default_view_outlives_this_clients_switches` plus `a_fresh_session_opens_the_documents_default_view`, because a deliberate split with no guard is how one silently regresses into the other. And **a deleted view is the case the split creates**: `DeleteView` can take away the view another client is on, so `Sessions::adopt_deleted_views` moves that mirror to the default and tells it, and `effect::DeleteView` does the same for the host — observed ("does this mirror still name a view the document has"), never predicted from the command variant. **Testing any of it needs a bystander that can be moved**: both audience tests first sat theirs on the default, where a wrongly-addressed relay folds to a no-op, and review demonstrated the vacuity by injecting `ToAll` twice. Two gaps remain, both filed: a view-scoped `ModelCommand` names no view, so a connected client's `SetAxis` lands on the host's view (improvise-lltt — deferred on a *condition*, that no shipped client emits `SwitchView`, while the browser already emits `TransposeAxes`), and `Initial View` no longer follows the TUI user (improvise-azhb). Follow mode is deliberately not foreclosed. 30. **Two tests gate `ViewState` membership.** `ViewState` is the *portable* session slice — the one thing serialized into the Snapshot a newly-connected client gets (improvise-q08). A field may live there only if it is **portable** (plain data from the allowlisted crates, no `Rc`/`Arc`) and **snapshot-safe** (a fresh client inheriting the value is correct). `wizard` fails both — `ImportWizard` is an `improvise-io` type, and a half-open dialog is not something to inherit — and `drill_state` fails both — it holds an `Rc`, and `pending_edits` are uncommitted writes belonging to whoever staged them. Both live on `App` under the **session-local** residue tag (improvise-h0b). The `ViewState: Send` static assertion in `app.rs`'s tests is the compile-time proxy for portability: `Rc` is not `Send`, so a re-added shared pointer fails the build (the `Deserialize` derive from gotcha 25 now rejects it too, serde's `rc` feature being off). `Send` does *not* catch the origin-crate or snapshot-safety halves — check those by hand. @@ -207,11 +207,11 @@ Region=West, Measure=Revenue = |pending| # pipe-quoted text 35. **`Dirty` over-reports on purpose, and its doc comment is the derivation.** `Dirty::of` maps an `EffectKind` to the widgets that kind can reach, and the two directions do not cost the same: over-reporting pays one redundant `Projection`, under-reporting leaves a client's cache wrong **forever**, with nothing in the stream to correct it. `LayoutGeometry` therefore includes `formulas`, because `RemoveCategory` wears it and `Model::remove_category` ends by dropping every formula targeting the category — a client's formula panel otherwise kept showing a formula the document no longer had. It now coincides with `Dirty::EVERYTHING` and is still spelled out, because the two are derived from different effects and need not stay equal. The doc comment on `Dirty::of` is the **only** record of that derivation, and it went stale once already — claiming `AddCategory` was `CellData` after it was reclassified — so reclassifying an effect means re-reading it. -36. **A client's command has one door, and the door takes the `App`.** `Sessions::apply(app, id, cmd)` is the only way a connected client's `Command` reaches the server; `ClientSession::fold` and the mirror's `ViewState` are private, so a transport cannot reach the mirror at all. The door takes the authoritative `App` because some commands have a **document half**: `SwitchView` routes through `apply_command`/`App::switch_to_view` (gotcha 29), and **every** mirror then takes what the document *accepted*, not what was asked — so a switch to a view that does not exist leaves the mirrors put. Not just the asker's: `grid_of` builds every client's grid from `workbook.active_view()`, so a bystander left naming the outgoing view has its cursor read under a stale key and is projected the new view's rows positioned by the old view's scroll — gotcha 24's mismatch arriving through somebody else's keystroke. `apply_switch` therefore takes **no `SessionId`**; the switch is addressed to no one session and that absence is the statement. A client's own slice lives on the client and stays in step only by folding the same command, so `Sessions::apply` returns `Applied { dirty, broadcast }`, where `broadcast` is a **`Vec`** and each item names its own audience (improvise-gvnb). **The audience follows from whether the asker folded.** `Broadcast::ToOthers` is a *correction* of something the asker already applied optimistically; `Broadcast::ToAll` is a *consequence* the asker is owed like everybody else. A `Command::Model` is the one kind a client **cannot** fold optimistically — it cannot predict what the document will accept — so **every** relay originating in `apply_model` is `ToAll`: the `ResetCursor` from `adopt_cursor_epoch`, and the `SwitchView` that `ViewBack`/`ViewForward` move. `apply_switch` is the exception, and only because its asker really did fold. **Copying `ToOthers` from `apply_switch` into `apply_model` is the trap, and it is invisible from the bystander's side** — `adopt_active_view` moves the asker's *mirror* either way, so the drift is between the server's picture of the asker and the asker's own slice, and a test asserting through a "what do the others hear" helper looks away from it by construction. (`apply_switch` has the mirror-image hole on its *refused* path, where the asker folded something the document turned down and is the one client not told: improvise-6c6q.) A `Vec` rather than an `Option` because one command can owe two relays with different audiences, and an `Option` would have had to drop one silently. Everything else is that client's alone and never touches the `App`'s own view slice: **the model is shared, the UI is not.** Folding `SwitchView` into the mirror alone was the bug — `viewport()` then read the cursor under the new view's key and the next `project()` built that client a grid out of the *other* view. +36. **A client's command has one door, and the door takes the `App`.** `Sessions::apply(app, id, cmd)` is the only way a connected client's `Command` reaches the server; `ClientSession::fold` and the mirror's `ViewState` are private, so a transport cannot reach the mirror at all. `apply_switch` now **takes a `SessionId`** — it is addressed to exactly one session, which is the opposite of what this gotcha used to say — moves only that session's mirror, and dirties nothing. The one thing it still asks the document is whether the view exists, because a mirror keys `ViewState::cursors` and is read by `grid_in`. The audience is conditional: an accepted switch relays **nothing** (the asker folded it, and no other client's view moves), a refused one is `Broadcast::To(asker, …)` — improvise-6c6q, where `ToOthers` told every client but the one that was wrong. `adopt_active_view` and `Broadcast::ToOthers` are **deleted**; the variants are `ToAll` and `To(SessionId, _)`, the second because the deleted-view case needs a one-session audience that is *not* the asker. The paragraph's warning about the "what do the others hear" helper survives and is why the 6c6q test drives the asker's own `ViewState` — that helper is itself deleted. What survives unchanged: the model is shared and the UI is not, and `Broadcast` is a `Vec` because one command can owe two relays with different audiences. **The trap this paragraph used to describe is gone with `ToOthers`, and the lesson it taught is not.** It was copying `ToOthers` from `apply_switch` into `apply_model`, invisible from the bystander's side because `adopt_active_view` moved the asker's mirror either way — so the drift sat between the server's picture of the asker and the asker's own slice, where a test written through a "what do the others hear" helper looks away by construction. improvise-6c6q closed the mirror-image hole on the refused path. Two rules survive the mechanism: **assert the `Broadcast` itself**, since the audience is the message rather than something to infer from folded state, and **seat a bystander somewhere a wrong relay can move it from** — a bystander already on the relayed value cannot see a wrongly *widened* audience either, because the wrong relay folds to a no-op. Review demonstrated that vacuity twice on PR #48. A `Vec` rather than an `Option` because one command can owe two relays with different audiences, and an `Option` would have had to drop one silently. Everything else is that client's alone and never touches the `App`'s own view slice: **the model is shared, the UI is not.** Folding `SwitchView` into the mirror alone was the bug — `viewport()` then read the cursor under the new view's key and the next `project()` built that client a grid out of the *other* view. 37. **`gp-tree` returns data and never prints, and a fit takes two things to reproduce.** Two rules with one root — the crate is a library a TUI, a wasm worker or a CLI may each host. (a) A decision the user should hear about comes back as **data**: `Dataset::warnings` carries the columns `preprocess` dropped for being entirely missing (improvise-d44s), and `fit.rs` hands back pooled `(prediction, actual)` pairs and a score per feature rather than tables. `examples/fit_csv.rs` owns every `println!`, and that is the only reason its output stayed byte-identical across these moves — adding an `eprintln!` to either module is the regression. (b) **The seed is necessary and not sufficient**: `data::Model` records `seed: Option` *and* `config: Option`, because the same seed under a different population/generations/elite/penalty lands on a different tree (improvise-ksst). Both are `Option` for one reason — every `FitConfig` value and the seed `0` are legal, so nothing can double as "unknown". Note `#[serde(default)]` on an `Option` field is **documentation, not the mechanism**: serde defaults it anyway, so deleting the attribute fails no test, and a backward-compatibility test that only deletes it is not discriminating. Aim such a test at an absent field loading as the *default* instead of as unknown. The GA loop itself is `fit::fit` (improvise-0ai); `breed` remains one fitness-agnostic generation for callers whose fitness is not "predict this column". (c) **A scoring function takes one slice of rows.** `Row` pairs a feature vector with what it predicts, so the parallel `x`/`y` lengths that `zip` used to truncate silently cannot be built (improvise-dkqn, `doctrine(an_invariant_is_made_unrepresentable_not_validated)` — the same answer `prune_axis_items` got, and the reason no `Result` entered five signatures). The pairing is made at the boundary that *builds* the data: `preprocess` walks one row index and the rows live inside `Target`, because a `Dataset` carrying `rows` and `target` separately just moves the zip up one level into every caller. The empty row set is one shape, answered once in `mean_over`. Changing any of this needs **bit-level** evidence: `tests/scoring_known_answers.rs` pins every path as f32 bit patterns plus a `next_u64` witness that catches a changed RNG call order, because the CLI prints three or four decimals and byte-identical output alone cannot see a moved accumulation. (d) **A fit is storable only when the document can *name* its features.** `preprocess` invents `Region=North`, `Cost.missing`, `when.year`; those are **encoding artefacts, not items of the document**, and `Expr::Ref` resolves through `Model::find_item_category` — so a tree splitting on one emitted a well-formed formula whose every cell was `Error("ref:…")`, silently (improvise-8xhy). `fitted_formula` therefore takes the `&Model` and refuses, listing what it could not resolve; `formula_text` still spells it, being the tree's own text. **The check asks the document, never the name's shape**, because a measure literally named `Region=North` is a legal document and the two are indistinguishable as strings — which also means `.missing` and the date parts are covered with no second copy of `preprocess`'s expansion rules to drift from. **This is an interim** (improvise-4zl7): a `WHERE` predicate needs a *scalar coordinate predicate* the formula language does not have — `Filter` gates a formula or an aggregate, it is not an `Expr` — would still leave `.missing` and the date parts, and must first answer what such a predicate means where `context.get(cat)` is `None`, since `Formula::filter` falls through to `aggregate_raw` and a categorical fit would silently degrade in every view that aggregates over the category. The test that missed all this is the shape to remember: `improvise_eval` **fabricates** a `_Measure` item per feature name, so it asserted the emitter and `gp_tree::eval` agree against a model that could not exist. It is kept, with its scope now stated, and the property it could not state is asserted separately against a real document. -38. **The wire vocabulary is derived twice, by the same recipe.** `Command`'s view half is the `EffectKind::ViewOnly` effects targeting the portable slice (`decision(improvise-cqi, command_set_derived_not_invented)`); its model half, `ModelCommand`, is the `EffectKind::Model(..)` effects — **29** of them, one variant apiece, minus `SwitchView`, which `Command` already names and routes (gotcha 36), leaving 28 (improvise-kmgs). Note the obvious `awk` extraction reports **30**: it picks up `HelpPageSet`, which is `ViewOnly`, because that is the last `pub struct` before `#[cfg(test)]` and the test module mentions `ModelChange::` — brace-match the `impl Effect` blocks instead (`doctrine(derive_then_verify)`). Three things follow. **A `ModelCommand` does not mutate**: `ui/reduce.rs::model_effects` maps it back to the effect it came from and `apply_command` runs that through `App::apply_effects`, so effects stay the only mutation site and the `Dirty` is the effect's own `EffectKind`, never a second classification beside gotcha 35's table. **Two model effects are deliberately off the wire.** `ApplyAndClearDrill` and `AcceptWizardModel` were once kept on the grounds that the *command* is portable even where the operand is not; **that was the wrong criterion** (improvise-3x3u). A `Command::Model` is routed with no `SessionId`, which is right exactly when the change is addressed to *no one* session — and these are addressed to precisely one, never the sender: their operands are `drill_state.pending_edits` and the built wizard workbook, session-local residue (gotcha 30) private to whoever staged them (improvise-tom1). Unaddressed, B's `ApplyAndClearDrill` commits A's uncommitted typing and B's `AcceptWizardModel` swaps the document out of a dialogue B never opened — both demonstrated against `Sessions::apply` before removal. So 29 − `SwitchView` − these two = **26**. The effects are untouched and still reached by the key path. Not permanent: multi-client drill wants the *addressed* form, which is where ADR 001 layer 2 goes anyway. Guarded by `no_model_command_commits_another_clients_session_local_state`, plus a companion asserting both effects still exist and are still model changes so the first cannot pass by naming nothing. **`Sessions::apply` routes the model half to `apply_model`**, which owes the same lockstep as `apply_switch`: `ViewBack`, `ViewForward` and a document-replacing `LoadModel` all end in `App::switch_to_view`, so every mirror takes the accepted active view. Its broadcast is conditional where `apply_switch`'s is not, because no client optimistically folded an active view here. A keymap lookup still cannot return a `Command`, and not for want of vocabulary: a binding names an *intent* resolved against `CmdContext` (`move-cursor down`) while a `Command` is the *resolved mutation* (`SetSelected { row, col }`) — improvise-gxi's AC 4 asks for something the split forbids. +38. **The wire vocabulary is derived twice, by the same recipe.** `Command`'s view half is the `EffectKind::ViewOnly` effects targeting the portable slice (`decision(improvise-cqi, command_set_derived_not_invented)`); its model half, `ModelCommand`, is the `EffectKind::Model(..)` effects — **26** of them since improvise-jytm made `SwitchView`, `ViewBack` and `ViewForward` `ViewOnly` — minus `ApplyAndClearDrill` and `AcceptWizardModel`, leaving **24** variants (improvise-kmgs). `SwitchView` no longer needs subtracting separately: it is not a model effect any more. `ViewBack`/`ViewForward` left for the *same* reason as the other two exclusions — their operand is `ViewState::view_back_stack`, session-local residue belonging to whoever navigated. Note the obvious `awk` extraction reports **30**: it picks up `HelpPageSet`, which is `ViewOnly`, because that is the last `pub struct` before `#[cfg(test)]` and the test module mentions `ModelChange::` — brace-match the `impl Effect` blocks instead (`doctrine(derive_then_verify)`). Three things follow. **A `ModelCommand` does not mutate**: `ui/reduce.rs::model_effects` maps it back to the effect it came from and `apply_command` runs that through `App::apply_effects`, so effects stay the only mutation site and the `Dirty` is the effect's own `EffectKind`, never a second classification beside gotcha 35's table. **Two model effects are deliberately off the wire.** `ApplyAndClearDrill` and `AcceptWizardModel` were once kept on the grounds that the *command* is portable even where the operand is not; **that was the wrong criterion** (improvise-3x3u). A `Command::Model` is routed with no `SessionId`, which is right exactly when the change is addressed to *no one* session — and these are addressed to precisely one, never the sender: their operands are `drill_state.pending_edits` and the built wizard workbook, session-local residue (gotcha 30) private to whoever staged them (improvise-tom1). Unaddressed, B's `ApplyAndClearDrill` commits A's uncommitted typing and B's `AcceptWizardModel` swaps the document out of a dialogue B never opened — both demonstrated against `Sessions::apply` before removal. So 29 − `SwitchView` − these two = **26**. The effects are untouched and still reached by the key path. Not permanent: multi-client drill wants the *addressed* form, which is where ADR 001 layer 2 goes anyway. Guarded by `no_model_command_commits_another_clients_session_local_state`, plus a companion asserting both effects still exist and are still model changes so the first cannot pass by naming nothing. **`Sessions::apply` routes the model half to `apply_model`**, which relays `adopt_cursor_epoch`'s `ResetCursor` and `adopt_deleted_views`'s per-client switch; a document-replacing `LoadModel` bumps the generation and lands in the rebase arm. The former claim here — that `ViewBack`, `ViewForward` and `LoadModel` all end in `App::switch_to_view` so every mirror takes the accepted active view — was wrong on all three clauses after improvise-jytm and is deleted. A keymap lookup still cannot return a `Command`, and not for want of vocabulary: a binding names an *intent* resolved against `CmdContext` (`move-cursor down`) while a `Command` is the *resolved mutation* (`SetSelected { row, col }`) — improvise-gxi's AC 4 asks for something the split forbids. 39. **`Applied` is an enum because a document swap is not a diff.** `Sessions::apply` returns `Applied::Diff { dirty, broadcast }`, `Applied::Rebased { snapshots }` or — since ADR 001 layer 1 — `Applied::Rejected { reason }`, and the enum *is* the mechanism: a host that wants the `Dirty` must match, and matching names the case where a `Snapshot` is owed. The server's mirrors self-heal — `ClientSession::project` reseeds on the `document_generation` bump — but `ViewState::cursors` lives on the **client**, keyed by the outgoing document's view names (gotcha 24), and only a fresh `Snapshot` replaces it. `Command::Model` put the three `WholeWorkbook` mutations on the wire (gotcha 38), so the host that must send the snapshots is no longer the host that asked. `apply_model` therefore calls `rebase` itself rather than instructing the host to, exactly as `apply_switch` adopts the accepted view itself. **The trigger is the generation moving, not the command variant**: a `LoadModel` that cannot read its path leaves the document alone, and reseeding on the variant would cost a client its cursors for a *failed* load — `a_load_that_fails_is_not_a_rebase` guards that arm. A `rebase: bool` beside `dirty` was rejected, because the host that forgets to call `rebase` is the host that forgets to read the flag. The **third** case is a *normal outcome*, not an error, and it is in the enum for the same reason the other two are: a host that wants the `Dirty` must match, and matching names it. `ServerMsg::Rejected` is the matching frame, addressed to **the asker alone** — nothing happened, so nobody else is owed anything — and the client keeps it as a `Rejection` rather than a sentence, because force needs to know *which* record and *which* kind (gotcha 43). **Fixture warning.** When testing a document swap, the incoming document's active view must have the **same name** as the outgoing one. A rename makes `apply_model` broadcast a `SwitchView`; the client folds it, and the stale cursor is then read under a key nothing has written — right *by accident*, with the bug invisible. `other_document()` renames, and a first version of this test passed because of it; `a_document_shaped_differently_under_the_same_view_name()` keeps the name and changes only the shape, so there is nothing to hide behind. @@ -230,7 +230,7 @@ Region=West, Measure=Revenue = |pending| # pipe-quoted text 43. **A batch carries its basis, and the server had to publish it first.** `Command::Model { basis, cmd }` is a **struct variant** so the basis cannot be forgotten, and `Sessions::apply_model` checks it *before* applying anything — a stale batch is never applied, not even partially (improvise-xwi6/fkfg, ADR 001 layer 1). Four things to know. **`Basis` has no `Default` and no public constructor**: a zero-valued basis is not "no claim", it is the claim that *the document is fresh*, which in a new `App` is true — so a default would be a forgeable bypass rather than an absence. `BasisCache::claim` is the only door a client has and it returns `None` until the server has published one. Note what that does and does not buy: `Basis` derives `Deserialize` because it must cross the wire, and its fields are public, so **a hand-rolled client can state any tiers it likes**. This is an *anti-staleness coordination check, not a security boundary* — `RecordUnstated` closes **omission**, not **invention**, and nothing here tries to close invention, because a client that forges a current basis is indistinguishable from one genuinely caught up. "The only constructor" is a statement about clients written against this crate, which is still worth having: it makes the honest path the easy one. **Which records are checked is the server's decision** — `reduce.rs::records_named`, an exhaustive match with one or-pattern for the variants that name no record, so a new record-writing variant fails the build. Reading the list off the *client's* claim would be a **force by omission**: the record tier skipped, with no flag to remove and nothing in the message saying so. A claim that omits a record the command writes gets `Rejection::RecordUnstated`, a case of its own because "you lost a race" and "you were never in one" are different things to tell a user. **`Rejection` keeps structural and data staleness apart**, which is the whole reason ADR 002 put a maintained `RecordHash` index in `DataStore`: `RecordGone` means nothing resolves and there is nothing to force (**force never creates structure**), `RecordMoved` means it resolves at another version and force is one keystroke — which is also why the wasm client stores the `Rejection` itself rather than collapsing it into `status_msg`, since a string cannot carry which record or which kind. **And the ADR omitted the publication.** It said a batch carries the basis it was composed against and never said how a client learns one; nothing in the projection stream carried any of the three tiers, so the check as written was satisfiable only by forging. `Projection::Basis` + `BasisCache` close that — the server publishes the two document tiers plus the counters for the records *this client's grid names*, diffed against what that client was last told and seeded in the bootstrap — and it is emitted **after** the grid updates in a batch, because the counters describe the cells those updates carry: a basis arriving first would let a client claim a version for a value it had not been shown, the mirror image of gotcha 33. That ordering was documented as load-bearing in three places and **held up only by the order of two statements** until `a_published_basis_follows_the_grid_updates_it_describes` pinned it (proved by injecting the swap) — the same job `a_cell_update_that_overtakes_its_labels_is_lost` does for the mirror-image rule. Two things are unchecked and deliberately so: `Command::SwitchView` carries no basis (it sits outside `Command::Model`; the workbook refuses a view it does not have, so it cannot corrupt, but it is a hole in the rule), and `apply_command`/`reduce_full` do not check at all — **the check is a property of the session door, not of the mutation path**, which is right because a host holding the `App` is the truth rather than a claimant. That second one **was** a door rather than an exemption, and needed a type to stop being one (improvise-ondj): both took a wire `Command` and their `Command::Model { cmd, .. }` arm **discarded the basis with `..`**, while `ui/reduce.rs`'s own module doc pointed every future host — the browser worker included — at exactly those two names. **The struct variant closed omission *from the message*; it did nothing to close ignoring *on arrival*, which is the half a struct variant is usually reached for.** So both now take `Checked<'_>`, a private-field newtype over a `&Command` whose only constructor is `Checked::against(&App, &Command) -> Result<_, Rejection>` — also the only caller of `stale_basis`. `Sessions::apply` answers **once, before the dispatch**, so `apply_switch`/`apply_model` receive the proof rather than each remembering to ask: an invariant made unrepresentable rather than validated, the same answer `prune_axis_items` and `gp-tree`'s `Row` got. Visibility was the weaker option and was rejected for a concrete reason — `pub(crate)` closes only the out-of-crate path, and `reduce_full` has **no production caller anywhere in the workspace**, so it would have become dead code. **The regression guard is therefore the signature**: the test that reproduced the bug no longer compiles, and what remains asserts the constructor refuses and that refusing leaves the document untouched. One residual hole, documented on the type and filed: a `Checked` does not name *which* `App` it was checked against, so a multi-document host could in principle check one and apply to another. That is also why `ui::reduce::caught_up(&App, cmd)` is not a bypass: it takes `&App`, so nothing reachable from a wire can call it. **Since improvise-955n the frame, not the command, is what is answered — and only two of the three tiers hoist.** `CheckedBatch::against` runs before anything in a `ClientFrame::Commands` is applied. `document_generation` and `layout_version` describe the *document*, so answering them once for the frame is exactly right, and a first version hoisted all three — which was a lost update: the record tier asks "is the client's picture of **this record** current", and which records are at stake is a property of the *command* (`records_named`). Skipping it for later commands let `[SetCell(North), SetCell(South)]` overwrite a South somebody else had moved, silently, with the frame reported accepted, and invisible to the other two tiers because a `SetCell` is `CellData` and moves neither. So every mutation answers for its own records **against the frame's basis** — the one the first basis-carrying command stated, never each command's own field, or a later command could claim a fresher picture than the frame was composed under. The one exemption is stated over a *property* rather than a list of commands: a record that **neither resolves in the document nor is claimed by the basis** is one the frame is about to create (`[AddItem(East), SetCell(East)]`, which `BasisCache::claim` cannot possibly claim for), and only that combination is exempt; a record that does not resolve but *is* claimed is still `RecordGone`. That the `Basis` inside a *later* `Command::Model` is ignored rather than compared is an honest interim: clause 1 says the **frame** states one basis, and moving the field from `Command::Model` to `ClientFrame::Commands` would make that unrepresentable-otherwise instead of a convention — a protocol change that belongs with clause 4. Two things worth keeping from the rejected design: **arity is not a property of a variant** (a future `Command::RunScript` is one variant performing unboundedly many mutations), so no count over the vocabulary can enforce "one mutation per frame"; and **the transaction boundary is not the frame**, because one command may already be a chain, which is why clause 4's mechanism belongs at `App::apply_effects`. -44. **The TUI grid draws from its cache, and since improvise-anr9 the cache frames it too.** `GridWidget` holds no `&Model` and no `&View`: cell text, aggregate-ness (`CellId::Aggregate`, ADR 002), column widths, the page bar, the totals strip and now the **frame** all come off `GridCache`. The TUI still builds that cache over the **whole** grid (offsets zero), so a cache position is an absolute position — the browser's `Viewport` is a real window, the TUI's is not, and the two clients therefore use it differently; column totals **must** be projected rather than client-derived, because the sum runs over every row *including the ones a viewport scrolled past*. The frame is an `AxisFrame` per axis — `cats` (the categories, outermost first) plus `entries: Vec` interleaving `Group(FrameGroup)` headers with `Item(Vec)` label *tuples*, exactly as `GridLayout::row_items` interleaves them. That replaced a `Vec` built by `GridLayout::row_label`, which slash-joined the tuple — the exact rendering `two_row_categories_produce_cross_product_labels` says must not appear — so the browser drew `Food/Alice` where the TUI drew two header columns, with no groups and no collapse indicators. It is **one** type and not a `labels` plus a `levels` beside it: two answers to "what are the labels" is the disease (improvise-edp), and this is the second cure, which cost a *type* change rather than an addition. Three things to know before touching it. **`leaf_labels()` is not "the labels"** — it is the innermost label, what a one-line summary calls a row by; a draw loop wants `header_labels(first_rendered)`, `groups()` and `shares_ancestry(sel)`, each of which is one pass, because `item`/`label`/`shows_level`/`group_of` scan `entries` from the start and asking them per position is quadratic in the frame *and grows with the TUI's absolute scroll offset* (found three times on anr9; the per-position forms survive for one-off lookups and say so). **The width rule has one home**, `improvise_protocol::header_level_widths` — widest label per level, plus a gap, clamped — reached by `AxisFrame::header_widths` and by `ui::viewport::compute_row_header_width`, which borrows level tuples off a `GridLayout` rather than building a frame on the keypress path. **A `cats` change re-identifies every position**: a cell's identity is `cats` zipped with its tuple, so `invalidate_relabelled` blanks the whole axis when the categories move, not merely the positions whose tuples differ, and blanks the **totals** on a recategorization of *either* axis — a column total is a sum over the rows, so re-identifying the rows leaves a number answering coordinates the grid no longer has (gotcha 33(b): the rule lives in `apply`, not in a producer's memory). What is still **not** on the cache is `record_col`, and that is correct: it keys pending drill edits and is session-local (gotcha 1). `body_from(idx)` takes an argument only because the two front ends scroll differently — a browser's frame is already windowed and passes `0`, the TUI's covers the whole grid and passes its row offset — not because the rule differs. `group_of` answers "nearest preceding header", which is what the TUI has always painted rather than membership; changing that means changing `AxisEntry` in `improvise-core` and is filed, not smuggled in. And `project::grid_of` evaluates every cell per frame where the widget evaluated only the visible ones — a widening of an existing whole-grid cost, still unbenchmarked. +44. **The TUI grid draws from its cache, and since improvise-anr9 the cache frames it too.** `GridWidget` holds no `&Model` and no `&View`: cell text, aggregate-ness (`CellId::Aggregate`, ADR 002), column widths, the page bar, the totals strip and now the **frame** all come off `GridCache`. The TUI still builds that cache over the **whole** grid (offsets zero), so a cache position is an absolute position — the browser's `Viewport` is a real window, the TUI's is not, and the two clients therefore use it differently; column totals **must** be projected rather than client-derived, because the sum runs over every row *including the ones a viewport scrolled past*. The frame is an `AxisFrame` per axis — `cats` (the categories, outermost first) plus `entries: Vec` interleaving `Group(FrameGroup)` headers with `Item(Vec)` label *tuples*, exactly as `GridLayout::row_items` interleaves them. That replaced a `Vec` built by `GridLayout::row_label`, which slash-joined the tuple — the exact rendering `two_row_categories_produce_cross_product_labels` says must not appear — so the browser drew `Food/Alice` where the TUI drew two header columns, with no groups and no collapse indicators. It is **one** type and not a `labels` plus a `levels` beside it: two answers to "what are the labels" is the disease (improvise-edp), and this is the second cure, which cost a *type* change rather than an addition. Three things to know before touching it. **`leaf_labels()` is not "the labels"** — it is the innermost label, what a one-line summary calls a row by; a draw loop wants `header_labels(first_rendered)`, `groups()` and `shares_ancestry(sel)`, each of which is one pass, because `item`/`label`/`shows_level`/`group_of` scan `entries` from the start and asking them per position is quadratic in the frame *and grows with the TUI's absolute scroll offset* (found three times on anr9; the per-position forms survive for one-off lookups and say so). **The width rule has one home**, `improvise_protocol::header_level_widths` — widest label per level, plus a gap, clamped — reached by `AxisFrame::header_widths` and by `ui::viewport::compute_row_header_width`, which borrows level tuples off a `GridLayout` rather than building a frame on the keypress path. **A `cats` change re-identifies every position**: a cell's identity is `cats` zipped with its tuple, so `invalidate_relabelled` blanks the whole axis when the categories move, not merely the positions whose tuples differ, and blanks the **totals** on a recategorization of *either* axis — a column total is a sum over the rows, so re-identifying the rows leaves a number answering coordinates the grid no longer has (gotcha 33(b): the rule lives in `apply`, not in a producer's memory). What is still **not** on the cache is `record_col`, and that is correct: it keys pending drill edits and is session-local (gotcha 1). `body_from(idx)` takes an argument only because the two front ends scroll differently — a browser's frame is already windowed and passes `0`, the TUI's covers the whole grid and passes its row offset — not because the rule differs. `group_of` answers "nearest preceding header", which is what the TUI has always painted rather than membership; changing that means changing `AxisEntry` in `improvise-core` and is filed, not smuggled in. And `project::grid_of` evaluates every cell per frame where the widget evaluated only the visible ones — a widening of an existing whole-grid cost. Since improvise-jytm `grid_in` may additionally **derive a `GridLayout`** for a session on a view the host is not on; the host's layout is reused where the names agree, which is the common case and the only one that can carry a drill snapshot. 45. **One session loop, two transports — and the browser saves itself.** `Host` is `App` + `Sessions` + `handle(id, ClientFrame) -> Vec` and lives in `improvise_command::ui::host`, **not** in `improvise-ws-server`, which re-exports it. It moved for improvise-9x6: the worker hosts the same authoritative `App` over a `MessagePort` and cannot depend on tokio, and a second copy would have been exactly the mirror improvise-xks6 had just deleted for the frames — a drifting `Applied`/`Broadcast` match is invisible until a client sees it. `registry` stayed on the server, because a worker hosts **one** document. What the worker adds is only what a `MessagePort` differs in: **one client by construction** (the `Addressed` filter is honoured anyway, against a future `SharedWorker`), and **a handshake with no close** — `ClientFrame::Viewport` must still come first, but a pre-handshake frame is *dropped* rather than closing a connection that does not exist. diff --git a/prolog-sessions/kb.pl b/prolog-sessions/kb.pl index ac0c0e72..76ec8d37 100644 --- a/prolog-sessions/kb.pl +++ b/prolog-sessions/kb.pl @@ -122,11 +122,12 @@ doctrine(two_questions_one_enum,'when a new enum variant shares one property wit doctrine(contract_test_for_the_unreachable_backend,'when one implementation of a trait cannot be exercised by the test harness, write the contract as a function over &dyn Trait and run it against the reachable implementations; it becomes the executable specification the unreachable one is held to'). doctrine(derive_the_enum_from_the_effects,'when a wire vocabulary must mirror an existing mutation set, enumerate the mutations and let that be the enum; inventing variants ahead of the mutations produces a protocol nobody can implement against'). doctrine(force_the_failure,'an unchecked status whose failure yields a usable-looking value is not a style nit: force the failure and look at what the next line does with it. Four on one branch -- bd init exits non-zero but still leaves an empty database behind, an unchecked mv lands the new tracker INSIDE the old directory and reports success, jq status swallowed by a process substitution reads as \"no inputs found\" and exits 0, and a failed mktemp turns \"$TMP/$NAME\" into an absolute path at the filesystem root. Every one looked like success at runtime, so reading the failure paths found none of them; stubbing mv and mktemp to fail is what exposed them. Note chmod a-w proves nothing when the tests run as root -- it returned 0 and looked like a pass. The same shape appears in tooling: requesting a Copilot PR review over REST returns HTTP 200 and silently ignores the reviewer.'). +doctrine(an_injection_that_did_not_compile_reads_as_a_clean_run,'a fault injection is evidence only if the build RAN. A grep for compiler errors that matches ^error[ but not a bare error: line turns a failed compile into a green read -- the identical defect class injection exists to catch, one level up in the instrument. Caught mid-review on PR #48 by the reviewer in its own tooling, one step from a false unguarded finding, and then re-checked against the earlier runs rather than assumed away. The fix is not a wider grep, because a pattern can always be one case short: require a POSITIVE signal that the injected build ran -- the test count moved, or the named test appears in the output. A count cannot be silently empty.'). doctrine(kb_is_maintained_through_the_mcp,'prolog-sessions/kb.pl is the MCP serialized clause database, not a hand-written source file. Add facts by loadSession, then loadProgram, then saveSession -- never by appending text to the file. Note loadProgram REPLACES a whole predicate rather than appending to it, so the program you load must carry every existing clause of every predicate it touches, or the rest are silently dropped. Verify counts with findall/length before saving. assertz is NOT an escape hatch: a consulted predicate is static and assertz raises permission_error, so the whole-predicate reload is the only route.'). doctrine(fold_tests_must_test_order_not_the_axiom,'a test asserting fold(prefix) then fold(rest) == fold(whole) is VACUOUS: that identity is the definition of a left fold and holds for any apply() whatsoever. Proven 2026-09-19 by rewriting GridCache::apply to write every cell update to (0,0) -- both headline fold tests stayed green while the two real tests failed. Test the properties the design actually rests on: ORDER SENSITIVITY (which updates are dropped when they arrive early, i.e. that the fold is deliberately not commutative) and COMPACTION EQUIVALENCE (fold(compact(history)) == fold(history)), which is what \"seeded, then follows the event stream\" means in production. A seed taken as the literal head of the history presupposes the conclusion.'). doctrine(a_macro_must_not_supply_a_forced_classification,'invariant(effect_kind_no_default) makes the compiler demand an EffectKind per effect precisely because a wrong one is silent. A macro that hard-codes the kind reintroduces the default under another name, for a category defined by SYNTAX (one field, one place) rather than SEMANTICS (per-client). Proven 2026-09-19: a simple_effect! instantiation writing the active view number format -- a textbook LayoutGeometry change -- compiled, reported ViewOnly, and passed all 294 command tests. If a macro covers a classified thing, make the classification a mandatory argument even when the answer is always the same.'). doctrine(one_arm_of_a_symmetric_rule_is_evidence_about_that_arm_only,'when a rule must hold on several arms -- both directions of a fit, every variant that touches one collection, each side of a symmetry -- a test driving ONE arm says nothing about the others, and will sit there green while the rule is violated next door. Worked example 2026-09-19: improvise-rkeq fixed col_widths never being re-fitted when ColLabels changed, and fixed only the label arms; the ColWidths arm, named after the very collection it fits, kept assigning verbatim and put the length mismatch straight back. col_widths_always_span_the_column_labels passed the entire time because it only ever drove the label arm. Copilot caught it on the PR. Enumerate the arms and test each, or assert the rule over a generated set of arms rather than one chosen by hand.'). -doctrine(lockstep_binds_every_session_not_the_asker,'when two copies of a value must move together and one copy is per-client, moving it for the client that ASKED is not lockstep -- it is a desync with a polite name. Proven 2026-09-19 (improvise-kvyt, found by Copilot): Sessions::apply moved workbook.active_view and then only the addressing session mirror, but grid_of builds EVERY client grid from workbook.active_view(), so a bystander left naming the outgoing view had its cursor read under a stale key and was projected the new view rows positioned by the old view scroll. The fix gives every mirror the ACCEPTED value -- not the requested one, which also corrects a client that optimistically folded a name the document turned down -- and apply_switch takes no SessionId at all, because the change is addressed to no one session and that absence is the statement. Ask of any shared value: who else derives something from it?'). +doctrine(lockstep_binds_every_session_not_the_asker,'when two copies of a SHARED value must move together and one copy is per-client, moving it for the client that ASKED is not lockstep -- it is a desync with a polite name. Proven 2026-09-19 (improvise-kvyt, found by Copilot) on active_view, and the reasoning generalises past the example that taught it: ask of any shared value, who else derives something from it? improvise-jytm then REMOVED active_view from the doctrine scope by making it per-client (the model is shared, the UI is not), so what remains under it is the document half -- cursor_epoch, document_generation -- and the doctrine is now a test for whether a value is shared AT ALL before it is a test for who hears about it.'). doctrine(an_invariant_is_made_unrepresentable_not_validated,'when a reviewer asks a function to validate a precondition -- matching lengths of parallel slices, a count supplied alongside the collection it counts -- prefer changing the type so the bad state cannot be built. Proven twice on one branch 2026-09-19: prune_axis_items stopped taking &[bool] plus a debug_assert and took a predicate over the data index instead, so no parallel length exists to get wrong; and the same reviewer request against gp-tree five public scoring functions was declined in favour of giving them one slice of rows rather than parallel x/y, which removes the zip truncation AND the empty-input NaN as one shape rather than four edge cases. Validation also pushes Result into five signatures, which AGENTS.md keeps at the boundaries.'). doctrine(same_kind_is_not_the_same_effect,'a classification shared by an operation and its INVERSE cannot check a mapping between them. Proven 2026-09-19 (improvise-kmgs review): model_effects sends 28 ModelCommand variants to one effect each, and HideItem/ShowItem, AddItem/RemoveItem, CreateView/DeleteView and SetCell/ClearCell all declare the same EffectKind -- so swapping three of them simultaneously left the workspace at 918 passing, 0 failing, with three wire messages doing the exact inverse of what they said. A test asserting only that the target declares the right KIND is therefore no guard at all. Where a mapping is name-for-name by construction, ASSERT THE NAME: it is total, costs nothing at runtime, and a variant whose effect must be named something else is a variant that stopped being derived and should fail loudly.'). doctrine(a_rename_can_hide_a_stale_key,'a fixture that RENAMES a key across the change under test can make a stale entry unreachable rather than wrong, so the test passes for the wrong reason. Proven 2026-09-19: the first document-swap test used a fixture whose incoming active view was renamed Default->Sheet, which made apply_model broadcast a SwitchView; the client folded it and then read its cursor under a key nothing had written -- right by accident, bug invisible. The replacement keeps the view NAME and changes only the shape, so there is nothing to hide behind. General form: when testing that stale state is corrected, hold the identifiers constant and vary only what makes the state stale. Same family as the one-category fixture that could not tell TransposeAxes from CycleAxis.'). @@ -136,7 +137,7 @@ doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished,'two de doctrine(a_missing_variant_is_a_date_before_it_is_a_decision,'when two vocabularies for the same job differ, ask of each difference whether the ARGUMENT for it names the thing they differ in. In improvise-xks6 none of Viewport, Key or batching mentioned a transport -- each was justified by the session model or the fold -- so each difference was an unfinished feature or a stale file. THE TELL FOR STALENESS IS INCOHERENCE RATHER THAN DIFFERENCE: the worker envelope carried a Basis it had no Rejection to answer.'). doctrine(a_struct_variant_closes_omission_not_ignoring,'making a field mandatory in the MESSAGE stops a sender forgetting it. It does nothing to stop a RECEIVER discarding it: the two-dot rest pattern is exactly that, with no compile error. If the receiver must not ignore it, the receiver PARAMETER is the place to say so -- improvise-ondj gave apply_command a Checked newtype rather than a guard.'). doctrine(a_documented_door_with_no_caller_is_the_dangerous_kind,'reduce_full was named by ui/reduce.rs module doc as the entry point every host should use, and used by NONE of them. Nothing exercised its Command::Model arm, so nothing could notice it skipped the basis check. Audit the functions the DOCS ADVERTISE, not only the ones the callers reach.'). -doctrine(the_audience_follows_the_optimistic_fold,'a relay to other clients is ToOthers exactly when the asker folded the command optimistically; otherwise ToAll. A Command::Model is never folded optimistically, so every relay out of apply_model is ToAll. Copying apply_switch ToOthers into apply_model left the asker own slice naming the outgoing view while its server mirror named the incoming one -- and a test asserting through a what-do-the-others-hear helper cannot see it, because that helper looks away from the asker by construction.'). +doctrine(the_audience_follows_the_optimistic_fold,'a relay is addressed by whether the asker folded the command optimistically and by whose UI the change reaches. A Command::Model is never folded optimistically, so every relay out of apply_model is ToAll. A view switch IS folded optimistically and reaches nobody else UI, so an accepted one relays NOTHING and a refused one is To(asker) alone -- improvise-6c6q, where ToOthers told every client but the one that was wrong. Two traps, both proven by injection on PR #48. A test written through a what-do-the-others-hear helper looks away from the asker by construction, which is how the sibling bug in apply_model shipped green. And a test whose bystander is already on the value being relayed cannot see a wrongly WIDENED audience either, because the wrong relay folds to a no-op -- so assert the Broadcast ITSELF, since the audience is the message, and seat the bystander somewhere it can be yanked off.'). doctrine(half_a_lockstep_fix_is_worse_than_none,'resetting the server mirrors without telling the clients makes the DIFF believe they are already current, so it sends nothing: stale FOREVER rather than until the next scroll. When a fix has a server half and a client half, shipping one is not partial progress, it is a worse bug than the original.'). doctrine(force_is_a_recomposition_not_a_bypass,'force never applies a batch whose basis is stale; it re-composes the same INTENT against the current state and submits a fresh batch with a current basis. A mechanism whose only purpose is to violate the invariant is not a feature, it is the invariant deleted with extra steps.'). doctrine(a_simulated_fix_must_be_the_fix_you_named,'The sibling of an_injected_fault_must_be_the_fault_you_named, one level up, and it fails in the opposite direction: an injected FAULT that is not the one named proves a check that does not fire; a simulated FIX that is not the one named proves a BUG THAT DOES NOT EXIST. Worked example: the harness agent measured the 8s watchdog overwrite honestly, on a tree predating PR #21, then reported it "survives improvise-taxx, confirmed by simulating the opened flag". PR #21 is TWO changes -- the flag AND routing both close arms through setShellFailure, which clears the timer. Simulating half of it reproduced the bug and read exactly like a confirmation. improvise-xps8 was filed on that report, propagated into repo-map gotcha 48 and a spec comment, and was closed INVALID. The repair is not a deletion: the property is now executed by a test that outlasts HANDSHAKE_TIMEOUT_MS, proved discriminating by removing setShellFailures clearTimeout and watching "sent no document" appear.'). @@ -200,7 +201,8 @@ decision('improvise-dz8',filter_persists_in_the_format,'added \"filter\" to axis decision('improvise-i34',no_indexeddb_fallback,'user decision 2026-08-31: skip the IndexedDB fallback unless recent Safari needs it. It does not -- Safari 16.4+ has synchronous access handles -- so the fallback is out of scope and the async question it would have forced does not arise.'). decision('improvise-i34',delete_is_deferred,'removeEntry is async and the trait is sync, so delete/1 drops the name from the index and queues the removal. A delete is therefore visible to read and list at once and lands in OPFS when commit_deletes is awaited. Invisible to a single worker; the alternative was making the whole trait async for one operation.'). decision('improvise-cqi',command_set_derived_not_invented,'the client->server half of Command is exactly the set of EffectKind::ViewOnly effects whose target is the portable slice. Effects touching host-local state (wizard, drill_state, storage, layout cache) are absent by construction -- improvise-h0b had already moved those off ViewState, so the eviction did the enumeration.'). -decision('improvise-cqi',view_state_carries_its_own_active_view,'reduce_view runs on a client with no Workbook, and cursors is keyed by view name, so ViewState needs its own active_view. Kept identical to Workbook::active_view by App::switch_to_view and App::install_workbook; whether they MAY diverge is improvise-g0f.'). +decision('improvise-cqi',view_state_carries_its_own_active_view,'reduce_view runs on a client with no Workbook, and cursors is keyed by view name, so ViewState needs its own active_view. Kept identical to Workbook::active_view until improvise-jytm, which answered improvise-g0f: they MAY diverge. Workbook::active_view is now the document DEFAULT (what a fresh client opens, what .improv writes as Initial View) and ViewState::active_view is authoritative per client.'). +decision('improvise-jytm',active_view_is_split_default_from_client,'the model is shared and the UI is not (user, 2026-09-19), so which view you are looking at is UI. Four things worth keeping. The split is made to AGREE WITH EffectKind rather than by a new mechanism: SwitchView/ViewBack/ViewForward became ViewOnly, which took the latter two off ModelCommand for free, because ModelCommand is DERIVED from the Model effects. A switch is therefore invisible to Dirty, so ClientSession OBSERVES its own view move (projected_view) rather than being told -- the cursor_epoch shape. App::resolve_view is the ONE answer to what a view name names in this document; a second copy of that fallback was the first thing review found. And the split CREATES a case it must then close: a DeleteView can take away the view another client is on, answered by observation in adopt_deleted_views rather than by a table of which commands remove views. Follow mode is deliberately NOT foreclosed: per-session UI state stays addressable, so a follow relationship can later be one session DERIVING its ViewState from another.'). decision('improvise-sef',retroactive_ticket,'The three kb commits landed with no beads issue at all. Filed and closed 2026-09-19 during a commit/tracker reconciliation rather than left untracked, because the CI job they add is a durable capability someone will later ask the provenance of. Every other commit back to cc3889d already named a ticket that is closed with a close_reason.'). decision('backlog-groom-2026-09-19',render_cache_name_collision,'improvise-cqi landed RenderCache as a flat grid-only wire type, while six open migration tickets (35e, n10, pca, 764, jb3, edp) were written against a fat per-widget cache of the same name. Two objects, one name, and nothing in the tree complains until someone writes from_app. Filed improvise-rkeq to decide the shape and made it block 35e rather than silently rewriting six tickets to match whichever shape a groomer preferred.'). decision('backlog-groom-2026-09-19',retroactive_tickets_for_untracked_merges,'PR #7 (cloud-session tooling) landed 18 commits with no beads issue, the same gap the kb commits had. Filed improvise-3qz2 closed. Rule being followed: work that adds a durable capability -- a CI job, a hook, a script someone will later ask the provenance of -- gets a ticket even after the fact; a chore commit does not.'). @@ -379,7 +381,7 @@ kb_issue_found(mcp_lenience,'the prolog MCP server accepted \`:- discontiguous f kb_issue_found(static_str_blocks_deserialize,'a borrowed &\'static str field makes Deserialize unimplementable, and rustc reports it on every ENCLOSING enum variant rather than at the offending field -- five near-identical \"lifetime may not live long enough\" errors pointing at MinibufferConfig, none naming buffer_key. Read past the reported site to the borrowed field.'). kb_issue_found(ticket_premise_false_8uz,'improvise-8uz asserted selected/row_offset/col_offset were \"PERSISTED in .improv\". They were not: improv.pest has no cursor production and format_md emitted none. The claim shaped the ticket\'s whole option set, so it was checked with a test before any of the options was weighed.'). kb_issue_found(ticket_premise_false_rml,'improvise-rml called category_panel.rs an \"exact copy\" of tile_bar.rs. It was not: the colors were identical but the labels differed (\"Row ↕\" vs \"Row\", \"None ∅\" vs \"·\"). Only the color table was true duplication; the four label sets are four deliberate vocabularies and were preserved as four methods.'). -kb_issue_found(duplicated_active_view,'adding ViewState::active_view created a second copy of \"which view is active\". The improvise-8uz characterization test caught the desync immediately because it called workbook.switch_view directly. Two writers only (switch_to_view, install_workbook), guarded by the_two_active_view_names_never_diverge.'). +kb_issue_found(duplicated_active_view,'adding ViewState::active_view created a second copy of "which view is active". The improvise-8uz characterization test caught the desync immediately because it called workbook.switch_view directly. Held in lockstep by two writers until improvise-jytm, which made the divergence deliberate: the guard test the_two_active_view_names_never_diverge was REPLACED, not deleted, by the_documents_default_view_outlives_this_clients_switches and a_fresh_session_opens_the_documents_default_view -- a deliberate split with no guard is how one silently regresses into the other.'). functional(invariant/2). functional(location/3). functional(doctrine/2). From d025b2a3884150c63756e19b0b18422935e3124a Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Sun, 20 Sep 2026 12:03:25 -0700 Subject: [PATCH 09/17] chore(bd): auto-export Co-Authored-By: Claude Opus 5 (1M context) --- .beads/issues.jsonl | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 2cbd9d70..c64703fa 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -37,6 +37,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-pdvm","title":"AddItem/RemoveItem reshape the grid but declare CellData, so layout_version and cursor_epoch disagree","description":"Found in the adversarial review of PR #49 (improvise-9mo9). PRE-EXISTING, but\nthat PR is the first thing in the repo to produce evidence for it.\n\n`AddItem`, `AddItemInGroup` and `RemoveItem` declare\n`EffectKind::Model(ModelChange::CellData)`. `App::apply_effects` bumps\n`layout_version` only for `ModelChange::LayoutGeometry`, so these three do not\nmove it. Since improvise-9mo9 they DO call `app.reset_cursor()`, which bumps\n`cursor_epoch`.\n\nSo one event moves one counter and not the other, and the two counters are\nanswering the same question: \"has the grid's shape changed under you\". A client\nis told its cursor is invalid (`Command::ResetCursor`) while its `layout_version`\nbasis tier still reads current, so a batch composed against the old layout is\naccepted.\n\nTHE EVIDENCE IS THIS PR'S OWN TEST. `every_reshaping_model_command_invalidates_\nevery_cursor` now asserts, with a `grid_shape()` guard over `row_items`/\n`col_items`, that `AddItem`/`AddItemInGroup`/`RemoveItem` REALLY DO reshape the\ngrid. That is the assertion `ModelChange::CellData` contradicts.\n\nWHY IT IS NOT OBVIOUSLY \"RECLASSIFY THEM\". `EffectKind` is read by three\nconsumers off one classification (gotcha 40): `Dirty::of`, `layout_version`, and\n`model_state.dirty`. Moving these three to `LayoutGeometry` changes what every\nclient is told is dirty and widens the basis rejection surface. It also interacts\nwith gotcha 3's traps -- `AddCategory`/`RemoveCategory` are already the\n\"looks like data, is geometry\" case -- and with the `ModelCommand` derivation.\n\nRELATED, NOT DUPLICATE: improvise-75ld is the same family in the other direction\n(a refused or no-op AddCategory bumps layout_version and raises dirty when\nnothing happened). Decide them together; a single question -- \"what does an\nEffectKind entitle a counter to assume\" -- is under both.\n\n## Steps to Reproduce\n\n1. On a view with a category on Row, apply `ModelCommand::AddItem` for a new item\n in that category.\n2. `grid_shape()` (or `layout.row_count()`) changes: a row was inserted.\n3. `app.cursor_epoch()` has moved and clients receive `Command::ResetCursor`.\n4. `app.layout_version()` has NOT moved, because the effect declares CellData.\n5. A client may now compose a `Command::Model` whose `Basis.layout_version` is\n the pre-change value and have it accepted.","acceptance_criteria":"One event moves both counters or neither. Either AddItem/AddItemInGroup/RemoveItem are reclassified as LayoutGeometry -- in which case the blast radius through Dirty::of, the basis rejection surface and gotcha 3's traps is argued, not assumed -- or layout_version stops being read off EffectKind and is observed the way cursor_epoch and record_versions already are. Decide alongside improvise-75ld, which is the same question in the other direction, and alongside improvise-ni6k, whose grid-frame fingerprint would answer all three at once. The regression guard must assert the two counters agree over the same table every_reshaping_model_command_invalidates_every_cursor already drives, so it cannot pass by naming nothing.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:57:17Z","created_by":"wave2-n-cleanups","updated_at":"2026-09-20T18:57:17Z","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_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":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:22:32Z","created_by":"wave2-p-milestones","updated_at":"2026-09-20T18:22:32Z","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":"open","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-20T18:18:24Z","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-lltt","title":"[bug] A view-scoped ModelCommand names no view, so it lands on the host's","description":"Opened by improvise-jytm, which made `ViewState::active_view` authoritative per\nclient. The view-configuration half of `ModelCommand` — SetAxis, CycleAxis,\nTransposeAxes, SetPageSelection, HideItem, ShowItem, ToggleGroup, TogglePruneEmpty,\nSetNumberFormat — acts on \"the active view\", which is now an ambiguous phrase.\n\n`Sessions::apply_model` routes with no SessionId (gotcha 38, correctly: the change\nis addressed to no one session, because a `View` is document state shared by every\nclient sitting on it). But it must still name WHICH view, and today it names it\nimplicitly: `apply_command` runs the effect against the `App`, and the effect reads\n`App::view()`, which is the HOST's slice. So a connected client on another view\nmoves the host's view rather than its own.\n\nBefore jytm every client was on the same view, so the implicit answer was right.\nIt is now wrong exactly when two clients are on different views, which is the\ncapability jytm exists to add.\n\nThe honest fix is for those variants to name their view — a protocol change — so\nthe command carries the operand rather than borrowing an ambient one. Rejected\nduring jytm: retargeting `app.view_state.active_view` around `apply_command` at the\ndoor. It makes the host's own UI slice a parameter channel, and `reset_cursor`/\n`cursor_epoch` would then write the asker's key into the host's cursor map.\n\nNote the `_Drill`/`_Records` virtual views make this sharper than it looks: a host\nin records mode is on a view no client can name.\n\n## Steps to Reproduce\n\n1. Two clients, A and B, on one document with views \"Default\" and \"Second\".\n2. B sends `Command::SwitchView(\"Second\")`. Its mirror moves; the host's App stays\n on \"Default\" (correct, per improvise-jytm).\n3. B sends `Command::Model { cmd: ModelCommand::SetAxis { category: \"Region\",\n axis: Row } }`.\n4. The axis moves on \"Default\" — the view B is not on — and B's next projection\n shows nothing changed while A's shows a change A did not ask for.\n\nNot reachable from the TUI, which is the only client that drives the App's own\nslice, and not reachable from `web/app.js` today because it sends no SwitchView.\n","acceptance_criteria":"The view-configuration ModelCommand variants name the view they act on, so a connected client's SetAxis moves the view that client is on and not the host's. A test drives two sessions onto different views and asserts an axis change by one is invisible to the other. Deciding NOT to fix is also an acceptable close, but then gotcha 38 must say so.","notes":"THE TRIGGER, added after the adversarial review of PR #48 (2026-09-20). This is deferrable, but it is deferred on a CONDITION rather than on a judgement, and the condition is one line of somebody else's future work away. It is unreachable today ONLY because no shipped client emits Command::SwitchView. That is the whole of the argument. The browser already emits view-configuration model commands -- keys.rs resolves a keypress to ModelCommand::TransposeAxes locally and sends it -- so the machinery on the other side of the ambiguity is live. THE DAY ANY WAVE WIRES A VIEW SWITCH INTO THE BROWSER, THIS GOES LIVE ON THE NEXT TRANSPOSE KEYSTROKE, AND SILENTLY: a client on Second presses transpose, the axes of Default move instead, that client's grid does not change and every other client's does. Two consequences. (1) Do not close this by arguing it is unreachable -- reachability is the thing that is about to change, and gotcha 47 already records that 'unreachable today' was the argument under which the lost update CheckedBatch later fixed had shipped. (2) There is a cheap interim guard worth landing before the real fix: assert that no shipped browser client emits Command::SwitchView, a test over improvise-wasm-client's key resolver. It turns the silent failure into a loud one at exactly the moment the premise stops holding, which is the only moment anybody would otherwise not be looking.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:05:49Z","created_by":"wave2-e-active-view","updated_at":"2026-09-20T18:34:24Z","labels":["area:active-view"],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -170,7 +171,7 @@ {"_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":"open","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-20T18:53:08Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-9iyv","title":"The route-then-aggregate fold is unbenchmarked","description":"improvise-4zl7 made `SUM(\u003cexpr\u003e)` fold over the member records rather than over\nthe raw matching cells. The fold is not cached and not memoised: for one\naggregated formula cell it walks `matching_cells(partial)`, dedupes the record\ntuples through an `IndexSet`, and evaluates the inner expression once per\nrecord. `recompute_formulas` runs that for every stem, every formula target, and\nup to `MAX_EVAL_DEPTH` (16) passes.\n\nNothing here is known to be slow. Nothing here has been measured either, and the\nwork is O(cells + members × expression size) per cell per pass where the\nprevious code was one `matching_values` scan. A routed formula over a document\nwith thousands of records is the shape to measure.\n\nNote what must NOT be \"fixed\" by memoising carelessly: `formula_cache` holds\nderived values and the fold deliberately reads *raw* data through the same\n`evaluate_depth` / `aggregate_raw` path the non-folding evaluator uses, which is\nwhy the fixed point still converges (gotcha 9, context/code/core.md 3 and 13). A\nper-record memo keyed by anything less than the whole record key would be a\nsecond answer to \"what does this cell depend on\".\n\n## Steps to Reproduce\n\n1. Build a document with several thousand records — `examples/gen-grammar.rs`\n or a CSV import will do.\n2. Add a routing formula over them, e.g.\n `Pred = SUM(IF(Cost \u003c 50, 1, 10))`, or run `fit-measure` and store the\n result.\n3. Time `Model::recompute_formulas`, and compare against the same document with\n a linear formula (`Profit = Revenue - Cost`), which takes the `Expr::Ref`\n fast path and does not fold.\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.","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-20T18:51:21Z","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":"open","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-20T18:50:51Z","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.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:45:39Z","created_by":"wave2-h-frontend","updated_at":"2026-09-20T18:45:39Z","labels":["area:shared-frontend"],"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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:45:39Z","created_by":"wave2-h-frontend","updated_at":"2026-09-20T18:58:18Z","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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:33:24Z","created_by":"wave2-e-active-view","updated_at":"2026-09-20T18:33:24Z","labels":["area:active-view"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-w3bf","title":"A session mirror's active_view is written in four places, none through reduce_view","description":"Found by the adversarial review of PR #48 (improvise-jytm). Not a defect today: the\nfour writers agree. It is the SHAPE the divergence bugs in this area have taken every\ntime, filed before it becomes one.\n\n`ClientSession::view.active_view` — the server's mirror of a client's view — is\nwritten in four places, and none of them goes through `reduce_view`:\n\n1. `Sessions::apply_switch` — sets it directly from the accepted name.\n2. `Sessions::adopt_deleted_views` — sets it directly to the document default.\n3. `ClientSession::reseed` — replaces the whole slice via `fresh_view`.\n4. `Sessions::connect` — same, through `reseed`.\n\nEverywhere else in this codebase, the mirror stays in step with the client by folding\nthe SAME command the client folds (`ClientSession::fold` → `reduce_view`, the\n\"same function runs on both ends\" rule of improvise-cqq/m91). These four bypass it.\nThey are correct today only because `reduce_view`'s `SwitchView` arm is one line\n(`view.active_view = name.clone()`) and each of these reproduces it.\n\nThe day that arm grows a second statement — clearing a per-view buffer, resetting a\ncursor, anything — the mirror and the client diverge and nothing fails. That is\nexactly gotcha 36's \"folding SwitchView into the mirror alone was the bug\", arriving\nfrom the other direction.\n\nTwo shapes worth weighing:\n\n- Route all four through `reduce_view(\u0026mut session.view, \u0026Command::SwitchView(name))`.\n Cheap, and makes the bypass unrepresentable for this command.\n- A test that the two agree: fold a `SwitchView` into a bare `ViewState` and into a\n `ClientSession` and compare. Weaker (it is a compatibility test, and\n `doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)` says\n those are a definition waiting to be made) but it costs nothing.\n\n## Steps to Reproduce\n\nNot reproducible today; the four copies agree. To make it reproduce: add any second\nstatement to `reduce_view`'s `Command::SwitchView` arm in\n`crates/improvise-protocol/src/reduce.rs` — for instance clearing `status_msg` — then\nrun the suite. It stays green, and a client's own slice now carries something its\nserver-side mirror does not.\n","acceptance_criteria":"Either the four writers route through reduce_view, or a test pins that a ClientSession's mirror and a bare ViewState fold a SwitchView identically. Closing it as 'the copies agree' is not acceptable — that is the state it is filed in.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:32:43Z","created_by":"wave2-e-active-view","updated_at":"2026-09-20T18:32:43Z","labels":["area:active-view"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_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.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:23:51Z","created_by":"orchestrator","updated_at":"2026-09-20T18:23:51Z","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -264,7 +265,7 @@ {"_type":"issue","id":"improvise-jjx","title":"Scrolled grid suppresses first visible row-header label","description":"src/ui/grid.rs:338-350: repeat-suppression shows a row-header sub-label only when ri == 0 or the prefix differs from data_row_items[ri-1]. ri is the absolute data-row index (starts at row_offset, grid.rs:269), so when the view is scrolled the first visible row compares against the off-screen row above and its label is blanked — the user loses the group context at the top of the viewport. Fix: compare against the previous *visible* row, i.e. always show labels for the first rendered data row. 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:58:38Z","created_by":"Edward Langley","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-byz","title":"records_display returns Some(\"\") for out-of-bounds columns instead of None","description":"crates/improvise-core/src/view/layout.rs:186-202: records_display() calls col_label(col), which returns an empty string for out-of-bounds indices; the empty string then fails the == \"Value\" check, is looked up as a category name, and the function returns Some(\"\") via unwrap_or_default(). cell_key() (~line 396) explicitly returns None for an empty col_label — records_display should be symmetric so view/data mismatches are not masked. 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:58:35Z","created_by":"Edward Langley","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-zgb","title":".improv formula loader mis-splits category suffix via rfind(\" [\")","description":"crates/improvise-io/src/persistence/mod.rs:403-411: the formulas_section walker finds the [Category] suffix with raw.rfind(\" [\") + ends_with(']'). Two failure modes: (1) a pipe-quoted identifier at the end of the formula containing ' [' (e.g. |Revenue [USD]|) is mis-split, treating part of the quoted name as the category; (2) a suffix written without a leading space (X=Y[Cat]) is not recognized and the whole line is parsed as a _Measure formula, losing the target category. Fix: move the category suffix into the pest grammar instead of string scanning. Write failing tests first per AGENTS.md. 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:58:32Z","created_by":"Edward Langley","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-drg","title":"vb4 step 5: narrow Effect::apply signatures where one-slice","description":"Optional follow-up to vb4 structural split. For each impl Effect for Foo, audit which slice(s) it mutates. If apply only touches ModelState (or only ViewState), narrow its signature from fn apply(\u0026self, app: \u0026mut App) to fn apply(\u0026self, model: \u0026mut ModelState) (or ViewState). Cross-cutting effects (read layout, change mode that triggers layout rebuild, etc.) stay on \u0026mut App. Improves type-level documentation and enables finer-grained effect tests. Lower priority — vb4's acceptance criterion (4) explicitly allows leaving cross-cutting on \u0026mut App.","design":"Hidden trait-design decision surfaced during vb4 step 4 review: the issue's\nphrasing — \"narrow its signature from fn apply(\u0026self, app: \u0026mut App) to\nfn apply(\u0026self, model: \u0026mut ModelState)\" — is not directly possible. You\ncannot change one Effect impl's signature without changing the Effect\ntrait. Three approaches, none picked:\n\n(A) Split into multiple traits — ModelEffect / ViewEffect / AppEffect.\n Each impl becomes the narrower thing it actually is. Cost: ~50\n existing impls reorganize across three traits, and Vec\u003cBox\u003cdyn Effect\u003e\u003e\n has to become an enum wrapper (or three parallel queues) to keep\n apply_effects working. Highest fidelity to the issue's literal wording.\n\n(B) Keep one trait, rebind inside the impl body — e.g.\n fn apply(\u0026self, app: \u0026mut App) { let view = \u0026mut app.view_state; view.mode = ... }\n so the slice is visible at the call site without changing the\n signature. Lowest disruption. Doesn't strictly satisfy acceptance\n criterion (1) (a \"narrower borrow\") — only the body is narrower.\n\n(C) Default-method dispatch on a single trait — Effect grows\n apply_view(\u0026mut ViewState) and apply_model(\u0026mut ModelState) with\n default impls that fall through to apply(\u0026mut App). Each impl\n overrides only the method that matches its slice. Middle ground:\n type-level narrowing for impls that opt in, \u0026mut App stays available\n for cross-cutting effects. Wraps cleanly around the existing\n apply_effects loop.\n\nPicking (A) commits to the architecture for downstream effect tagging\n(improvise-mae) and the protocol extraction (improvise-cqi). (B) and (C)\nleave that decision unmade. Recommend deciding before claiming.","acceptance_criteria":"Each Effect whose apply touches only one slice takes \u0026mut ModelState or \u0026mut ViewState; cross-cutting effects stay on \u0026mut App, which vb4's own acceptance explicitly allows. The win is type-level documentation and finer-grained effect tests, not line count. CAUTION, new since this ticket was filed: App::apply_effects now reads dirtiness, layout_version and the record fingerprints off the EffectKind (improvise-gv9r, gotcha 40) — narrowing a signature must not put an effect somewhere that loop cannot see.","notes":"DECLINED 2026-09-20 (wave 2), with counts. Reasoning in context/code/command.md,\n'Effect::apply stays on \u0026mut App'. NOTE: this bead carries the `human` label, so\nthe decline is a recommendation the user may overturn; it is recorded rather than\nsmuggled.\n\nCOUNTED over the 49 `impl Effect` blocks in ui/effect.rs:\n now (this branch) 8 view_state-only, 9 model_state-only, 32 cross-cutting\n base 52507a6 9 view_state-only, 16 model_state-only, 24 cross-cutting\nThe narrow set fell from 25 to 17 in one wave. The mover was improvise-9mo9,\nwhich made nine reshaping effects call app.reset_cursor(): a reshape lives in\nmodel_state.workbook and the cursor it invalidates lives in ViewState, so the\neffects that matter most are precisely the ones that reconcile the two slices.\nNarrowing bets against the direction the code moves in.\n\nFOUR GROUNDS\n1. The narrow set is small and shrinking, for that architectural reason.\n2. The type-level documentation already exists and is CHECKED: Effect::kind()\n has no default (gotcha 3) and apply_effects reads that one answer three times\n (gotchas 40, 45). A narrowed signature is a second statement of the same fact,\n free to disagree -- the defect, not the cure.\n3. Every offered mechanism weakens a guard. (A) three traits needs three queues\n (destroys batch order, which gotchas 21/23/43 lean on) or an enum wrapper (a\n fourth classification beside EffectKind). (C) default methods give `apply` a\n DEFAULT, exactly what kind() does without and for the same reason: overriding\n apply_view on an effect that needs the model compiles and writes the wrong\n slice silently; overriding neither compiles as a no-op. (B) does not meet the\n ticket's own acceptance criterion.\n4. \"Finer-grained effect tests\" is not blocked: effect.rs's 64 tests already call\n Effect.apply(\u0026mut app) against a one-line App::new(wb, None) fixture.\n\nWHAT IS NOT DECLINED: approach (B), rebinding the slice at the top of an apply\nbody, remains available as a per-function style choice. It is not mandated.\nUSER CONFIRMATION 2026-09-20: the decline is CONFIRMED and the bead stays closed. This bead carries the 'human' label, so the WU-N agent correctly recorded its decline as a recommendation rather than acting on it unilaterally; the user was shown the counts (8 view-only / 9 model-only / 32 cross-cutting of 49, down from 9/16/24 at the wave base) and all three approaches (A: three traits; B: rebind in the body; C: default-method dispatch), and chose to confirm the decline.\n\nThe reasoning the user accepted: the classification ALREADY EXISTS and is checked — Effect::kind has no default and apply_effects reads that one answer three times — so a narrowed signature is a second statement free to disagree with it. Three traits need three queues (destroying batch order) or an enum wrapper (a fourth classification). Default methods would give apply a DEFAULT whose wrong answer is silent, which is precisely what kind()'s no-default rule exists to prevent.\n\nThe fact worth carrying past this bead: improvise-9mo9 moved nine effects from 'touches one slice' to 'cross-cuts' INSIDE THE SAME BRANCH that declined this, and that is not a coincidence to route around. The App exists because the grid's shape lives in model_state.workbook and the cursor into it lives in ViewState, so the effects that matter most are exactly the ones that RECONCILE the two slices. A design that rewards single-slice effects would have been pulling against the repo's actual centre of gravity.","status":"closed","priority":3,"issue_type":"task","assignee":"wave2-n-cleanups","owner":"el-github@elangley.org","created_at":"2026-04-26T18:16:52Z","created_by":"jeeves","updated_at":"2026-09-20T18:25:53Z","started_at":"2026-09-20T18:08:16Z","closed_at":"2026-09-20T18:11:26Z","close_reason":"Closed","labels":["area:command","human"],"dependencies":[{"issue_id":"improvise-drg","depends_on_id":"improvise-99k","type":"blocks","created_at":"2026-04-26T11:17:16Z","created_by":"jeeves","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-drg","title":"vb4 step 5: narrow Effect::apply signatures where one-slice","description":"Optional follow-up to vb4 structural split. For each impl Effect for Foo, audit which slice(s) it mutates. If apply only touches ModelState (or only ViewState), narrow its signature from fn apply(\u0026self, app: \u0026mut App) to fn apply(\u0026self, model: \u0026mut ModelState) (or ViewState). Cross-cutting effects (read layout, change mode that triggers layout rebuild, etc.) stay on \u0026mut App. Improves type-level documentation and enables finer-grained effect tests. Lower priority — vb4's acceptance criterion (4) explicitly allows leaving cross-cutting on \u0026mut App.","design":"Hidden trait-design decision surfaced during vb4 step 4 review: the issue's\nphrasing — \"narrow its signature from fn apply(\u0026self, app: \u0026mut App) to\nfn apply(\u0026self, model: \u0026mut ModelState)\" — is not directly possible. You\ncannot change one Effect impl's signature without changing the Effect\ntrait. Three approaches, none picked:\n\n(A) Split into multiple traits — ModelEffect / ViewEffect / AppEffect.\n Each impl becomes the narrower thing it actually is. Cost: ~50\n existing impls reorganize across three traits, and Vec\u003cBox\u003cdyn Effect\u003e\u003e\n has to become an enum wrapper (or three parallel queues) to keep\n apply_effects working. Highest fidelity to the issue's literal wording.\n\n(B) Keep one trait, rebind inside the impl body — e.g.\n fn apply(\u0026self, app: \u0026mut App) { let view = \u0026mut app.view_state; view.mode = ... }\n so the slice is visible at the call site without changing the\n signature. Lowest disruption. Doesn't strictly satisfy acceptance\n criterion (1) (a \"narrower borrow\") — only the body is narrower.\n\n(C) Default-method dispatch on a single trait — Effect grows\n apply_view(\u0026mut ViewState) and apply_model(\u0026mut ModelState) with\n default impls that fall through to apply(\u0026mut App). Each impl\n overrides only the method that matches its slice. Middle ground:\n type-level narrowing for impls that opt in, \u0026mut App stays available\n for cross-cutting effects. Wraps cleanly around the existing\n apply_effects loop.\n\nPicking (A) commits to the architecture for downstream effect tagging\n(improvise-mae) and the protocol extraction (improvise-cqi). (B) and (C)\nleave that decision unmade. Recommend deciding before claiming.","acceptance_criteria":"Each Effect whose apply touches only one slice takes \u0026mut ModelState or \u0026mut ViewState; cross-cutting effects stay on \u0026mut App, which vb4's own acceptance explicitly allows. The win is type-level documentation and finer-grained effect tests, not line count. CAUTION, new since this ticket was filed: App::apply_effects now reads dirtiness, layout_version and the record fingerprints off the EffectKind (improvise-gv9r, gotcha 40) — narrowing a signature must not put an effect somewhere that loop cannot see.","notes":"DECLINED 2026-09-20 (wave 2), with counts. Reasoning in context/code/command.md,\n'Effect::apply stays on \u0026mut App'. NOTE: this bead carries the `human` label, so\nthe decline is a recommendation the user may overturn; it is recorded rather than\nsmuggled.\n\nCOUNTED over the 49 `impl Effect` blocks in ui/effect.rs:\n now (this branch) 8 view_state-only, 9 model_state-only, 32 cross-cutting\n base 52507a6 9 view_state-only, 16 model_state-only, 24 cross-cutting\nThe narrow set fell from 25 to 17 in one wave. The mover was improvise-9mo9,\nwhich made nine reshaping effects call app.reset_cursor(): a reshape lives in\nmodel_state.workbook and the cursor it invalidates lives in ViewState, so the\neffects that matter most are precisely the ones that reconcile the two slices.\nNarrowing bets against the direction the code moves in.\n\nFOUR GROUNDS\n1. The narrow set is small and shrinking, for that architectural reason.\n2. The type-level documentation already exists and is CHECKED: Effect::kind()\n has no default (gotcha 3) and apply_effects reads that one answer three times\n (gotchas 40, 45). A narrowed signature is a second statement of the same fact,\n free to disagree -- the defect, not the cure.\n3. Every offered mechanism weakens a guard. (A) three traits needs three queues\n (destroys batch order, which gotchas 21/23/43 lean on) or an enum wrapper (a\n fourth classification beside EffectKind). (C) default methods give `apply` a\n DEFAULT, exactly what kind() does without and for the same reason: overriding\n apply_view on an effect that needs the model compiles and writes the wrong\n slice silently; overriding neither compiles as a no-op. (B) does not meet the\n ticket's own acceptance criterion.\n4. \"Finer-grained effect tests\" is not blocked: effect.rs's 64 tests already call\n Effect.apply(\u0026mut app) against a one-line App::new(wb, None) fixture.\n\nWHAT IS NOT DECLINED: approach (B), rebinding the slice at the top of an apply\nbody, remains available as a per-function style choice. It is not mandated.\nUSER CONFIRMATION 2026-09-20: the decline is CONFIRMED and the bead stays closed. This bead carries the 'human' label, so the WU-N agent correctly recorded its decline as a recommendation rather than acting on it unilaterally; the user was shown the counts (8 view-only / 9 model-only / 32 cross-cutting of 49, down from 9/16/24 at the wave base) and all three approaches (A: three traits; B: rebind in the body; C: default-method dispatch), and chose to confirm the decline.\n\nThe reasoning the user accepted: the classification ALREADY EXISTS and is checked — Effect::kind has no default and apply_effects reads that one answer three times — so a narrowed signature is a second statement free to disagree with it. Three traits need three queues (destroying batch order) or an enum wrapper (a fourth classification). Default methods would give apply a DEFAULT whose wrong answer is silent, which is precisely what kind()'s no-default rule exists to prevent.\n\nThe fact worth carrying past this bead: improvise-9mo9 moved nine effects from 'touches one slice' to 'cross-cuts' INSIDE THE SAME BRANCH that declined this, and that is not a coincidence to route around. The App exists because the grid's shape lives in model_state.workbook and the cursor into it lives in ViewState, so the effects that matter most are exactly the ones that RECONCILE the two slices. A design that rewards single-slice effects would have been pulling against the repo's actual centre of gravity.\nCOUNTS CORRECTED 2026-09-20, after adversarial review of PR #49. The decline\nstands; the numbers it was argued on were wrong twice and the user confirmed it\npartly on them, so the correction is recorded loudly rather than restated.\n\nPUBLISHED (wrong): 49 effects; 8 view-only, 9 model-only, 32 cross; narrow set\nfell 25 -\u003e 17.\nCORRECT: 57 effects; 16 view-only, 9 model-only, 32 cross; narrow set\nfell 32 -\u003e 25. Base 52507a6 was 16/16/25.\n\nTwo errors. (a) It counted only the 49 `impl Effect` blocks and omitted the 8\n`simple_effect!` invocations, which also expand to an impl and are ALL view-only.\n(b) It misclassified AddItem at the base commit, because the extraction regex\ncould not see `app` followed by a newline and `.model_state` -- which is\ndoctrine(derive_then_verify) catching the person quoting it, with the exact\nfailure that doctrine was written about.\n\nWHAT THIS COSTS THE ARGUMENT: 25 of 57 is 44% of effects that could narrow, not\nthe 35% claimed; and the fall is 22%, not 32%. The direction is unchanged and is\nstill the point -- the seven movers are SetAxis, HideItem, ShowItem, ToggleGroup,\nTogglePruneEmpty, RemoveItem and RemoveCategory, every one an effect\nimprovise-9mo9 gave a reset_cursor, so the shrinkage is architectural rather than\nincidental. But the headline was overstated by about half again, and anyone\nre-opening this should argue from 32 -\u003e 25 of 57.\n\nThe other three grounds for the decline are untouched by this: the classification\nalready exists and is checked; three traits break batch order and default methods\nreintroduce a silent default; and finer-grained tests are not blocked.","status":"closed","priority":3,"issue_type":"task","assignee":"wave2-n-cleanups","owner":"el-github@elangley.org","created_at":"2026-04-26T18:16:52Z","created_by":"jeeves","updated_at":"2026-09-20T18:57:42Z","started_at":"2026-09-20T18:08:16Z","closed_at":"2026-09-20T18:11:26Z","close_reason":"Closed","labels":["area:command","human"],"dependencies":[{"issue_id":"improvise-drg","depends_on_id":"improvise-99k","type":"blocks","created_at":"2026-04-26T11:17:16Z","created_by":"jeeves","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-1ud","title":"Document RecordsEditing variant in repo-map.md","description":"context/repo-map.md currently lists 15 AppMode variants but does not include RecordsEditing, which is referenced at src/draw.rs:142 and src/ui/app.rs:442. Add the variant to the AppMode list in the 'Key Types' section so coding agents see it.\\n\\nTrivial doc fix.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:54:16Z","created_by":"Ed L","updated_at":"2026-06-10T04:05:13Z","closed_at":"2026-06-10T04:05:13Z","close_reason":"Done in commit 0fe04de (2026-06-09 repo-map refresh): AppMode list now reads 16 variants including RecordsNormal and RecordsEditing, and notes MinibufferConfig + the ModelState/ViewState split.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-8fy","title":"Introduce simple_effect! macro for single-field setter effects","description":"crates/improvise-command/src/ui/effect.rs has ~15 simple setter effects (SetSelected, SetRowOffset, SetColOffset, SetYanked, SetSearchQuery, SetSearchMode, SetStatus, etc.) that each implement the same 4-line apply: pub struct SetX(pub T); impl Effect { fn apply(\u0026self, app: \u0026mut App) { app.field = self.0.clone(); } }.\\n\\nAbstraction: simple_effect!(SetRowOffset, usize, row_offset) macro generates struct + Effect impl.\\n\\nCorrectness win: provides a single hook for cross-cutting concerns. If layout-affecting setters need to mark dirty or rebuild layout, one macro body covers all of them. Currently each setter independently decides whether to mark_dirty(), and silent omissions are plausible. ~50 LOC.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:54:14Z","created_by":"Ed L","updated_at":"2026-09-19T20:35:54Z","closed_at":"2026-09-19T20:35:54Z","close_reason":"simple_effect! macro added beside the Effect trait; eight single-field ViewOnly setters declared through it (-42 lines net). The macro HARD-CODES EffectKind::ViewOnly by design: an effect other clients must see is not 'simple' and keeps a hand-written impl, which is how gotcha 3's no-default-kind rule survives a macro at all. SetSelected (two fields) and SetNumberFormat (a Model change) stay hand-written. Existing effect tests already cover all eight, including view_only_effects_leave_shared_state_alone.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-1wu","title":"Extract axis label helper in view/layout.rs","description":"crates/improvise-core/crates/improvise-core/src/view/layout.rs:323-336 (row_label) and :338-351 (col_label) are character-identical except for which items list they read. Small syntactic duplication but trivial to consolidate.\\n\\nFix: fn axis_label(items: \u0026[AxisEntry], idx: usize) -\u003e String, call from both. ~14 LOC.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:54:13Z","created_by":"Ed L","updated_at":"2026-08-14T01:11:07Z","closed_at":"2026-08-14T01:11:07Z","dependencies":[{"issue_id":"improvise-1wu","depends_on_id":"improvise-ai7","type":"supersedes","created_at":"2026-08-13T18:11:07Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -290,6 +291,7 @@ {"_type":"issue","id":"improvise-l36","title":"3.2 Publish to crates.io","description":"After cargo publish --dry-run is clean and user confirms, run cargo publish. Verify crate appears at crates.io/crates/improvise and cargo install improvise works.","acceptance_criteria":"improvise is on crates.io and 'cargo install improvise' works from a clean machine. NEEDS THE USER: crates.io credentials, and their confirmation after 'cargo publish --dry-run' is clean. Note the workspace has grown to nine crates since this was filed — decide which are published and in what order, since a dependent cannot publish before its dependency, and improvise-io/protocol/command are all now real crates rather than internal modules.","notes":"DRY RUN ACTUALLY RUN, 2026-09-20 at 52507a6 (wave 2, WU-P). It is NOT clean,\nand the blocker is mechanical rather than a judgement call:\n\n error: failed to verify manifest at Cargo.toml\n all dependencies must have a version requirement specified when publishing.\n dependency `gp-tree` does not specify a version\n\ncargo stops at the FIRST offender, and gp-tree is not the only one. Every\ninter-crate dependency in this workspace is `{ path = \"...\" }` with no\n`version`, which crates.io forbids for normal dependencies:\n\n Cargo.toml (root) gp-tree, improvise-core, improvise-formula,\n improvise-io, improvise-command\n improvise-core improvise-formula\n improvise-formula (none)\n improvise-protocol improvise-core\n improvise-io improvise-core, improvise-formula\n improvise-gp gp-tree, improvise-core, improvise-formula\n improvise-command improvise-core, improvise-protocol,\n improvise-formula, improvise-io, improvise-gp\n\nDev-dependencies are exempt — cargo strips them on publish — so the root's\n`improvise-wasm-client`/`improvise-protocol` dev deps and improvise-wasm-\nclient's dev dep on the unpublishable improvise-ws-server are fine as they are.\n\nWHICH CRATES PUBLISH. `improvise-ws-server` and `improvise-worker-server` both\ncarry `publish = false` already, so the set is eight, and a dependent cannot go\nbefore its dependency. The only order that works:\n\n 1. improvise-formula (no intra-workspace deps)\n 2. improvise-core\n 3. gp-tree (no intra-workspace deps; can go any time before 4)\n 4. improvise-gp\n 5. improvise-protocol\n 6. improvise-io\n 7. improvise-command\n 8. improvise (the binary)\n\ncrates.io needs a minute or two to index each one before the next can resolve\nit, so this is eight sequential `cargo publish` calls, not one.\n\nTWO VERSION FACTS THE USER MUST DECIDE. `gp-tree` is at `0.1.0` while every\nother crate is at `0.1.0-rc2`; and a `-rc2` version requirement in a published\nmanifest pins consumers to a prerelease. If the intent is to publish 0.1.0\n(which improvise-3tj's tag implies), the version bump across ten manifests\nshould happen BEFORE the version requirements are written, or they will be\nwritten twice.\n\nNAME AVAILABILITY CHECKED: crates.io returns \"crate `improvise` does not exist\",\nso the name is free. Not checked: the other seven names.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:58Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:01:38Z","labels":["area:release","needs:user"],"dependencies":[{"issue_id":"improvise-l36","depends_on_id":"improvise-11a","type":"blocks","created_at":"2026-04-08T21:09:29Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-l36","depends_on_id":"improvise-2fr","type":"blocks","created_at":"2026-04-08T21:09:28Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":2,"comment_count":0} {"_type":"issue","id":"improvise-11a","title":"3.1 Configure cargo dist","description":"Run cargo dist init targeting x86_64-unknown-linux-gnu, aarch64-apple-darwin, x86_64-apple-darwin. Skip Windows and musl. Commit generated .github/workflows/release.yml and Cargo.toml additions. Test with v0.1.0-rc1 tag, verify builds, delete rc tag.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:55Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-11T07:49:57Z","close_reason":"Closed","dependencies":[{"issue_id":"improvise-11a","depends_on_id":"improvise-abz","type":"blocks","created_at":"2026-04-08T21:09:28Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":3,"comment_count":0} {"_type":"issue","id":"improvise-0s6","title":"Phase 3: Distribution","description":"Configure cargo dist, publish to crates.io, tag v0.1.0 release. Produces prebuilt binaries for Linux x86_64 and macOS (Intel + Apple Silicon).","acceptance_criteria":"Closes when improvise-l36 and improvise-3tj close. Both need the user; nothing here is agent work. improvise-11a (cargo dist) is already closed.","notes":"CHECKED, wave 2 WU-P: STAYS OPEN, correctly — both children need the user and\nneither was touched. Nothing was published and nothing was tagged.\n\nWhat this pass DID do is make them ready, and one of them is not as ready as\nit looked. `cargo publish --dry-run` was actually run and FAILS: every\ninter-crate dependency in the workspace is `{ path = ... }` with no `version`,\nwhich crates.io forbids. cargo reports only the first offender (gp-tree), so\nthe dry run understates it — the full list, the publish order for the eight\npublishable crates, and the gp-tree/rc2 version disagreement are on\nimprovise-l36.\n\nimprovise-3tj's premise is already partly false and its note now says so: the\nREADME prebuilt-binaries link is not a placeholder, v0.1.0-rc2 carries real\ncargo-dist artifacts for all four targets, and what is actually missing is the\nv0.1.0 tag and the manifest bump that must precede it.","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-09T04:05:51Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:05:07Z","labels":["area:release","needs:user"],"dependencies":[{"issue_id":"improvise-0s6","depends_on_id":"improvise-11a","type":"blocks","created_at":"2026-04-08T23:37:39Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-0s6","depends_on_id":"improvise-3tj","type":"blocks","created_at":"2026-04-08T23:37:41Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-0s6","depends_on_id":"improvise-l36","type":"blocks","created_at":"2026-04-08T23:37:40Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":3,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-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":"open","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-20T18:58:19Z","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":"open","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-20T18:45:36Z","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.","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-20T18:48:10Z","labels":["area:active-view"],"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":"open","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:14:57Z","created_by":"orchestrator","updated_at":"2026-09-20T18:14:57Z","dependency_count":0,"dependent_count":0,"comment_count":0} From 5a1aa283ade3e7ce092623239ef577a927e254b3 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Sun, 20 Sep 2026 12:15:32 -0700 Subject: [PATCH 10/17] chore(bd): drop the issues.jsonl export from this branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bd owns .beads/ and auto-exports on every write, so the file rides along on any branch an agent or the orchestrator does bd work from — and then conflicts with every other branch that did the same. Treated like context/repo-map.md from here: nobody commits it on a feature branch. Co-Authored-By: Claude Opus 5 (1M context) --- .beads/issues.jsonl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index c64703fa..f0d4eb95 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -174,7 +174,7 @@ {"_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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:45:39Z","created_by":"wave2-h-frontend","updated_at":"2026-09-20T18:58:18Z","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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:33:24Z","created_by":"wave2-e-active-view","updated_at":"2026-09-20T18:33:24Z","labels":["area:active-view"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-w3bf","title":"A session mirror's active_view is written in four places, none through reduce_view","description":"Found by the adversarial review of PR #48 (improvise-jytm). Not a defect today: the\nfour writers agree. It is the SHAPE the divergence bugs in this area have taken every\ntime, filed before it becomes one.\n\n`ClientSession::view.active_view` — the server's mirror of a client's view — is\nwritten in four places, and none of them goes through `reduce_view`:\n\n1. `Sessions::apply_switch` — sets it directly from the accepted name.\n2. `Sessions::adopt_deleted_views` — sets it directly to the document default.\n3. `ClientSession::reseed` — replaces the whole slice via `fresh_view`.\n4. `Sessions::connect` — same, through `reseed`.\n\nEverywhere else in this codebase, the mirror stays in step with the client by folding\nthe SAME command the client folds (`ClientSession::fold` → `reduce_view`, the\n\"same function runs on both ends\" rule of improvise-cqq/m91). These four bypass it.\nThey are correct today only because `reduce_view`'s `SwitchView` arm is one line\n(`view.active_view = name.clone()`) and each of these reproduces it.\n\nThe day that arm grows a second statement — clearing a per-view buffer, resetting a\ncursor, anything — the mirror and the client diverge and nothing fails. That is\nexactly gotcha 36's \"folding SwitchView into the mirror alone was the bug\", arriving\nfrom the other direction.\n\nTwo shapes worth weighing:\n\n- Route all four through `reduce_view(\u0026mut session.view, \u0026Command::SwitchView(name))`.\n Cheap, and makes the bypass unrepresentable for this command.\n- A test that the two agree: fold a `SwitchView` into a bare `ViewState` and into a\n `ClientSession` and compare. Weaker (it is a compatibility test, and\n `doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)` says\n those are a definition waiting to be made) but it costs nothing.\n\n## Steps to Reproduce\n\nNot reproducible today; the four copies agree. To make it reproduce: add any second\nstatement to `reduce_view`'s `Command::SwitchView` arm in\n`crates/improvise-protocol/src/reduce.rs` — for instance clearing `status_msg` — then\nrun the suite. It stays green, and a client's own slice now carries something its\nserver-side mirror does not.\n","acceptance_criteria":"Either the four writers route through reduce_view, or a test pins that a ClientSession's mirror and a bare ViewState fold a SwitchView identically. Closing it as 'the copies agree' is not acceptable — that is the state it is filed in.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:32:43Z","created_by":"wave2-e-active-view","updated_at":"2026-09-20T18:32:43Z","labels":["area:active-view"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_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.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:23:51Z","created_by":"orchestrator","updated_at":"2026-09-20T18:23:51Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_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.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:23:51Z","created_by":"orchestrator","updated_at":"2026-09-20T19:08:21Z","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":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:11:01Z","created_by":"wave2-b-projection","updated_at":"2026-09-20T18:11:01Z","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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:06:03Z","created_by":"orchestrator","updated_at":"2026-09-20T18:06:03Z","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.","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-20T18:06:00Z","labels":["area:active-view"],"dependency_count":0,"dependent_count":0,"comment_count":0} From 4348d59e03cb3ea403d6a9987b08ab889036c525 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Sun, 20 Sep 2026 12:15:33 -0700 Subject: [PATCH 11/17] chore(bd): drop the issues.jsonl export from this branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bd owns .beads/ and auto-exports on every write, so the file rides along on any branch an agent or the orchestrator does bd work from — and then conflicts with every other branch that did the same. Treated like context/repo-map.md from here: nobody commits it on a feature branch. Co-Authored-By: Claude Opus 5 (1M context) --- .beads/issues.jsonl | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 582d023a..f0d4eb95 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -22,7 +22,7 @@ {"_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.","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-20T18:19:45Z","labels":["area:epic"],"dependencies":[{"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-zkkw","type":"blocks","created_at":"2026-09-20T18:02:34Z","created_by":"wave2-p-milestones","metadata":"{}"}],"dependency_count":5,"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.","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-20T18:22:57Z","labels":["area:epic"],"dependencies":[{"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-zkkw","type":"blocks","created_at":"2026-09-20T18:02:34Z","created_by":"wave2-p-milestones","metadata":"{}"}],"dependency_count":5,"dependent_count":1,"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} @@ -37,9 +37,10 @@ {"_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-pdvm","title":"AddItem/RemoveItem reshape the grid but declare CellData, so layout_version and cursor_epoch disagree","description":"Found in the adversarial review of PR #49 (improvise-9mo9). PRE-EXISTING, but\nthat PR is the first thing in the repo to produce evidence for it.\n\n`AddItem`, `AddItemInGroup` and `RemoveItem` declare\n`EffectKind::Model(ModelChange::CellData)`. `App::apply_effects` bumps\n`layout_version` only for `ModelChange::LayoutGeometry`, so these three do not\nmove it. Since improvise-9mo9 they DO call `app.reset_cursor()`, which bumps\n`cursor_epoch`.\n\nSo one event moves one counter and not the other, and the two counters are\nanswering the same question: \"has the grid's shape changed under you\". A client\nis told its cursor is invalid (`Command::ResetCursor`) while its `layout_version`\nbasis tier still reads current, so a batch composed against the old layout is\naccepted.\n\nTHE EVIDENCE IS THIS PR'S OWN TEST. `every_reshaping_model_command_invalidates_\nevery_cursor` now asserts, with a `grid_shape()` guard over `row_items`/\n`col_items`, that `AddItem`/`AddItemInGroup`/`RemoveItem` REALLY DO reshape the\ngrid. That is the assertion `ModelChange::CellData` contradicts.\n\nWHY IT IS NOT OBVIOUSLY \"RECLASSIFY THEM\". `EffectKind` is read by three\nconsumers off one classification (gotcha 40): `Dirty::of`, `layout_version`, and\n`model_state.dirty`. Moving these three to `LayoutGeometry` changes what every\nclient is told is dirty and widens the basis rejection surface. It also interacts\nwith gotcha 3's traps -- `AddCategory`/`RemoveCategory` are already the\n\"looks like data, is geometry\" case -- and with the `ModelCommand` derivation.\n\nRELATED, NOT DUPLICATE: improvise-75ld is the same family in the other direction\n(a refused or no-op AddCategory bumps layout_version and raises dirty when\nnothing happened). Decide them together; a single question -- \"what does an\nEffectKind entitle a counter to assume\" -- is under both.\n\n## Steps to Reproduce\n\n1. On a view with a category on Row, apply `ModelCommand::AddItem` for a new item\n in that category.\n2. `grid_shape()` (or `layout.row_count()`) changes: a row was inserted.\n3. `app.cursor_epoch()` has moved and clients receive `Command::ResetCursor`.\n4. `app.layout_version()` has NOT moved, because the effect declares CellData.\n5. A client may now compose a `Command::Model` whose `Basis.layout_version` is\n the pre-change value and have it accepted.","acceptance_criteria":"One event moves both counters or neither. Either AddItem/AddItemInGroup/RemoveItem are reclassified as LayoutGeometry -- in which case the blast radius through Dirty::of, the basis rejection surface and gotcha 3's traps is argued, not assumed -- or layout_version stops being read off EffectKind and is observed the way cursor_epoch and record_versions already are. Decide alongside improvise-75ld, which is the same question in the other direction, and alongside improvise-ni6k, whose grid-frame fingerprint would answer all three at once. The regression guard must assert the two counters agree over the same table every_reshaping_model_command_invalidates_every_cursor already drives, so it cannot pass by naming nothing.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:57:17Z","created_by":"wave2-n-cleanups","updated_at":"2026-09-20T18:57:17Z","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_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":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:22:32Z","created_by":"wave2-p-milestones","updated_at":"2026-09-20T18:22:32Z","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":"open","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-20T18:18:24Z","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-lltt","title":"[bug] A view-scoped ModelCommand names no view, so it lands on the host's","description":"Opened by improvise-jytm, which made `ViewState::active_view` authoritative per\nclient. The view-configuration half of `ModelCommand` — SetAxis, CycleAxis,\nTransposeAxes, SetPageSelection, HideItem, ShowItem, ToggleGroup, TogglePruneEmpty,\nSetNumberFormat — acts on \"the active view\", which is now an ambiguous phrase.\n\n`Sessions::apply_model` routes with no SessionId (gotcha 38, correctly: the change\nis addressed to no one session, because a `View` is document state shared by every\nclient sitting on it). But it must still name WHICH view, and today it names it\nimplicitly: `apply_command` runs the effect against the `App`, and the effect reads\n`App::view()`, which is the HOST's slice. So a connected client on another view\nmoves the host's view rather than its own.\n\nBefore jytm every client was on the same view, so the implicit answer was right.\nIt is now wrong exactly when two clients are on different views, which is the\ncapability jytm exists to add.\n\nThe honest fix is for those variants to name their view — a protocol change — so\nthe command carries the operand rather than borrowing an ambient one. Rejected\nduring jytm: retargeting `app.view_state.active_view` around `apply_command` at the\ndoor. It makes the host's own UI slice a parameter channel, and `reset_cursor`/\n`cursor_epoch` would then write the asker's key into the host's cursor map.\n\nNote the `_Drill`/`_Records` virtual views make this sharper than it looks: a host\nin records mode is on a view no client can name.\n\n## Steps to Reproduce\n\n1. Two clients, A and B, on one document with views \"Default\" and \"Second\".\n2. B sends `Command::SwitchView(\"Second\")`. Its mirror moves; the host's App stays\n on \"Default\" (correct, per improvise-jytm).\n3. B sends `Command::Model { cmd: ModelCommand::SetAxis { category: \"Region\",\n axis: Row } }`.\n4. The axis moves on \"Default\" — the view B is not on — and B's next projection\n shows nothing changed while A's shows a change A did not ask for.\n\nNot reachable from the TUI, which is the only client that drives the App's own\nslice, and not reachable from `web/app.js` today because it sends no SwitchView.\n","acceptance_criteria":"The view-configuration ModelCommand variants name the view they act on, so a connected client's SetAxis moves the view that client is on and not the host's. A test drives two sessions onto different views and asserts an axis change by one is invisible to the other. Deciding NOT to fix is also an acceptable close, but then gotcha 38 must say so.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:05:49Z","created_by":"wave2-e-active-view","updated_at":"2026-09-20T18:05:49Z","labels":["area:active-view"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-lltt","title":"[bug] A view-scoped ModelCommand names no view, so it lands on the host's","description":"Opened by improvise-jytm, which made `ViewState::active_view` authoritative per\nclient. The view-configuration half of `ModelCommand` — SetAxis, CycleAxis,\nTransposeAxes, SetPageSelection, HideItem, ShowItem, ToggleGroup, TogglePruneEmpty,\nSetNumberFormat — acts on \"the active view\", which is now an ambiguous phrase.\n\n`Sessions::apply_model` routes with no SessionId (gotcha 38, correctly: the change\nis addressed to no one session, because a `View` is document state shared by every\nclient sitting on it). But it must still name WHICH view, and today it names it\nimplicitly: `apply_command` runs the effect against the `App`, and the effect reads\n`App::view()`, which is the HOST's slice. So a connected client on another view\nmoves the host's view rather than its own.\n\nBefore jytm every client was on the same view, so the implicit answer was right.\nIt is now wrong exactly when two clients are on different views, which is the\ncapability jytm exists to add.\n\nThe honest fix is for those variants to name their view — a protocol change — so\nthe command carries the operand rather than borrowing an ambient one. Rejected\nduring jytm: retargeting `app.view_state.active_view` around `apply_command` at the\ndoor. It makes the host's own UI slice a parameter channel, and `reset_cursor`/\n`cursor_epoch` would then write the asker's key into the host's cursor map.\n\nNote the `_Drill`/`_Records` virtual views make this sharper than it looks: a host\nin records mode is on a view no client can name.\n\n## Steps to Reproduce\n\n1. Two clients, A and B, on one document with views \"Default\" and \"Second\".\n2. B sends `Command::SwitchView(\"Second\")`. Its mirror moves; the host's App stays\n on \"Default\" (correct, per improvise-jytm).\n3. B sends `Command::Model { cmd: ModelCommand::SetAxis { category: \"Region\",\n axis: Row } }`.\n4. The axis moves on \"Default\" — the view B is not on — and B's next projection\n shows nothing changed while A's shows a change A did not ask for.\n\nNot reachable from the TUI, which is the only client that drives the App's own\nslice, and not reachable from `web/app.js` today because it sends no SwitchView.\n","acceptance_criteria":"The view-configuration ModelCommand variants name the view they act on, so a connected client's SetAxis moves the view that client is on and not the host's. A test drives two sessions onto different views and asserts an axis change by one is invisible to the other. Deciding NOT to fix is also an acceptable close, but then gotcha 38 must say so.","notes":"THE TRIGGER, added after the adversarial review of PR #48 (2026-09-20). This is deferrable, but it is deferred on a CONDITION rather than on a judgement, and the condition is one line of somebody else's future work away. It is unreachable today ONLY because no shipped client emits Command::SwitchView. That is the whole of the argument. The browser already emits view-configuration model commands -- keys.rs resolves a keypress to ModelCommand::TransposeAxes locally and sends it -- so the machinery on the other side of the ambiguity is live. THE DAY ANY WAVE WIRES A VIEW SWITCH INTO THE BROWSER, THIS GOES LIVE ON THE NEXT TRANSPOSE KEYSTROKE, AND SILENTLY: a client on Second presses transpose, the axes of Default move instead, that client's grid does not change and every other client's does. Two consequences. (1) Do not close this by arguing it is unreachable -- reachability is the thing that is about to change, and gotcha 47 already records that 'unreachable today' was the argument under which the lost update CheckedBatch later fixed had shipped. (2) There is a cheap interim guard worth landing before the real fix: assert that no shipped browser client emits Command::SwitchView, a test over improvise-wasm-client's key resolver. It turns the silent failure into a loud one at exactly the moment the premise stops holding, which is the only moment anybody would otherwise not be looking.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:05:49Z","created_by":"wave2-e-active-view","updated_at":"2026-09-20T18:34:24Z","labels":["area:active-view"],"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":"CORRECTION, same session: this bead as first written read as though nobody had\ndecided the Chromium-only question. Somebody had.\n`invariant(opfs_is_run_but_bindings_is_not)` in prolog-sessions/kb.pl records\nit, and gives TWO reasons, not one:\n\n \"harness/tests/shell.spec.js runs web/app.js under CHROMIUM ONLY --\n engine-independent logic, and routeWebSocket is slower across three.\"\n\nThe description above only answers the second. The first — the logic is\nengine-independent — is a general claim covering all six specs, and against\nthe specs as a test of web/app.js it is correct: app.js is plain DOM and\nplain event wiring, and running the same assertions on a second engine buys\nvery little.\n\nWHAT IT DOES NOT COVER is the claim improvise-bck criterion (1) actually\nmakes. That criterion is not \"app.js's logic works in Firefox\", it is \"the\nstandalone deployment works in Firefox\" — and the standalone deployment is\nmodule workers, `wasm-pack` output, `createSyncAccessHandle` through\nbindings.rs and the seed fetch, none of which is app.js's logic and all of\nwhich is engine-specific. opfs.spec.js covers Firefox's OPFS through\n`OpfsFs` directly and, per the same invariant, NEVER INSTANTIATES\nbindings.rs. So the two standalone specs on Firefox would be the first\nexecution of that shim on a second engine, and near enough the first on any.\n\nSo the disagreement with the invariant is narrow and should be argued on\nthose terms: the engine-independence reason is a statement about app.js, and\nthe two standalone specs are not only a test of app.js. If that argument is\nrejected, the right outcome is to REWRITE improvise-bck criterion (1) to say\nChromium, not to leave a criterion standing that names an engine nothing\nruns on — a criterion nobody checks is the failure mode this whole pass\nexists to catch.\n\nEither way the invariant's wording is owed an update, since it currently\nreads as a blanket justification for a split that has two different reasons\nbehind its two halves.\nCORRECTION — MY OWN PREMISE WAS WRONG, found in adversarial review of PR #47.\n\nThe note above says the two standalone specs on Firefox \"would be the first\nexecution of that shim on a second engine, and near enough the first on any\".\nThe second half is false. `bindings.rs` HAS been executed, on Chromium, since\nimprovise-vvl3 landed the harness. The chain is unbroken and each link is\ncheckable:\n\n /app/index.html with no ?ws= -\u003e app.js `new Worker(\"./worker.js\")`\n worker.js `import { start } from \"./worker-pkg/improvise_worker_server.js\"`\n bindings.rs `pub async fn start()` -\u003e `boot()` -\u003e `OpfsFs::open().await`\n\nand `harness/tests/shell.spec.js`'s own comment on the standalone test has said\nso all along: \"If this draws a grid, worker.js, bindings.rs, OpfsFs and the seed\nfetch all ran.\"\n\nI RAN THAT SPEC GREEN AT 2ccd21f while verifying the /app/ move, and watched the\nbrowser fetch /app/worker.js and /app/worker-pkg/improvise_worker_server_bg.wasm\nand draw the grid with #status reading \"in-page worker\". The evidence was in my\nhand and I still repeated the claim, because I took it from kb.pl and the repo\nmap instead of from the run.\n\nWHAT SURVIVES: the argument for running the two standalone specs on Firefox is\nUNAFFECTED and is still the point of this ticket. Firefox would be a genuine\nsecond engine for the shim, and improvise-bck's criterion (1) still names an\nengine nothing runs. Only the \"first on any engine\" rhetoric goes.\n\nFIVE PLACES STATE THE FALSE VERSION and want one grep, not five edits:\nkb.pl:11, kb.pl:105 (invariant opfs_is_run_but_bindings_is_not), repo-map.md:241,\nrepo-map.md:467, and improvise-bck's own NOT-DEMONSTRATED list. Those three files\nare orchestrator-owned; the bck half is corrected in bck's notes.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:02:30Z","created_by":"wave2-p-milestones","updated_at":"2026-09-20T18:19:44Z","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":"Three PRs merged within 20 seconds on 2026-09-20 (#37, #41, #42). Each CI run built the site and uploaded a github-pages artifact, and each queued a deploy behind 'concurrency: group: pages, cancel-in-progress: false'.\n\nOnly the newest run kept its artifact:\n\n run 35526758561 (04e4e3d, #37) conclusion=failure artifacts=0\n run 35526767814 (5f871f7, #41) conclusion=success artifacts=1\n run 35526774007 (52507a6, #42) conclusion=failure artifacts=0\n\nThe two superseded deploys failed with:\n\n Fetching artifact metadata for \"github-pages\" in this workflow run\n Found 0 artifact(s)\n Error: No artifacts named \"github-pages\" were found for this workflow run.\n\nThe upload step was GREEN in all three runs, so this is not a build failure —\nthe artifact is gone by the time the losing deploy runs. The successful deploy\n(17:47:28Z) preceded the failing one (17:47:44Z) by 16 seconds.\n\nDeploying a superseded commit is pointless anyway: the tip of main is what\nshould be published. Cancelling is deliberately NOT the answer — the existing\ncomment on the concurrency block records that a deploy-pages cancelled midway\ncan leave the Pages deployment half-applied.\n\n## Steps to Reproduce\n\n1. Merge three PRs to main within about a minute.\n2. Watch the CI runs: the newest deploy succeeds, the older ones fail on an\n empty artifact fetch and mark otherwise-green runs as failed.\n\nReported by the user from the Actions log.","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-20T18:03:04Z","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":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T07:55:22Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:57:55Z","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} @@ -57,12 +58,12 @@ {"_type":"issue","id":"improvise-o8fa","title":"improvise-ws-server speaks ws:// only, so the deployed page cannot reach it","description":"Found by opening the deployed page (2026-09-20). The guidance the page and the README carried was WRONG in a way that sends someone down a dead end.\n\nFROM AN https ORIGIN A ws:// ADDRESS IS BLOCKED AS MIXED CONTENT, so the deployed GitHub Pages site needs wss://. improvise-ws-server has NO TLS AT ALL -- no rustls, no native-tls, nothing in the crate or its dependencies -- so wss:// is not obtainable from it. The text said \"note that from an https origin a ws:// address is blocked\", which correctly named the symptom and then offered an address that can never work.\n\nTEXT CORRECTED ALREADY: both web/index.html and README.md now say the address must be wss:// from the deployed page, that the server speaks plain ws:// only, and that the ws:// form works when the page is served over http://localhost. This ticket is the missing CAPABILITY.\n\nTHREE WAYS TO PROVIDE IT, and they are not equivalent:\n * TERMINATE TLS IN FRONT (nginx, caddy, cloudflared, ngrok). Zero code, works today, and is what the corrected text points at. But it means the documented path to sharing a TUI-hosted document involves a second piece of software, which is a poor answer for a tool whose selling point is that it is one binary.\n * NATIVE wss:// IN improvise-ws-server -- rustls plus a cert. Real work, and it raises the certificate question: a self-signed cert makes the browser refuse the socket with no usable error, so this is only pleasant with a real cert or an explicitly trusted local CA (mkcert).\n * DECIDE THE DEPLOYED PAGE DOES NOT PAIR WITH A LOCAL SERVER at all, and say so. Defensible: the standalone worker deployment is the point of the Pages site, and ws:// pairing is a LOCAL development convenience that works fine from the local page. Cheapest, and it makes the docs shorter rather than longer.\n\nThe third is probably right for now and the first is the escape hatch; the second only earns its place if sharing a TUI-hosted document to a remote browser becomes a real use case. Do not implement the second by default.","status":"closed","priority":2,"issue_type":"feature","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T03:53:54Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:22:10Z","started_at":"2026-09-20T05:32:35Z","closed_at":"2026-09-20T06:22:10Z","close_reason":"Closed by PR #21 (WU-I). DOCUMENTATION ONLY, per the user's ruling; native wss:// DECLINED.\n\nTHE DECLINE'S SUBSTANCE IS THE CERTIFICATE, NOT rustls. A self-signed cert makes the browser refuse the socket with no usable error, because a WebSocket handshake has no interstitial -- there is no place to click through. A real cert or mkcert is a second piece of software too, so the choice is two-either-way, with one option additionally shipping a TLS stack and a cert-loading surface in a pivot-table binary.\n\nWHAT LANDED: a worked Caddy example, the nginx difference that bites (proxy_http_version 1.1 plus the Upgrade/Connection headers), the honest cost (a second binary, a poor answer for a one-binary tool), and the framing that the standalone worker deployment is the point of the Pages site while ws:// pairing works fine from a local http://localhost page.\n\nTHIS BEAD COST SEVEN COPILOT ROUNDS AND NINE FINDINGS, EVERY ONE IN PROSE. The sequence is the value and is recorded in kb.pl:\n * the first recipe exposed an unauthenticated world-writable server -- documenting a proxy documents an exposure, because TLS termination REMOVES the loopback binding that was the only control;\n * the \"fix\" put Caddy basic_auth in front of a socket the page opens as new WebSocket(url), an API that can set no headers over a handshake that shows no prompt -- BROKEN reads as protection, which is worse than absent;\n * an ngrok --cidr-allow recipe was written without being run (deprecated-but-accepted, so the reviewer's own \"fails at startup\" was also wrong);\n * \"the browser cannot send a credential on a WebSocket handshake\" over-generalised the governing fact and contradicted the Cloudflare Access bullet three paragraphs up, which works BECAUSE the handshake carries a cookie;\n * an IP allow-list cannot see a cross-site socket at all, which is improvise-kc7n;\n * an absolute loopback claim denied a browser mitigation that exists but is mid-rollout;\n * and \"the auth above\" called an allow-list authentication in a section whose opening sentence is that the server has none.\n\nThe governing fact was ALREADY IN THE SAME FILE at every step, as the basis of this very decline. The lesson the repo did not have: a reviewed paragraph is not evidence.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-955n","title":"[bug] Host::commands applies a batch partially before rejecting","description":"Found by Copilot on PR #13 (the retro review of the unreviewed batch), DECLINED there with reasons on the thread, and filed rather than patched.\n\nADR 001 is emphatic that a stale batch is never applied, NOT EVEN PARTIALLY -- and improvise-ondj went to the trouble of a TYPE (Checked) to make the unchecked path unconstructible. But Checked is PER-COMMAND: Checked::against answers for one Command against the App AS IT STANDS. A ClientFrame::Commands is a SEQUENCE of them, and Host::commands applies in order and abandons at the first rejection. So a frame carrying SwitchView followed by a stale Command::Model has already applied the SwitchView.\n\nSO ADR 001 GUARANTEE IS CURRENTLY TRUE OF A SINGLE COMMAND AND FALSE OF A BATCH.\n\nA SECOND-ORDER EFFECT NOBODY HAD WRITTEN DOWN, found while declining it: Worker::on_frame skips persist() when ANY frame in the answer is Rejected. So a batch that DID apply a SwitchView before being refused leaves Workbook::active_view moved in memory and NOT WRITTEN BACK -- the document on disk and the document in the worker disagree, silently.\n\nWHY IT WAS DECLINED RATHER THAN PATCHED: closing it means the basis check moving from the COMMAND to the BATCH -- every command answered before any is applied. That is an ADR 001 design change with a real question inside it: WHAT DOES A BATCH BASIS MEAN WHEN ITS LATER COMMANDS WERE COMPOSED AGAINST ITS EARLIER ONES EFFECTS? A client resolving one keypress produces SetSelected plus offsets computed from the selection it just set. Answer that before writing code.\n\nREACHABILITY IS BOUNDED TODAY: no client in this workspace composes a batch containing more than one basis-carrying Command::Model -- the batches improvise-bpq2 produces are view commands, which carry no basis. Do this before the second tranche of client-side resolution creates a reason to batch two model commands.\n\nSupersedes the earlier ticket filed from bpq2 if there is one -- dedupe.","status":"closed","priority":2,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T03:35:27Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:29:48Z","started_at":"2026-09-20T05:32:29Z","closed_at":"2026-09-20T07:29:48Z","close_reason":"Closed by PR #26 (WU-C). improvise-08wb is closed as a duplicate of this one.\n\nADR 001 said a stale batch is never applied, NOT EVEN PARTIALLY, and improvise-ondj went to the trouble of a type (Checked) to make the unchecked path unconstructible -- but Checked is PER-COMMAND and a ClientFrame::Commands is a sequence of them, so Host::commands applied in order and abandoned at the first rejection. The guarantee was true of a single command and false of a batch.\n\nCheckedBatch::against now answers for EVERY command before any is applied.\n\nTHE DESIGN QUESTION THE TICKET DEMANDED WAS ANSWERED BY THE USER, not by the code, and it is larger than the ticket imagined. The ticket asked \"what does a batch's basis mean when its later commands were composed against its earlier ones' effects\". The user's ruling: \"a sequence of commands sent to the server should assume that it's played in order from start to end and that each command sees the state after the previous command runs\", as \"a single transaction from a given base\". Four clauses -- one basis per frame; checked once before anything; applied in order with data dependence EXPECTED; all or nothing. Clauses 1-3 landed here; clause 4 (rollback) is improvise-yqpd.\n\nAN EARLIER \"at most one document mutation per frame\" RULE WAS SHIPPED AND THEN REJECTED BY THE USER as forbidding the capability. It was wrong twice over: it forbids data-dependent chains, and arity is not a property of a variant anyway -- a future Command::RunScript is one variant performing unboundedly many mutations.\n\nTHE FINDING THAT MATTERED MOST came from the independent adversarial review that substituted for Copilot (quota exhausted), and it was a REGRESSION THIS WORK INTRODUCED: hoisting the basis check to the frame hoisted ALL THREE TIERS. The document and layout tiers are legitimately frame-level facts; THE RECORD TIER IS NOT AND CANNOT BE, because which records are at stake is a property of the command. So every mutation after the first had its records checked against nothing -- a silent lost update, confirmed by executing a real Host: client A's stale [SetCell(North), SetCell(South)] clobbered client B's write to South with no rejection, where the base commit refused it. Fixed: every mutation answers for its own records against the FRAME'S basis, with an exemption stated over a property -- a record that neither resolves nor is claimed is one the frame is creating. The reviewer attacked that exemption at all four corners and could not break it: the predicate is a conjunction and the client controls only one conjunct.\n\nTHE SECOND-ORDER WORKER EFFECT the ticket named is closed twice over; its test fails only when BOTH old behaviours are restored.\n\nA CAPABILITY NOBODY HAD NAMED, now guarded: a frame may write THE SAME RECORD TWICE -- two measures on one record IS one record -- and that works only because every check happens before any apply. Moving the checks into the loop breaks it, which is what stops someone closing clause 4 by taking that trade unnoticed.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-08wb","title":"[bug] A refused ClientFrame::Commands keeps the commands before the refusal","description":"Found by improvise-bpq2 (2026-09-20), stated on the variant, and currently UNREACHABLE from any client in this workspace -- filed so it is a decision rather than a discovery.\n\nClientFrame::Commands(Vec\u003cCommand\u003e) batches, because one cursor key resolved client-side is SetSelected plus the offsets viewport_effects returns, and a host that folds the selection but not the offset projects the wrong window. Host::commands applies IN ORDER and ABANDONS AT THE FIRST REJECTION.\n\nTHAT LEAVES A HOLE ADR 001 DOES NOT HAVE. ADR 001 is emphatic that a stale batch is never applied, NOT EVEN PARTIALLY -- and improvise-ondj went to the trouble of a type (Checked) to make the unchecked path unconstructible. But Checked is per-COMMAND, and a ClientFrame::Commands is a sequence of them, so a frame whose third command is refused has already applied the first two.\n\nWHY IT IS NOT LIVE: no client in this workspace composes a Commands frame containing more than one basis-carrying Command::Model -- the batches bpq2 produces are view commands (SetSelected + offsets), which carry no basis.\n\nTHE FIX IS TO MOVE THE BASIS CHECK TO THE BATCH: check every Command::Model in the frame before applying any of them. That is the shape ADR 001 already describes for an effect batch, one level up, and it would make Host::commands match the guarantee the ADR states.\n\nDo this before a client has reason to batch two model commands -- which the second tranche of client-side resolution (the ~15 bindings bpq2 left) may well create.","status":"closed","priority":2,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T02:36:42Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:29:28Z","started_at":"2026-09-20T05:32:29Z","closed_at":"2026-09-20T07:29:28Z","close_reason":"Closed as a DUPLICATE of improvise-955n, which PR #26 fixed. Both beads describe the same defect -- Host::commands applied a ClientFrame::Commands in order and abandoned at the first rejection, so a frame whose later command was refused had already applied the earlier ones, leaving ADR 001's 'never applied, not even partially' true of a single command and false of a batch. 08wb was filed from improvise-bpq2's walk and 955n from Copilot's retro review of PR #13; 955n carries the fuller reasoning and the second-order worker effect, so it is the survivor.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-ir7l","title":"[bug] A formula cell is offered as editable and silently reverts","description":"Pre-existing and consistent with the TUI, but improvise-bck just put it on the DEMO FRONT PAGE, which changes who meets it: the first thing a stranger sees is Profit beside Revenue and Cost.\n\nA formula target cell projects CellId::Record, so render.rs::editable says true, so the browser offers an editor. Type into Profit and the write lands -- then recompute_formulas overwrites it on the next pass and the value REVERTS with no explanation.\n\nWHY IT IS NOT SIMPLY A BUG IN editable: a formula target IS a stored coordinate; the cell is a record. What makes it unwritable is not its identity but that a FORMULA OWNS IT. So the fix is a fact the projection does not currently carry -- something like \"this cell is derived\" -- and it belongs beside numeric/error on CellDisplay, which already exists precisely because the SERVER is the side that knows.\n\nCHECK THE TUI BEFORE CHANGING ANYTHING: it has the same behaviour, so whatever is decided should land in both, or the divergence becomes the new bug (gotcha 44 is what that looks like). And note ADR 002 deliberately made settability a fact about the CellId VARIANT -- adding a second, independent \"but not really\" flag is exactly the two-answers-to-one-question shape this codebase keeps curing. Consider instead whether a formula-owned cell should project a different CellId at all.\n\n## Steps to Reproduce\n\n1. Open `web/demo.improv` in the browser — it ships `Profit = Revenue - Cost` beside Revenue and Cost on the front page.\n2. Click the Profit cell. It is offered as editable, because a formula target projects `CellId::Record` and `render.rs::editable` reads the variant.\n3. Type a number and commit. The write lands.\n4. `recompute_formulas` overwrites it on the next pass and the value reverts, with no explanation.\n\nThe TUI behaves the same way, so whatever is decided must land in both or the divergence becomes the new bug.\n","acceptance_criteria":"A formula-owned cell projects a non-Record CellId, so the write is unconstructible rather than flagged — the discipline ADR 002 used for aggregates and ADR 003 reaches for again for read-only sources. It lands in the TUI in the same change, or the two front ends diverge (gotcha 44 is what that looks like). Do NOT add a second 'but not really' flag beside CellId: two answers to 'can I write here' is the disease. Demonstrated on web/demo.improv, where Profit sits beside Revenue and Cost on the front page.","notes":"VERIFIED CONCRETELY during the PR #13 review (2026-09-20), and the diagnosis is sharper than when this was filed.\n\nTHE ROOT CAUSE: project::cell_id decides Record vs Aggregate purely on whether a regular category sits on Axis::None. IT NEVER ASKS WHETHER THE _Measure COORDINATE NAMES A FORMULA TARGET. So a formula cell is a Record, render.rs::editable says true, and the browser offers an editor.\n\nMEASURED AGAINST THE SHIPPED DEMO:\n col 2 label=\"Profit\" is_record=true text=\"12,200\"\n after applying SetCell(key, Number(999.0)), Profit reads \"12,200\"\n\nThe write is ACCEPTED AND STORED. Model::evaluate_aggregated checks formula_cache BEFORE raw data (model/types.rs:409), so the typed value is shadowed forever with nothing telling the user. Not a revert -- a permanent silent shadow.\n\nAND THE TEST THAT COVERS THIS AREA IS LYING BY ITS NAME. every_visible_cell_in_the_web_demo_is_settable PASSES while its name is FALSE for the Profit column -- a third of the landing-page grid. It was proved discriminating against the one thing it CAN see (the aggregate at (0,0) in examples/demo.improv) and is structurally blind to this. That is exactly the shape AGENTS.md calls this repo's characteristic find, arriving in the test written to prevent it.\n\nTHE DECISION IS STILL THE DECISION, and ADR 002 is where it goes:\n * TEACH cell_id ABOUT FORMULA TARGETS -- changes what EVERY client calls a record, and record identity is also ADR 001's unit of atomicity, so this reaches the basis check too.\n * OR GIVE editable A SECOND INPUT -- but that is the conflation gotcha 42 exists to forbid, and ADR 002 deliberately made settability a fact about the CellId VARIANT precisely so there could not be a second answer.\n\nThe first is probably right and is not small. Until it lands, the demo's front page teaches a visitor that typing into Profit does nothing.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-20T02:29:51Z","created_by":"Edward Langley","updated_at":"2026-09-20T17:53:19Z","started_at":"2026-09-20T17:53:19Z","lease_expires_at":"2026-09-20T17:58:19Z","heartbeat_at":"2026-09-20T17:53:19Z","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-ir7l","title":"[bug] A formula cell is offered as editable and silently reverts","description":"Pre-existing and consistent with the TUI, but improvise-bck just put it on the DEMO FRONT PAGE, which changes who meets it: the first thing a stranger sees is Profit beside Revenue and Cost.\n\nA formula target cell projects CellId::Record, so render.rs::editable says true, so the browser offers an editor. Type into Profit and the write lands -- then recompute_formulas overwrites it on the next pass and the value REVERTS with no explanation.\n\nWHY IT IS NOT SIMPLY A BUG IN editable: a formula target IS a stored coordinate; the cell is a record. What makes it unwritable is not its identity but that a FORMULA OWNS IT. So the fix is a fact the projection does not currently carry -- something like \"this cell is derived\" -- and it belongs beside numeric/error on CellDisplay, which already exists precisely because the SERVER is the side that knows.\n\nCHECK THE TUI BEFORE CHANGING ANYTHING: it has the same behaviour, so whatever is decided should land in both, or the divergence becomes the new bug (gotcha 44 is what that looks like). And note ADR 002 deliberately made settability a fact about the CellId VARIANT -- adding a second, independent \"but not really\" flag is exactly the two-answers-to-one-question shape this codebase keeps curing. Consider instead whether a formula-owned cell should project a different CellId at all.\n\n## Steps to Reproduce\n\n1. Open `web/demo.improv` in the browser — it ships `Profit = Revenue - Cost` beside Revenue and Cost on the front page.\n2. Click the Profit cell. It is offered as editable, because a formula target projects `CellId::Record` and `render.rs::editable` reads the variant.\n3. Type a number and commit. The write lands.\n4. `recompute_formulas` overwrites it on the next pass and the value reverts, with no explanation.\n\nThe TUI behaves the same way, so whatever is decided must land in both or the divergence becomes the new bug.\n","acceptance_criteria":"A formula-owned cell projects a non-Record CellId, so the write is unconstructible rather than flagged — the discipline ADR 002 used for aggregates and ADR 003 reaches for again for read-only sources. It lands in the TUI in the same change, or the two front ends diverge (gotcha 44 is what that looks like). Do NOT add a second 'but not really' flag beside CellId: two answers to 'can I write here' is the disease. Demonstrated on web/demo.improv, where Profit sits beside Revenue and Cost on the front page.","notes":"VERIFIED CONCRETELY during the PR #13 review (2026-09-20), and the diagnosis is sharper than when this was filed.\n\nTHE ROOT CAUSE: project::cell_id decides Record vs Aggregate purely on whether a regular category sits on Axis::None. IT NEVER ASKS WHETHER THE _Measure COORDINATE NAMES A FORMULA TARGET. So a formula cell is a Record, render.rs::editable says true, and the browser offers an editor.\n\nMEASURED AGAINST THE SHIPPED DEMO:\n col 2 label=\"Profit\" is_record=true text=\"12,200\"\n after applying SetCell(key, Number(999.0)), Profit reads \"12,200\"\n\nThe write is ACCEPTED AND STORED. Model::evaluate_aggregated checks formula_cache BEFORE raw data (model/types.rs:409), so the typed value is shadowed forever with nothing telling the user. Not a revert -- a permanent silent shadow.\n\nAND THE TEST THAT COVERS THIS AREA IS LYING BY ITS NAME. every_visible_cell_in_the_web_demo_is_settable PASSES while its name is FALSE for the Profit column -- a third of the landing-page grid. It was proved discriminating against the one thing it CAN see (the aggregate at (0,0) in examples/demo.improv) and is structurally blind to this. That is exactly the shape AGENTS.md calls this repo's characteristic find, arriving in the test written to prevent it.\n\nTHE DECISION IS STILL THE DECISION, and ADR 002 is where it goes:\n * TEACH cell_id ABOUT FORMULA TARGETS -- changes what EVERY client calls a record, and record identity is also ADR 001's unit of atomicity, so this reaches the basis check too.\n * OR GIVE editable A SECOND INPUT -- but that is the conflation gotcha 42 exists to forbid, and ADR 002 deliberately made settability a fact about the CellId VARIANT precisely so there could not be a second answer.\n\nThe first is probably right and is not small. Until it lands, the demo's front page teaches a visitor that typing into Profit does nothing.","status":"closed","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-20T02:29:51Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:22:48Z","started_at":"2026-09-20T17:53:19Z","closed_at":"2026-09-20T18:22:48Z","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-vhcz","title":"[bug] OpfsFs::list disagrees with the other backends on path shape","description":"Found by improvise-9x6 (2026-09-20) and currently PAPERED OVER by always passing the flat root.\n\nOpfsFs::list returns BARE NAMES; MemoryFs and PhysicalFs return dir-JOINED ones. So Path::new(\"\") is the ONE dir at which all three agree, and the only one at which storage_contract actually describes OpfsFs.\n\nTHE TRAP: a worker that organised documents under docs/ would get an EMPTY PICKER in a browser and a FULL ONE in every test. The harness cannot see the disagreement because it never runs OpfsFs -- improvise-xf1 is deferred -- so this is precisely the class of defect the missing browser harness exists to catch, discovered by reading instead.\n\nTWO FIXES:\n * Make OpfsFs::list join dir, matching the other two. Straightforward, and makes storage_contract describe all three at every dir.\n * Or document the flat root as PART OF THE CONTRACT and assert it in storage_contract, so the constraint is stated rather than discovered.\n\nThe first is better unless there is a reason OPFS cannot -- note gotcha 28 says backends that are not trees treat dir as a NAME PREFIX, which is already the trait doc contemplating this, so check whether the doc or the impl is the thing that is wrong.","status":"closed","priority":2,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T02:10:14Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:44:42Z","started_at":"2026-09-20T05:32:36Z","closed_at":"2026-09-20T06:44:42Z","close_reason":"Closed by PR #24 (WU-J). THE TICKET ASKED WHICH OF THE DOC OR THE IMPL WAS WRONG: IT WAS THE IMPL.\n\nThe Storage trait doc already said that backends which are not trees treat `dir` as a NAME PREFIX. OpfsFs::list ignored `dir` outbound as well as inbound, returning bare names where MemoryFs and PhysicalFs return dir-joined ones -- so Path::new(\"\") was the one dir at which all three agreed, and the only one at which storage_contract actually described OpfsFs.\n\nNow it joins. The three backends agree at EVERY dir, and storage_contract runs against OpfsFs at both \"\" and \"docs\" in a real browser. `dir` stays DECORATION rather than a filter, because entry_name reduces every path to its file name and an OPFS entry name may not contain a separator.\n\nFAULT INJECTED: reverting to bare names fails storage_contract_at_\"docs\" on `lists the one document`.\n\nTHE TRAP THE TICKET NAMED IS CLOSED: a worker that organised documents under docs/ would have got an empty picker in a browser and a full one in every test. That divergence was invisible to the harness because nothing ran OpfsFs -- which is exactly why improvise-vvl3 had to land alongside this, and why this bead could only ever have been found by reading until it did.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-hmws","title":"Storage cannot expose an async seam through Box\u003cdyn Storage\u003e","description":"Found by improvise-9x6 (2026-09-20). Every worker host will hit this, so it is worth solving once rather than in each.\n\nOpfsFs::open / attach / refresh / commit_deletes are on the STRUCT, not on the Storage trait -- deliberately, per gotcha 28: Storage stays synchronous because createSyncAccessHandle yields a blocking handle, and only four operations are genuinely async. But App::storage is a Box\u003cdyn Storage\u003e, so HANDING THE BACKEND TO THE App PUTS ALL FOUR PERMANENTLY OUT OF REACH. The App owns the only copy and the trait object cannot name them.\n\nAND THE OBVIOUS FIX IS REFUSED BY THE COMPILER: impl Storage for Rc\u003cOpfsFs\u003e is two foreign types, so the orphan rule forbids it. improvise-9x6 worked around it with a local newtype (bindings::Shared) holding an Rc\u003cOpfsFs\u003e and forwarding the four sync methods. That works but every worker must write it again.\n\nTWO CANDIDATE FIXES:\n * Ship the shared wrapper FROM improvise-io, so the newtype exists once where OpfsFs does and the orphan rule is satisfied.\n * Or have OpfsFs hand out an attachable handle -- separate the thing the App holds from the thing the message loop awaits, which is arguably the more honest modelling since they have different lifetimes and different call sites.\n\nPrefer whichever keeps Storage synchronous; that part is not negotiable (gotcha 28 is the argument).","status":"closed","priority":2,"issue_type":"task","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T02:10:13Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:44:43Z","started_at":"2026-09-20T05:32:37Z","closed_at":"2026-09-20T06:44:43Z","close_reason":"Closed by PR #24 (WU-J). NEITHER OF THE TICKET'S TWO OPTIONS WAS NEEDED, and the reason is worth more than the fix.\n\nThe ticket said `impl Storage for Rc\u003cOpfsFs\u003e` is refused by the orphan rule. That is true IN improvise-worker-server, where both the trait and Rc are foreign -- which is where improvise-9x6 hit it and wrote bindings::Shared. It is NOT true in improvise-io, where the trait is LOCAL and the impl is simply allowed.\n\nSo: improvise-io ships a blanket `impl\u003cT: Storage + ?Sized\u003e Storage for Rc\u003cT\u003e`. bindings::Shared and its _shim_is_a_storage canary are DELETED, not moved, and no future worker writes one. Storage stays synchronous, which gotcha 28 says is not negotiable.\n\nTHE GUARD IS THE SIGNATURE: a_shared_backend_is_a_backend_and_a_clone_is_the_same_one does not compile without the impl. The body asserts what a hand-written newtype could still get wrong -- that a clone addresses the same backend -- and a fault injected into the forwarded `write` (made a no-op) fails it.\n\nTHE GENERALISABLE PART: an orphan-rule refusal is a fact about WHERE YOU ARE STANDING, not about the pair of types. Before working around one, check whether the impl belongs in the crate that owns the trait.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-9mo9","title":"[bug] Geometry effects that renumber the grid but never reset the cursor","description":"Found by the code review of improvise-gvnb (2026-09-19). A PRE-EXISTING gap that gvnb inherited rather than introduced -- but gvnb is what makes it matter, because the epoch is now what tells every client their cursor is invalid.\n\nApp::reset_cursor bumps cursor_epoch; Sessions::adopt_cursor_epoch watches the COUNTER and broadcasts Command::ResetCursor to all. Nothing declares which effects renumber the grid -- deliberately, per gotcha 31 -- which means a geometry effect that forgets reset_cursor is INVISIBLE IN BOTH DIRECTIONS: the host own cursor stays stale AND no client is told.\n\nToday only TransposeAxes and CycleAxis call it. These also renumber rows or columns and do not:\n SetAxis, HideItem, ShowItem, ToggleGroup, TogglePruneEmpty, AddCategory, RemoveCategory\n (plus AddItem/RemoveItem, which are CellData rather than LayoutGeometry)\n\nSetAxis IS THE SHARPEST CASE: CycleAxis is literally SetAxis to Axis::next() and resets; SetAxis does not.\n\nWHY THE REVIEW DID NOT JUST PATCH IT: SetAxis is used by DrillIntoCell (cmd/grid.rs:365-471) to build the _Drill view, and resetting there could discard the user position in the view they drilled FROM, depending on effect order. That is a decision about effect ordering and intended UX, not a patch.\n\nKEEP THE NEGATIVE ARM. a_model_command_that_does_not_reshape_the_grid_leaves_cursors_alone uses SetNumberFormat -- a LayoutGeometry effect that must NOT reset. Without it, \"reset on every model command\" would pass the positive test. Whatever you do here, do not lose it.\n\n## Steps to Reproduce\n\n1. In a view with a cursor somewhere other than the origin, apply any of `SetAxis`, `HideItem`, `ShowItem`, `ToggleGroup`, `TogglePruneEmpty`, `AddCategory`, `RemoveCategory`, `AddItem` or `RemoveItem`.\n2. The grid renumbers and the cursor is not reset, so it addresses a position that has moved.\n3. Worse for other clients: `reset_cursor` is what bumps `cursor_epoch`, and `Sessions::adopt_cursor_epoch` watches that counter to broadcast `Command::ResetCursor`. No reset means **no client is told either**.\n\n`SetAxis` is the sharpest case: `CycleAxis` *is* `SetAxis` to `Axis::next()` and does reset.\n","acceptance_criteria":"Each of SetAxis, HideItem, ShowItem, ToggleGroup, TogglePruneEmpty, AddCategory, RemoveCategory, AddItem and RemoveItem either calls reset_cursor or carries a one-line reason why it does not. SetAxis is the sharp case — CycleAxis IS SetAxis to Axis::next() and resets, while SetAxis does not — and DrillIntoCell uses SetAxis to build the _Drill view, where resetting could discard the position in the view drilled FROM, so that is a decision about effect ordering and intended UX, not a patch. KEEP THE NEGATIVE ARM: a_model_command_that_does_not_reshape_the_grid_leaves_cursors_alone uses SetNumberFormat, and without it 'reset on every model command' passes the positive test. Note improvise-2zq changed which axis a new category lands on, so re-read on_category_added before assuming AddCategory renumbers the way it used to.","status":"closed","priority":2,"issue_type":"bug","assignee":"wave2-n-cleanups","owner":"el-github@elangley.org","created_at":"2026-09-20T01:05:15Z","created_by":"Edward Langley","updated_at":"2026-09-20T17:57:44Z","started_at":"2026-09-20T17:53:45Z","closed_at":"2026-09-20T17:57:44Z","close_reason":"Closed","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-s1xw","title":"Multi-level grid labels on the wire -- the browser shows Food/Alice where the TUI shows two columns","description":"THE LAST THING KEEPING GridLayout INSIDE GridWidget, and a LIVE DIVERGENCE today, not a hypothetical. Found closing improvise-n10.\n\nGridCache::row_labels / col_labels are Vec\u003cString\u003e produced by GridLayout::row_label, which JOINS A MULTI-LEVEL TUPLE WITH A SLASH. That is exactly the rendering src/ui/grid.rs::two_row_categories_produce_cross_product_labels asserts must NOT appear (\"slash-joined labels should be gone\"). So the protocol carries a form of the labels THE TUI ITSELF CONSIDERS WRONG, and crates/improvise-wasm-client/src/render.rs renders precisely that form.\n\nConsequence: the browser grid has no multi-level headers, no repeat suppression, no groups and no collapse indicators, and shows Food/Alice where the TUI shows two header columns.\n\nSIX FACTS THE CACHE DOES NOT CARRY (exhaustive as of improvise-n10, by grep over the non-test body of src/ui/grid.rs):\n 1. number of header levels -- layout.row_cats / col_cats\n 2. multi-level label TUPLES -- layout.row_items (AxisEntry::DataItem(Vec\u003cString\u003e))\n 3. inline GROUP HEADER rows -- layout.row_items (AxisEntry::GroupHeader)\n 4. column GROUP SPANS -- layout.col_group_for(ci)\n 5. collapse indicator (down/right triangle) -- view.is_group_collapsed(cat, group)\n 6. records-mode column identity -- layout.record_col(ci). THIS ONE IS CORRECT TO KEEP OFF: it keys pending drill edits and is session-local by design.\n\nSo 1-5 are the gap.\n\nTWO CONSTRAINTS ON THE FIX:\n * IT IS A CO-ORDINATED CHANGE, NOT AN ADDITIVE ONE. row_labels/col_labels change TYPE, and improvise-wasm-client/src/render.rs consumes both fields, so the client updates in the same change.\n * DO NOT ADD row_levels BESIDE row_labels. Two answers to \"what are the labels\" is the disease this whole projection architecture exists to cure -- see improvise-edp, where the category panel was walked twice, two different ways.","status":"closed","priority":2,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T00:58:33Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:41:34Z","started_at":"2026-09-20T05:32:27Z","closed_at":"2026-09-20T07:41:34Z","close_reason":"Closed as ABSORBED BY improvise-anr9, verified explicitly rather than assumed. s1xw enumerated six facts the cache did not carry; PR #23 landed 1-5 (header levels, multi-level label tuples, inline group headers, column group spans, the collapse indicator) and 6 is record_col, which s1xw itself says is CORRECT to keep off -- session-local, keys pending drill edits. Both of its stated constraints were honoured: it was a co-ordinated type change (row_labels/col_labels changed TYPE and improvise-wasm-client/src/render.rs updated in the same change) and no row_levels was added beside row_labels. The live divergence it reported -- the browser drawing Food/Alice where the TUI draws two header columns -- is closed, and tests/grid_frame_agreement.rs now drives both front ends over one cache and compares what each draws.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-4zl7","title":"A fitted tree must route per record and aggregate the results, not route on the aggregate","description":"Successor to improvise-8xhy, which shipped remedy 1 (refuse at emit) as an explicit interim. This is the remedy that produces a formula A HUMAN WOULD HAVE WRITTEN, and it is the right end state -- but it was costed during 8xhy and it is much larger than it looks.\n\nANSWER THIS FIRST, BEFORE TOUCHING THE GRAMMAR. A fitted measure cell is evaluated at a CellKey context. In any view that aggregates over the split category -- Region on Page or None -- context.get(\"Region\") is None, and today Formula::filter falls straight through to aggregate_raw. So a categorical fit would SILENTLY DEGRADE to something else in exactly the views a pivot-table user spends most of their time in. That is a product decision about what a fit MEANS for an aggregated cell, not an emitter detail.\n\nSCOPE, once that is answered:\n * Expr::Is(Filter) evaluating to 1.0/0.0. This keeps the f64-only evaluator and avoids introducing string comparison into eval_expr. The primitive already exists in the right shape -- types.rs reads context.get(\u0026filter.category), the current cell own coordinate.\n * A grammar production plus tokenizer handling in the 1109-line parser.rs, with quoting rules for the category and item inside it (gotcha 34 -- one home, improvise_formula::quote).\n * Arms in BOTH evaluators: eval_expr and eval_expr_cached.\n * Coverage in the grammar-walking generator.\n * Thread Dataset::columns (the ColumnSpec/ColumnKind plan) into the emitter, so feature index -\u003e role is read off preprocess own plan. Do NOT reconstruct it: Spec::names is \u0026[\u0026str], and reconstructing index-\u003erole is a second implementation of preprocess expansion order, exactly the drift risk gotcha 41 names in another key.\n * .improv is free -- the writer emits Formula::raw.\n\nIT CLOSES ONLY A THIRD OF 8xhy EITHER WAY. A WHERE predicate covers the one-hots. col.missing means \"this record has no value for measure X\", a different vocabulary. The date sub-features (when.year, when.month) need date arithmetic improvise does not have at all. Those stay refused, or get their own tickets.\n\nPAIRS WITH improvise-vx3 (the fit-measure command), which is where a user would actually learn about the restriction.","acceptance_criteria":"A fitted measure evaluated at an aggregating cell routes PER RECORD and aggregates the results, rather than routing on the aggregate. fit-measure on web/demo.improv succeeds and the resulting formula means the same thing on a record row and one row up. The seed-dependence is gone: whether the command succeeds no longer depends on which features the search happened to pick. Note what this costs — evaluate_aggregated deliberately does not iterate the records behind a formula cell — and state how it interacts with gotcha 9's fixed point and Model::formula_cache. col.missing and the date sub-features remain out of scope.","notes":"USER RULING 2026-09-20: AGGREGATE THE RULE OVER THE MEMBERS. Route-then-aggregate, per record. The other three options -- Error(\"aggregated\"), refuse at fit time, and fall through to aggregate_raw -- are all rejected.\n\nTHE BEAD'S SCOPE HAS CHANGED AND THE TITLE NOW SAYS SO. It asked what a CATEGORICAL split means in a cell that aggregates over the split category. PR #25's review established the question has nothing to do with the split being categorical:\n\nA tree is fitted on individual RECORDS. A formula is evaluated at a CellKey, and in any aggregating cell evaluate_aggregated aggregates each Expr::Ref FIRST and then evaluates the IF. A linear formula survives that -- SUM(Revenue) - SUM(Cost) is SUM(Revenue - Cost) -- and a tree does not, because AGGREGATE-THEN-ROUTE IS NOT ROUTE-THEN-AGGREGATE. So EVERY fitted tree, numeric splits included, means one thing on a record row and something else one row up.\n\nIT IS NOT THEORETICAL: fit-measure Revenue on the repo's own shipped web/demo.improv is refused, naming Product=Espresso/Product=Latte. And because which features a champion picks is a property of the SEARCH, on a document with several categories the same command refuses or succeeds DEPENDING ON THE SEED. That is not a contract.\n\nWHAT THE RULING COSTS, stated plainly because it is the expensive answer and was chosen anyway: evaluate_aggregated deliberately does NOT iterate the underlying records for a formula cell -- it aggregates the refs and evaluates once. Routing per record means a formula cell must be able to ask for the records behind it and fold over them. That touches the evaluator, and it interacts with gotcha 9 (fixed-point recomputation) and with Model::formula_cache, which is where derived values live precisely so raw data and derived data stay separate.\n\nNOTE WHAT IT BUYS BESIDES CORRECTNESS: it is what a user means. A fitted rule applied to a group should say what the rule says about the members, summed -- not what the rule says about a fictional average member. The second is the thing that looks right and is wrong, which is the failure mode vx3's own ticket warned about ('a rule the user will believe').\n\nALSO NOTE: this ruling supersedes improvise-8xhy's remedy 1 (refuse at emit) as the END STATE, but not as the interim -- the refusal stays until this lands, or fit-measure silently produces the wrong number in aggregating views.\n\nTHE TWO THIRDS THIS STILL DOES NOT COVER, unchanged from the original text: col.missing means 'this record has no value for measure X', a different vocabulary; and the date sub-features (when.year, when.month) need date arithmetic improvise does not have at all. Those stay refused or get their own beads.","status":"open","priority":2,"issue_type":"feature","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T00:45:22Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:45:26Z","started_at":"2026-09-20T05:32:41Z","labels":["area:gp"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-4zl7","title":"A fitted tree must route per record and aggregate the results, not route on the aggregate","description":"Successor to improvise-8xhy, which shipped remedy 1 (refuse at emit) as an explicit interim. This is the remedy that produces a formula A HUMAN WOULD HAVE WRITTEN, and it is the right end state -- but it was costed during 8xhy and it is much larger than it looks.\n\nANSWER THIS FIRST, BEFORE TOUCHING THE GRAMMAR. A fitted measure cell is evaluated at a CellKey context. In any view that aggregates over the split category -- Region on Page or None -- context.get(\"Region\") is None, and today Formula::filter falls straight through to aggregate_raw. So a categorical fit would SILENTLY DEGRADE to something else in exactly the views a pivot-table user spends most of their time in. That is a product decision about what a fit MEANS for an aggregated cell, not an emitter detail.\n\nSCOPE, once that is answered:\n * Expr::Is(Filter) evaluating to 1.0/0.0. This keeps the f64-only evaluator and avoids introducing string comparison into eval_expr. The primitive already exists in the right shape -- types.rs reads context.get(\u0026filter.category), the current cell own coordinate.\n * A grammar production plus tokenizer handling in the 1109-line parser.rs, with quoting rules for the category and item inside it (gotcha 34 -- one home, improvise_formula::quote).\n * Arms in BOTH evaluators: eval_expr and eval_expr_cached.\n * Coverage in the grammar-walking generator.\n * Thread Dataset::columns (the ColumnSpec/ColumnKind plan) into the emitter, so feature index -\u003e role is read off preprocess own plan. Do NOT reconstruct it: Spec::names is \u0026[\u0026str], and reconstructing index-\u003erole is a second implementation of preprocess expansion order, exactly the drift risk gotcha 41 names in another key.\n * .improv is free -- the writer emits Formula::raw.\n\nIT CLOSES ONLY A THIRD OF 8xhy EITHER WAY. A WHERE predicate covers the one-hots. col.missing means \"this record has no value for measure X\", a different vocabulary. The date sub-features (when.year, when.month) need date arithmetic improvise does not have at all. Those stay refused, or get their own tickets.\n\nPAIRS WITH improvise-vx3 (the fit-measure command), which is where a user would actually learn about the restriction.","acceptance_criteria":"A fitted measure evaluated at an aggregating cell routes PER RECORD and aggregates the results, rather than routing on the aggregate. fit-measure on web/demo.improv succeeds and the resulting formula means the same thing on a record row and one row up. The seed-dependence is gone: whether the command succeeds no longer depends on which features the search happened to pick. Note what this costs — evaluate_aggregated deliberately does not iterate the records behind a formula cell — and state how it interacts with gotcha 9's fixed point and Model::formula_cache. col.missing and the date sub-features remain out of scope.","notes":"USER RULING 2026-09-20: AGGREGATE THE RULE OVER THE MEMBERS. Route-then-aggregate, per record. The other three options -- Error(\"aggregated\"), refuse at fit time, and fall through to aggregate_raw -- are all rejected.\n\nTHE BEAD'S SCOPE HAS CHANGED AND THE TITLE NOW SAYS SO. It asked what a CATEGORICAL split means in a cell that aggregates over the split category. PR #25's review established the question has nothing to do with the split being categorical:\n\nA tree is fitted on individual RECORDS. A formula is evaluated at a CellKey, and in any aggregating cell evaluate_aggregated aggregates each Expr::Ref FIRST and then evaluates the IF. A linear formula survives that -- SUM(Revenue) - SUM(Cost) is SUM(Revenue - Cost) -- and a tree does not, because AGGREGATE-THEN-ROUTE IS NOT ROUTE-THEN-AGGREGATE. So EVERY fitted tree, numeric splits included, means one thing on a record row and something else one row up.\n\nIT IS NOT THEORETICAL: fit-measure Revenue on the repo's own shipped web/demo.improv is refused, naming Product=Espresso/Product=Latte. And because which features a champion picks is a property of the SEARCH, on a document with several categories the same command refuses or succeeds DEPENDING ON THE SEED. That is not a contract.\n\nWHAT THE RULING COSTS, stated plainly because it is the expensive answer and was chosen anyway: evaluate_aggregated deliberately does NOT iterate the underlying records for a formula cell -- it aggregates the refs and evaluates once. Routing per record means a formula cell must be able to ask for the records behind it and fold over them. That touches the evaluator, and it interacts with gotcha 9 (fixed-point recomputation) and with Model::formula_cache, which is where derived values live precisely so raw data and derived data stay separate.\n\nNOTE WHAT IT BUYS BESIDES CORRECTNESS: it is what a user means. A fitted rule applied to a group should say what the rule says about the members, summed -- not what the rule says about a fictional average member. The second is the thing that looks right and is wrong, which is the failure mode vx3's own ticket warned about ('a rule the user will believe').\n\nALSO NOTE: this ruling supersedes improvise-8xhy's remedy 1 (refuse at emit) as the END STATE, but not as the interim -- the refusal stays until this lands, or fit-measure silently produces the wrong number in aggregating views.\n\nTHE TWO THIRDS THIS STILL DOES NOT COVER, unchanged from the original text: col.missing means 'this record has no value for measure X', a different vocabulary; and the date sub-features (when.year, when.month) need date arithmetic improvise does not have at all. Those stay refused or get their own beads.","status":"in_progress","priority":2,"issue_type":"feature","assignee":"wave3-4zl7","owner":"el-github@elangley.org","created_at":"2026-09-20T00:45:22Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:37:13Z","started_at":"2026-09-20T05:32:41Z","labels":["area:gp"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-hwpi","title":"The browser shell fails quietly, and never reads a key","description":"Found by the improvise-cr3 code review (2026-09-19) by READING web/app.js. There is no browser and no headless DOM in this environment, so none of this is observed -- it is what the code says will happen, and it is filed rather than fixed because each item is a judgement call about the shell rather than a defect in the Rust.\n\nFOUR THINGS, in descending order of how much they would cost a person at a demo:\n\n1. NO KEYBOARD AT ALL. #grid carries tabindex=\"0\" but the page registers no keydown handler, so Client::on_key_event -- and the whole keys.rs classifier and the lockstep machinery behind it -- is UNREACHED from the browser. Consistent with improvise-bpq2 (ClientMsg has no Key variant server-side) but larger than it: even a LOCAL key, one that needs no round trip, does nothing. improvise-1ey acceptance (3) is \"arrow keys move the cursor locally with no round-trip\", and nothing in the page can do it.\n\n2. A MALFORMED FRAME IS QUIET. client.on_frame(event.data) returns Result\u003c_, JsError\u003e, which wasm-bindgen turns into a THROWN EXCEPTION. It is thrown inside the message listener with no try, so it surfaces as an uncaught error in the devtools console and the following repaint() is skipped. Nothing in the UI says so: the grid simply stops updating. A try/catch writing to el.status would make it visible.\n\n3. NO error LISTENER ON THE SOCKET. A connection that never opens leaves #status blank -- only close writes \"disconnected\". So \"server not running\" and \"still connecting\" look the same.\n\n4. close() RUNS TWICE ON COMMIT, benignly. Enter -\u003e close(true) -\u003e td.replaceChildren(original) removes the focused \u003cinput\u003e -\u003e blur fires -\u003e close(false). editing is already null so it is harmless (one extra repaint), but there is no re-entry guard and only a real browser would show it.\n\nNOTE WHAT MAKES THIS TICKET DIFFERENT: items 2-4 are the kind of thing a test would catch instantly and we have no way to run one. Before fixing them, consider whether the right first move is a headless DOM in CI (jsdom or playwright) -- otherwise the fixes are as unverified as the bugs. improvise-xf1 (verify OpfsFs against a real browser) wants the same harness.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T00:40:36Z","created_by":"Edward Langley","updated_at":"2026-09-20T02:29:50Z","closed_at":"2026-09-20T02:29:50Z","close_reason":"All four items done, plus a fifth the ticket could not have predicted.\n\n1. KEYDOWN WIRED -- a window listener feeding Client::on_key_event. preventDefault fires only when a frame was produced and no Ctrl/Alt is held, so browser shortcuts stay the browser's; metaKey is skipped entirely, matching BrowserKey::to_key_event dropping Meta so Cmd-R cannot arrive as a bare r. The inline editor stopPropagation keeps it out of the way, with an if (editing) return as belt and braces.\n2. MALFORMED FRAME -- try/catch around on_frame writing to #status, plus console.error with the offending frame. render_model() is guarded the same way; it also returns a Result.\n3. SOCKET error LISTENER -- 'cannot reach ... -- is improvise-ws-server running?', so that no longer reads like 'still connecting'.\n4. close() RE-ENTRY GUARD -- a closed flag in edit().\n\nTHE FIFTH, AND IT IS THE INTERESTING ONE: a worker whose start() REJECTS is caught INSIDE worker.js, so it fires NO error event on the main thread. The page would have sat on 'in-page worker - demo.improv' over an empty grid forever -- THIS TICKET'S EXACT DISEASE, REBUILT IN THE NEW DEPLOYMENT ON DAY ONE. The guard is a first-frame watchdog (8s, cleared by the first frame), and it deliberately covers BOTH transports rather than each peer's own failure modes, because 'the peer never answered the handshake' is the one condition that means the same thing over a socket and over a port -- a check aimed per-peer would have missed exactly this one.\n\nKEYS ARE INERT AND THAT IS STATED, NOT HIDDEN. Host::handle answers ClientFrame::Key with an empty vec; improvise-bpq2 is the other half.\n\nSTILL UNRUN -- and this ticket predicted that would be the problem: 'consider whether the right first move is a headless DOM, otherwise the fixes are as unverified as the bugs.' That sentence is now true OF THESE FIXES. Three of the four are one-assertion Playwright tests. See the harness ticket.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-zv4m","title":"improvise-protocol should own the websocket framing, so both ends stop mirroring it","description":"Raised by improvise-cr3 (2026-09-19) as the thing to push back on, and filed rather than fixed there.\n\nTODAY: improvise-wasm-client/src/socket.rs declares ClientFrame/ServerFrame, which MIRROR improvise-ws-server/src/wire.rs ClientMsg/ServerMsg field for field. The client cannot depend on the server crate -- tokio, tungstenite, Box\u003cdyn Storage\u003e, all native -- so the duplication was the only way to speak the protocol from wasm.\n\nWHAT HOLDS THE LINE MEANWHILE: improvise-ws-server is a DEV-dependency of the client (the same argument the crate already makes for improvise-command), and the_socket_framing_matches_the_server round-trips every variant THROUGH THE SERVERS OWN TYPES in both directions; a_frame_named_the_other_envelopes_way_does_not_parse is its check-the-checker. So a drift is caught -- but only by a test whose job is to notice, rather than by a shared definition that cannot drift.\n\nTHE FIX: move the framing into improvise-protocol, which both ends already depend on and which is the crate whose charter is exactly this -- \"the wire vocabulary and the portable session slice\". Then the mirror, the dev-dependency and both compatibility tests all delete.\n\nNOTE THE THIRD ENVELOPE while you are in here: improvise-wasm-client/src/wire.rs has ClientMessage/ServerMessage framing the WORKER deployment (improvise-tm6). Three envelopes for two deployments is worth resolving in one pass -- decide whether the worker and the socket want the same vocabulary before moving either.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T00:37:29Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:37:50Z","closed_at":"2026-09-20T00:37:50Z","close_reason":"Duplicate of improvise-xks6 ('One home for the client/server frame envelopes'), filed minutes apart from the improvise-cr3 hand-back without spotting the existing ticket. Closing this one; xks6 is the survivor and its description should absorb two facts this one carried:\n\n * WHAT HOLDS THE LINE MEANWHILE: improvise-ws-server is a DEV-dependency of improvise-wasm-client, and the_socket_framing_matches_the_server round-trips every variant THROUGH THE SERVERS OWN TYPES in both directions, with a_frame_named_the_other_envelopes_way_does_not_parse as its check-the-checker. The drift is caught by a test whose job is to notice, rather than prevented by a shared definition.\n * THERE ARE THREE ENVELOPES, NOT TWO: improvise-wasm-client/src/wire.rs frames the WORKER deployment (improvise-tm6) separately again. Decide whether the worker and the socket want the same vocabulary before moving either, or the move produces two homes instead of one.\n\n(See also memory agent-issue-drift-pattern -- search before filing.)","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-8xhy","title":"[bug] A fit over a categorical feature emits an unevaluable formula","description":"Found during improvise-7na (2026-09-19), verified by probe, and pinned as a characterization test rather than fixed.\n\ngp_tree::data::preprocess one-hots a categorical column into a feature named Region=North. That is an ENCODING ARTEFACT, not an item of the document, so the emitted formula Expr::Ref fails Model::find_item_category and EVERY CELL of the fitted measure evaluates to Error(\"ref:Region=North\"). col.missing and the col.year / col.month date sub-features are in exactly the same position.\n\nSo \"fit a tree and get a working formula\" holds ONLY for a fit over numeric measures.\n\nTHE TEST THAT SHOULD HAVE CAUGHT IT WAS LYING: improvise_evaluation_matches_gp_tree_eval cannot see this, because its improvise_eval helper FABRICATES a _Measure item for every feature name, including outlook=rainy. It asserts the emitter and gp_tree::eval agree, which they do -- against a model that could not exist. Worth remembering as a shape: a helper that builds the world the code wants is not a test of whether that world exists.\n\nPinned as a_split_on_a_one_hot_feature_does_not_evaluate -- characterization, flip it when fixed.\n\nTHREE REMEDIES, pick deliberately: restrict fits to numeric features (smallest, and honest about what works); materialise one-hot columns as real _Measure items (makes the formula evaluable but pollutes the document with encoding artefacts); or teach the emitter improvise WHERE vocabulary so a categorical split becomes a real predicate over the category (largest, and the only one that produces a formula a human would have written). The third is probably right and should be costed before the first is shipped as permanent.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T00:35:05Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:45:21Z","closed_at":"2026-09-20T00:45:21Z","close_reason":"Fixed as REMEDY 1, explicitly labelled interim. fitted_formula now takes the \u0026Model the formula is to be stored in and refuses a tree whose splits it cannot resolve, listing them. formula_text is untouched -- it is the tree's own text and there is nothing else it could write. Nothing outside the crate called fitted_formula.\n\nWHY THE EMITTER AND NOT THE DATASET LAYER: dropping categorical columns before the fit is smaller, but it throws away real signal (permutation importance, and any future remedy-3 emitter) and makes the crate's halves disagree -- one-hot encoding is gp-tree's whole job. The defect is that the EMITTER spells a formula the document cannot evaluate.\n\nWHY THE CHECK ASKS THE DOCUMENT, NOT THE NAME'S SHAPE: refusing names containing '=' or matching .missing would be a syntactic proxy and a wrong one, because a measure LITERALLY NAMED Region=North is a legal document and must be accepted. The two are indistinguishable as strings; only the document can tell them apart. So it calls Model::find_item_category -- the exact precondition Expr::Ref has -- which covers .missing, the date parts, and any future sub-feature naming scheme with no second copy of preprocess's expansion rules to drift from. Both directions pinned.\n\nTHE LYING TEST IS KEPT, NOT DELETED. Quoting and threshold routing are real things improvise_evaluation_matches_gp_tree_eval checks, and outlook=rainy earns its place there as an awkward NAME TO SPELL. What changed is that it and its helper now state their scope -- a test of arithmetic and quoting against a STIPULATED model, unable to see whether such a model could exist. The property it could not state is asserted separately against a real document: a_formula_the_emitter_accepted_evaluates_everywhere -- if fitted_formula says Ok, no cell of the target is a ref: error. That test and the refusal test are each other's check; without the accepting case the refusal could pass by refusing everything. The characterization is flipped to a_split_on_a_one_hot_feature_is_refused.\n\nREMEDY 3 IS STILL RIGHT AND IS NOT THIS TICKET -- successor filed. It is much larger than it looked: improvise has NO SCALAR COORDINATE PREDICATE (Filter gates a formula or an aggregate, it is not an Expr), it would still leave col.missing and the date parts uncovered, and it must first answer what such a predicate means where context.get(cat) is None.","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -167,13 +168,20 @@ {"_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-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":"open","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-20T18:53:08Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-9iyv","title":"The route-then-aggregate fold is unbenchmarked","description":"improvise-4zl7 made `SUM(\u003cexpr\u003e)` fold over the member records rather than over\nthe raw matching cells. The fold is not cached and not memoised: for one\naggregated formula cell it walks `matching_cells(partial)`, dedupes the record\ntuples through an `IndexSet`, and evaluates the inner expression once per\nrecord. `recompute_formulas` runs that for every stem, every formula target, and\nup to `MAX_EVAL_DEPTH` (16) passes.\n\nNothing here is known to be slow. Nothing here has been measured either, and the\nwork is O(cells + members × expression size) per cell per pass where the\nprevious code was one `matching_values` scan. A routed formula over a document\nwith thousands of records is the shape to measure.\n\nNote what must NOT be \"fixed\" by memoising carelessly: `formula_cache` holds\nderived values and the fold deliberately reads *raw* data through the same\n`evaluate_depth` / `aggregate_raw` path the non-folding evaluator uses, which is\nwhy the fixed point still converges (gotcha 9, context/code/core.md 3 and 13). A\nper-record memo keyed by anything less than the whole record key would be a\nsecond answer to \"what does this cell depend on\".\n\n## Steps to Reproduce\n\n1. Build a document with several thousand records — `examples/gen-grammar.rs`\n or a CSV import will do.\n2. Add a routing formula over them, e.g.\n `Pred = SUM(IF(Cost \u003c 50, 1, 10))`, or run `fit-measure` and store the\n result.\n3. Time `Model::recompute_formulas`, and compare against the same document with\n a linear formula (`Profit = Revenue - Cost`), which takes the `Expr::Ref`\n fast path and does not fold.\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.","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-20T18:51:21Z","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":"open","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-20T18:50:51Z","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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:45:39Z","created_by":"wave2-h-frontend","updated_at":"2026-09-20T18:58:18Z","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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:33:24Z","created_by":"wave2-e-active-view","updated_at":"2026-09-20T18:33:24Z","labels":["area:active-view"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-w3bf","title":"A session mirror's active_view is written in four places, none through reduce_view","description":"Found by the adversarial review of PR #48 (improvise-jytm). Not a defect today: the\nfour writers agree. It is the SHAPE the divergence bugs in this area have taken every\ntime, filed before it becomes one.\n\n`ClientSession::view.active_view` — the server's mirror of a client's view — is\nwritten in four places, and none of them goes through `reduce_view`:\n\n1. `Sessions::apply_switch` — sets it directly from the accepted name.\n2. `Sessions::adopt_deleted_views` — sets it directly to the document default.\n3. `ClientSession::reseed` — replaces the whole slice via `fresh_view`.\n4. `Sessions::connect` — same, through `reseed`.\n\nEverywhere else in this codebase, the mirror stays in step with the client by folding\nthe SAME command the client folds (`ClientSession::fold` → `reduce_view`, the\n\"same function runs on both ends\" rule of improvise-cqq/m91). These four bypass it.\nThey are correct today only because `reduce_view`'s `SwitchView` arm is one line\n(`view.active_view = name.clone()`) and each of these reproduces it.\n\nThe day that arm grows a second statement — clearing a per-view buffer, resetting a\ncursor, anything — the mirror and the client diverge and nothing fails. That is\nexactly gotcha 36's \"folding SwitchView into the mirror alone was the bug\", arriving\nfrom the other direction.\n\nTwo shapes worth weighing:\n\n- Route all four through `reduce_view(\u0026mut session.view, \u0026Command::SwitchView(name))`.\n Cheap, and makes the bypass unrepresentable for this command.\n- A test that the two agree: fold a `SwitchView` into a bare `ViewState` and into a\n `ClientSession` and compare. Weaker (it is a compatibility test, and\n `doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)` says\n those are a definition waiting to be made) but it costs nothing.\n\n## Steps to Reproduce\n\nNot reproducible today; the four copies agree. To make it reproduce: add any second\nstatement to `reduce_view`'s `Command::SwitchView` arm in\n`crates/improvise-protocol/src/reduce.rs` — for instance clearing `status_msg` — then\nrun the suite. It stays green, and a client's own slice now carries something its\nserver-side mirror does not.\n","acceptance_criteria":"Either the four writers route through reduce_view, or a test pins that a ClientSession's mirror and a bare ViewState fold a SwitchView identically. Closing it as 'the copies agree' is not acceptable — that is the state it is filed in.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:32:43Z","created_by":"wave2-e-active-view","updated_at":"2026-09-20T18:32:43Z","labels":["area:active-view"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_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.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:23:51Z","created_by":"orchestrator","updated_at":"2026-09-20T19:08:21Z","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":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:11:01Z","created_by":"wave2-b-projection","updated_at":"2026-09-20T18:11:01Z","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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:06:03Z","created_by":"orchestrator","updated_at":"2026-09-20T18:06:03Z","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.","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-20T18:06:00Z","labels":["area:active-view"],"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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:05:51Z","created_by":"orchestrator","updated_at":"2026-09-20T18:05:51Z","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":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:04:14Z","created_by":"wave2-h-frontend","updated_at":"2026-09-20T18:04:14Z","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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:01:04Z","created_by":"wave2-p-milestones","updated_at":"2026-09-20T18:01:04Z","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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T17:57:25Z","created_by":"wave2-n-cleanups","updated_at":"2026-09-20T17:57:25Z","labels":["area:command"],"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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T17:57:25Z","created_by":"wave2-n-cleanups","updated_at":"2026-09-20T17:57:25Z","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-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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T07:49:56Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:57:52Z","labels":["area:protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -257,7 +265,7 @@ {"_type":"issue","id":"improvise-jjx","title":"Scrolled grid suppresses first visible row-header label","description":"src/ui/grid.rs:338-350: repeat-suppression shows a row-header sub-label only when ri == 0 or the prefix differs from data_row_items[ri-1]. ri is the absolute data-row index (starts at row_offset, grid.rs:269), so when the view is scrolled the first visible row compares against the off-screen row above and its label is blanked — the user loses the group context at the top of the viewport. Fix: compare against the previous *visible* row, i.e. always show labels for the first rendered data row. 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:58:38Z","created_by":"Edward Langley","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-byz","title":"records_display returns Some(\"\") for out-of-bounds columns instead of None","description":"crates/improvise-core/src/view/layout.rs:186-202: records_display() calls col_label(col), which returns an empty string for out-of-bounds indices; the empty string then fails the == \"Value\" check, is looked up as a category name, and the function returns Some(\"\") via unwrap_or_default(). cell_key() (~line 396) explicitly returns None for an empty col_label — records_display should be symmetric so view/data mismatches are not masked. 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:58:35Z","created_by":"Edward Langley","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-zgb","title":".improv formula loader mis-splits category suffix via rfind(\" [\")","description":"crates/improvise-io/src/persistence/mod.rs:403-411: the formulas_section walker finds the [Category] suffix with raw.rfind(\" [\") + ends_with(']'). Two failure modes: (1) a pipe-quoted identifier at the end of the formula containing ' [' (e.g. |Revenue [USD]|) is mis-split, treating part of the quoted name as the category; (2) a suffix written without a leading space (X=Y[Cat]) is not recognized and the whole line is parsed as a _Measure formula, losing the target category. Fix: move the category suffix into the pest grammar instead of string scanning. Write failing tests first per AGENTS.md. 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:58:32Z","created_by":"Edward Langley","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-drg","title":"vb4 step 5: narrow Effect::apply signatures where one-slice","description":"Optional follow-up to vb4 structural split. For each impl Effect for Foo, audit which slice(s) it mutates. If apply only touches ModelState (or only ViewState), narrow its signature from fn apply(\u0026self, app: \u0026mut App) to fn apply(\u0026self, model: \u0026mut ModelState) (or ViewState). Cross-cutting effects (read layout, change mode that triggers layout rebuild, etc.) stay on \u0026mut App. Improves type-level documentation and enables finer-grained effect tests. Lower priority — vb4's acceptance criterion (4) explicitly allows leaving cross-cutting on \u0026mut App.","design":"Hidden trait-design decision surfaced during vb4 step 4 review: the issue's\nphrasing — \"narrow its signature from fn apply(\u0026self, app: \u0026mut App) to\nfn apply(\u0026self, model: \u0026mut ModelState)\" — is not directly possible. You\ncannot change one Effect impl's signature without changing the Effect\ntrait. Three approaches, none picked:\n\n(A) Split into multiple traits — ModelEffect / ViewEffect / AppEffect.\n Each impl becomes the narrower thing it actually is. Cost: ~50\n existing impls reorganize across three traits, and Vec\u003cBox\u003cdyn Effect\u003e\u003e\n has to become an enum wrapper (or three parallel queues) to keep\n apply_effects working. Highest fidelity to the issue's literal wording.\n\n(B) Keep one trait, rebind inside the impl body — e.g.\n fn apply(\u0026self, app: \u0026mut App) { let view = \u0026mut app.view_state; view.mode = ... }\n so the slice is visible at the call site without changing the\n signature. Lowest disruption. Doesn't strictly satisfy acceptance\n criterion (1) (a \"narrower borrow\") — only the body is narrower.\n\n(C) Default-method dispatch on a single trait — Effect grows\n apply_view(\u0026mut ViewState) and apply_model(\u0026mut ModelState) with\n default impls that fall through to apply(\u0026mut App). Each impl\n overrides only the method that matches its slice. Middle ground:\n type-level narrowing for impls that opt in, \u0026mut App stays available\n for cross-cutting effects. Wraps cleanly around the existing\n apply_effects loop.\n\nPicking (A) commits to the architecture for downstream effect tagging\n(improvise-mae) and the protocol extraction (improvise-cqi). (B) and (C)\nleave that decision unmade. Recommend deciding before claiming.","acceptance_criteria":"Each Effect whose apply touches only one slice takes \u0026mut ModelState or \u0026mut ViewState; cross-cutting effects stay on \u0026mut App, which vb4's own acceptance explicitly allows. The win is type-level documentation and finer-grained effect tests, not line count. CAUTION, new since this ticket was filed: App::apply_effects now reads dirtiness, layout_version and the record fingerprints off the EffectKind (improvise-gv9r, gotcha 40) — narrowing a signature must not put an effect somewhere that loop cannot see.","notes":"DECLINED 2026-09-20 (wave 2), with counts. Reasoning in context/code/command.md,\n'Effect::apply stays on \u0026mut App'. NOTE: this bead carries the `human` label, so\nthe decline is a recommendation the user may overturn; it is recorded rather than\nsmuggled.\n\nCOUNTED over the 49 `impl Effect` blocks in ui/effect.rs:\n now (this branch) 8 view_state-only, 9 model_state-only, 32 cross-cutting\n base 52507a6 9 view_state-only, 16 model_state-only, 24 cross-cutting\nThe narrow set fell from 25 to 17 in one wave. The mover was improvise-9mo9,\nwhich made nine reshaping effects call app.reset_cursor(): a reshape lives in\nmodel_state.workbook and the cursor it invalidates lives in ViewState, so the\neffects that matter most are precisely the ones that reconcile the two slices.\nNarrowing bets against the direction the code moves in.\n\nFOUR GROUNDS\n1. The narrow set is small and shrinking, for that architectural reason.\n2. The type-level documentation already exists and is CHECKED: Effect::kind()\n has no default (gotcha 3) and apply_effects reads that one answer three times\n (gotchas 40, 45). A narrowed signature is a second statement of the same fact,\n free to disagree -- the defect, not the cure.\n3. Every offered mechanism weakens a guard. (A) three traits needs three queues\n (destroys batch order, which gotchas 21/23/43 lean on) or an enum wrapper (a\n fourth classification beside EffectKind). (C) default methods give `apply` a\n DEFAULT, exactly what kind() does without and for the same reason: overriding\n apply_view on an effect that needs the model compiles and writes the wrong\n slice silently; overriding neither compiles as a no-op. (B) does not meet the\n ticket's own acceptance criterion.\n4. \"Finer-grained effect tests\" is not blocked: effect.rs's 64 tests already call\n Effect.apply(\u0026mut app) against a one-line App::new(wb, None) fixture.\n\nWHAT IS NOT DECLINED: approach (B), rebinding the slice at the top of an apply\nbody, remains available as a per-function style choice. It is not mandated.","status":"closed","priority":3,"issue_type":"task","assignee":"wave2-n-cleanups","owner":"el-github@elangley.org","created_at":"2026-04-26T18:16:52Z","created_by":"jeeves","updated_at":"2026-09-20T18:11:26Z","started_at":"2026-09-20T18:08:16Z","closed_at":"2026-09-20T18:11:26Z","close_reason":"Closed","labels":["area:command","human"],"dependencies":[{"issue_id":"improvise-drg","depends_on_id":"improvise-99k","type":"blocks","created_at":"2026-04-26T11:17:16Z","created_by":"jeeves","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-drg","title":"vb4 step 5: narrow Effect::apply signatures where one-slice","description":"Optional follow-up to vb4 structural split. For each impl Effect for Foo, audit which slice(s) it mutates. If apply only touches ModelState (or only ViewState), narrow its signature from fn apply(\u0026self, app: \u0026mut App) to fn apply(\u0026self, model: \u0026mut ModelState) (or ViewState). Cross-cutting effects (read layout, change mode that triggers layout rebuild, etc.) stay on \u0026mut App. Improves type-level documentation and enables finer-grained effect tests. Lower priority — vb4's acceptance criterion (4) explicitly allows leaving cross-cutting on \u0026mut App.","design":"Hidden trait-design decision surfaced during vb4 step 4 review: the issue's\nphrasing — \"narrow its signature from fn apply(\u0026self, app: \u0026mut App) to\nfn apply(\u0026self, model: \u0026mut ModelState)\" — is not directly possible. You\ncannot change one Effect impl's signature without changing the Effect\ntrait. Three approaches, none picked:\n\n(A) Split into multiple traits — ModelEffect / ViewEffect / AppEffect.\n Each impl becomes the narrower thing it actually is. Cost: ~50\n existing impls reorganize across three traits, and Vec\u003cBox\u003cdyn Effect\u003e\u003e\n has to become an enum wrapper (or three parallel queues) to keep\n apply_effects working. Highest fidelity to the issue's literal wording.\n\n(B) Keep one trait, rebind inside the impl body — e.g.\n fn apply(\u0026self, app: \u0026mut App) { let view = \u0026mut app.view_state; view.mode = ... }\n so the slice is visible at the call site without changing the\n signature. Lowest disruption. Doesn't strictly satisfy acceptance\n criterion (1) (a \"narrower borrow\") — only the body is narrower.\n\n(C) Default-method dispatch on a single trait — Effect grows\n apply_view(\u0026mut ViewState) and apply_model(\u0026mut ModelState) with\n default impls that fall through to apply(\u0026mut App). Each impl\n overrides only the method that matches its slice. Middle ground:\n type-level narrowing for impls that opt in, \u0026mut App stays available\n for cross-cutting effects. Wraps cleanly around the existing\n apply_effects loop.\n\nPicking (A) commits to the architecture for downstream effect tagging\n(improvise-mae) and the protocol extraction (improvise-cqi). (B) and (C)\nleave that decision unmade. Recommend deciding before claiming.","acceptance_criteria":"Each Effect whose apply touches only one slice takes \u0026mut ModelState or \u0026mut ViewState; cross-cutting effects stay on \u0026mut App, which vb4's own acceptance explicitly allows. The win is type-level documentation and finer-grained effect tests, not line count. CAUTION, new since this ticket was filed: App::apply_effects now reads dirtiness, layout_version and the record fingerprints off the EffectKind (improvise-gv9r, gotcha 40) — narrowing a signature must not put an effect somewhere that loop cannot see.","notes":"DECLINED 2026-09-20 (wave 2), with counts. Reasoning in context/code/command.md,\n'Effect::apply stays on \u0026mut App'. NOTE: this bead carries the `human` label, so\nthe decline is a recommendation the user may overturn; it is recorded rather than\nsmuggled.\n\nCOUNTED over the 49 `impl Effect` blocks in ui/effect.rs:\n now (this branch) 8 view_state-only, 9 model_state-only, 32 cross-cutting\n base 52507a6 9 view_state-only, 16 model_state-only, 24 cross-cutting\nThe narrow set fell from 25 to 17 in one wave. The mover was improvise-9mo9,\nwhich made nine reshaping effects call app.reset_cursor(): a reshape lives in\nmodel_state.workbook and the cursor it invalidates lives in ViewState, so the\neffects that matter most are precisely the ones that reconcile the two slices.\nNarrowing bets against the direction the code moves in.\n\nFOUR GROUNDS\n1. The narrow set is small and shrinking, for that architectural reason.\n2. The type-level documentation already exists and is CHECKED: Effect::kind()\n has no default (gotcha 3) and apply_effects reads that one answer three times\n (gotchas 40, 45). A narrowed signature is a second statement of the same fact,\n free to disagree -- the defect, not the cure.\n3. Every offered mechanism weakens a guard. (A) three traits needs three queues\n (destroys batch order, which gotchas 21/23/43 lean on) or an enum wrapper (a\n fourth classification beside EffectKind). (C) default methods give `apply` a\n DEFAULT, exactly what kind() does without and for the same reason: overriding\n apply_view on an effect that needs the model compiles and writes the wrong\n slice silently; overriding neither compiles as a no-op. (B) does not meet the\n ticket's own acceptance criterion.\n4. \"Finer-grained effect tests\" is not blocked: effect.rs's 64 tests already call\n Effect.apply(\u0026mut app) against a one-line App::new(wb, None) fixture.\n\nWHAT IS NOT DECLINED: approach (B), rebinding the slice at the top of an apply\nbody, remains available as a per-function style choice. It is not mandated.\nUSER CONFIRMATION 2026-09-20: the decline is CONFIRMED and the bead stays closed. This bead carries the 'human' label, so the WU-N agent correctly recorded its decline as a recommendation rather than acting on it unilaterally; the user was shown the counts (8 view-only / 9 model-only / 32 cross-cutting of 49, down from 9/16/24 at the wave base) and all three approaches (A: three traits; B: rebind in the body; C: default-method dispatch), and chose to confirm the decline.\n\nThe reasoning the user accepted: the classification ALREADY EXISTS and is checked — Effect::kind has no default and apply_effects reads that one answer three times — so a narrowed signature is a second statement free to disagree with it. Three traits need three queues (destroying batch order) or an enum wrapper (a fourth classification). Default methods would give apply a DEFAULT whose wrong answer is silent, which is precisely what kind()'s no-default rule exists to prevent.\n\nThe fact worth carrying past this bead: improvise-9mo9 moved nine effects from 'touches one slice' to 'cross-cuts' INSIDE THE SAME BRANCH that declined this, and that is not a coincidence to route around. The App exists because the grid's shape lives in model_state.workbook and the cursor into it lives in ViewState, so the effects that matter most are exactly the ones that RECONCILE the two slices. A design that rewards single-slice effects would have been pulling against the repo's actual centre of gravity.\nCOUNTS CORRECTED 2026-09-20, after adversarial review of PR #49. The decline\nstands; the numbers it was argued on were wrong twice and the user confirmed it\npartly on them, so the correction is recorded loudly rather than restated.\n\nPUBLISHED (wrong): 49 effects; 8 view-only, 9 model-only, 32 cross; narrow set\nfell 25 -\u003e 17.\nCORRECT: 57 effects; 16 view-only, 9 model-only, 32 cross; narrow set\nfell 32 -\u003e 25. Base 52507a6 was 16/16/25.\n\nTwo errors. (a) It counted only the 49 `impl Effect` blocks and omitted the 8\n`simple_effect!` invocations, which also expand to an impl and are ALL view-only.\n(b) It misclassified AddItem at the base commit, because the extraction regex\ncould not see `app` followed by a newline and `.model_state` -- which is\ndoctrine(derive_then_verify) catching the person quoting it, with the exact\nfailure that doctrine was written about.\n\nWHAT THIS COSTS THE ARGUMENT: 25 of 57 is 44% of effects that could narrow, not\nthe 35% claimed; and the fall is 22%, not 32%. The direction is unchanged and is\nstill the point -- the seven movers are SetAxis, HideItem, ShowItem, ToggleGroup,\nTogglePruneEmpty, RemoveItem and RemoveCategory, every one an effect\nimprovise-9mo9 gave a reset_cursor, so the shrinkage is architectural rather than\nincidental. But the headline was overstated by about half again, and anyone\nre-opening this should argue from 32 -\u003e 25 of 57.\n\nThe other three grounds for the decline are untouched by this: the classification\nalready exists and is checked; three traits break batch order and default methods\nreintroduce a silent default; and finer-grained tests are not blocked.","status":"closed","priority":3,"issue_type":"task","assignee":"wave2-n-cleanups","owner":"el-github@elangley.org","created_at":"2026-04-26T18:16:52Z","created_by":"jeeves","updated_at":"2026-09-20T18:57:42Z","started_at":"2026-09-20T18:08:16Z","closed_at":"2026-09-20T18:11:26Z","close_reason":"Closed","labels":["area:command","human"],"dependencies":[{"issue_id":"improvise-drg","depends_on_id":"improvise-99k","type":"blocks","created_at":"2026-04-26T11:17:16Z","created_by":"jeeves","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-1ud","title":"Document RecordsEditing variant in repo-map.md","description":"context/repo-map.md currently lists 15 AppMode variants but does not include RecordsEditing, which is referenced at src/draw.rs:142 and src/ui/app.rs:442. Add the variant to the AppMode list in the 'Key Types' section so coding agents see it.\\n\\nTrivial doc fix.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:54:16Z","created_by":"Ed L","updated_at":"2026-06-10T04:05:13Z","closed_at":"2026-06-10T04:05:13Z","close_reason":"Done in commit 0fe04de (2026-06-09 repo-map refresh): AppMode list now reads 16 variants including RecordsNormal and RecordsEditing, and notes MinibufferConfig + the ModelState/ViewState split.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-8fy","title":"Introduce simple_effect! macro for single-field setter effects","description":"crates/improvise-command/src/ui/effect.rs has ~15 simple setter effects (SetSelected, SetRowOffset, SetColOffset, SetYanked, SetSearchQuery, SetSearchMode, SetStatus, etc.) that each implement the same 4-line apply: pub struct SetX(pub T); impl Effect { fn apply(\u0026self, app: \u0026mut App) { app.field = self.0.clone(); } }.\\n\\nAbstraction: simple_effect!(SetRowOffset, usize, row_offset) macro generates struct + Effect impl.\\n\\nCorrectness win: provides a single hook for cross-cutting concerns. If layout-affecting setters need to mark dirty or rebuild layout, one macro body covers all of them. Currently each setter independently decides whether to mark_dirty(), and silent omissions are plausible. ~50 LOC.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:54:14Z","created_by":"Ed L","updated_at":"2026-09-19T20:35:54Z","closed_at":"2026-09-19T20:35:54Z","close_reason":"simple_effect! macro added beside the Effect trait; eight single-field ViewOnly setters declared through it (-42 lines net). The macro HARD-CODES EffectKind::ViewOnly by design: an effect other clients must see is not 'simple' and keeps a hand-written impl, which is how gotcha 3's no-default-kind rule survives a macro at all. SetSelected (two fields) and SetNumberFormat (a Model change) stay hand-written. Existing effect tests already cover all eight, including view_only_effects_leave_shared_state_alone.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-1wu","title":"Extract axis label helper in view/layout.rs","description":"crates/improvise-core/crates/improvise-core/src/view/layout.rs:323-336 (row_label) and :338-351 (col_label) are character-identical except for which items list they read. Small syntactic duplication but trivial to consolidate.\\n\\nFix: fn axis_label(items: \u0026[AxisEntry], idx: usize) -\u003e String, call from both. ~14 LOC.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:54:13Z","created_by":"Ed L","updated_at":"2026-08-14T01:11:07Z","closed_at":"2026-08-14T01:11:07Z","dependencies":[{"issue_id":"improvise-1wu","depends_on_id":"improvise-ai7","type":"supersedes","created_at":"2026-08-13T18:11:07Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -283,10 +291,13 @@ {"_type":"issue","id":"improvise-l36","title":"3.2 Publish to crates.io","description":"After cargo publish --dry-run is clean and user confirms, run cargo publish. Verify crate appears at crates.io/crates/improvise and cargo install improvise works.","acceptance_criteria":"improvise is on crates.io and 'cargo install improvise' works from a clean machine. NEEDS THE USER: crates.io credentials, and their confirmation after 'cargo publish --dry-run' is clean. Note the workspace has grown to nine crates since this was filed — decide which are published and in what order, since a dependent cannot publish before its dependency, and improvise-io/protocol/command are all now real crates rather than internal modules.","notes":"DRY RUN ACTUALLY RUN, 2026-09-20 at 52507a6 (wave 2, WU-P). It is NOT clean,\nand the blocker is mechanical rather than a judgement call:\n\n error: failed to verify manifest at Cargo.toml\n all dependencies must have a version requirement specified when publishing.\n dependency `gp-tree` does not specify a version\n\ncargo stops at the FIRST offender, and gp-tree is not the only one. Every\ninter-crate dependency in this workspace is `{ path = \"...\" }` with no\n`version`, which crates.io forbids for normal dependencies:\n\n Cargo.toml (root) gp-tree, improvise-core, improvise-formula,\n improvise-io, improvise-command\n improvise-core improvise-formula\n improvise-formula (none)\n improvise-protocol improvise-core\n improvise-io improvise-core, improvise-formula\n improvise-gp gp-tree, improvise-core, improvise-formula\n improvise-command improvise-core, improvise-protocol,\n improvise-formula, improvise-io, improvise-gp\n\nDev-dependencies are exempt — cargo strips them on publish — so the root's\n`improvise-wasm-client`/`improvise-protocol` dev deps and improvise-wasm-\nclient's dev dep on the unpublishable improvise-ws-server are fine as they are.\n\nWHICH CRATES PUBLISH. `improvise-ws-server` and `improvise-worker-server` both\ncarry `publish = false` already, so the set is eight, and a dependent cannot go\nbefore its dependency. The only order that works:\n\n 1. improvise-formula (no intra-workspace deps)\n 2. improvise-core\n 3. gp-tree (no intra-workspace deps; can go any time before 4)\n 4. improvise-gp\n 5. improvise-protocol\n 6. improvise-io\n 7. improvise-command\n 8. improvise (the binary)\n\ncrates.io needs a minute or two to index each one before the next can resolve\nit, so this is eight sequential `cargo publish` calls, not one.\n\nTWO VERSION FACTS THE USER MUST DECIDE. `gp-tree` is at `0.1.0` while every\nother crate is at `0.1.0-rc2`; and a `-rc2` version requirement in a published\nmanifest pins consumers to a prerelease. If the intent is to publish 0.1.0\n(which improvise-3tj's tag implies), the version bump across ten manifests\nshould happen BEFORE the version requirements are written, or they will be\nwritten twice.\n\nNAME AVAILABILITY CHECKED: crates.io returns \"crate `improvise` does not exist\",\nso the name is free. Not checked: the other seven names.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:58Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:01:38Z","labels":["area:release","needs:user"],"dependencies":[{"issue_id":"improvise-l36","depends_on_id":"improvise-11a","type":"blocks","created_at":"2026-04-08T21:09:29Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-l36","depends_on_id":"improvise-2fr","type":"blocks","created_at":"2026-04-08T21:09:28Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":2,"comment_count":0} {"_type":"issue","id":"improvise-11a","title":"3.1 Configure cargo dist","description":"Run cargo dist init targeting x86_64-unknown-linux-gnu, aarch64-apple-darwin, x86_64-apple-darwin. Skip Windows and musl. Commit generated .github/workflows/release.yml and Cargo.toml additions. Test with v0.1.0-rc1 tag, verify builds, delete rc tag.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:55Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-11T07:49:57Z","close_reason":"Closed","dependencies":[{"issue_id":"improvise-11a","depends_on_id":"improvise-abz","type":"blocks","created_at":"2026-04-08T21:09:28Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":3,"comment_count":0} {"_type":"issue","id":"improvise-0s6","title":"Phase 3: Distribution","description":"Configure cargo dist, publish to crates.io, tag v0.1.0 release. Produces prebuilt binaries for Linux x86_64 and macOS (Intel + Apple Silicon).","acceptance_criteria":"Closes when improvise-l36 and improvise-3tj close. Both need the user; nothing here is agent work. improvise-11a (cargo dist) is already closed.","notes":"CHECKED, wave 2 WU-P: STAYS OPEN, correctly — both children need the user and\nneither was touched. Nothing was published and nothing was tagged.\n\nWhat this pass DID do is make them ready, and one of them is not as ready as\nit looked. `cargo publish --dry-run` was actually run and FAILS: every\ninter-crate dependency in the workspace is `{ path = ... }` with no `version`,\nwhich crates.io forbids. cargo reports only the first offender (gp-tree), so\nthe dry run understates it — the full list, the publish order for the eight\npublishable crates, and the gp-tree/rc2 version disagreement are on\nimprovise-l36.\n\nimprovise-3tj's premise is already partly false and its note now says so: the\nREADME prebuilt-binaries link is not a placeholder, v0.1.0-rc2 carries real\ncargo-dist artifacts for all four targets, and what is actually missing is the\nv0.1.0 tag and the manifest bump that must precede it.","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-09T04:05:51Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:05:07Z","labels":["area:release","needs:user"],"dependencies":[{"issue_id":"improvise-0s6","depends_on_id":"improvise-11a","type":"blocks","created_at":"2026-04-08T23:37:39Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-0s6","depends_on_id":"improvise-3tj","type":"blocks","created_at":"2026-04-08T23:37:41Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-0s6","depends_on_id":"improvise-l36","type":"blocks","created_at":"2026-04-08T23:37:40Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":3,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-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":"open","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-20T18:58:19Z","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":"open","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-20T18:45:36Z","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.","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-20T18:48:10Z","labels":["area:active-view"],"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":"open","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:14:57Z","created_by":"orchestrator","updated_at":"2026-09-20T18:14:57Z","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":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:13:31Z","created_by":"wave2-b-projection","updated_at":"2026-09-20T18:13:31Z","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-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":"TWO ADDITIONS, both instances of this ticket's one fact rather than new ones.\n\nFIRST, WHAT A CHECK MUST ACTUALLY DO — the part that gets lost, and the reason\nthis ticket is not a one-liner. Counting the four `.ap-player` elements PROVES\nNOTHING. `autoPlay` is false, so asciinema-player does not fetch a cast until\nthe viewer presses play; a page whose every cast path 404s constructs four\nplayers, throws no page error before the click, and is indistinguishable from a\nworking one. This was verified by injecting exactly that fault (`./casts/` -\u003e\n`./cats/`): the construction-only check passed on the broken page, and pressing\nplay returned 0 rendered lines and surfaced four 404s. So the spec must press\nplay on each of the four and assert a non-zero rendered line count, and its own\nfault injection must be recorded in `context/test-evidence/`.\n\nThe same applies to the page's one functional link. Since the user's ruling\n(improvise-kh8) the landing page IS the site root and its whole job is to reach\nthe app at `./app/`; a broken link there is a dead front door, and the page\nlooks entirely correct with one. Proved able to fail by pointing it at `./aap/`\n-- 404 while nothing on screen changes -- so the spec should resolve the\ncall-to-action href and assert the response status.\n\nSECOND, THE LINE BUDGET, which is an instance of \"asserted by nothing\" and not\na separate defect. improvise-e61's criterion is \"under 200 lines\" and\n`docs/index.html` is now 199 after the root/app restructure. A criterion met by\none line, with nothing checking it, is met by accident: the next person to touch\nthe file either trims something or silently breaks it, and no test, lint or CI\njob would say so. If this ticket's spec lands, the cheapest place for the budget\nis beside it -- a line count assertion is one statement and needs no browser.\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/` -- the landing page renders and the casts play,\n and no test in the repository would have noticed if they had not.\n4. Break a cast path in `docs/index.html`, repeat, and observe that the page is\n still structurally correct until a play button is pressed.\n5. `wc -l docs/index.html` -- 199, against a stated limit of 200, checked by\n nothing.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:01:16Z","created_by":"wave2-p-milestones","updated_at":"2026-09-20T18:14:01Z","labels":["area:release"],"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":"TWO ADDITIONS, both instances of this ticket's one fact rather than new ones.\n\nFIRST, WHAT A CHECK MUST ACTUALLY DO — the part that gets lost, and the reason\nthis ticket is not a one-liner. Counting the four `.ap-player` elements PROVES\nNOTHING. `autoPlay` is false, so asciinema-player does not fetch a cast until\nthe viewer presses play; a page whose every cast path 404s constructs four\nplayers, throws no page error before the click, and is indistinguishable from a\nworking one. This was verified by injecting exactly that fault (`./casts/` -\u003e\n`./cats/`): the construction-only check passed on the broken page, and pressing\nplay returned 0 rendered lines and surfaced four 404s. So the spec must press\nplay on each of the four and assert a non-zero rendered line count, and its own\nfault injection must be recorded in `context/test-evidence/`.\n\nThe same applies to the page's one functional link. Since the user's ruling\n(improvise-kh8) the landing page IS the site root and its whole job is to reach\nthe app at `./app/`; a broken link there is a dead front door, and the page\nlooks entirely correct with one. Proved able to fail by pointing it at `./aap/`\n-- 404 while nothing on screen changes -- so the spec should resolve the\ncall-to-action href and assert the response status.\n\nSECOND, THE LINE BUDGET, which is an instance of \"asserted by nothing\" and not\na separate defect. improvise-e61's criterion is \"under 200 lines\" and\n`docs/index.html` is now 199 after the root/app restructure. A criterion met by\none line, with nothing checking it, is met by accident: the next person to touch\nthe file either trims something or silently breaks it, and no test, lint or CI\njob would say so. If this ticket's spec lands, the cheapest place for the budget\nis beside it -- a line count assertion is one statement and needs no browser.\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/` -- the landing page renders and the casts play,\n and no test in the repository would have noticed if they had not.\n4. Break a cast path in `docs/index.html`, repeat, and observe that the page is\n still structurally correct until a play button is pressed.\n5. `wc -l docs/index.html` -- 199, against a stated limit of 200, checked by\n nothing.\nWITHDRAWN FROM THIS TICKET: the 199-line budget paragraph above.\n\nIt was added here on the reasoning that \"a 200-line limit met by one line with\nnothing checking it\" is an instance of this ticket's one fact. On review that is\nthe wrong home: the better answer is to rewrite improvise-e61's criterion, since\nthe line count was only ever a proxy for single file / vanilla / no build step /\nno framework — properties nothing threatens and which a line count measures\nbadly. That has been done on improvise-e61.\n\nThis ticket is about the landing page being SERVED and ASSERTED BY NOTHING, and\nit should stay that. Read the paragraph above as superseded; the rest of the\nnotes — press play, count rendered lines, resolve the call-to-action href — is\nwhat this ticket is for.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:01:16Z","created_by":"wave2-p-milestones","updated_at":"2026-09-20T18:22:56Z","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":"open","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-20T16:53:36Z","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":"open","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T07:50:12Z","created_by":"Edward Langley","updated_at":"2026-09-20T16:53:34Z","labels":["area:core-io"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-zx8t","title":"draw.rs::mode_style matches AppMode with a wildcard, so the repo map's exhaustiveness promise is false there","description":"Found by WU-G on PR #27 while adding AppMode::SavePrompt, and it is a correction to something context/repo-map.md currently promises.\n\nThe repo map says of AppMode: \"Per-variant UI facts live on the enum as exhaustive-match methods -- label(), hint_text(), panel(), minibuffer(), is_editing(), is_records() (improvise-2hi); ADD A VARIANT AND THE COMPILER NAMES EVERY ONE YOU MUST FILL IN.\"\n\nThat is TRUE of the five enum methods and FALSE of src/draw.rs::mode_style, which matches AppMode with a `_ =\u003e` WILDCARD. So a new variant silently inherits whatever the wildcard arm gives -- SavePrompt currently takes ExportPrompt's DarkGray by accident rather than by decision, which happens to be defensible and was nobody's choice.\n\nWHY IT IS P4 AND NOT HIGHER: the consequence is a colour, and the wrong colour is visible rather than silent. But the reason to fix it is that the repo map states a PROPERTY, and a property that holds in five places and not the sixth is worse than one nobody claimed -- the next person adding a variant will trust the sentence.\n\nTHE FIX IS ONE OF TWO, and they are not equivalent:\n * Make mode_style exhaustive, so the compiler does name it. Note mode_style stays in src/draw.rs deliberately, because Style is a ratatui type and improvise-protocol must not name one (gotcha 26, the AxisColor/MinibufColor pattern).\n * Or give AppMode a semantic colour method in the protocol crate -- the AxisColor treatment -- and let draw.rs do the one mapping. That is the established shape here and it would also give a browser something to use, which draw.rs's Style cannot.\n\nThe second is probably right and is the same question improvise-k099 asks about hint_text/label. Decide them together.\n","acceptance_criteria":"mode_style either matches AppMode exhaustively, or AppMode gains a semantic colour method in improvise-protocol that draw.rs maps to a ratatui Style (the AxisColor/MinibufColor shape). Either way, adding an AppMode variant fails the build until someone chooses its colour. The repo-map sentence promising compiler-named sites is true of every listed method, or it is corrected to say which one it is not true of.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T06:04:22Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:44:19Z","labels":["area:tui"],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -311,5 +322,5 @@ {"_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-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.","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:19:45Z","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-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 37c6741d364cea312a022225ddbedbaff7d0008e Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Sun, 20 Sep 2026 12:16:11 -0700 Subject: [PATCH 12/17] chore(bd): keep issues.jsonl off this branch bd's pre-commit hook re-exports and stages .beads/issues.jsonl on every commit, so the file rides along on any branch that does bd work and then conflicts with every other branch that did the same. Restored to main's content with --no-verify so the hook cannot re-add it. Treated like context/repo-map.md from here: nobody commits it on a feature branch. Co-Authored-By: Claude Opus 5 (1M context) --- .beads/issues.jsonl | 28 ++++++++-------------------- 1 file changed, 8 insertions(+), 20 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index f0d4eb95..0c30e6d4 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -22,7 +22,7 @@ {"_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.","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-20T18:22:57Z","labels":["area:epic"],"dependencies":[{"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-zkkw","type":"blocks","created_at":"2026-09-20T18:02:34Z","created_by":"wave2-p-milestones","metadata":"{}"}],"dependency_count":5,"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.","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-20T18:19:45Z","labels":["area:epic"],"dependencies":[{"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-zkkw","type":"blocks","created_at":"2026-09-20T18:02:34Z","created_by":"wave2-p-milestones","metadata":"{}"}],"dependency_count":5,"dependent_count":1,"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} @@ -37,10 +37,8 @@ {"_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-pdvm","title":"AddItem/RemoveItem reshape the grid but declare CellData, so layout_version and cursor_epoch disagree","description":"Found in the adversarial review of PR #49 (improvise-9mo9). PRE-EXISTING, but\nthat PR is the first thing in the repo to produce evidence for it.\n\n`AddItem`, `AddItemInGroup` and `RemoveItem` declare\n`EffectKind::Model(ModelChange::CellData)`. `App::apply_effects` bumps\n`layout_version` only for `ModelChange::LayoutGeometry`, so these three do not\nmove it. Since improvise-9mo9 they DO call `app.reset_cursor()`, which bumps\n`cursor_epoch`.\n\nSo one event moves one counter and not the other, and the two counters are\nanswering the same question: \"has the grid's shape changed under you\". A client\nis told its cursor is invalid (`Command::ResetCursor`) while its `layout_version`\nbasis tier still reads current, so a batch composed against the old layout is\naccepted.\n\nTHE EVIDENCE IS THIS PR'S OWN TEST. `every_reshaping_model_command_invalidates_\nevery_cursor` now asserts, with a `grid_shape()` guard over `row_items`/\n`col_items`, that `AddItem`/`AddItemInGroup`/`RemoveItem` REALLY DO reshape the\ngrid. That is the assertion `ModelChange::CellData` contradicts.\n\nWHY IT IS NOT OBVIOUSLY \"RECLASSIFY THEM\". `EffectKind` is read by three\nconsumers off one classification (gotcha 40): `Dirty::of`, `layout_version`, and\n`model_state.dirty`. Moving these three to `LayoutGeometry` changes what every\nclient is told is dirty and widens the basis rejection surface. It also interacts\nwith gotcha 3's traps -- `AddCategory`/`RemoveCategory` are already the\n\"looks like data, is geometry\" case -- and with the `ModelCommand` derivation.\n\nRELATED, NOT DUPLICATE: improvise-75ld is the same family in the other direction\n(a refused or no-op AddCategory bumps layout_version and raises dirty when\nnothing happened). Decide them together; a single question -- \"what does an\nEffectKind entitle a counter to assume\" -- is under both.\n\n## Steps to Reproduce\n\n1. On a view with a category on Row, apply `ModelCommand::AddItem` for a new item\n in that category.\n2. `grid_shape()` (or `layout.row_count()`) changes: a row was inserted.\n3. `app.cursor_epoch()` has moved and clients receive `Command::ResetCursor`.\n4. `app.layout_version()` has NOT moved, because the effect declares CellData.\n5. A client may now compose a `Command::Model` whose `Basis.layout_version` is\n the pre-change value and have it accepted.","acceptance_criteria":"One event moves both counters or neither. Either AddItem/AddItemInGroup/RemoveItem are reclassified as LayoutGeometry -- in which case the blast radius through Dirty::of, the basis rejection surface and gotcha 3's traps is argued, not assumed -- or layout_version stops being read off EffectKind and is observed the way cursor_epoch and record_versions already are. Decide alongside improvise-75ld, which is the same question in the other direction, and alongside improvise-ni6k, whose grid-frame fingerprint would answer all three at once. The regression guard must assert the two counters agree over the same table every_reshaping_model_command_invalidates_every_cursor already drives, so it cannot pass by naming nothing.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:57:17Z","created_by":"wave2-n-cleanups","updated_at":"2026-09-20T18:57:17Z","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_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":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:22:32Z","created_by":"wave2-p-milestones","updated_at":"2026-09-20T18:22:32Z","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":"open","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-20T18:18:24Z","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-lltt","title":"[bug] A view-scoped ModelCommand names no view, so it lands on the host's","description":"Opened by improvise-jytm, which made `ViewState::active_view` authoritative per\nclient. The view-configuration half of `ModelCommand` — SetAxis, CycleAxis,\nTransposeAxes, SetPageSelection, HideItem, ShowItem, ToggleGroup, TogglePruneEmpty,\nSetNumberFormat — acts on \"the active view\", which is now an ambiguous phrase.\n\n`Sessions::apply_model` routes with no SessionId (gotcha 38, correctly: the change\nis addressed to no one session, because a `View` is document state shared by every\nclient sitting on it). But it must still name WHICH view, and today it names it\nimplicitly: `apply_command` runs the effect against the `App`, and the effect reads\n`App::view()`, which is the HOST's slice. So a connected client on another view\nmoves the host's view rather than its own.\n\nBefore jytm every client was on the same view, so the implicit answer was right.\nIt is now wrong exactly when two clients are on different views, which is the\ncapability jytm exists to add.\n\nThe honest fix is for those variants to name their view — a protocol change — so\nthe command carries the operand rather than borrowing an ambient one. Rejected\nduring jytm: retargeting `app.view_state.active_view` around `apply_command` at the\ndoor. It makes the host's own UI slice a parameter channel, and `reset_cursor`/\n`cursor_epoch` would then write the asker's key into the host's cursor map.\n\nNote the `_Drill`/`_Records` virtual views make this sharper than it looks: a host\nin records mode is on a view no client can name.\n\n## Steps to Reproduce\n\n1. Two clients, A and B, on one document with views \"Default\" and \"Second\".\n2. B sends `Command::SwitchView(\"Second\")`. Its mirror moves; the host's App stays\n on \"Default\" (correct, per improvise-jytm).\n3. B sends `Command::Model { cmd: ModelCommand::SetAxis { category: \"Region\",\n axis: Row } }`.\n4. The axis moves on \"Default\" — the view B is not on — and B's next projection\n shows nothing changed while A's shows a change A did not ask for.\n\nNot reachable from the TUI, which is the only client that drives the App's own\nslice, and not reachable from `web/app.js` today because it sends no SwitchView.\n","acceptance_criteria":"The view-configuration ModelCommand variants name the view they act on, so a connected client's SetAxis moves the view that client is on and not the host's. A test drives two sessions onto different views and asserts an axis change by one is invisible to the other. Deciding NOT to fix is also an acceptable close, but then gotcha 38 must say so.","notes":"THE TRIGGER, added after the adversarial review of PR #48 (2026-09-20). This is deferrable, but it is deferred on a CONDITION rather than on a judgement, and the condition is one line of somebody else's future work away. It is unreachable today ONLY because no shipped client emits Command::SwitchView. That is the whole of the argument. The browser already emits view-configuration model commands -- keys.rs resolves a keypress to ModelCommand::TransposeAxes locally and sends it -- so the machinery on the other side of the ambiguity is live. THE DAY ANY WAVE WIRES A VIEW SWITCH INTO THE BROWSER, THIS GOES LIVE ON THE NEXT TRANSPOSE KEYSTROKE, AND SILENTLY: a client on Second presses transpose, the axes of Default move instead, that client's grid does not change and every other client's does. Two consequences. (1) Do not close this by arguing it is unreachable -- reachability is the thing that is about to change, and gotcha 47 already records that 'unreachable today' was the argument under which the lost update CheckedBatch later fixed had shipped. (2) There is a cheap interim guard worth landing before the real fix: assert that no shipped browser client emits Command::SwitchView, a test over improvise-wasm-client's key resolver. It turns the silent failure into a loud one at exactly the moment the premise stops holding, which is the only moment anybody would otherwise not be looking.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:05:49Z","created_by":"wave2-e-active-view","updated_at":"2026-09-20T18:34:24Z","labels":["area:active-view"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-lltt","title":"[bug] A view-scoped ModelCommand names no view, so it lands on the host's","description":"Opened by improvise-jytm, which made `ViewState::active_view` authoritative per\nclient. The view-configuration half of `ModelCommand` — SetAxis, CycleAxis,\nTransposeAxes, SetPageSelection, HideItem, ShowItem, ToggleGroup, TogglePruneEmpty,\nSetNumberFormat — acts on \"the active view\", which is now an ambiguous phrase.\n\n`Sessions::apply_model` routes with no SessionId (gotcha 38, correctly: the change\nis addressed to no one session, because a `View` is document state shared by every\nclient sitting on it). But it must still name WHICH view, and today it names it\nimplicitly: `apply_command` runs the effect against the `App`, and the effect reads\n`App::view()`, which is the HOST's slice. So a connected client on another view\nmoves the host's view rather than its own.\n\nBefore jytm every client was on the same view, so the implicit answer was right.\nIt is now wrong exactly when two clients are on different views, which is the\ncapability jytm exists to add.\n\nThe honest fix is for those variants to name their view — a protocol change — so\nthe command carries the operand rather than borrowing an ambient one. Rejected\nduring jytm: retargeting `app.view_state.active_view` around `apply_command` at the\ndoor. It makes the host's own UI slice a parameter channel, and `reset_cursor`/\n`cursor_epoch` would then write the asker's key into the host's cursor map.\n\nNote the `_Drill`/`_Records` virtual views make this sharper than it looks: a host\nin records mode is on a view no client can name.\n\n## Steps to Reproduce\n\n1. Two clients, A and B, on one document with views \"Default\" and \"Second\".\n2. B sends `Command::SwitchView(\"Second\")`. Its mirror moves; the host's App stays\n on \"Default\" (correct, per improvise-jytm).\n3. B sends `Command::Model { cmd: ModelCommand::SetAxis { category: \"Region\",\n axis: Row } }`.\n4. The axis moves on \"Default\" — the view B is not on — and B's next projection\n shows nothing changed while A's shows a change A did not ask for.\n\nNot reachable from the TUI, which is the only client that drives the App's own\nslice, and not reachable from `web/app.js` today because it sends no SwitchView.\n","acceptance_criteria":"The view-configuration ModelCommand variants name the view they act on, so a connected client's SetAxis moves the view that client is on and not the host's. A test drives two sessions onto different views and asserts an axis change by one is invisible to the other. Deciding NOT to fix is also an acceptable close, but then gotcha 38 must say so.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:05:49Z","created_by":"wave2-e-active-view","updated_at":"2026-09-20T18:05:49Z","labels":["area:active-view"],"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":"CORRECTION, same session: this bead as first written read as though nobody had\ndecided the Chromium-only question. Somebody had.\n`invariant(opfs_is_run_but_bindings_is_not)` in prolog-sessions/kb.pl records\nit, and gives TWO reasons, not one:\n\n \"harness/tests/shell.spec.js runs web/app.js under CHROMIUM ONLY --\n engine-independent logic, and routeWebSocket is slower across three.\"\n\nThe description above only answers the second. The first — the logic is\nengine-independent — is a general claim covering all six specs, and against\nthe specs as a test of web/app.js it is correct: app.js is plain DOM and\nplain event wiring, and running the same assertions on a second engine buys\nvery little.\n\nWHAT IT DOES NOT COVER is the claim improvise-bck criterion (1) actually\nmakes. That criterion is not \"app.js's logic works in Firefox\", it is \"the\nstandalone deployment works in Firefox\" — and the standalone deployment is\nmodule workers, `wasm-pack` output, `createSyncAccessHandle` through\nbindings.rs and the seed fetch, none of which is app.js's logic and all of\nwhich is engine-specific. opfs.spec.js covers Firefox's OPFS through\n`OpfsFs` directly and, per the same invariant, NEVER INSTANTIATES\nbindings.rs. So the two standalone specs on Firefox would be the first\nexecution of that shim on a second engine, and near enough the first on any.\n\nSo the disagreement with the invariant is narrow and should be argued on\nthose terms: the engine-independence reason is a statement about app.js, and\nthe two standalone specs are not only a test of app.js. If that argument is\nrejected, the right outcome is to REWRITE improvise-bck criterion (1) to say\nChromium, not to leave a criterion standing that names an engine nothing\nruns on — a criterion nobody checks is the failure mode this whole pass\nexists to catch.\n\nEither way the invariant's wording is owed an update, since it currently\nreads as a blanket justification for a split that has two different reasons\nbehind its two halves.\nCORRECTION — MY OWN PREMISE WAS WRONG, found in adversarial review of PR #47.\n\nThe note above says the two standalone specs on Firefox \"would be the first\nexecution of that shim on a second engine, and near enough the first on any\".\nThe second half is false. `bindings.rs` HAS been executed, on Chromium, since\nimprovise-vvl3 landed the harness. The chain is unbroken and each link is\ncheckable:\n\n /app/index.html with no ?ws= -\u003e app.js `new Worker(\"./worker.js\")`\n worker.js `import { start } from \"./worker-pkg/improvise_worker_server.js\"`\n bindings.rs `pub async fn start()` -\u003e `boot()` -\u003e `OpfsFs::open().await`\n\nand `harness/tests/shell.spec.js`'s own comment on the standalone test has said\nso all along: \"If this draws a grid, worker.js, bindings.rs, OpfsFs and the seed\nfetch all ran.\"\n\nI RAN THAT SPEC GREEN AT 2ccd21f while verifying the /app/ move, and watched the\nbrowser fetch /app/worker.js and /app/worker-pkg/improvise_worker_server_bg.wasm\nand draw the grid with #status reading \"in-page worker\". The evidence was in my\nhand and I still repeated the claim, because I took it from kb.pl and the repo\nmap instead of from the run.\n\nWHAT SURVIVES: the argument for running the two standalone specs on Firefox is\nUNAFFECTED and is still the point of this ticket. Firefox would be a genuine\nsecond engine for the shim, and improvise-bck's criterion (1) still names an\nengine nothing runs. Only the \"first on any engine\" rhetoric goes.\n\nFIVE PLACES STATE THE FALSE VERSION and want one grep, not five edits:\nkb.pl:11, kb.pl:105 (invariant opfs_is_run_but_bindings_is_not), repo-map.md:241,\nrepo-map.md:467, and improvise-bck's own NOT-DEMONSTRATED list. Those three files\nare orchestrator-owned; the bck half is corrected in bck's notes.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:02:30Z","created_by":"wave2-p-milestones","updated_at":"2026-09-20T18:19:44Z","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":"Three PRs merged within 20 seconds on 2026-09-20 (#37, #41, #42). Each CI run built the site and uploaded a github-pages artifact, and each queued a deploy behind 'concurrency: group: pages, cancel-in-progress: false'.\n\nOnly the newest run kept its artifact:\n\n run 35526758561 (04e4e3d, #37) conclusion=failure artifacts=0\n run 35526767814 (5f871f7, #41) conclusion=success artifacts=1\n run 35526774007 (52507a6, #42) conclusion=failure artifacts=0\n\nThe two superseded deploys failed with:\n\n Fetching artifact metadata for \"github-pages\" in this workflow run\n Found 0 artifact(s)\n Error: No artifacts named \"github-pages\" were found for this workflow run.\n\nThe upload step was GREEN in all three runs, so this is not a build failure —\nthe artifact is gone by the time the losing deploy runs. The successful deploy\n(17:47:28Z) preceded the failing one (17:47:44Z) by 16 seconds.\n\nDeploying a superseded commit is pointless anyway: the tip of main is what\nshould be published. Cancelling is deliberately NOT the answer — the existing\ncomment on the concurrency block records that a deploy-pages cancelled midway\ncan leave the Pages deployment half-applied.\n\n## Steps to Reproduce\n\n1. Merge three PRs to main within about a minute.\n2. Watch the CI runs: the newest deploy succeeds, the older ones fail on an\n empty artifact fetch and mark otherwise-green runs as failed.\n\nReported by the user from the Actions log.","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-20T18:03:04Z","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":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T07:55:22Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:57:55Z","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} @@ -58,12 +56,12 @@ {"_type":"issue","id":"improvise-o8fa","title":"improvise-ws-server speaks ws:// only, so the deployed page cannot reach it","description":"Found by opening the deployed page (2026-09-20). The guidance the page and the README carried was WRONG in a way that sends someone down a dead end.\n\nFROM AN https ORIGIN A ws:// ADDRESS IS BLOCKED AS MIXED CONTENT, so the deployed GitHub Pages site needs wss://. improvise-ws-server has NO TLS AT ALL -- no rustls, no native-tls, nothing in the crate or its dependencies -- so wss:// is not obtainable from it. The text said \"note that from an https origin a ws:// address is blocked\", which correctly named the symptom and then offered an address that can never work.\n\nTEXT CORRECTED ALREADY: both web/index.html and README.md now say the address must be wss:// from the deployed page, that the server speaks plain ws:// only, and that the ws:// form works when the page is served over http://localhost. This ticket is the missing CAPABILITY.\n\nTHREE WAYS TO PROVIDE IT, and they are not equivalent:\n * TERMINATE TLS IN FRONT (nginx, caddy, cloudflared, ngrok). Zero code, works today, and is what the corrected text points at. But it means the documented path to sharing a TUI-hosted document involves a second piece of software, which is a poor answer for a tool whose selling point is that it is one binary.\n * NATIVE wss:// IN improvise-ws-server -- rustls plus a cert. Real work, and it raises the certificate question: a self-signed cert makes the browser refuse the socket with no usable error, so this is only pleasant with a real cert or an explicitly trusted local CA (mkcert).\n * DECIDE THE DEPLOYED PAGE DOES NOT PAIR WITH A LOCAL SERVER at all, and say so. Defensible: the standalone worker deployment is the point of the Pages site, and ws:// pairing is a LOCAL development convenience that works fine from the local page. Cheapest, and it makes the docs shorter rather than longer.\n\nThe third is probably right for now and the first is the escape hatch; the second only earns its place if sharing a TUI-hosted document to a remote browser becomes a real use case. Do not implement the second by default.","status":"closed","priority":2,"issue_type":"feature","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T03:53:54Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:22:10Z","started_at":"2026-09-20T05:32:35Z","closed_at":"2026-09-20T06:22:10Z","close_reason":"Closed by PR #21 (WU-I). DOCUMENTATION ONLY, per the user's ruling; native wss:// DECLINED.\n\nTHE DECLINE'S SUBSTANCE IS THE CERTIFICATE, NOT rustls. A self-signed cert makes the browser refuse the socket with no usable error, because a WebSocket handshake has no interstitial -- there is no place to click through. A real cert or mkcert is a second piece of software too, so the choice is two-either-way, with one option additionally shipping a TLS stack and a cert-loading surface in a pivot-table binary.\n\nWHAT LANDED: a worked Caddy example, the nginx difference that bites (proxy_http_version 1.1 plus the Upgrade/Connection headers), the honest cost (a second binary, a poor answer for a one-binary tool), and the framing that the standalone worker deployment is the point of the Pages site while ws:// pairing works fine from a local http://localhost page.\n\nTHIS BEAD COST SEVEN COPILOT ROUNDS AND NINE FINDINGS, EVERY ONE IN PROSE. The sequence is the value and is recorded in kb.pl:\n * the first recipe exposed an unauthenticated world-writable server -- documenting a proxy documents an exposure, because TLS termination REMOVES the loopback binding that was the only control;\n * the \"fix\" put Caddy basic_auth in front of a socket the page opens as new WebSocket(url), an API that can set no headers over a handshake that shows no prompt -- BROKEN reads as protection, which is worse than absent;\n * an ngrok --cidr-allow recipe was written without being run (deprecated-but-accepted, so the reviewer's own \"fails at startup\" was also wrong);\n * \"the browser cannot send a credential on a WebSocket handshake\" over-generalised the governing fact and contradicted the Cloudflare Access bullet three paragraphs up, which works BECAUSE the handshake carries a cookie;\n * an IP allow-list cannot see a cross-site socket at all, which is improvise-kc7n;\n * an absolute loopback claim denied a browser mitigation that exists but is mid-rollout;\n * and \"the auth above\" called an allow-list authentication in a section whose opening sentence is that the server has none.\n\nThe governing fact was ALREADY IN THE SAME FILE at every step, as the basis of this very decline. The lesson the repo did not have: a reviewed paragraph is not evidence.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-955n","title":"[bug] Host::commands applies a batch partially before rejecting","description":"Found by Copilot on PR #13 (the retro review of the unreviewed batch), DECLINED there with reasons on the thread, and filed rather than patched.\n\nADR 001 is emphatic that a stale batch is never applied, NOT EVEN PARTIALLY -- and improvise-ondj went to the trouble of a TYPE (Checked) to make the unchecked path unconstructible. But Checked is PER-COMMAND: Checked::against answers for one Command against the App AS IT STANDS. A ClientFrame::Commands is a SEQUENCE of them, and Host::commands applies in order and abandons at the first rejection. So a frame carrying SwitchView followed by a stale Command::Model has already applied the SwitchView.\n\nSO ADR 001 GUARANTEE IS CURRENTLY TRUE OF A SINGLE COMMAND AND FALSE OF A BATCH.\n\nA SECOND-ORDER EFFECT NOBODY HAD WRITTEN DOWN, found while declining it: Worker::on_frame skips persist() when ANY frame in the answer is Rejected. So a batch that DID apply a SwitchView before being refused leaves Workbook::active_view moved in memory and NOT WRITTEN BACK -- the document on disk and the document in the worker disagree, silently.\n\nWHY IT WAS DECLINED RATHER THAN PATCHED: closing it means the basis check moving from the COMMAND to the BATCH -- every command answered before any is applied. That is an ADR 001 design change with a real question inside it: WHAT DOES A BATCH BASIS MEAN WHEN ITS LATER COMMANDS WERE COMPOSED AGAINST ITS EARLIER ONES EFFECTS? A client resolving one keypress produces SetSelected plus offsets computed from the selection it just set. Answer that before writing code.\n\nREACHABILITY IS BOUNDED TODAY: no client in this workspace composes a batch containing more than one basis-carrying Command::Model -- the batches improvise-bpq2 produces are view commands, which carry no basis. Do this before the second tranche of client-side resolution creates a reason to batch two model commands.\n\nSupersedes the earlier ticket filed from bpq2 if there is one -- dedupe.","status":"closed","priority":2,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T03:35:27Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:29:48Z","started_at":"2026-09-20T05:32:29Z","closed_at":"2026-09-20T07:29:48Z","close_reason":"Closed by PR #26 (WU-C). improvise-08wb is closed as a duplicate of this one.\n\nADR 001 said a stale batch is never applied, NOT EVEN PARTIALLY, and improvise-ondj went to the trouble of a type (Checked) to make the unchecked path unconstructible -- but Checked is PER-COMMAND and a ClientFrame::Commands is a sequence of them, so Host::commands applied in order and abandoned at the first rejection. The guarantee was true of a single command and false of a batch.\n\nCheckedBatch::against now answers for EVERY command before any is applied.\n\nTHE DESIGN QUESTION THE TICKET DEMANDED WAS ANSWERED BY THE USER, not by the code, and it is larger than the ticket imagined. The ticket asked \"what does a batch's basis mean when its later commands were composed against its earlier ones' effects\". The user's ruling: \"a sequence of commands sent to the server should assume that it's played in order from start to end and that each command sees the state after the previous command runs\", as \"a single transaction from a given base\". Four clauses -- one basis per frame; checked once before anything; applied in order with data dependence EXPECTED; all or nothing. Clauses 1-3 landed here; clause 4 (rollback) is improvise-yqpd.\n\nAN EARLIER \"at most one document mutation per frame\" RULE WAS SHIPPED AND THEN REJECTED BY THE USER as forbidding the capability. It was wrong twice over: it forbids data-dependent chains, and arity is not a property of a variant anyway -- a future Command::RunScript is one variant performing unboundedly many mutations.\n\nTHE FINDING THAT MATTERED MOST came from the independent adversarial review that substituted for Copilot (quota exhausted), and it was a REGRESSION THIS WORK INTRODUCED: hoisting the basis check to the frame hoisted ALL THREE TIERS. The document and layout tiers are legitimately frame-level facts; THE RECORD TIER IS NOT AND CANNOT BE, because which records are at stake is a property of the command. So every mutation after the first had its records checked against nothing -- a silent lost update, confirmed by executing a real Host: client A's stale [SetCell(North), SetCell(South)] clobbered client B's write to South with no rejection, where the base commit refused it. Fixed: every mutation answers for its own records against the FRAME'S basis, with an exemption stated over a property -- a record that neither resolves nor is claimed is one the frame is creating. The reviewer attacked that exemption at all four corners and could not break it: the predicate is a conjunction and the client controls only one conjunct.\n\nTHE SECOND-ORDER WORKER EFFECT the ticket named is closed twice over; its test fails only when BOTH old behaviours are restored.\n\nA CAPABILITY NOBODY HAD NAMED, now guarded: a frame may write THE SAME RECORD TWICE -- two measures on one record IS one record -- and that works only because every check happens before any apply. Moving the checks into the loop breaks it, which is what stops someone closing clause 4 by taking that trade unnoticed.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-08wb","title":"[bug] A refused ClientFrame::Commands keeps the commands before the refusal","description":"Found by improvise-bpq2 (2026-09-20), stated on the variant, and currently UNREACHABLE from any client in this workspace -- filed so it is a decision rather than a discovery.\n\nClientFrame::Commands(Vec\u003cCommand\u003e) batches, because one cursor key resolved client-side is SetSelected plus the offsets viewport_effects returns, and a host that folds the selection but not the offset projects the wrong window. Host::commands applies IN ORDER and ABANDONS AT THE FIRST REJECTION.\n\nTHAT LEAVES A HOLE ADR 001 DOES NOT HAVE. ADR 001 is emphatic that a stale batch is never applied, NOT EVEN PARTIALLY -- and improvise-ondj went to the trouble of a type (Checked) to make the unchecked path unconstructible. But Checked is per-COMMAND, and a ClientFrame::Commands is a sequence of them, so a frame whose third command is refused has already applied the first two.\n\nWHY IT IS NOT LIVE: no client in this workspace composes a Commands frame containing more than one basis-carrying Command::Model -- the batches bpq2 produces are view commands (SetSelected + offsets), which carry no basis.\n\nTHE FIX IS TO MOVE THE BASIS CHECK TO THE BATCH: check every Command::Model in the frame before applying any of them. That is the shape ADR 001 already describes for an effect batch, one level up, and it would make Host::commands match the guarantee the ADR states.\n\nDo this before a client has reason to batch two model commands -- which the second tranche of client-side resolution (the ~15 bindings bpq2 left) may well create.","status":"closed","priority":2,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T02:36:42Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:29:28Z","started_at":"2026-09-20T05:32:29Z","closed_at":"2026-09-20T07:29:28Z","close_reason":"Closed as a DUPLICATE of improvise-955n, which PR #26 fixed. Both beads describe the same defect -- Host::commands applied a ClientFrame::Commands in order and abandoned at the first rejection, so a frame whose later command was refused had already applied the earlier ones, leaving ADR 001's 'never applied, not even partially' true of a single command and false of a batch. 08wb was filed from improvise-bpq2's walk and 955n from Copilot's retro review of PR #13; 955n carries the fuller reasoning and the second-order worker effect, so it is the survivor.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-ir7l","title":"[bug] A formula cell is offered as editable and silently reverts","description":"Pre-existing and consistent with the TUI, but improvise-bck just put it on the DEMO FRONT PAGE, which changes who meets it: the first thing a stranger sees is Profit beside Revenue and Cost.\n\nA formula target cell projects CellId::Record, so render.rs::editable says true, so the browser offers an editor. Type into Profit and the write lands -- then recompute_formulas overwrites it on the next pass and the value REVERTS with no explanation.\n\nWHY IT IS NOT SIMPLY A BUG IN editable: a formula target IS a stored coordinate; the cell is a record. What makes it unwritable is not its identity but that a FORMULA OWNS IT. So the fix is a fact the projection does not currently carry -- something like \"this cell is derived\" -- and it belongs beside numeric/error on CellDisplay, which already exists precisely because the SERVER is the side that knows.\n\nCHECK THE TUI BEFORE CHANGING ANYTHING: it has the same behaviour, so whatever is decided should land in both, or the divergence becomes the new bug (gotcha 44 is what that looks like). And note ADR 002 deliberately made settability a fact about the CellId VARIANT -- adding a second, independent \"but not really\" flag is exactly the two-answers-to-one-question shape this codebase keeps curing. Consider instead whether a formula-owned cell should project a different CellId at all.\n\n## Steps to Reproduce\n\n1. Open `web/demo.improv` in the browser — it ships `Profit = Revenue - Cost` beside Revenue and Cost on the front page.\n2. Click the Profit cell. It is offered as editable, because a formula target projects `CellId::Record` and `render.rs::editable` reads the variant.\n3. Type a number and commit. The write lands.\n4. `recompute_formulas` overwrites it on the next pass and the value reverts, with no explanation.\n\nThe TUI behaves the same way, so whatever is decided must land in both or the divergence becomes the new bug.\n","acceptance_criteria":"A formula-owned cell projects a non-Record CellId, so the write is unconstructible rather than flagged — the discipline ADR 002 used for aggregates and ADR 003 reaches for again for read-only sources. It lands in the TUI in the same change, or the two front ends diverge (gotcha 44 is what that looks like). Do NOT add a second 'but not really' flag beside CellId: two answers to 'can I write here' is the disease. Demonstrated on web/demo.improv, where Profit sits beside Revenue and Cost on the front page.","notes":"VERIFIED CONCRETELY during the PR #13 review (2026-09-20), and the diagnosis is sharper than when this was filed.\n\nTHE ROOT CAUSE: project::cell_id decides Record vs Aggregate purely on whether a regular category sits on Axis::None. IT NEVER ASKS WHETHER THE _Measure COORDINATE NAMES A FORMULA TARGET. So a formula cell is a Record, render.rs::editable says true, and the browser offers an editor.\n\nMEASURED AGAINST THE SHIPPED DEMO:\n col 2 label=\"Profit\" is_record=true text=\"12,200\"\n after applying SetCell(key, Number(999.0)), Profit reads \"12,200\"\n\nThe write is ACCEPTED AND STORED. Model::evaluate_aggregated checks formula_cache BEFORE raw data (model/types.rs:409), so the typed value is shadowed forever with nothing telling the user. Not a revert -- a permanent silent shadow.\n\nAND THE TEST THAT COVERS THIS AREA IS LYING BY ITS NAME. every_visible_cell_in_the_web_demo_is_settable PASSES while its name is FALSE for the Profit column -- a third of the landing-page grid. It was proved discriminating against the one thing it CAN see (the aggregate at (0,0) in examples/demo.improv) and is structurally blind to this. That is exactly the shape AGENTS.md calls this repo's characteristic find, arriving in the test written to prevent it.\n\nTHE DECISION IS STILL THE DECISION, and ADR 002 is where it goes:\n * TEACH cell_id ABOUT FORMULA TARGETS -- changes what EVERY client calls a record, and record identity is also ADR 001's unit of atomicity, so this reaches the basis check too.\n * OR GIVE editable A SECOND INPUT -- but that is the conflation gotcha 42 exists to forbid, and ADR 002 deliberately made settability a fact about the CellId VARIANT precisely so there could not be a second answer.\n\nThe first is probably right and is not small. Until it lands, the demo's front page teaches a visitor that typing into Profit does nothing.","status":"closed","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-20T02:29:51Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:22:48Z","started_at":"2026-09-20T17:53:19Z","closed_at":"2026-09-20T18:22:48Z","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-ir7l","title":"[bug] A formula cell is offered as editable and silently reverts","description":"Pre-existing and consistent with the TUI, but improvise-bck just put it on the DEMO FRONT PAGE, which changes who meets it: the first thing a stranger sees is Profit beside Revenue and Cost.\n\nA formula target cell projects CellId::Record, so render.rs::editable says true, so the browser offers an editor. Type into Profit and the write lands -- then recompute_formulas overwrites it on the next pass and the value REVERTS with no explanation.\n\nWHY IT IS NOT SIMPLY A BUG IN editable: a formula target IS a stored coordinate; the cell is a record. What makes it unwritable is not its identity but that a FORMULA OWNS IT. So the fix is a fact the projection does not currently carry -- something like \"this cell is derived\" -- and it belongs beside numeric/error on CellDisplay, which already exists precisely because the SERVER is the side that knows.\n\nCHECK THE TUI BEFORE CHANGING ANYTHING: it has the same behaviour, so whatever is decided should land in both, or the divergence becomes the new bug (gotcha 44 is what that looks like). And note ADR 002 deliberately made settability a fact about the CellId VARIANT -- adding a second, independent \"but not really\" flag is exactly the two-answers-to-one-question shape this codebase keeps curing. Consider instead whether a formula-owned cell should project a different CellId at all.\n\n## Steps to Reproduce\n\n1. Open `web/demo.improv` in the browser — it ships `Profit = Revenue - Cost` beside Revenue and Cost on the front page.\n2. Click the Profit cell. It is offered as editable, because a formula target projects `CellId::Record` and `render.rs::editable` reads the variant.\n3. Type a number and commit. The write lands.\n4. `recompute_formulas` overwrites it on the next pass and the value reverts, with no explanation.\n\nThe TUI behaves the same way, so whatever is decided must land in both or the divergence becomes the new bug.\n","acceptance_criteria":"A formula-owned cell projects a non-Record CellId, so the write is unconstructible rather than flagged — the discipline ADR 002 used for aggregates and ADR 003 reaches for again for read-only sources. It lands in the TUI in the same change, or the two front ends diverge (gotcha 44 is what that looks like). Do NOT add a second 'but not really' flag beside CellId: two answers to 'can I write here' is the disease. Demonstrated on web/demo.improv, where Profit sits beside Revenue and Cost on the front page.","notes":"VERIFIED CONCRETELY during the PR #13 review (2026-09-20), and the diagnosis is sharper than when this was filed.\n\nTHE ROOT CAUSE: project::cell_id decides Record vs Aggregate purely on whether a regular category sits on Axis::None. IT NEVER ASKS WHETHER THE _Measure COORDINATE NAMES A FORMULA TARGET. So a formula cell is a Record, render.rs::editable says true, and the browser offers an editor.\n\nMEASURED AGAINST THE SHIPPED DEMO:\n col 2 label=\"Profit\" is_record=true text=\"12,200\"\n after applying SetCell(key, Number(999.0)), Profit reads \"12,200\"\n\nThe write is ACCEPTED AND STORED. Model::evaluate_aggregated checks formula_cache BEFORE raw data (model/types.rs:409), so the typed value is shadowed forever with nothing telling the user. Not a revert -- a permanent silent shadow.\n\nAND THE TEST THAT COVERS THIS AREA IS LYING BY ITS NAME. every_visible_cell_in_the_web_demo_is_settable PASSES while its name is FALSE for the Profit column -- a third of the landing-page grid. It was proved discriminating against the one thing it CAN see (the aggregate at (0,0) in examples/demo.improv) and is structurally blind to this. That is exactly the shape AGENTS.md calls this repo's characteristic find, arriving in the test written to prevent it.\n\nTHE DECISION IS STILL THE DECISION, and ADR 002 is where it goes:\n * TEACH cell_id ABOUT FORMULA TARGETS -- changes what EVERY client calls a record, and record identity is also ADR 001's unit of atomicity, so this reaches the basis check too.\n * OR GIVE editable A SECOND INPUT -- but that is the conflation gotcha 42 exists to forbid, and ADR 002 deliberately made settability a fact about the CellId VARIANT precisely so there could not be a second answer.\n\nThe first is probably right and is not small. Until it lands, the demo's front page teaches a visitor that typing into Profit does nothing.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-20T02:29:51Z","created_by":"Edward Langley","updated_at":"2026-09-20T17:53:19Z","started_at":"2026-09-20T17:53:19Z","lease_expires_at":"2026-09-20T17:58:19Z","heartbeat_at":"2026-09-20T17:53:19Z","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-vhcz","title":"[bug] OpfsFs::list disagrees with the other backends on path shape","description":"Found by improvise-9x6 (2026-09-20) and currently PAPERED OVER by always passing the flat root.\n\nOpfsFs::list returns BARE NAMES; MemoryFs and PhysicalFs return dir-JOINED ones. So Path::new(\"\") is the ONE dir at which all three agree, and the only one at which storage_contract actually describes OpfsFs.\n\nTHE TRAP: a worker that organised documents under docs/ would get an EMPTY PICKER in a browser and a FULL ONE in every test. The harness cannot see the disagreement because it never runs OpfsFs -- improvise-xf1 is deferred -- so this is precisely the class of defect the missing browser harness exists to catch, discovered by reading instead.\n\nTWO FIXES:\n * Make OpfsFs::list join dir, matching the other two. Straightforward, and makes storage_contract describe all three at every dir.\n * Or document the flat root as PART OF THE CONTRACT and assert it in storage_contract, so the constraint is stated rather than discovered.\n\nThe first is better unless there is a reason OPFS cannot -- note gotcha 28 says backends that are not trees treat dir as a NAME PREFIX, which is already the trait doc contemplating this, so check whether the doc or the impl is the thing that is wrong.","status":"closed","priority":2,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T02:10:14Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:44:42Z","started_at":"2026-09-20T05:32:36Z","closed_at":"2026-09-20T06:44:42Z","close_reason":"Closed by PR #24 (WU-J). THE TICKET ASKED WHICH OF THE DOC OR THE IMPL WAS WRONG: IT WAS THE IMPL.\n\nThe Storage trait doc already said that backends which are not trees treat `dir` as a NAME PREFIX. OpfsFs::list ignored `dir` outbound as well as inbound, returning bare names where MemoryFs and PhysicalFs return dir-joined ones -- so Path::new(\"\") was the one dir at which all three agreed, and the only one at which storage_contract actually described OpfsFs.\n\nNow it joins. The three backends agree at EVERY dir, and storage_contract runs against OpfsFs at both \"\" and \"docs\" in a real browser. `dir` stays DECORATION rather than a filter, because entry_name reduces every path to its file name and an OPFS entry name may not contain a separator.\n\nFAULT INJECTED: reverting to bare names fails storage_contract_at_\"docs\" on `lists the one document`.\n\nTHE TRAP THE TICKET NAMED IS CLOSED: a worker that organised documents under docs/ would have got an empty picker in a browser and a full one in every test. That divergence was invisible to the harness because nothing ran OpfsFs -- which is exactly why improvise-vvl3 had to land alongside this, and why this bead could only ever have been found by reading until it did.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-hmws","title":"Storage cannot expose an async seam through Box\u003cdyn Storage\u003e","description":"Found by improvise-9x6 (2026-09-20). Every worker host will hit this, so it is worth solving once rather than in each.\n\nOpfsFs::open / attach / refresh / commit_deletes are on the STRUCT, not on the Storage trait -- deliberately, per gotcha 28: Storage stays synchronous because createSyncAccessHandle yields a blocking handle, and only four operations are genuinely async. But App::storage is a Box\u003cdyn Storage\u003e, so HANDING THE BACKEND TO THE App PUTS ALL FOUR PERMANENTLY OUT OF REACH. The App owns the only copy and the trait object cannot name them.\n\nAND THE OBVIOUS FIX IS REFUSED BY THE COMPILER: impl Storage for Rc\u003cOpfsFs\u003e is two foreign types, so the orphan rule forbids it. improvise-9x6 worked around it with a local newtype (bindings::Shared) holding an Rc\u003cOpfsFs\u003e and forwarding the four sync methods. That works but every worker must write it again.\n\nTWO CANDIDATE FIXES:\n * Ship the shared wrapper FROM improvise-io, so the newtype exists once where OpfsFs does and the orphan rule is satisfied.\n * Or have OpfsFs hand out an attachable handle -- separate the thing the App holds from the thing the message loop awaits, which is arguably the more honest modelling since they have different lifetimes and different call sites.\n\nPrefer whichever keeps Storage synchronous; that part is not negotiable (gotcha 28 is the argument).","status":"closed","priority":2,"issue_type":"task","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T02:10:13Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:44:43Z","started_at":"2026-09-20T05:32:37Z","closed_at":"2026-09-20T06:44:43Z","close_reason":"Closed by PR #24 (WU-J). NEITHER OF THE TICKET'S TWO OPTIONS WAS NEEDED, and the reason is worth more than the fix.\n\nThe ticket said `impl Storage for Rc\u003cOpfsFs\u003e` is refused by the orphan rule. That is true IN improvise-worker-server, where both the trait and Rc are foreign -- which is where improvise-9x6 hit it and wrote bindings::Shared. It is NOT true in improvise-io, where the trait is LOCAL and the impl is simply allowed.\n\nSo: improvise-io ships a blanket `impl\u003cT: Storage + ?Sized\u003e Storage for Rc\u003cT\u003e`. bindings::Shared and its _shim_is_a_storage canary are DELETED, not moved, and no future worker writes one. Storage stays synchronous, which gotcha 28 says is not negotiable.\n\nTHE GUARD IS THE SIGNATURE: a_shared_backend_is_a_backend_and_a_clone_is_the_same_one does not compile without the impl. The body asserts what a hand-written newtype could still get wrong -- that a clone addresses the same backend -- and a fault injected into the forwarded `write` (made a no-op) fails it.\n\nTHE GENERALISABLE PART: an orphan-rule refusal is a fact about WHERE YOU ARE STANDING, not about the pair of types. Before working around one, check whether the impl belongs in the crate that owns the trait.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-9mo9","title":"[bug] Geometry effects that renumber the grid but never reset the cursor","description":"Found by the code review of improvise-gvnb (2026-09-19). A PRE-EXISTING gap that gvnb inherited rather than introduced -- but gvnb is what makes it matter, because the epoch is now what tells every client their cursor is invalid.\n\nApp::reset_cursor bumps cursor_epoch; Sessions::adopt_cursor_epoch watches the COUNTER and broadcasts Command::ResetCursor to all. Nothing declares which effects renumber the grid -- deliberately, per gotcha 31 -- which means a geometry effect that forgets reset_cursor is INVISIBLE IN BOTH DIRECTIONS: the host own cursor stays stale AND no client is told.\n\nToday only TransposeAxes and CycleAxis call it. These also renumber rows or columns and do not:\n SetAxis, HideItem, ShowItem, ToggleGroup, TogglePruneEmpty, AddCategory, RemoveCategory\n (plus AddItem/RemoveItem, which are CellData rather than LayoutGeometry)\n\nSetAxis IS THE SHARPEST CASE: CycleAxis is literally SetAxis to Axis::next() and resets; SetAxis does not.\n\nWHY THE REVIEW DID NOT JUST PATCH IT: SetAxis is used by DrillIntoCell (cmd/grid.rs:365-471) to build the _Drill view, and resetting there could discard the user position in the view they drilled FROM, depending on effect order. That is a decision about effect ordering and intended UX, not a patch.\n\nKEEP THE NEGATIVE ARM. a_model_command_that_does_not_reshape_the_grid_leaves_cursors_alone uses SetNumberFormat -- a LayoutGeometry effect that must NOT reset. Without it, \"reset on every model command\" would pass the positive test. Whatever you do here, do not lose it.\n\n## Steps to Reproduce\n\n1. In a view with a cursor somewhere other than the origin, apply any of `SetAxis`, `HideItem`, `ShowItem`, `ToggleGroup`, `TogglePruneEmpty`, `AddCategory`, `RemoveCategory`, `AddItem` or `RemoveItem`.\n2. The grid renumbers and the cursor is not reset, so it addresses a position that has moved.\n3. Worse for other clients: `reset_cursor` is what bumps `cursor_epoch`, and `Sessions::adopt_cursor_epoch` watches that counter to broadcast `Command::ResetCursor`. No reset means **no client is told either**.\n\n`SetAxis` is the sharpest case: `CycleAxis` *is* `SetAxis` to `Axis::next()` and does reset.\n","acceptance_criteria":"Each of SetAxis, HideItem, ShowItem, ToggleGroup, TogglePruneEmpty, AddCategory, RemoveCategory, AddItem and RemoveItem either calls reset_cursor or carries a one-line reason why it does not. SetAxis is the sharp case — CycleAxis IS SetAxis to Axis::next() and resets, while SetAxis does not — and DrillIntoCell uses SetAxis to build the _Drill view, where resetting could discard the position in the view drilled FROM, so that is a decision about effect ordering and intended UX, not a patch. KEEP THE NEGATIVE ARM: a_model_command_that_does_not_reshape_the_grid_leaves_cursors_alone uses SetNumberFormat, and without it 'reset on every model command' passes the positive test. Note improvise-2zq changed which axis a new category lands on, so re-read on_category_added before assuming AddCategory renumbers the way it used to.","status":"closed","priority":2,"issue_type":"bug","assignee":"wave2-n-cleanups","owner":"el-github@elangley.org","created_at":"2026-09-20T01:05:15Z","created_by":"Edward Langley","updated_at":"2026-09-20T17:57:44Z","started_at":"2026-09-20T17:53:45Z","closed_at":"2026-09-20T17:57:44Z","close_reason":"Closed","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-s1xw","title":"Multi-level grid labels on the wire -- the browser shows Food/Alice where the TUI shows two columns","description":"THE LAST THING KEEPING GridLayout INSIDE GridWidget, and a LIVE DIVERGENCE today, not a hypothetical. Found closing improvise-n10.\n\nGridCache::row_labels / col_labels are Vec\u003cString\u003e produced by GridLayout::row_label, which JOINS A MULTI-LEVEL TUPLE WITH A SLASH. That is exactly the rendering src/ui/grid.rs::two_row_categories_produce_cross_product_labels asserts must NOT appear (\"slash-joined labels should be gone\"). So the protocol carries a form of the labels THE TUI ITSELF CONSIDERS WRONG, and crates/improvise-wasm-client/src/render.rs renders precisely that form.\n\nConsequence: the browser grid has no multi-level headers, no repeat suppression, no groups and no collapse indicators, and shows Food/Alice where the TUI shows two header columns.\n\nSIX FACTS THE CACHE DOES NOT CARRY (exhaustive as of improvise-n10, by grep over the non-test body of src/ui/grid.rs):\n 1. number of header levels -- layout.row_cats / col_cats\n 2. multi-level label TUPLES -- layout.row_items (AxisEntry::DataItem(Vec\u003cString\u003e))\n 3. inline GROUP HEADER rows -- layout.row_items (AxisEntry::GroupHeader)\n 4. column GROUP SPANS -- layout.col_group_for(ci)\n 5. collapse indicator (down/right triangle) -- view.is_group_collapsed(cat, group)\n 6. records-mode column identity -- layout.record_col(ci). THIS ONE IS CORRECT TO KEEP OFF: it keys pending drill edits and is session-local by design.\n\nSo 1-5 are the gap.\n\nTWO CONSTRAINTS ON THE FIX:\n * IT IS A CO-ORDINATED CHANGE, NOT AN ADDITIVE ONE. row_labels/col_labels change TYPE, and improvise-wasm-client/src/render.rs consumes both fields, so the client updates in the same change.\n * DO NOT ADD row_levels BESIDE row_labels. Two answers to \"what are the labels\" is the disease this whole projection architecture exists to cure -- see improvise-edp, where the category panel was walked twice, two different ways.","status":"closed","priority":2,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T00:58:33Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:41:34Z","started_at":"2026-09-20T05:32:27Z","closed_at":"2026-09-20T07:41:34Z","close_reason":"Closed as ABSORBED BY improvise-anr9, verified explicitly rather than assumed. s1xw enumerated six facts the cache did not carry; PR #23 landed 1-5 (header levels, multi-level label tuples, inline group headers, column group spans, the collapse indicator) and 6 is record_col, which s1xw itself says is CORRECT to keep off -- session-local, keys pending drill edits. Both of its stated constraints were honoured: it was a co-ordinated type change (row_labels/col_labels changed TYPE and improvise-wasm-client/src/render.rs updated in the same change) and no row_levels was added beside row_labels. The live divergence it reported -- the browser drawing Food/Alice where the TUI draws two header columns -- is closed, and tests/grid_frame_agreement.rs now drives both front ends over one cache and compares what each draws.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-4zl7","title":"A fitted tree must route per record and aggregate the results, not route on the aggregate","description":"Successor to improvise-8xhy, which shipped remedy 1 (refuse at emit) as an explicit interim. This is the remedy that produces a formula A HUMAN WOULD HAVE WRITTEN, and it is the right end state -- but it was costed during 8xhy and it is much larger than it looks.\n\nANSWER THIS FIRST, BEFORE TOUCHING THE GRAMMAR. A fitted measure cell is evaluated at a CellKey context. In any view that aggregates over the split category -- Region on Page or None -- context.get(\"Region\") is None, and today Formula::filter falls straight through to aggregate_raw. So a categorical fit would SILENTLY DEGRADE to something else in exactly the views a pivot-table user spends most of their time in. That is a product decision about what a fit MEANS for an aggregated cell, not an emitter detail.\n\nSCOPE, once that is answered:\n * Expr::Is(Filter) evaluating to 1.0/0.0. This keeps the f64-only evaluator and avoids introducing string comparison into eval_expr. The primitive already exists in the right shape -- types.rs reads context.get(\u0026filter.category), the current cell own coordinate.\n * A grammar production plus tokenizer handling in the 1109-line parser.rs, with quoting rules for the category and item inside it (gotcha 34 -- one home, improvise_formula::quote).\n * Arms in BOTH evaluators: eval_expr and eval_expr_cached.\n * Coverage in the grammar-walking generator.\n * Thread Dataset::columns (the ColumnSpec/ColumnKind plan) into the emitter, so feature index -\u003e role is read off preprocess own plan. Do NOT reconstruct it: Spec::names is \u0026[\u0026str], and reconstructing index-\u003erole is a second implementation of preprocess expansion order, exactly the drift risk gotcha 41 names in another key.\n * .improv is free -- the writer emits Formula::raw.\n\nIT CLOSES ONLY A THIRD OF 8xhy EITHER WAY. A WHERE predicate covers the one-hots. col.missing means \"this record has no value for measure X\", a different vocabulary. The date sub-features (when.year, when.month) need date arithmetic improvise does not have at all. Those stay refused, or get their own tickets.\n\nPAIRS WITH improvise-vx3 (the fit-measure command), which is where a user would actually learn about the restriction.","acceptance_criteria":"A fitted measure evaluated at an aggregating cell routes PER RECORD and aggregates the results, rather than routing on the aggregate. fit-measure on web/demo.improv succeeds and the resulting formula means the same thing on a record row and one row up. The seed-dependence is gone: whether the command succeeds no longer depends on which features the search happened to pick. Note what this costs — evaluate_aggregated deliberately does not iterate the records behind a formula cell — and state how it interacts with gotcha 9's fixed point and Model::formula_cache. col.missing and the date sub-features remain out of scope.","notes":"USER RULING 2026-09-20: AGGREGATE THE RULE OVER THE MEMBERS. Route-then-aggregate, per record. The other three options -- Error(\"aggregated\"), refuse at fit time, and fall through to aggregate_raw -- are all rejected.\n\nTHE BEAD'S SCOPE HAS CHANGED AND THE TITLE NOW SAYS SO. It asked what a CATEGORICAL split means in a cell that aggregates over the split category. PR #25's review established the question has nothing to do with the split being categorical:\n\nA tree is fitted on individual RECORDS. A formula is evaluated at a CellKey, and in any aggregating cell evaluate_aggregated aggregates each Expr::Ref FIRST and then evaluates the IF. A linear formula survives that -- SUM(Revenue) - SUM(Cost) is SUM(Revenue - Cost) -- and a tree does not, because AGGREGATE-THEN-ROUTE IS NOT ROUTE-THEN-AGGREGATE. So EVERY fitted tree, numeric splits included, means one thing on a record row and something else one row up.\n\nIT IS NOT THEORETICAL: fit-measure Revenue on the repo's own shipped web/demo.improv is refused, naming Product=Espresso/Product=Latte. And because which features a champion picks is a property of the SEARCH, on a document with several categories the same command refuses or succeeds DEPENDING ON THE SEED. That is not a contract.\n\nWHAT THE RULING COSTS, stated plainly because it is the expensive answer and was chosen anyway: evaluate_aggregated deliberately does NOT iterate the underlying records for a formula cell -- it aggregates the refs and evaluates once. Routing per record means a formula cell must be able to ask for the records behind it and fold over them. That touches the evaluator, and it interacts with gotcha 9 (fixed-point recomputation) and with Model::formula_cache, which is where derived values live precisely so raw data and derived data stay separate.\n\nNOTE WHAT IT BUYS BESIDES CORRECTNESS: it is what a user means. A fitted rule applied to a group should say what the rule says about the members, summed -- not what the rule says about a fictional average member. The second is the thing that looks right and is wrong, which is the failure mode vx3's own ticket warned about ('a rule the user will believe').\n\nALSO NOTE: this ruling supersedes improvise-8xhy's remedy 1 (refuse at emit) as the END STATE, but not as the interim -- the refusal stays until this lands, or fit-measure silently produces the wrong number in aggregating views.\n\nTHE TWO THIRDS THIS STILL DOES NOT COVER, unchanged from the original text: col.missing means 'this record has no value for measure X', a different vocabulary; and the date sub-features (when.year, when.month) need date arithmetic improvise does not have at all. Those stay refused or get their own beads.","status":"in_progress","priority":2,"issue_type":"feature","assignee":"wave3-4zl7","owner":"el-github@elangley.org","created_at":"2026-09-20T00:45:22Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:37:13Z","started_at":"2026-09-20T05:32:41Z","labels":["area:gp"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-4zl7","title":"A fitted tree must route per record and aggregate the results, not route on the aggregate","description":"Successor to improvise-8xhy, which shipped remedy 1 (refuse at emit) as an explicit interim. This is the remedy that produces a formula A HUMAN WOULD HAVE WRITTEN, and it is the right end state -- but it was costed during 8xhy and it is much larger than it looks.\n\nANSWER THIS FIRST, BEFORE TOUCHING THE GRAMMAR. A fitted measure cell is evaluated at a CellKey context. In any view that aggregates over the split category -- Region on Page or None -- context.get(\"Region\") is None, and today Formula::filter falls straight through to aggregate_raw. So a categorical fit would SILENTLY DEGRADE to something else in exactly the views a pivot-table user spends most of their time in. That is a product decision about what a fit MEANS for an aggregated cell, not an emitter detail.\n\nSCOPE, once that is answered:\n * Expr::Is(Filter) evaluating to 1.0/0.0. This keeps the f64-only evaluator and avoids introducing string comparison into eval_expr. The primitive already exists in the right shape -- types.rs reads context.get(\u0026filter.category), the current cell own coordinate.\n * A grammar production plus tokenizer handling in the 1109-line parser.rs, with quoting rules for the category and item inside it (gotcha 34 -- one home, improvise_formula::quote).\n * Arms in BOTH evaluators: eval_expr and eval_expr_cached.\n * Coverage in the grammar-walking generator.\n * Thread Dataset::columns (the ColumnSpec/ColumnKind plan) into the emitter, so feature index -\u003e role is read off preprocess own plan. Do NOT reconstruct it: Spec::names is \u0026[\u0026str], and reconstructing index-\u003erole is a second implementation of preprocess expansion order, exactly the drift risk gotcha 41 names in another key.\n * .improv is free -- the writer emits Formula::raw.\n\nIT CLOSES ONLY A THIRD OF 8xhy EITHER WAY. A WHERE predicate covers the one-hots. col.missing means \"this record has no value for measure X\", a different vocabulary. The date sub-features (when.year, when.month) need date arithmetic improvise does not have at all. Those stay refused, or get their own tickets.\n\nPAIRS WITH improvise-vx3 (the fit-measure command), which is where a user would actually learn about the restriction.","acceptance_criteria":"A fitted measure evaluated at an aggregating cell routes PER RECORD and aggregates the results, rather than routing on the aggregate. fit-measure on web/demo.improv succeeds and the resulting formula means the same thing on a record row and one row up. The seed-dependence is gone: whether the command succeeds no longer depends on which features the search happened to pick. Note what this costs — evaluate_aggregated deliberately does not iterate the records behind a formula cell — and state how it interacts with gotcha 9's fixed point and Model::formula_cache. col.missing and the date sub-features remain out of scope.","notes":"USER RULING 2026-09-20: AGGREGATE THE RULE OVER THE MEMBERS. Route-then-aggregate, per record. The other three options -- Error(\"aggregated\"), refuse at fit time, and fall through to aggregate_raw -- are all rejected.\n\nTHE BEAD'S SCOPE HAS CHANGED AND THE TITLE NOW SAYS SO. It asked what a CATEGORICAL split means in a cell that aggregates over the split category. PR #25's review established the question has nothing to do with the split being categorical:\n\nA tree is fitted on individual RECORDS. A formula is evaluated at a CellKey, and in any aggregating cell evaluate_aggregated aggregates each Expr::Ref FIRST and then evaluates the IF. A linear formula survives that -- SUM(Revenue) - SUM(Cost) is SUM(Revenue - Cost) -- and a tree does not, because AGGREGATE-THEN-ROUTE IS NOT ROUTE-THEN-AGGREGATE. So EVERY fitted tree, numeric splits included, means one thing on a record row and something else one row up.\n\nIT IS NOT THEORETICAL: fit-measure Revenue on the repo's own shipped web/demo.improv is refused, naming Product=Espresso/Product=Latte. And because which features a champion picks is a property of the SEARCH, on a document with several categories the same command refuses or succeeds DEPENDING ON THE SEED. That is not a contract.\n\nWHAT THE RULING COSTS, stated plainly because it is the expensive answer and was chosen anyway: evaluate_aggregated deliberately does NOT iterate the underlying records for a formula cell -- it aggregates the refs and evaluates once. Routing per record means a formula cell must be able to ask for the records behind it and fold over them. That touches the evaluator, and it interacts with gotcha 9 (fixed-point recomputation) and with Model::formula_cache, which is where derived values live precisely so raw data and derived data stay separate.\n\nNOTE WHAT IT BUYS BESIDES CORRECTNESS: it is what a user means. A fitted rule applied to a group should say what the rule says about the members, summed -- not what the rule says about a fictional average member. The second is the thing that looks right and is wrong, which is the failure mode vx3's own ticket warned about ('a rule the user will believe').\n\nALSO NOTE: this ruling supersedes improvise-8xhy's remedy 1 (refuse at emit) as the END STATE, but not as the interim -- the refusal stays until this lands, or fit-measure silently produces the wrong number in aggregating views.\n\nTHE TWO THIRDS THIS STILL DOES NOT COVER, unchanged from the original text: col.missing means 'this record has no value for measure X', a different vocabulary; and the date sub-features (when.year, when.month) need date arithmetic improvise does not have at all. Those stay refused or get their own beads.","status":"open","priority":2,"issue_type":"feature","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T00:45:22Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:45:26Z","started_at":"2026-09-20T05:32:41Z","labels":["area:gp"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-hwpi","title":"The browser shell fails quietly, and never reads a key","description":"Found by the improvise-cr3 code review (2026-09-19) by READING web/app.js. There is no browser and no headless DOM in this environment, so none of this is observed -- it is what the code says will happen, and it is filed rather than fixed because each item is a judgement call about the shell rather than a defect in the Rust.\n\nFOUR THINGS, in descending order of how much they would cost a person at a demo:\n\n1. NO KEYBOARD AT ALL. #grid carries tabindex=\"0\" but the page registers no keydown handler, so Client::on_key_event -- and the whole keys.rs classifier and the lockstep machinery behind it -- is UNREACHED from the browser. Consistent with improvise-bpq2 (ClientMsg has no Key variant server-side) but larger than it: even a LOCAL key, one that needs no round trip, does nothing. improvise-1ey acceptance (3) is \"arrow keys move the cursor locally with no round-trip\", and nothing in the page can do it.\n\n2. A MALFORMED FRAME IS QUIET. client.on_frame(event.data) returns Result\u003c_, JsError\u003e, which wasm-bindgen turns into a THROWN EXCEPTION. It is thrown inside the message listener with no try, so it surfaces as an uncaught error in the devtools console and the following repaint() is skipped. Nothing in the UI says so: the grid simply stops updating. A try/catch writing to el.status would make it visible.\n\n3. NO error LISTENER ON THE SOCKET. A connection that never opens leaves #status blank -- only close writes \"disconnected\". So \"server not running\" and \"still connecting\" look the same.\n\n4. close() RUNS TWICE ON COMMIT, benignly. Enter -\u003e close(true) -\u003e td.replaceChildren(original) removes the focused \u003cinput\u003e -\u003e blur fires -\u003e close(false). editing is already null so it is harmless (one extra repaint), but there is no re-entry guard and only a real browser would show it.\n\nNOTE WHAT MAKES THIS TICKET DIFFERENT: items 2-4 are the kind of thing a test would catch instantly and we have no way to run one. Before fixing them, consider whether the right first move is a headless DOM in CI (jsdom or playwright) -- otherwise the fixes are as unverified as the bugs. improvise-xf1 (verify OpfsFs against a real browser) wants the same harness.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T00:40:36Z","created_by":"Edward Langley","updated_at":"2026-09-20T02:29:50Z","closed_at":"2026-09-20T02:29:50Z","close_reason":"All four items done, plus a fifth the ticket could not have predicted.\n\n1. KEYDOWN WIRED -- a window listener feeding Client::on_key_event. preventDefault fires only when a frame was produced and no Ctrl/Alt is held, so browser shortcuts stay the browser's; metaKey is skipped entirely, matching BrowserKey::to_key_event dropping Meta so Cmd-R cannot arrive as a bare r. The inline editor stopPropagation keeps it out of the way, with an if (editing) return as belt and braces.\n2. MALFORMED FRAME -- try/catch around on_frame writing to #status, plus console.error with the offending frame. render_model() is guarded the same way; it also returns a Result.\n3. SOCKET error LISTENER -- 'cannot reach ... -- is improvise-ws-server running?', so that no longer reads like 'still connecting'.\n4. close() RE-ENTRY GUARD -- a closed flag in edit().\n\nTHE FIFTH, AND IT IS THE INTERESTING ONE: a worker whose start() REJECTS is caught INSIDE worker.js, so it fires NO error event on the main thread. The page would have sat on 'in-page worker - demo.improv' over an empty grid forever -- THIS TICKET'S EXACT DISEASE, REBUILT IN THE NEW DEPLOYMENT ON DAY ONE. The guard is a first-frame watchdog (8s, cleared by the first frame), and it deliberately covers BOTH transports rather than each peer's own failure modes, because 'the peer never answered the handshake' is the one condition that means the same thing over a socket and over a port -- a check aimed per-peer would have missed exactly this one.\n\nKEYS ARE INERT AND THAT IS STATED, NOT HIDDEN. Host::handle answers ClientFrame::Key with an empty vec; improvise-bpq2 is the other half.\n\nSTILL UNRUN -- and this ticket predicted that would be the problem: 'consider whether the right first move is a headless DOM, otherwise the fixes are as unverified as the bugs.' That sentence is now true OF THESE FIXES. Three of the four are one-assertion Playwright tests. See the harness ticket.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-zv4m","title":"improvise-protocol should own the websocket framing, so both ends stop mirroring it","description":"Raised by improvise-cr3 (2026-09-19) as the thing to push back on, and filed rather than fixed there.\n\nTODAY: improvise-wasm-client/src/socket.rs declares ClientFrame/ServerFrame, which MIRROR improvise-ws-server/src/wire.rs ClientMsg/ServerMsg field for field. The client cannot depend on the server crate -- tokio, tungstenite, Box\u003cdyn Storage\u003e, all native -- so the duplication was the only way to speak the protocol from wasm.\n\nWHAT HOLDS THE LINE MEANWHILE: improvise-ws-server is a DEV-dependency of the client (the same argument the crate already makes for improvise-command), and the_socket_framing_matches_the_server round-trips every variant THROUGH THE SERVERS OWN TYPES in both directions; a_frame_named_the_other_envelopes_way_does_not_parse is its check-the-checker. So a drift is caught -- but only by a test whose job is to notice, rather than by a shared definition that cannot drift.\n\nTHE FIX: move the framing into improvise-protocol, which both ends already depend on and which is the crate whose charter is exactly this -- \"the wire vocabulary and the portable session slice\". Then the mirror, the dev-dependency and both compatibility tests all delete.\n\nNOTE THE THIRD ENVELOPE while you are in here: improvise-wasm-client/src/wire.rs has ClientMessage/ServerMessage framing the WORKER deployment (improvise-tm6). Three envelopes for two deployments is worth resolving in one pass -- decide whether the worker and the socket want the same vocabulary before moving either.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T00:37:29Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:37:50Z","closed_at":"2026-09-20T00:37:50Z","close_reason":"Duplicate of improvise-xks6 ('One home for the client/server frame envelopes'), filed minutes apart from the improvise-cr3 hand-back without spotting the existing ticket. Closing this one; xks6 is the survivor and its description should absorb two facts this one carried:\n\n * WHAT HOLDS THE LINE MEANWHILE: improvise-ws-server is a DEV-dependency of improvise-wasm-client, and the_socket_framing_matches_the_server round-trips every variant THROUGH THE SERVERS OWN TYPES in both directions, with a_frame_named_the_other_envelopes_way_does_not_parse as its check-the-checker. The drift is caught by a test whose job is to notice, rather than prevented by a shared definition.\n * THERE ARE THREE ENVELOPES, NOT TWO: improvise-wasm-client/src/wire.rs frames the WORKER deployment (improvise-tm6) separately again. Decide whether the worker and the socket want the same vocabulary before moving either, or the move produces two homes instead of one.\n\n(See also memory agent-issue-drift-pattern -- search before filing.)","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-8xhy","title":"[bug] A fit over a categorical feature emits an unevaluable formula","description":"Found during improvise-7na (2026-09-19), verified by probe, and pinned as a characterization test rather than fixed.\n\ngp_tree::data::preprocess one-hots a categorical column into a feature named Region=North. That is an ENCODING ARTEFACT, not an item of the document, so the emitted formula Expr::Ref fails Model::find_item_category and EVERY CELL of the fitted measure evaluates to Error(\"ref:Region=North\"). col.missing and the col.year / col.month date sub-features are in exactly the same position.\n\nSo \"fit a tree and get a working formula\" holds ONLY for a fit over numeric measures.\n\nTHE TEST THAT SHOULD HAVE CAUGHT IT WAS LYING: improvise_evaluation_matches_gp_tree_eval cannot see this, because its improvise_eval helper FABRICATES a _Measure item for every feature name, including outlook=rainy. It asserts the emitter and gp_tree::eval agree, which they do -- against a model that could not exist. Worth remembering as a shape: a helper that builds the world the code wants is not a test of whether that world exists.\n\nPinned as a_split_on_a_one_hot_feature_does_not_evaluate -- characterization, flip it when fixed.\n\nTHREE REMEDIES, pick deliberately: restrict fits to numeric features (smallest, and honest about what works); materialise one-hot columns as real _Measure items (makes the formula evaluable but pollutes the document with encoding artefacts); or teach the emitter improvise WHERE vocabulary so a categorical split becomes a real predicate over the category (largest, and the only one that produces a formula a human would have written). The third is probably right and should be costed before the first is shipped as permanent.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T00:35:05Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:45:21Z","closed_at":"2026-09-20T00:45:21Z","close_reason":"Fixed as REMEDY 1, explicitly labelled interim. fitted_formula now takes the \u0026Model the formula is to be stored in and refuses a tree whose splits it cannot resolve, listing them. formula_text is untouched -- it is the tree's own text and there is nothing else it could write. Nothing outside the crate called fitted_formula.\n\nWHY THE EMITTER AND NOT THE DATASET LAYER: dropping categorical columns before the fit is smaller, but it throws away real signal (permutation importance, and any future remedy-3 emitter) and makes the crate's halves disagree -- one-hot encoding is gp-tree's whole job. The defect is that the EMITTER spells a formula the document cannot evaluate.\n\nWHY THE CHECK ASKS THE DOCUMENT, NOT THE NAME'S SHAPE: refusing names containing '=' or matching .missing would be a syntactic proxy and a wrong one, because a measure LITERALLY NAMED Region=North is a legal document and must be accepted. The two are indistinguishable as strings; only the document can tell them apart. So it calls Model::find_item_category -- the exact precondition Expr::Ref has -- which covers .missing, the date parts, and any future sub-feature naming scheme with no second copy of preprocess's expansion rules to drift from. Both directions pinned.\n\nTHE LYING TEST IS KEPT, NOT DELETED. Quoting and threshold routing are real things improvise_evaluation_matches_gp_tree_eval checks, and outlook=rainy earns its place there as an awkward NAME TO SPELL. What changed is that it and its helper now state their scope -- a test of arithmetic and quoting against a STIPULATED model, unable to see whether such a model could exist. The property it could not state is asserted separately against a real document: a_formula_the_emitter_accepted_evaluates_everywhere -- if fitted_formula says Ok, no cell of the target is a ref: error. That test and the refusal test are each other's check; without the accepting case the refusal could pass by refusing everything. The characterization is flipped to a_split_on_a_one_hot_feature_is_refused.\n\nREMEDY 3 IS STILL RIGHT AND IS NOT THIS TICKET -- successor filed. It is much larger than it looked: improvise has NO SCALAR COORDINATE PREDICATE (Filter gates a formula or an aggregate, it is not an Expr), it would still leave col.missing and the date parts uncovered, and it must first answer what such a predicate means where context.get(cat) is None.","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -168,20 +166,13 @@ {"_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-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":"open","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-20T18:53:08Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-9iyv","title":"The route-then-aggregate fold is unbenchmarked","description":"improvise-4zl7 made `SUM(\u003cexpr\u003e)` fold over the member records rather than over\nthe raw matching cells. The fold is not cached and not memoised: for one\naggregated formula cell it walks `matching_cells(partial)`, dedupes the record\ntuples through an `IndexSet`, and evaluates the inner expression once per\nrecord. `recompute_formulas` runs that for every stem, every formula target, and\nup to `MAX_EVAL_DEPTH` (16) passes.\n\nNothing here is known to be slow. Nothing here has been measured either, and the\nwork is O(cells + members × expression size) per cell per pass where the\nprevious code was one `matching_values` scan. A routed formula over a document\nwith thousands of records is the shape to measure.\n\nNote what must NOT be \"fixed\" by memoising carelessly: `formula_cache` holds\nderived values and the fold deliberately reads *raw* data through the same\n`evaluate_depth` / `aggregate_raw` path the non-folding evaluator uses, which is\nwhy the fixed point still converges (gotcha 9, context/code/core.md 3 and 13). A\nper-record memo keyed by anything less than the whole record key would be a\nsecond answer to \"what does this cell depend on\".\n\n## Steps to Reproduce\n\n1. Build a document with several thousand records — `examples/gen-grammar.rs`\n or a CSV import will do.\n2. Add a routing formula over them, e.g.\n `Pred = SUM(IF(Cost \u003c 50, 1, 10))`, or run `fit-measure` and store the\n result.\n3. Time `Model::recompute_formulas`, and compare against the same document with\n a linear formula (`Profit = Revenue - Cost`), which takes the `Expr::Ref`\n fast path and does not fold.\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.","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-20T18:51:21Z","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":"open","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-20T18:50:51Z","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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:45:39Z","created_by":"wave2-h-frontend","updated_at":"2026-09-20T18:58:18Z","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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:33:24Z","created_by":"wave2-e-active-view","updated_at":"2026-09-20T18:33:24Z","labels":["area:active-view"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-w3bf","title":"A session mirror's active_view is written in four places, none through reduce_view","description":"Found by the adversarial review of PR #48 (improvise-jytm). Not a defect today: the\nfour writers agree. It is the SHAPE the divergence bugs in this area have taken every\ntime, filed before it becomes one.\n\n`ClientSession::view.active_view` — the server's mirror of a client's view — is\nwritten in four places, and none of them goes through `reduce_view`:\n\n1. `Sessions::apply_switch` — sets it directly from the accepted name.\n2. `Sessions::adopt_deleted_views` — sets it directly to the document default.\n3. `ClientSession::reseed` — replaces the whole slice via `fresh_view`.\n4. `Sessions::connect` — same, through `reseed`.\n\nEverywhere else in this codebase, the mirror stays in step with the client by folding\nthe SAME command the client folds (`ClientSession::fold` → `reduce_view`, the\n\"same function runs on both ends\" rule of improvise-cqq/m91). These four bypass it.\nThey are correct today only because `reduce_view`'s `SwitchView` arm is one line\n(`view.active_view = name.clone()`) and each of these reproduces it.\n\nThe day that arm grows a second statement — clearing a per-view buffer, resetting a\ncursor, anything — the mirror and the client diverge and nothing fails. That is\nexactly gotcha 36's \"folding SwitchView into the mirror alone was the bug\", arriving\nfrom the other direction.\n\nTwo shapes worth weighing:\n\n- Route all four through `reduce_view(\u0026mut session.view, \u0026Command::SwitchView(name))`.\n Cheap, and makes the bypass unrepresentable for this command.\n- A test that the two agree: fold a `SwitchView` into a bare `ViewState` and into a\n `ClientSession` and compare. Weaker (it is a compatibility test, and\n `doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)` says\n those are a definition waiting to be made) but it costs nothing.\n\n## Steps to Reproduce\n\nNot reproducible today; the four copies agree. To make it reproduce: add any second\nstatement to `reduce_view`'s `Command::SwitchView` arm in\n`crates/improvise-protocol/src/reduce.rs` — for instance clearing `status_msg` — then\nrun the suite. It stays green, and a client's own slice now carries something its\nserver-side mirror does not.\n","acceptance_criteria":"Either the four writers route through reduce_view, or a test pins that a ClientSession's mirror and a bare ViewState fold a SwitchView identically. Closing it as 'the copies agree' is not acceptable — that is the state it is filed in.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:32:43Z","created_by":"wave2-e-active-view","updated_at":"2026-09-20T18:32:43Z","labels":["area:active-view"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_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.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:23:51Z","created_by":"orchestrator","updated_at":"2026-09-20T19:08:21Z","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":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:11:01Z","created_by":"wave2-b-projection","updated_at":"2026-09-20T18:11:01Z","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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:06:03Z","created_by":"orchestrator","updated_at":"2026-09-20T18:06:03Z","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.","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-20T18:06:00Z","labels":["area:active-view"],"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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:05:51Z","created_by":"orchestrator","updated_at":"2026-09-20T18:05:51Z","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":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:04:14Z","created_by":"wave2-h-frontend","updated_at":"2026-09-20T18:04:14Z","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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:01:04Z","created_by":"wave2-p-milestones","updated_at":"2026-09-20T18:01:04Z","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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T17:57:25Z","created_by":"wave2-n-cleanups","updated_at":"2026-09-20T17:57:25Z","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-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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T17:57:25Z","created_by":"wave2-n-cleanups","updated_at":"2026-09-20T17:57:25Z","labels":["area:command"],"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-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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T07:49:56Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:57:52Z","labels":["area:protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -265,7 +256,7 @@ {"_type":"issue","id":"improvise-jjx","title":"Scrolled grid suppresses first visible row-header label","description":"src/ui/grid.rs:338-350: repeat-suppression shows a row-header sub-label only when ri == 0 or the prefix differs from data_row_items[ri-1]. ri is the absolute data-row index (starts at row_offset, grid.rs:269), so when the view is scrolled the first visible row compares against the off-screen row above and its label is blanked — the user loses the group context at the top of the viewport. Fix: compare against the previous *visible* row, i.e. always show labels for the first rendered data row. 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:58:38Z","created_by":"Edward Langley","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-byz","title":"records_display returns Some(\"\") for out-of-bounds columns instead of None","description":"crates/improvise-core/src/view/layout.rs:186-202: records_display() calls col_label(col), which returns an empty string for out-of-bounds indices; the empty string then fails the == \"Value\" check, is looked up as a category name, and the function returns Some(\"\") via unwrap_or_default(). cell_key() (~line 396) explicitly returns None for an empty col_label — records_display should be symmetric so view/data mismatches are not masked. 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:58:35Z","created_by":"Edward Langley","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-zgb","title":".improv formula loader mis-splits category suffix via rfind(\" [\")","description":"crates/improvise-io/src/persistence/mod.rs:403-411: the formulas_section walker finds the [Category] suffix with raw.rfind(\" [\") + ends_with(']'). Two failure modes: (1) a pipe-quoted identifier at the end of the formula containing ' [' (e.g. |Revenue [USD]|) is mis-split, treating part of the quoted name as the category; (2) a suffix written without a leading space (X=Y[Cat]) is not recognized and the whole line is parsed as a _Measure formula, losing the target category. Fix: move the category suffix into the pest grammar instead of string scanning. Write failing tests first per AGENTS.md. 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:58:32Z","created_by":"Edward Langley","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-drg","title":"vb4 step 5: narrow Effect::apply signatures where one-slice","description":"Optional follow-up to vb4 structural split. For each impl Effect for Foo, audit which slice(s) it mutates. If apply only touches ModelState (or only ViewState), narrow its signature from fn apply(\u0026self, app: \u0026mut App) to fn apply(\u0026self, model: \u0026mut ModelState) (or ViewState). Cross-cutting effects (read layout, change mode that triggers layout rebuild, etc.) stay on \u0026mut App. Improves type-level documentation and enables finer-grained effect tests. Lower priority — vb4's acceptance criterion (4) explicitly allows leaving cross-cutting on \u0026mut App.","design":"Hidden trait-design decision surfaced during vb4 step 4 review: the issue's\nphrasing — \"narrow its signature from fn apply(\u0026self, app: \u0026mut App) to\nfn apply(\u0026self, model: \u0026mut ModelState)\" — is not directly possible. You\ncannot change one Effect impl's signature without changing the Effect\ntrait. Three approaches, none picked:\n\n(A) Split into multiple traits — ModelEffect / ViewEffect / AppEffect.\n Each impl becomes the narrower thing it actually is. Cost: ~50\n existing impls reorganize across three traits, and Vec\u003cBox\u003cdyn Effect\u003e\u003e\n has to become an enum wrapper (or three parallel queues) to keep\n apply_effects working. Highest fidelity to the issue's literal wording.\n\n(B) Keep one trait, rebind inside the impl body — e.g.\n fn apply(\u0026self, app: \u0026mut App) { let view = \u0026mut app.view_state; view.mode = ... }\n so the slice is visible at the call site without changing the\n signature. Lowest disruption. Doesn't strictly satisfy acceptance\n criterion (1) (a \"narrower borrow\") — only the body is narrower.\n\n(C) Default-method dispatch on a single trait — Effect grows\n apply_view(\u0026mut ViewState) and apply_model(\u0026mut ModelState) with\n default impls that fall through to apply(\u0026mut App). Each impl\n overrides only the method that matches its slice. Middle ground:\n type-level narrowing for impls that opt in, \u0026mut App stays available\n for cross-cutting effects. Wraps cleanly around the existing\n apply_effects loop.\n\nPicking (A) commits to the architecture for downstream effect tagging\n(improvise-mae) and the protocol extraction (improvise-cqi). (B) and (C)\nleave that decision unmade. Recommend deciding before claiming.","acceptance_criteria":"Each Effect whose apply touches only one slice takes \u0026mut ModelState or \u0026mut ViewState; cross-cutting effects stay on \u0026mut App, which vb4's own acceptance explicitly allows. The win is type-level documentation and finer-grained effect tests, not line count. CAUTION, new since this ticket was filed: App::apply_effects now reads dirtiness, layout_version and the record fingerprints off the EffectKind (improvise-gv9r, gotcha 40) — narrowing a signature must not put an effect somewhere that loop cannot see.","notes":"DECLINED 2026-09-20 (wave 2), with counts. Reasoning in context/code/command.md,\n'Effect::apply stays on \u0026mut App'. NOTE: this bead carries the `human` label, so\nthe decline is a recommendation the user may overturn; it is recorded rather than\nsmuggled.\n\nCOUNTED over the 49 `impl Effect` blocks in ui/effect.rs:\n now (this branch) 8 view_state-only, 9 model_state-only, 32 cross-cutting\n base 52507a6 9 view_state-only, 16 model_state-only, 24 cross-cutting\nThe narrow set fell from 25 to 17 in one wave. The mover was improvise-9mo9,\nwhich made nine reshaping effects call app.reset_cursor(): a reshape lives in\nmodel_state.workbook and the cursor it invalidates lives in ViewState, so the\neffects that matter most are precisely the ones that reconcile the two slices.\nNarrowing bets against the direction the code moves in.\n\nFOUR GROUNDS\n1. The narrow set is small and shrinking, for that architectural reason.\n2. The type-level documentation already exists and is CHECKED: Effect::kind()\n has no default (gotcha 3) and apply_effects reads that one answer three times\n (gotchas 40, 45). A narrowed signature is a second statement of the same fact,\n free to disagree -- the defect, not the cure.\n3. Every offered mechanism weakens a guard. (A) three traits needs three queues\n (destroys batch order, which gotchas 21/23/43 lean on) or an enum wrapper (a\n fourth classification beside EffectKind). (C) default methods give `apply` a\n DEFAULT, exactly what kind() does without and for the same reason: overriding\n apply_view on an effect that needs the model compiles and writes the wrong\n slice silently; overriding neither compiles as a no-op. (B) does not meet the\n ticket's own acceptance criterion.\n4. \"Finer-grained effect tests\" is not blocked: effect.rs's 64 tests already call\n Effect.apply(\u0026mut app) against a one-line App::new(wb, None) fixture.\n\nWHAT IS NOT DECLINED: approach (B), rebinding the slice at the top of an apply\nbody, remains available as a per-function style choice. It is not mandated.\nUSER CONFIRMATION 2026-09-20: the decline is CONFIRMED and the bead stays closed. This bead carries the 'human' label, so the WU-N agent correctly recorded its decline as a recommendation rather than acting on it unilaterally; the user was shown the counts (8 view-only / 9 model-only / 32 cross-cutting of 49, down from 9/16/24 at the wave base) and all three approaches (A: three traits; B: rebind in the body; C: default-method dispatch), and chose to confirm the decline.\n\nThe reasoning the user accepted: the classification ALREADY EXISTS and is checked — Effect::kind has no default and apply_effects reads that one answer three times — so a narrowed signature is a second statement free to disagree with it. Three traits need three queues (destroying batch order) or an enum wrapper (a fourth classification). Default methods would give apply a DEFAULT whose wrong answer is silent, which is precisely what kind()'s no-default rule exists to prevent.\n\nThe fact worth carrying past this bead: improvise-9mo9 moved nine effects from 'touches one slice' to 'cross-cuts' INSIDE THE SAME BRANCH that declined this, and that is not a coincidence to route around. The App exists because the grid's shape lives in model_state.workbook and the cursor into it lives in ViewState, so the effects that matter most are exactly the ones that RECONCILE the two slices. A design that rewards single-slice effects would have been pulling against the repo's actual centre of gravity.\nCOUNTS CORRECTED 2026-09-20, after adversarial review of PR #49. The decline\nstands; the numbers it was argued on were wrong twice and the user confirmed it\npartly on them, so the correction is recorded loudly rather than restated.\n\nPUBLISHED (wrong): 49 effects; 8 view-only, 9 model-only, 32 cross; narrow set\nfell 25 -\u003e 17.\nCORRECT: 57 effects; 16 view-only, 9 model-only, 32 cross; narrow set\nfell 32 -\u003e 25. Base 52507a6 was 16/16/25.\n\nTwo errors. (a) It counted only the 49 `impl Effect` blocks and omitted the 8\n`simple_effect!` invocations, which also expand to an impl and are ALL view-only.\n(b) It misclassified AddItem at the base commit, because the extraction regex\ncould not see `app` followed by a newline and `.model_state` -- which is\ndoctrine(derive_then_verify) catching the person quoting it, with the exact\nfailure that doctrine was written about.\n\nWHAT THIS COSTS THE ARGUMENT: 25 of 57 is 44% of effects that could narrow, not\nthe 35% claimed; and the fall is 22%, not 32%. The direction is unchanged and is\nstill the point -- the seven movers are SetAxis, HideItem, ShowItem, ToggleGroup,\nTogglePruneEmpty, RemoveItem and RemoveCategory, every one an effect\nimprovise-9mo9 gave a reset_cursor, so the shrinkage is architectural rather than\nincidental. But the headline was overstated by about half again, and anyone\nre-opening this should argue from 32 -\u003e 25 of 57.\n\nThe other three grounds for the decline are untouched by this: the classification\nalready exists and is checked; three traits break batch order and default methods\nreintroduce a silent default; and finer-grained tests are not blocked.","status":"closed","priority":3,"issue_type":"task","assignee":"wave2-n-cleanups","owner":"el-github@elangley.org","created_at":"2026-04-26T18:16:52Z","created_by":"jeeves","updated_at":"2026-09-20T18:57:42Z","started_at":"2026-09-20T18:08:16Z","closed_at":"2026-09-20T18:11:26Z","close_reason":"Closed","labels":["area:command","human"],"dependencies":[{"issue_id":"improvise-drg","depends_on_id":"improvise-99k","type":"blocks","created_at":"2026-04-26T11:17:16Z","created_by":"jeeves","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-drg","title":"vb4 step 5: narrow Effect::apply signatures where one-slice","description":"Optional follow-up to vb4 structural split. For each impl Effect for Foo, audit which slice(s) it mutates. If apply only touches ModelState (or only ViewState), narrow its signature from fn apply(\u0026self, app: \u0026mut App) to fn apply(\u0026self, model: \u0026mut ModelState) (or ViewState). Cross-cutting effects (read layout, change mode that triggers layout rebuild, etc.) stay on \u0026mut App. Improves type-level documentation and enables finer-grained effect tests. Lower priority — vb4's acceptance criterion (4) explicitly allows leaving cross-cutting on \u0026mut App.","design":"Hidden trait-design decision surfaced during vb4 step 4 review: the issue's\nphrasing — \"narrow its signature from fn apply(\u0026self, app: \u0026mut App) to\nfn apply(\u0026self, model: \u0026mut ModelState)\" — is not directly possible. You\ncannot change one Effect impl's signature without changing the Effect\ntrait. Three approaches, none picked:\n\n(A) Split into multiple traits — ModelEffect / ViewEffect / AppEffect.\n Each impl becomes the narrower thing it actually is. Cost: ~50\n existing impls reorganize across three traits, and Vec\u003cBox\u003cdyn Effect\u003e\u003e\n has to become an enum wrapper (or three parallel queues) to keep\n apply_effects working. Highest fidelity to the issue's literal wording.\n\n(B) Keep one trait, rebind inside the impl body — e.g.\n fn apply(\u0026self, app: \u0026mut App) { let view = \u0026mut app.view_state; view.mode = ... }\n so the slice is visible at the call site without changing the\n signature. Lowest disruption. Doesn't strictly satisfy acceptance\n criterion (1) (a \"narrower borrow\") — only the body is narrower.\n\n(C) Default-method dispatch on a single trait — Effect grows\n apply_view(\u0026mut ViewState) and apply_model(\u0026mut ModelState) with\n default impls that fall through to apply(\u0026mut App). Each impl\n overrides only the method that matches its slice. Middle ground:\n type-level narrowing for impls that opt in, \u0026mut App stays available\n for cross-cutting effects. Wraps cleanly around the existing\n apply_effects loop.\n\nPicking (A) commits to the architecture for downstream effect tagging\n(improvise-mae) and the protocol extraction (improvise-cqi). (B) and (C)\nleave that decision unmade. Recommend deciding before claiming.","acceptance_criteria":"Each Effect whose apply touches only one slice takes \u0026mut ModelState or \u0026mut ViewState; cross-cutting effects stay on \u0026mut App, which vb4's own acceptance explicitly allows. The win is type-level documentation and finer-grained effect tests, not line count. CAUTION, new since this ticket was filed: App::apply_effects now reads dirtiness, layout_version and the record fingerprints off the EffectKind (improvise-gv9r, gotcha 40) — narrowing a signature must not put an effect somewhere that loop cannot see.","notes":"DECLINED 2026-09-20 (wave 2), with counts. Reasoning in context/code/command.md,\n'Effect::apply stays on \u0026mut App'. NOTE: this bead carries the `human` label, so\nthe decline is a recommendation the user may overturn; it is recorded rather than\nsmuggled.\n\nCOUNTED over the 49 `impl Effect` blocks in ui/effect.rs:\n now (this branch) 8 view_state-only, 9 model_state-only, 32 cross-cutting\n base 52507a6 9 view_state-only, 16 model_state-only, 24 cross-cutting\nThe narrow set fell from 25 to 17 in one wave. The mover was improvise-9mo9,\nwhich made nine reshaping effects call app.reset_cursor(): a reshape lives in\nmodel_state.workbook and the cursor it invalidates lives in ViewState, so the\neffects that matter most are precisely the ones that reconcile the two slices.\nNarrowing bets against the direction the code moves in.\n\nFOUR GROUNDS\n1. The narrow set is small and shrinking, for that architectural reason.\n2. The type-level documentation already exists and is CHECKED: Effect::kind()\n has no default (gotcha 3) and apply_effects reads that one answer three times\n (gotchas 40, 45). A narrowed signature is a second statement of the same fact,\n free to disagree -- the defect, not the cure.\n3. Every offered mechanism weakens a guard. (A) three traits needs three queues\n (destroys batch order, which gotchas 21/23/43 lean on) or an enum wrapper (a\n fourth classification beside EffectKind). (C) default methods give `apply` a\n DEFAULT, exactly what kind() does without and for the same reason: overriding\n apply_view on an effect that needs the model compiles and writes the wrong\n slice silently; overriding neither compiles as a no-op. (B) does not meet the\n ticket's own acceptance criterion.\n4. \"Finer-grained effect tests\" is not blocked: effect.rs's 64 tests already call\n Effect.apply(\u0026mut app) against a one-line App::new(wb, None) fixture.\n\nWHAT IS NOT DECLINED: approach (B), rebinding the slice at the top of an apply\nbody, remains available as a per-function style choice. It is not mandated.","status":"closed","priority":3,"issue_type":"task","assignee":"wave2-n-cleanups","owner":"el-github@elangley.org","created_at":"2026-04-26T18:16:52Z","created_by":"jeeves","updated_at":"2026-09-20T18:11:26Z","started_at":"2026-09-20T18:08:16Z","closed_at":"2026-09-20T18:11:26Z","close_reason":"Closed","labels":["area:command","human"],"dependencies":[{"issue_id":"improvise-drg","depends_on_id":"improvise-99k","type":"blocks","created_at":"2026-04-26T11:17:16Z","created_by":"jeeves","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-1ud","title":"Document RecordsEditing variant in repo-map.md","description":"context/repo-map.md currently lists 15 AppMode variants but does not include RecordsEditing, which is referenced at src/draw.rs:142 and src/ui/app.rs:442. Add the variant to the AppMode list in the 'Key Types' section so coding agents see it.\\n\\nTrivial doc fix.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:54:16Z","created_by":"Ed L","updated_at":"2026-06-10T04:05:13Z","closed_at":"2026-06-10T04:05:13Z","close_reason":"Done in commit 0fe04de (2026-06-09 repo-map refresh): AppMode list now reads 16 variants including RecordsNormal and RecordsEditing, and notes MinibufferConfig + the ModelState/ViewState split.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-8fy","title":"Introduce simple_effect! macro for single-field setter effects","description":"crates/improvise-command/src/ui/effect.rs has ~15 simple setter effects (SetSelected, SetRowOffset, SetColOffset, SetYanked, SetSearchQuery, SetSearchMode, SetStatus, etc.) that each implement the same 4-line apply: pub struct SetX(pub T); impl Effect { fn apply(\u0026self, app: \u0026mut App) { app.field = self.0.clone(); } }.\\n\\nAbstraction: simple_effect!(SetRowOffset, usize, row_offset) macro generates struct + Effect impl.\\n\\nCorrectness win: provides a single hook for cross-cutting concerns. If layout-affecting setters need to mark dirty or rebuild layout, one macro body covers all of them. Currently each setter independently decides whether to mark_dirty(), and silent omissions are plausible. ~50 LOC.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:54:14Z","created_by":"Ed L","updated_at":"2026-09-19T20:35:54Z","closed_at":"2026-09-19T20:35:54Z","close_reason":"simple_effect! macro added beside the Effect trait; eight single-field ViewOnly setters declared through it (-42 lines net). The macro HARD-CODES EffectKind::ViewOnly by design: an effect other clients must see is not 'simple' and keeps a hand-written impl, which is how gotcha 3's no-default-kind rule survives a macro at all. SetSelected (two fields) and SetNumberFormat (a Model change) stay hand-written. Existing effect tests already cover all eight, including view_only_effects_leave_shared_state_alone.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-1wu","title":"Extract axis label helper in view/layout.rs","description":"crates/improvise-core/crates/improvise-core/src/view/layout.rs:323-336 (row_label) and :338-351 (col_label) are character-identical except for which items list they read. Small syntactic duplication but trivial to consolidate.\\n\\nFix: fn axis_label(items: \u0026[AxisEntry], idx: usize) -\u003e String, call from both. ~14 LOC.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:54:13Z","created_by":"Ed L","updated_at":"2026-08-14T01:11:07Z","closed_at":"2026-08-14T01:11:07Z","dependencies":[{"issue_id":"improvise-1wu","depends_on_id":"improvise-ai7","type":"supersedes","created_at":"2026-08-13T18:11:07Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -291,13 +282,10 @@ {"_type":"issue","id":"improvise-l36","title":"3.2 Publish to crates.io","description":"After cargo publish --dry-run is clean and user confirms, run cargo publish. Verify crate appears at crates.io/crates/improvise and cargo install improvise works.","acceptance_criteria":"improvise is on crates.io and 'cargo install improvise' works from a clean machine. NEEDS THE USER: crates.io credentials, and their confirmation after 'cargo publish --dry-run' is clean. Note the workspace has grown to nine crates since this was filed — decide which are published and in what order, since a dependent cannot publish before its dependency, and improvise-io/protocol/command are all now real crates rather than internal modules.","notes":"DRY RUN ACTUALLY RUN, 2026-09-20 at 52507a6 (wave 2, WU-P). It is NOT clean,\nand the blocker is mechanical rather than a judgement call:\n\n error: failed to verify manifest at Cargo.toml\n all dependencies must have a version requirement specified when publishing.\n dependency `gp-tree` does not specify a version\n\ncargo stops at the FIRST offender, and gp-tree is not the only one. Every\ninter-crate dependency in this workspace is `{ path = \"...\" }` with no\n`version`, which crates.io forbids for normal dependencies:\n\n Cargo.toml (root) gp-tree, improvise-core, improvise-formula,\n improvise-io, improvise-command\n improvise-core improvise-formula\n improvise-formula (none)\n improvise-protocol improvise-core\n improvise-io improvise-core, improvise-formula\n improvise-gp gp-tree, improvise-core, improvise-formula\n improvise-command improvise-core, improvise-protocol,\n improvise-formula, improvise-io, improvise-gp\n\nDev-dependencies are exempt — cargo strips them on publish — so the root's\n`improvise-wasm-client`/`improvise-protocol` dev deps and improvise-wasm-\nclient's dev dep on the unpublishable improvise-ws-server are fine as they are.\n\nWHICH CRATES PUBLISH. `improvise-ws-server` and `improvise-worker-server` both\ncarry `publish = false` already, so the set is eight, and a dependent cannot go\nbefore its dependency. The only order that works:\n\n 1. improvise-formula (no intra-workspace deps)\n 2. improvise-core\n 3. gp-tree (no intra-workspace deps; can go any time before 4)\n 4. improvise-gp\n 5. improvise-protocol\n 6. improvise-io\n 7. improvise-command\n 8. improvise (the binary)\n\ncrates.io needs a minute or two to index each one before the next can resolve\nit, so this is eight sequential `cargo publish` calls, not one.\n\nTWO VERSION FACTS THE USER MUST DECIDE. `gp-tree` is at `0.1.0` while every\nother crate is at `0.1.0-rc2`; and a `-rc2` version requirement in a published\nmanifest pins consumers to a prerelease. If the intent is to publish 0.1.0\n(which improvise-3tj's tag implies), the version bump across ten manifests\nshould happen BEFORE the version requirements are written, or they will be\nwritten twice.\n\nNAME AVAILABILITY CHECKED: crates.io returns \"crate `improvise` does not exist\",\nso the name is free. Not checked: the other seven names.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:58Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:01:38Z","labels":["area:release","needs:user"],"dependencies":[{"issue_id":"improvise-l36","depends_on_id":"improvise-11a","type":"blocks","created_at":"2026-04-08T21:09:29Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-l36","depends_on_id":"improvise-2fr","type":"blocks","created_at":"2026-04-08T21:09:28Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":2,"comment_count":0} {"_type":"issue","id":"improvise-11a","title":"3.1 Configure cargo dist","description":"Run cargo dist init targeting x86_64-unknown-linux-gnu, aarch64-apple-darwin, x86_64-apple-darwin. Skip Windows and musl. Commit generated .github/workflows/release.yml and Cargo.toml additions. Test with v0.1.0-rc1 tag, verify builds, delete rc tag.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:55Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-11T07:49:57Z","close_reason":"Closed","dependencies":[{"issue_id":"improvise-11a","depends_on_id":"improvise-abz","type":"blocks","created_at":"2026-04-08T21:09:28Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":3,"comment_count":0} {"_type":"issue","id":"improvise-0s6","title":"Phase 3: Distribution","description":"Configure cargo dist, publish to crates.io, tag v0.1.0 release. Produces prebuilt binaries for Linux x86_64 and macOS (Intel + Apple Silicon).","acceptance_criteria":"Closes when improvise-l36 and improvise-3tj close. Both need the user; nothing here is agent work. improvise-11a (cargo dist) is already closed.","notes":"CHECKED, wave 2 WU-P: STAYS OPEN, correctly — both children need the user and\nneither was touched. Nothing was published and nothing was tagged.\n\nWhat this pass DID do is make them ready, and one of them is not as ready as\nit looked. `cargo publish --dry-run` was actually run and FAILS: every\ninter-crate dependency in the workspace is `{ path = ... }` with no `version`,\nwhich crates.io forbids. cargo reports only the first offender (gp-tree), so\nthe dry run understates it — the full list, the publish order for the eight\npublishable crates, and the gp-tree/rc2 version disagreement are on\nimprovise-l36.\n\nimprovise-3tj's premise is already partly false and its note now says so: the\nREADME prebuilt-binaries link is not a placeholder, v0.1.0-rc2 carries real\ncargo-dist artifacts for all four targets, and what is actually missing is the\nv0.1.0 tag and the manifest bump that must precede it.","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-09T04:05:51Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:05:07Z","labels":["area:release","needs:user"],"dependencies":[{"issue_id":"improvise-0s6","depends_on_id":"improvise-11a","type":"blocks","created_at":"2026-04-08T23:37:39Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-0s6","depends_on_id":"improvise-3tj","type":"blocks","created_at":"2026-04-08T23:37:41Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-0s6","depends_on_id":"improvise-l36","type":"blocks","created_at":"2026-04-08T23:37:40Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":3,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-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":"open","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-20T18:58:19Z","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":"open","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-20T18:45:36Z","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.","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-20T18:48:10Z","labels":["area:active-view"],"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":"open","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:14:57Z","created_by":"orchestrator","updated_at":"2026-09-20T18:14:57Z","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":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:13:31Z","created_by":"wave2-b-projection","updated_at":"2026-09-20T18:13:31Z","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-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":"TWO ADDITIONS, both instances of this ticket's one fact rather than new ones.\n\nFIRST, WHAT A CHECK MUST ACTUALLY DO — the part that gets lost, and the reason\nthis ticket is not a one-liner. Counting the four `.ap-player` elements PROVES\nNOTHING. `autoPlay` is false, so asciinema-player does not fetch a cast until\nthe viewer presses play; a page whose every cast path 404s constructs four\nplayers, throws no page error before the click, and is indistinguishable from a\nworking one. This was verified by injecting exactly that fault (`./casts/` -\u003e\n`./cats/`): the construction-only check passed on the broken page, and pressing\nplay returned 0 rendered lines and surfaced four 404s. So the spec must press\nplay on each of the four and assert a non-zero rendered line count, and its own\nfault injection must be recorded in `context/test-evidence/`.\n\nThe same applies to the page's one functional link. Since the user's ruling\n(improvise-kh8) the landing page IS the site root and its whole job is to reach\nthe app at `./app/`; a broken link there is a dead front door, and the page\nlooks entirely correct with one. Proved able to fail by pointing it at `./aap/`\n-- 404 while nothing on screen changes -- so the spec should resolve the\ncall-to-action href and assert the response status.\n\nSECOND, THE LINE BUDGET, which is an instance of \"asserted by nothing\" and not\na separate defect. improvise-e61's criterion is \"under 200 lines\" and\n`docs/index.html` is now 199 after the root/app restructure. A criterion met by\none line, with nothing checking it, is met by accident: the next person to touch\nthe file either trims something or silently breaks it, and no test, lint or CI\njob would say so. If this ticket's spec lands, the cheapest place for the budget\nis beside it -- a line count assertion is one statement and needs no browser.\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/` -- the landing page renders and the casts play,\n and no test in the repository would have noticed if they had not.\n4. Break a cast path in `docs/index.html`, repeat, and observe that the page is\n still structurally correct until a play button is pressed.\n5. `wc -l docs/index.html` -- 199, against a stated limit of 200, checked by\n nothing.\nWITHDRAWN FROM THIS TICKET: the 199-line budget paragraph above.\n\nIt was added here on the reasoning that \"a 200-line limit met by one line with\nnothing checking it\" is an instance of this ticket's one fact. On review that is\nthe wrong home: the better answer is to rewrite improvise-e61's criterion, since\nthe line count was only ever a proxy for single file / vanilla / no build step /\nno framework — properties nothing threatens and which a line count measures\nbadly. That has been done on improvise-e61.\n\nThis ticket is about the landing page being SERVED and ASSERTED BY NOTHING, and\nit should stay that. Read the paragraph above as superseded; the rest of the\nnotes — press play, count rendered lines, resolve the call-to-action href — is\nwhat this ticket is for.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:01:16Z","created_by":"wave2-p-milestones","updated_at":"2026-09-20T18:22:56Z","labels":["area:release"],"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":"TWO ADDITIONS, both instances of this ticket's one fact rather than new ones.\n\nFIRST, WHAT A CHECK MUST ACTUALLY DO — the part that gets lost, and the reason\nthis ticket is not a one-liner. Counting the four `.ap-player` elements PROVES\nNOTHING. `autoPlay` is false, so asciinema-player does not fetch a cast until\nthe viewer presses play; a page whose every cast path 404s constructs four\nplayers, throws no page error before the click, and is indistinguishable from a\nworking one. This was verified by injecting exactly that fault (`./casts/` -\u003e\n`./cats/`): the construction-only check passed on the broken page, and pressing\nplay returned 0 rendered lines and surfaced four 404s. So the spec must press\nplay on each of the four and assert a non-zero rendered line count, and its own\nfault injection must be recorded in `context/test-evidence/`.\n\nThe same applies to the page's one functional link. Since the user's ruling\n(improvise-kh8) the landing page IS the site root and its whole job is to reach\nthe app at `./app/`; a broken link there is a dead front door, and the page\nlooks entirely correct with one. Proved able to fail by pointing it at `./aap/`\n-- 404 while nothing on screen changes -- so the spec should resolve the\ncall-to-action href and assert the response status.\n\nSECOND, THE LINE BUDGET, which is an instance of \"asserted by nothing\" and not\na separate defect. improvise-e61's criterion is \"under 200 lines\" and\n`docs/index.html` is now 199 after the root/app restructure. A criterion met by\none line, with nothing checking it, is met by accident: the next person to touch\nthe file either trims something or silently breaks it, and no test, lint or CI\njob would say so. If this ticket's spec lands, the cheapest place for the budget\nis beside it -- a line count assertion is one statement and needs no browser.\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/` -- the landing page renders and the casts play,\n and no test in the repository would have noticed if they had not.\n4. Break a cast path in `docs/index.html`, repeat, and observe that the page is\n still structurally correct until a play button is pressed.\n5. `wc -l docs/index.html` -- 199, against a stated limit of 200, checked by\n nothing.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:01:16Z","created_by":"wave2-p-milestones","updated_at":"2026-09-20T18:14:01Z","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":"open","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-20T16:53:36Z","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":"open","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T07:50:12Z","created_by":"Edward Langley","updated_at":"2026-09-20T16:53:34Z","labels":["area:core-io"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-zx8t","title":"draw.rs::mode_style matches AppMode with a wildcard, so the repo map's exhaustiveness promise is false there","description":"Found by WU-G on PR #27 while adding AppMode::SavePrompt, and it is a correction to something context/repo-map.md currently promises.\n\nThe repo map says of AppMode: \"Per-variant UI facts live on the enum as exhaustive-match methods -- label(), hint_text(), panel(), minibuffer(), is_editing(), is_records() (improvise-2hi); ADD A VARIANT AND THE COMPILER NAMES EVERY ONE YOU MUST FILL IN.\"\n\nThat is TRUE of the five enum methods and FALSE of src/draw.rs::mode_style, which matches AppMode with a `_ =\u003e` WILDCARD. So a new variant silently inherits whatever the wildcard arm gives -- SavePrompt currently takes ExportPrompt's DarkGray by accident rather than by decision, which happens to be defensible and was nobody's choice.\n\nWHY IT IS P4 AND NOT HIGHER: the consequence is a colour, and the wrong colour is visible rather than silent. But the reason to fix it is that the repo map states a PROPERTY, and a property that holds in five places and not the sixth is worse than one nobody claimed -- the next person adding a variant will trust the sentence.\n\nTHE FIX IS ONE OF TWO, and they are not equivalent:\n * Make mode_style exhaustive, so the compiler does name it. Note mode_style stays in src/draw.rs deliberately, because Style is a ratatui type and improvise-protocol must not name one (gotcha 26, the AxisColor/MinibufColor pattern).\n * Or give AppMode a semantic colour method in the protocol crate -- the AxisColor treatment -- and let draw.rs do the one mapping. That is the established shape here and it would also give a browser something to use, which draw.rs's Style cannot.\n\nThe second is probably right and is the same question improvise-k099 asks about hint_text/label. Decide them together.\n","acceptance_criteria":"mode_style either matches AppMode exhaustively, or AppMode gains a semantic colour method in improvise-protocol that draw.rs maps to a ratatui Style (the AxisColor/MinibufColor shape). Either way, adding an AppMode variant fails the build until someone chooses its colour. The repo-map sentence promising compiler-named sites is true of every listed method, or it is corrected to say which one it is not true of.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T06:04:22Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:44:19Z","labels":["area:tui"],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -322,5 +310,5 @@ {"_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-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-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.","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:19:45Z","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 47f0996b2e625c88e58e9eb52ba7a3df89cb9fa7 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Sun, 20 Sep 2026 12:16:14 -0700 Subject: [PATCH 13/17] chore(bd): keep issues.jsonl off this branch bd's pre-commit hook re-exports and stages .beads/issues.jsonl on every commit, so the file rides along on any branch that does bd work and then conflicts with every other branch that did the same. Restored to main's content with --no-verify so the hook cannot re-add it. Treated like context/repo-map.md from here: nobody commits it on a feature branch. Co-Authored-By: Claude Opus 5 (1M context) --- .beads/issues.jsonl | 28 ++++++++-------------------- 1 file changed, 8 insertions(+), 20 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index f0d4eb95..0c30e6d4 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -22,7 +22,7 @@ {"_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.","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-20T18:22:57Z","labels":["area:epic"],"dependencies":[{"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-zkkw","type":"blocks","created_at":"2026-09-20T18:02:34Z","created_by":"wave2-p-milestones","metadata":"{}"}],"dependency_count":5,"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.","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-20T18:19:45Z","labels":["area:epic"],"dependencies":[{"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-zkkw","type":"blocks","created_at":"2026-09-20T18:02:34Z","created_by":"wave2-p-milestones","metadata":"{}"}],"dependency_count":5,"dependent_count":1,"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} @@ -37,10 +37,8 @@ {"_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-pdvm","title":"AddItem/RemoveItem reshape the grid but declare CellData, so layout_version and cursor_epoch disagree","description":"Found in the adversarial review of PR #49 (improvise-9mo9). PRE-EXISTING, but\nthat PR is the first thing in the repo to produce evidence for it.\n\n`AddItem`, `AddItemInGroup` and `RemoveItem` declare\n`EffectKind::Model(ModelChange::CellData)`. `App::apply_effects` bumps\n`layout_version` only for `ModelChange::LayoutGeometry`, so these three do not\nmove it. Since improvise-9mo9 they DO call `app.reset_cursor()`, which bumps\n`cursor_epoch`.\n\nSo one event moves one counter and not the other, and the two counters are\nanswering the same question: \"has the grid's shape changed under you\". A client\nis told its cursor is invalid (`Command::ResetCursor`) while its `layout_version`\nbasis tier still reads current, so a batch composed against the old layout is\naccepted.\n\nTHE EVIDENCE IS THIS PR'S OWN TEST. `every_reshaping_model_command_invalidates_\nevery_cursor` now asserts, with a `grid_shape()` guard over `row_items`/\n`col_items`, that `AddItem`/`AddItemInGroup`/`RemoveItem` REALLY DO reshape the\ngrid. That is the assertion `ModelChange::CellData` contradicts.\n\nWHY IT IS NOT OBVIOUSLY \"RECLASSIFY THEM\". `EffectKind` is read by three\nconsumers off one classification (gotcha 40): `Dirty::of`, `layout_version`, and\n`model_state.dirty`. Moving these three to `LayoutGeometry` changes what every\nclient is told is dirty and widens the basis rejection surface. It also interacts\nwith gotcha 3's traps -- `AddCategory`/`RemoveCategory` are already the\n\"looks like data, is geometry\" case -- and with the `ModelCommand` derivation.\n\nRELATED, NOT DUPLICATE: improvise-75ld is the same family in the other direction\n(a refused or no-op AddCategory bumps layout_version and raises dirty when\nnothing happened). Decide them together; a single question -- \"what does an\nEffectKind entitle a counter to assume\" -- is under both.\n\n## Steps to Reproduce\n\n1. On a view with a category on Row, apply `ModelCommand::AddItem` for a new item\n in that category.\n2. `grid_shape()` (or `layout.row_count()`) changes: a row was inserted.\n3. `app.cursor_epoch()` has moved and clients receive `Command::ResetCursor`.\n4. `app.layout_version()` has NOT moved, because the effect declares CellData.\n5. A client may now compose a `Command::Model` whose `Basis.layout_version` is\n the pre-change value and have it accepted.","acceptance_criteria":"One event moves both counters or neither. Either AddItem/AddItemInGroup/RemoveItem are reclassified as LayoutGeometry -- in which case the blast radius through Dirty::of, the basis rejection surface and gotcha 3's traps is argued, not assumed -- or layout_version stops being read off EffectKind and is observed the way cursor_epoch and record_versions already are. Decide alongside improvise-75ld, which is the same question in the other direction, and alongside improvise-ni6k, whose grid-frame fingerprint would answer all three at once. The regression guard must assert the two counters agree over the same table every_reshaping_model_command_invalidates_every_cursor already drives, so it cannot pass by naming nothing.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:57:17Z","created_by":"wave2-n-cleanups","updated_at":"2026-09-20T18:57:17Z","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_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":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:22:32Z","created_by":"wave2-p-milestones","updated_at":"2026-09-20T18:22:32Z","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":"open","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-20T18:18:24Z","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-lltt","title":"[bug] A view-scoped ModelCommand names no view, so it lands on the host's","description":"Opened by improvise-jytm, which made `ViewState::active_view` authoritative per\nclient. The view-configuration half of `ModelCommand` — SetAxis, CycleAxis,\nTransposeAxes, SetPageSelection, HideItem, ShowItem, ToggleGroup, TogglePruneEmpty,\nSetNumberFormat — acts on \"the active view\", which is now an ambiguous phrase.\n\n`Sessions::apply_model` routes with no SessionId (gotcha 38, correctly: the change\nis addressed to no one session, because a `View` is document state shared by every\nclient sitting on it). But it must still name WHICH view, and today it names it\nimplicitly: `apply_command` runs the effect against the `App`, and the effect reads\n`App::view()`, which is the HOST's slice. So a connected client on another view\nmoves the host's view rather than its own.\n\nBefore jytm every client was on the same view, so the implicit answer was right.\nIt is now wrong exactly when two clients are on different views, which is the\ncapability jytm exists to add.\n\nThe honest fix is for those variants to name their view — a protocol change — so\nthe command carries the operand rather than borrowing an ambient one. Rejected\nduring jytm: retargeting `app.view_state.active_view` around `apply_command` at the\ndoor. It makes the host's own UI slice a parameter channel, and `reset_cursor`/\n`cursor_epoch` would then write the asker's key into the host's cursor map.\n\nNote the `_Drill`/`_Records` virtual views make this sharper than it looks: a host\nin records mode is on a view no client can name.\n\n## Steps to Reproduce\n\n1. Two clients, A and B, on one document with views \"Default\" and \"Second\".\n2. B sends `Command::SwitchView(\"Second\")`. Its mirror moves; the host's App stays\n on \"Default\" (correct, per improvise-jytm).\n3. B sends `Command::Model { cmd: ModelCommand::SetAxis { category: \"Region\",\n axis: Row } }`.\n4. The axis moves on \"Default\" — the view B is not on — and B's next projection\n shows nothing changed while A's shows a change A did not ask for.\n\nNot reachable from the TUI, which is the only client that drives the App's own\nslice, and not reachable from `web/app.js` today because it sends no SwitchView.\n","acceptance_criteria":"The view-configuration ModelCommand variants name the view they act on, so a connected client's SetAxis moves the view that client is on and not the host's. A test drives two sessions onto different views and asserts an axis change by one is invisible to the other. Deciding NOT to fix is also an acceptable close, but then gotcha 38 must say so.","notes":"THE TRIGGER, added after the adversarial review of PR #48 (2026-09-20). This is deferrable, but it is deferred on a CONDITION rather than on a judgement, and the condition is one line of somebody else's future work away. It is unreachable today ONLY because no shipped client emits Command::SwitchView. That is the whole of the argument. The browser already emits view-configuration model commands -- keys.rs resolves a keypress to ModelCommand::TransposeAxes locally and sends it -- so the machinery on the other side of the ambiguity is live. THE DAY ANY WAVE WIRES A VIEW SWITCH INTO THE BROWSER, THIS GOES LIVE ON THE NEXT TRANSPOSE KEYSTROKE, AND SILENTLY: a client on Second presses transpose, the axes of Default move instead, that client's grid does not change and every other client's does. Two consequences. (1) Do not close this by arguing it is unreachable -- reachability is the thing that is about to change, and gotcha 47 already records that 'unreachable today' was the argument under which the lost update CheckedBatch later fixed had shipped. (2) There is a cheap interim guard worth landing before the real fix: assert that no shipped browser client emits Command::SwitchView, a test over improvise-wasm-client's key resolver. It turns the silent failure into a loud one at exactly the moment the premise stops holding, which is the only moment anybody would otherwise not be looking.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:05:49Z","created_by":"wave2-e-active-view","updated_at":"2026-09-20T18:34:24Z","labels":["area:active-view"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-lltt","title":"[bug] A view-scoped ModelCommand names no view, so it lands on the host's","description":"Opened by improvise-jytm, which made `ViewState::active_view` authoritative per\nclient. The view-configuration half of `ModelCommand` — SetAxis, CycleAxis,\nTransposeAxes, SetPageSelection, HideItem, ShowItem, ToggleGroup, TogglePruneEmpty,\nSetNumberFormat — acts on \"the active view\", which is now an ambiguous phrase.\n\n`Sessions::apply_model` routes with no SessionId (gotcha 38, correctly: the change\nis addressed to no one session, because a `View` is document state shared by every\nclient sitting on it). But it must still name WHICH view, and today it names it\nimplicitly: `apply_command` runs the effect against the `App`, and the effect reads\n`App::view()`, which is the HOST's slice. So a connected client on another view\nmoves the host's view rather than its own.\n\nBefore jytm every client was on the same view, so the implicit answer was right.\nIt is now wrong exactly when two clients are on different views, which is the\ncapability jytm exists to add.\n\nThe honest fix is for those variants to name their view — a protocol change — so\nthe command carries the operand rather than borrowing an ambient one. Rejected\nduring jytm: retargeting `app.view_state.active_view` around `apply_command` at the\ndoor. It makes the host's own UI slice a parameter channel, and `reset_cursor`/\n`cursor_epoch` would then write the asker's key into the host's cursor map.\n\nNote the `_Drill`/`_Records` virtual views make this sharper than it looks: a host\nin records mode is on a view no client can name.\n\n## Steps to Reproduce\n\n1. Two clients, A and B, on one document with views \"Default\" and \"Second\".\n2. B sends `Command::SwitchView(\"Second\")`. Its mirror moves; the host's App stays\n on \"Default\" (correct, per improvise-jytm).\n3. B sends `Command::Model { cmd: ModelCommand::SetAxis { category: \"Region\",\n axis: Row } }`.\n4. The axis moves on \"Default\" — the view B is not on — and B's next projection\n shows nothing changed while A's shows a change A did not ask for.\n\nNot reachable from the TUI, which is the only client that drives the App's own\nslice, and not reachable from `web/app.js` today because it sends no SwitchView.\n","acceptance_criteria":"The view-configuration ModelCommand variants name the view they act on, so a connected client's SetAxis moves the view that client is on and not the host's. A test drives two sessions onto different views and asserts an axis change by one is invisible to the other. Deciding NOT to fix is also an acceptable close, but then gotcha 38 must say so.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:05:49Z","created_by":"wave2-e-active-view","updated_at":"2026-09-20T18:05:49Z","labels":["area:active-view"],"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":"CORRECTION, same session: this bead as first written read as though nobody had\ndecided the Chromium-only question. Somebody had.\n`invariant(opfs_is_run_but_bindings_is_not)` in prolog-sessions/kb.pl records\nit, and gives TWO reasons, not one:\n\n \"harness/tests/shell.spec.js runs web/app.js under CHROMIUM ONLY --\n engine-independent logic, and routeWebSocket is slower across three.\"\n\nThe description above only answers the second. The first — the logic is\nengine-independent — is a general claim covering all six specs, and against\nthe specs as a test of web/app.js it is correct: app.js is plain DOM and\nplain event wiring, and running the same assertions on a second engine buys\nvery little.\n\nWHAT IT DOES NOT COVER is the claim improvise-bck criterion (1) actually\nmakes. That criterion is not \"app.js's logic works in Firefox\", it is \"the\nstandalone deployment works in Firefox\" — and the standalone deployment is\nmodule workers, `wasm-pack` output, `createSyncAccessHandle` through\nbindings.rs and the seed fetch, none of which is app.js's logic and all of\nwhich is engine-specific. opfs.spec.js covers Firefox's OPFS through\n`OpfsFs` directly and, per the same invariant, NEVER INSTANTIATES\nbindings.rs. So the two standalone specs on Firefox would be the first\nexecution of that shim on a second engine, and near enough the first on any.\n\nSo the disagreement with the invariant is narrow and should be argued on\nthose terms: the engine-independence reason is a statement about app.js, and\nthe two standalone specs are not only a test of app.js. If that argument is\nrejected, the right outcome is to REWRITE improvise-bck criterion (1) to say\nChromium, not to leave a criterion standing that names an engine nothing\nruns on — a criterion nobody checks is the failure mode this whole pass\nexists to catch.\n\nEither way the invariant's wording is owed an update, since it currently\nreads as a blanket justification for a split that has two different reasons\nbehind its two halves.\nCORRECTION — MY OWN PREMISE WAS WRONG, found in adversarial review of PR #47.\n\nThe note above says the two standalone specs on Firefox \"would be the first\nexecution of that shim on a second engine, and near enough the first on any\".\nThe second half is false. `bindings.rs` HAS been executed, on Chromium, since\nimprovise-vvl3 landed the harness. The chain is unbroken and each link is\ncheckable:\n\n /app/index.html with no ?ws= -\u003e app.js `new Worker(\"./worker.js\")`\n worker.js `import { start } from \"./worker-pkg/improvise_worker_server.js\"`\n bindings.rs `pub async fn start()` -\u003e `boot()` -\u003e `OpfsFs::open().await`\n\nand `harness/tests/shell.spec.js`'s own comment on the standalone test has said\nso all along: \"If this draws a grid, worker.js, bindings.rs, OpfsFs and the seed\nfetch all ran.\"\n\nI RAN THAT SPEC GREEN AT 2ccd21f while verifying the /app/ move, and watched the\nbrowser fetch /app/worker.js and /app/worker-pkg/improvise_worker_server_bg.wasm\nand draw the grid with #status reading \"in-page worker\". The evidence was in my\nhand and I still repeated the claim, because I took it from kb.pl and the repo\nmap instead of from the run.\n\nWHAT SURVIVES: the argument for running the two standalone specs on Firefox is\nUNAFFECTED and is still the point of this ticket. Firefox would be a genuine\nsecond engine for the shim, and improvise-bck's criterion (1) still names an\nengine nothing runs. Only the \"first on any engine\" rhetoric goes.\n\nFIVE PLACES STATE THE FALSE VERSION and want one grep, not five edits:\nkb.pl:11, kb.pl:105 (invariant opfs_is_run_but_bindings_is_not), repo-map.md:241,\nrepo-map.md:467, and improvise-bck's own NOT-DEMONSTRATED list. Those three files\nare orchestrator-owned; the bck half is corrected in bck's notes.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:02:30Z","created_by":"wave2-p-milestones","updated_at":"2026-09-20T18:19:44Z","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":"Three PRs merged within 20 seconds on 2026-09-20 (#37, #41, #42). Each CI run built the site and uploaded a github-pages artifact, and each queued a deploy behind 'concurrency: group: pages, cancel-in-progress: false'.\n\nOnly the newest run kept its artifact:\n\n run 35526758561 (04e4e3d, #37) conclusion=failure artifacts=0\n run 35526767814 (5f871f7, #41) conclusion=success artifacts=1\n run 35526774007 (52507a6, #42) conclusion=failure artifacts=0\n\nThe two superseded deploys failed with:\n\n Fetching artifact metadata for \"github-pages\" in this workflow run\n Found 0 artifact(s)\n Error: No artifacts named \"github-pages\" were found for this workflow run.\n\nThe upload step was GREEN in all three runs, so this is not a build failure —\nthe artifact is gone by the time the losing deploy runs. The successful deploy\n(17:47:28Z) preceded the failing one (17:47:44Z) by 16 seconds.\n\nDeploying a superseded commit is pointless anyway: the tip of main is what\nshould be published. Cancelling is deliberately NOT the answer — the existing\ncomment on the concurrency block records that a deploy-pages cancelled midway\ncan leave the Pages deployment half-applied.\n\n## Steps to Reproduce\n\n1. Merge three PRs to main within about a minute.\n2. Watch the CI runs: the newest deploy succeeds, the older ones fail on an\n empty artifact fetch and mark otherwise-green runs as failed.\n\nReported by the user from the Actions log.","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-20T18:03:04Z","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":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T07:55:22Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:57:55Z","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} @@ -58,12 +56,12 @@ {"_type":"issue","id":"improvise-o8fa","title":"improvise-ws-server speaks ws:// only, so the deployed page cannot reach it","description":"Found by opening the deployed page (2026-09-20). The guidance the page and the README carried was WRONG in a way that sends someone down a dead end.\n\nFROM AN https ORIGIN A ws:// ADDRESS IS BLOCKED AS MIXED CONTENT, so the deployed GitHub Pages site needs wss://. improvise-ws-server has NO TLS AT ALL -- no rustls, no native-tls, nothing in the crate or its dependencies -- so wss:// is not obtainable from it. The text said \"note that from an https origin a ws:// address is blocked\", which correctly named the symptom and then offered an address that can never work.\n\nTEXT CORRECTED ALREADY: both web/index.html and README.md now say the address must be wss:// from the deployed page, that the server speaks plain ws:// only, and that the ws:// form works when the page is served over http://localhost. This ticket is the missing CAPABILITY.\n\nTHREE WAYS TO PROVIDE IT, and they are not equivalent:\n * TERMINATE TLS IN FRONT (nginx, caddy, cloudflared, ngrok). Zero code, works today, and is what the corrected text points at. But it means the documented path to sharing a TUI-hosted document involves a second piece of software, which is a poor answer for a tool whose selling point is that it is one binary.\n * NATIVE wss:// IN improvise-ws-server -- rustls plus a cert. Real work, and it raises the certificate question: a self-signed cert makes the browser refuse the socket with no usable error, so this is only pleasant with a real cert or an explicitly trusted local CA (mkcert).\n * DECIDE THE DEPLOYED PAGE DOES NOT PAIR WITH A LOCAL SERVER at all, and say so. Defensible: the standalone worker deployment is the point of the Pages site, and ws:// pairing is a LOCAL development convenience that works fine from the local page. Cheapest, and it makes the docs shorter rather than longer.\n\nThe third is probably right for now and the first is the escape hatch; the second only earns its place if sharing a TUI-hosted document to a remote browser becomes a real use case. Do not implement the second by default.","status":"closed","priority":2,"issue_type":"feature","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T03:53:54Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:22:10Z","started_at":"2026-09-20T05:32:35Z","closed_at":"2026-09-20T06:22:10Z","close_reason":"Closed by PR #21 (WU-I). DOCUMENTATION ONLY, per the user's ruling; native wss:// DECLINED.\n\nTHE DECLINE'S SUBSTANCE IS THE CERTIFICATE, NOT rustls. A self-signed cert makes the browser refuse the socket with no usable error, because a WebSocket handshake has no interstitial -- there is no place to click through. A real cert or mkcert is a second piece of software too, so the choice is two-either-way, with one option additionally shipping a TLS stack and a cert-loading surface in a pivot-table binary.\n\nWHAT LANDED: a worked Caddy example, the nginx difference that bites (proxy_http_version 1.1 plus the Upgrade/Connection headers), the honest cost (a second binary, a poor answer for a one-binary tool), and the framing that the standalone worker deployment is the point of the Pages site while ws:// pairing works fine from a local http://localhost page.\n\nTHIS BEAD COST SEVEN COPILOT ROUNDS AND NINE FINDINGS, EVERY ONE IN PROSE. The sequence is the value and is recorded in kb.pl:\n * the first recipe exposed an unauthenticated world-writable server -- documenting a proxy documents an exposure, because TLS termination REMOVES the loopback binding that was the only control;\n * the \"fix\" put Caddy basic_auth in front of a socket the page opens as new WebSocket(url), an API that can set no headers over a handshake that shows no prompt -- BROKEN reads as protection, which is worse than absent;\n * an ngrok --cidr-allow recipe was written without being run (deprecated-but-accepted, so the reviewer's own \"fails at startup\" was also wrong);\n * \"the browser cannot send a credential on a WebSocket handshake\" over-generalised the governing fact and contradicted the Cloudflare Access bullet three paragraphs up, which works BECAUSE the handshake carries a cookie;\n * an IP allow-list cannot see a cross-site socket at all, which is improvise-kc7n;\n * an absolute loopback claim denied a browser mitigation that exists but is mid-rollout;\n * and \"the auth above\" called an allow-list authentication in a section whose opening sentence is that the server has none.\n\nThe governing fact was ALREADY IN THE SAME FILE at every step, as the basis of this very decline. The lesson the repo did not have: a reviewed paragraph is not evidence.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-955n","title":"[bug] Host::commands applies a batch partially before rejecting","description":"Found by Copilot on PR #13 (the retro review of the unreviewed batch), DECLINED there with reasons on the thread, and filed rather than patched.\n\nADR 001 is emphatic that a stale batch is never applied, NOT EVEN PARTIALLY -- and improvise-ondj went to the trouble of a TYPE (Checked) to make the unchecked path unconstructible. But Checked is PER-COMMAND: Checked::against answers for one Command against the App AS IT STANDS. A ClientFrame::Commands is a SEQUENCE of them, and Host::commands applies in order and abandons at the first rejection. So a frame carrying SwitchView followed by a stale Command::Model has already applied the SwitchView.\n\nSO ADR 001 GUARANTEE IS CURRENTLY TRUE OF A SINGLE COMMAND AND FALSE OF A BATCH.\n\nA SECOND-ORDER EFFECT NOBODY HAD WRITTEN DOWN, found while declining it: Worker::on_frame skips persist() when ANY frame in the answer is Rejected. So a batch that DID apply a SwitchView before being refused leaves Workbook::active_view moved in memory and NOT WRITTEN BACK -- the document on disk and the document in the worker disagree, silently.\n\nWHY IT WAS DECLINED RATHER THAN PATCHED: closing it means the basis check moving from the COMMAND to the BATCH -- every command answered before any is applied. That is an ADR 001 design change with a real question inside it: WHAT DOES A BATCH BASIS MEAN WHEN ITS LATER COMMANDS WERE COMPOSED AGAINST ITS EARLIER ONES EFFECTS? A client resolving one keypress produces SetSelected plus offsets computed from the selection it just set. Answer that before writing code.\n\nREACHABILITY IS BOUNDED TODAY: no client in this workspace composes a batch containing more than one basis-carrying Command::Model -- the batches improvise-bpq2 produces are view commands, which carry no basis. Do this before the second tranche of client-side resolution creates a reason to batch two model commands.\n\nSupersedes the earlier ticket filed from bpq2 if there is one -- dedupe.","status":"closed","priority":2,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T03:35:27Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:29:48Z","started_at":"2026-09-20T05:32:29Z","closed_at":"2026-09-20T07:29:48Z","close_reason":"Closed by PR #26 (WU-C). improvise-08wb is closed as a duplicate of this one.\n\nADR 001 said a stale batch is never applied, NOT EVEN PARTIALLY, and improvise-ondj went to the trouble of a type (Checked) to make the unchecked path unconstructible -- but Checked is PER-COMMAND and a ClientFrame::Commands is a sequence of them, so Host::commands applied in order and abandoned at the first rejection. The guarantee was true of a single command and false of a batch.\n\nCheckedBatch::against now answers for EVERY command before any is applied.\n\nTHE DESIGN QUESTION THE TICKET DEMANDED WAS ANSWERED BY THE USER, not by the code, and it is larger than the ticket imagined. The ticket asked \"what does a batch's basis mean when its later commands were composed against its earlier ones' effects\". The user's ruling: \"a sequence of commands sent to the server should assume that it's played in order from start to end and that each command sees the state after the previous command runs\", as \"a single transaction from a given base\". Four clauses -- one basis per frame; checked once before anything; applied in order with data dependence EXPECTED; all or nothing. Clauses 1-3 landed here; clause 4 (rollback) is improvise-yqpd.\n\nAN EARLIER \"at most one document mutation per frame\" RULE WAS SHIPPED AND THEN REJECTED BY THE USER as forbidding the capability. It was wrong twice over: it forbids data-dependent chains, and arity is not a property of a variant anyway -- a future Command::RunScript is one variant performing unboundedly many mutations.\n\nTHE FINDING THAT MATTERED MOST came from the independent adversarial review that substituted for Copilot (quota exhausted), and it was a REGRESSION THIS WORK INTRODUCED: hoisting the basis check to the frame hoisted ALL THREE TIERS. The document and layout tiers are legitimately frame-level facts; THE RECORD TIER IS NOT AND CANNOT BE, because which records are at stake is a property of the command. So every mutation after the first had its records checked against nothing -- a silent lost update, confirmed by executing a real Host: client A's stale [SetCell(North), SetCell(South)] clobbered client B's write to South with no rejection, where the base commit refused it. Fixed: every mutation answers for its own records against the FRAME'S basis, with an exemption stated over a property -- a record that neither resolves nor is claimed is one the frame is creating. The reviewer attacked that exemption at all four corners and could not break it: the predicate is a conjunction and the client controls only one conjunct.\n\nTHE SECOND-ORDER WORKER EFFECT the ticket named is closed twice over; its test fails only when BOTH old behaviours are restored.\n\nA CAPABILITY NOBODY HAD NAMED, now guarded: a frame may write THE SAME RECORD TWICE -- two measures on one record IS one record -- and that works only because every check happens before any apply. Moving the checks into the loop breaks it, which is what stops someone closing clause 4 by taking that trade unnoticed.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-08wb","title":"[bug] A refused ClientFrame::Commands keeps the commands before the refusal","description":"Found by improvise-bpq2 (2026-09-20), stated on the variant, and currently UNREACHABLE from any client in this workspace -- filed so it is a decision rather than a discovery.\n\nClientFrame::Commands(Vec\u003cCommand\u003e) batches, because one cursor key resolved client-side is SetSelected plus the offsets viewport_effects returns, and a host that folds the selection but not the offset projects the wrong window. Host::commands applies IN ORDER and ABANDONS AT THE FIRST REJECTION.\n\nTHAT LEAVES A HOLE ADR 001 DOES NOT HAVE. ADR 001 is emphatic that a stale batch is never applied, NOT EVEN PARTIALLY -- and improvise-ondj went to the trouble of a type (Checked) to make the unchecked path unconstructible. But Checked is per-COMMAND, and a ClientFrame::Commands is a sequence of them, so a frame whose third command is refused has already applied the first two.\n\nWHY IT IS NOT LIVE: no client in this workspace composes a Commands frame containing more than one basis-carrying Command::Model -- the batches bpq2 produces are view commands (SetSelected + offsets), which carry no basis.\n\nTHE FIX IS TO MOVE THE BASIS CHECK TO THE BATCH: check every Command::Model in the frame before applying any of them. That is the shape ADR 001 already describes for an effect batch, one level up, and it would make Host::commands match the guarantee the ADR states.\n\nDo this before a client has reason to batch two model commands -- which the second tranche of client-side resolution (the ~15 bindings bpq2 left) may well create.","status":"closed","priority":2,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T02:36:42Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:29:28Z","started_at":"2026-09-20T05:32:29Z","closed_at":"2026-09-20T07:29:28Z","close_reason":"Closed as a DUPLICATE of improvise-955n, which PR #26 fixed. Both beads describe the same defect -- Host::commands applied a ClientFrame::Commands in order and abandoned at the first rejection, so a frame whose later command was refused had already applied the earlier ones, leaving ADR 001's 'never applied, not even partially' true of a single command and false of a batch. 08wb was filed from improvise-bpq2's walk and 955n from Copilot's retro review of PR #13; 955n carries the fuller reasoning and the second-order worker effect, so it is the survivor.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-ir7l","title":"[bug] A formula cell is offered as editable and silently reverts","description":"Pre-existing and consistent with the TUI, but improvise-bck just put it on the DEMO FRONT PAGE, which changes who meets it: the first thing a stranger sees is Profit beside Revenue and Cost.\n\nA formula target cell projects CellId::Record, so render.rs::editable says true, so the browser offers an editor. Type into Profit and the write lands -- then recompute_formulas overwrites it on the next pass and the value REVERTS with no explanation.\n\nWHY IT IS NOT SIMPLY A BUG IN editable: a formula target IS a stored coordinate; the cell is a record. What makes it unwritable is not its identity but that a FORMULA OWNS IT. So the fix is a fact the projection does not currently carry -- something like \"this cell is derived\" -- and it belongs beside numeric/error on CellDisplay, which already exists precisely because the SERVER is the side that knows.\n\nCHECK THE TUI BEFORE CHANGING ANYTHING: it has the same behaviour, so whatever is decided should land in both, or the divergence becomes the new bug (gotcha 44 is what that looks like). And note ADR 002 deliberately made settability a fact about the CellId VARIANT -- adding a second, independent \"but not really\" flag is exactly the two-answers-to-one-question shape this codebase keeps curing. Consider instead whether a formula-owned cell should project a different CellId at all.\n\n## Steps to Reproduce\n\n1. Open `web/demo.improv` in the browser — it ships `Profit = Revenue - Cost` beside Revenue and Cost on the front page.\n2. Click the Profit cell. It is offered as editable, because a formula target projects `CellId::Record` and `render.rs::editable` reads the variant.\n3. Type a number and commit. The write lands.\n4. `recompute_formulas` overwrites it on the next pass and the value reverts, with no explanation.\n\nThe TUI behaves the same way, so whatever is decided must land in both or the divergence becomes the new bug.\n","acceptance_criteria":"A formula-owned cell projects a non-Record CellId, so the write is unconstructible rather than flagged — the discipline ADR 002 used for aggregates and ADR 003 reaches for again for read-only sources. It lands in the TUI in the same change, or the two front ends diverge (gotcha 44 is what that looks like). Do NOT add a second 'but not really' flag beside CellId: two answers to 'can I write here' is the disease. Demonstrated on web/demo.improv, where Profit sits beside Revenue and Cost on the front page.","notes":"VERIFIED CONCRETELY during the PR #13 review (2026-09-20), and the diagnosis is sharper than when this was filed.\n\nTHE ROOT CAUSE: project::cell_id decides Record vs Aggregate purely on whether a regular category sits on Axis::None. IT NEVER ASKS WHETHER THE _Measure COORDINATE NAMES A FORMULA TARGET. So a formula cell is a Record, render.rs::editable says true, and the browser offers an editor.\n\nMEASURED AGAINST THE SHIPPED DEMO:\n col 2 label=\"Profit\" is_record=true text=\"12,200\"\n after applying SetCell(key, Number(999.0)), Profit reads \"12,200\"\n\nThe write is ACCEPTED AND STORED. Model::evaluate_aggregated checks formula_cache BEFORE raw data (model/types.rs:409), so the typed value is shadowed forever with nothing telling the user. Not a revert -- a permanent silent shadow.\n\nAND THE TEST THAT COVERS THIS AREA IS LYING BY ITS NAME. every_visible_cell_in_the_web_demo_is_settable PASSES while its name is FALSE for the Profit column -- a third of the landing-page grid. It was proved discriminating against the one thing it CAN see (the aggregate at (0,0) in examples/demo.improv) and is structurally blind to this. That is exactly the shape AGENTS.md calls this repo's characteristic find, arriving in the test written to prevent it.\n\nTHE DECISION IS STILL THE DECISION, and ADR 002 is where it goes:\n * TEACH cell_id ABOUT FORMULA TARGETS -- changes what EVERY client calls a record, and record identity is also ADR 001's unit of atomicity, so this reaches the basis check too.\n * OR GIVE editable A SECOND INPUT -- but that is the conflation gotcha 42 exists to forbid, and ADR 002 deliberately made settability a fact about the CellId VARIANT precisely so there could not be a second answer.\n\nThe first is probably right and is not small. Until it lands, the demo's front page teaches a visitor that typing into Profit does nothing.","status":"closed","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-20T02:29:51Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:22:48Z","started_at":"2026-09-20T17:53:19Z","closed_at":"2026-09-20T18:22:48Z","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-ir7l","title":"[bug] A formula cell is offered as editable and silently reverts","description":"Pre-existing and consistent with the TUI, but improvise-bck just put it on the DEMO FRONT PAGE, which changes who meets it: the first thing a stranger sees is Profit beside Revenue and Cost.\n\nA formula target cell projects CellId::Record, so render.rs::editable says true, so the browser offers an editor. Type into Profit and the write lands -- then recompute_formulas overwrites it on the next pass and the value REVERTS with no explanation.\n\nWHY IT IS NOT SIMPLY A BUG IN editable: a formula target IS a stored coordinate; the cell is a record. What makes it unwritable is not its identity but that a FORMULA OWNS IT. So the fix is a fact the projection does not currently carry -- something like \"this cell is derived\" -- and it belongs beside numeric/error on CellDisplay, which already exists precisely because the SERVER is the side that knows.\n\nCHECK THE TUI BEFORE CHANGING ANYTHING: it has the same behaviour, so whatever is decided should land in both, or the divergence becomes the new bug (gotcha 44 is what that looks like). And note ADR 002 deliberately made settability a fact about the CellId VARIANT -- adding a second, independent \"but not really\" flag is exactly the two-answers-to-one-question shape this codebase keeps curing. Consider instead whether a formula-owned cell should project a different CellId at all.\n\n## Steps to Reproduce\n\n1. Open `web/demo.improv` in the browser — it ships `Profit = Revenue - Cost` beside Revenue and Cost on the front page.\n2. Click the Profit cell. It is offered as editable, because a formula target projects `CellId::Record` and `render.rs::editable` reads the variant.\n3. Type a number and commit. The write lands.\n4. `recompute_formulas` overwrites it on the next pass and the value reverts, with no explanation.\n\nThe TUI behaves the same way, so whatever is decided must land in both or the divergence becomes the new bug.\n","acceptance_criteria":"A formula-owned cell projects a non-Record CellId, so the write is unconstructible rather than flagged — the discipline ADR 002 used for aggregates and ADR 003 reaches for again for read-only sources. It lands in the TUI in the same change, or the two front ends diverge (gotcha 44 is what that looks like). Do NOT add a second 'but not really' flag beside CellId: two answers to 'can I write here' is the disease. Demonstrated on web/demo.improv, where Profit sits beside Revenue and Cost on the front page.","notes":"VERIFIED CONCRETELY during the PR #13 review (2026-09-20), and the diagnosis is sharper than when this was filed.\n\nTHE ROOT CAUSE: project::cell_id decides Record vs Aggregate purely on whether a regular category sits on Axis::None. IT NEVER ASKS WHETHER THE _Measure COORDINATE NAMES A FORMULA TARGET. So a formula cell is a Record, render.rs::editable says true, and the browser offers an editor.\n\nMEASURED AGAINST THE SHIPPED DEMO:\n col 2 label=\"Profit\" is_record=true text=\"12,200\"\n after applying SetCell(key, Number(999.0)), Profit reads \"12,200\"\n\nThe write is ACCEPTED AND STORED. Model::evaluate_aggregated checks formula_cache BEFORE raw data (model/types.rs:409), so the typed value is shadowed forever with nothing telling the user. Not a revert -- a permanent silent shadow.\n\nAND THE TEST THAT COVERS THIS AREA IS LYING BY ITS NAME. every_visible_cell_in_the_web_demo_is_settable PASSES while its name is FALSE for the Profit column -- a third of the landing-page grid. It was proved discriminating against the one thing it CAN see (the aggregate at (0,0) in examples/demo.improv) and is structurally blind to this. That is exactly the shape AGENTS.md calls this repo's characteristic find, arriving in the test written to prevent it.\n\nTHE DECISION IS STILL THE DECISION, and ADR 002 is where it goes:\n * TEACH cell_id ABOUT FORMULA TARGETS -- changes what EVERY client calls a record, and record identity is also ADR 001's unit of atomicity, so this reaches the basis check too.\n * OR GIVE editable A SECOND INPUT -- but that is the conflation gotcha 42 exists to forbid, and ADR 002 deliberately made settability a fact about the CellId VARIANT precisely so there could not be a second answer.\n\nThe first is probably right and is not small. Until it lands, the demo's front page teaches a visitor that typing into Profit does nothing.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-20T02:29:51Z","created_by":"Edward Langley","updated_at":"2026-09-20T17:53:19Z","started_at":"2026-09-20T17:53:19Z","lease_expires_at":"2026-09-20T17:58:19Z","heartbeat_at":"2026-09-20T17:53:19Z","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-vhcz","title":"[bug] OpfsFs::list disagrees with the other backends on path shape","description":"Found by improvise-9x6 (2026-09-20) and currently PAPERED OVER by always passing the flat root.\n\nOpfsFs::list returns BARE NAMES; MemoryFs and PhysicalFs return dir-JOINED ones. So Path::new(\"\") is the ONE dir at which all three agree, and the only one at which storage_contract actually describes OpfsFs.\n\nTHE TRAP: a worker that organised documents under docs/ would get an EMPTY PICKER in a browser and a FULL ONE in every test. The harness cannot see the disagreement because it never runs OpfsFs -- improvise-xf1 is deferred -- so this is precisely the class of defect the missing browser harness exists to catch, discovered by reading instead.\n\nTWO FIXES:\n * Make OpfsFs::list join dir, matching the other two. Straightforward, and makes storage_contract describe all three at every dir.\n * Or document the flat root as PART OF THE CONTRACT and assert it in storage_contract, so the constraint is stated rather than discovered.\n\nThe first is better unless there is a reason OPFS cannot -- note gotcha 28 says backends that are not trees treat dir as a NAME PREFIX, which is already the trait doc contemplating this, so check whether the doc or the impl is the thing that is wrong.","status":"closed","priority":2,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T02:10:14Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:44:42Z","started_at":"2026-09-20T05:32:36Z","closed_at":"2026-09-20T06:44:42Z","close_reason":"Closed by PR #24 (WU-J). THE TICKET ASKED WHICH OF THE DOC OR THE IMPL WAS WRONG: IT WAS THE IMPL.\n\nThe Storage trait doc already said that backends which are not trees treat `dir` as a NAME PREFIX. OpfsFs::list ignored `dir` outbound as well as inbound, returning bare names where MemoryFs and PhysicalFs return dir-joined ones -- so Path::new(\"\") was the one dir at which all three agreed, and the only one at which storage_contract actually described OpfsFs.\n\nNow it joins. The three backends agree at EVERY dir, and storage_contract runs against OpfsFs at both \"\" and \"docs\" in a real browser. `dir` stays DECORATION rather than a filter, because entry_name reduces every path to its file name and an OPFS entry name may not contain a separator.\n\nFAULT INJECTED: reverting to bare names fails storage_contract_at_\"docs\" on `lists the one document`.\n\nTHE TRAP THE TICKET NAMED IS CLOSED: a worker that organised documents under docs/ would have got an empty picker in a browser and a full one in every test. That divergence was invisible to the harness because nothing ran OpfsFs -- which is exactly why improvise-vvl3 had to land alongside this, and why this bead could only ever have been found by reading until it did.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-hmws","title":"Storage cannot expose an async seam through Box\u003cdyn Storage\u003e","description":"Found by improvise-9x6 (2026-09-20). Every worker host will hit this, so it is worth solving once rather than in each.\n\nOpfsFs::open / attach / refresh / commit_deletes are on the STRUCT, not on the Storage trait -- deliberately, per gotcha 28: Storage stays synchronous because createSyncAccessHandle yields a blocking handle, and only four operations are genuinely async. But App::storage is a Box\u003cdyn Storage\u003e, so HANDING THE BACKEND TO THE App PUTS ALL FOUR PERMANENTLY OUT OF REACH. The App owns the only copy and the trait object cannot name them.\n\nAND THE OBVIOUS FIX IS REFUSED BY THE COMPILER: impl Storage for Rc\u003cOpfsFs\u003e is two foreign types, so the orphan rule forbids it. improvise-9x6 worked around it with a local newtype (bindings::Shared) holding an Rc\u003cOpfsFs\u003e and forwarding the four sync methods. That works but every worker must write it again.\n\nTWO CANDIDATE FIXES:\n * Ship the shared wrapper FROM improvise-io, so the newtype exists once where OpfsFs does and the orphan rule is satisfied.\n * Or have OpfsFs hand out an attachable handle -- separate the thing the App holds from the thing the message loop awaits, which is arguably the more honest modelling since they have different lifetimes and different call sites.\n\nPrefer whichever keeps Storage synchronous; that part is not negotiable (gotcha 28 is the argument).","status":"closed","priority":2,"issue_type":"task","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T02:10:13Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:44:43Z","started_at":"2026-09-20T05:32:37Z","closed_at":"2026-09-20T06:44:43Z","close_reason":"Closed by PR #24 (WU-J). NEITHER OF THE TICKET'S TWO OPTIONS WAS NEEDED, and the reason is worth more than the fix.\n\nThe ticket said `impl Storage for Rc\u003cOpfsFs\u003e` is refused by the orphan rule. That is true IN improvise-worker-server, where both the trait and Rc are foreign -- which is where improvise-9x6 hit it and wrote bindings::Shared. It is NOT true in improvise-io, where the trait is LOCAL and the impl is simply allowed.\n\nSo: improvise-io ships a blanket `impl\u003cT: Storage + ?Sized\u003e Storage for Rc\u003cT\u003e`. bindings::Shared and its _shim_is_a_storage canary are DELETED, not moved, and no future worker writes one. Storage stays synchronous, which gotcha 28 says is not negotiable.\n\nTHE GUARD IS THE SIGNATURE: a_shared_backend_is_a_backend_and_a_clone_is_the_same_one does not compile without the impl. The body asserts what a hand-written newtype could still get wrong -- that a clone addresses the same backend -- and a fault injected into the forwarded `write` (made a no-op) fails it.\n\nTHE GENERALISABLE PART: an orphan-rule refusal is a fact about WHERE YOU ARE STANDING, not about the pair of types. Before working around one, check whether the impl belongs in the crate that owns the trait.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-9mo9","title":"[bug] Geometry effects that renumber the grid but never reset the cursor","description":"Found by the code review of improvise-gvnb (2026-09-19). A PRE-EXISTING gap that gvnb inherited rather than introduced -- but gvnb is what makes it matter, because the epoch is now what tells every client their cursor is invalid.\n\nApp::reset_cursor bumps cursor_epoch; Sessions::adopt_cursor_epoch watches the COUNTER and broadcasts Command::ResetCursor to all. Nothing declares which effects renumber the grid -- deliberately, per gotcha 31 -- which means a geometry effect that forgets reset_cursor is INVISIBLE IN BOTH DIRECTIONS: the host own cursor stays stale AND no client is told.\n\nToday only TransposeAxes and CycleAxis call it. These also renumber rows or columns and do not:\n SetAxis, HideItem, ShowItem, ToggleGroup, TogglePruneEmpty, AddCategory, RemoveCategory\n (plus AddItem/RemoveItem, which are CellData rather than LayoutGeometry)\n\nSetAxis IS THE SHARPEST CASE: CycleAxis is literally SetAxis to Axis::next() and resets; SetAxis does not.\n\nWHY THE REVIEW DID NOT JUST PATCH IT: SetAxis is used by DrillIntoCell (cmd/grid.rs:365-471) to build the _Drill view, and resetting there could discard the user position in the view they drilled FROM, depending on effect order. That is a decision about effect ordering and intended UX, not a patch.\n\nKEEP THE NEGATIVE ARM. a_model_command_that_does_not_reshape_the_grid_leaves_cursors_alone uses SetNumberFormat -- a LayoutGeometry effect that must NOT reset. Without it, \"reset on every model command\" would pass the positive test. Whatever you do here, do not lose it.\n\n## Steps to Reproduce\n\n1. In a view with a cursor somewhere other than the origin, apply any of `SetAxis`, `HideItem`, `ShowItem`, `ToggleGroup`, `TogglePruneEmpty`, `AddCategory`, `RemoveCategory`, `AddItem` or `RemoveItem`.\n2. The grid renumbers and the cursor is not reset, so it addresses a position that has moved.\n3. Worse for other clients: `reset_cursor` is what bumps `cursor_epoch`, and `Sessions::adopt_cursor_epoch` watches that counter to broadcast `Command::ResetCursor`. No reset means **no client is told either**.\n\n`SetAxis` is the sharpest case: `CycleAxis` *is* `SetAxis` to `Axis::next()` and does reset.\n","acceptance_criteria":"Each of SetAxis, HideItem, ShowItem, ToggleGroup, TogglePruneEmpty, AddCategory, RemoveCategory, AddItem and RemoveItem either calls reset_cursor or carries a one-line reason why it does not. SetAxis is the sharp case — CycleAxis IS SetAxis to Axis::next() and resets, while SetAxis does not — and DrillIntoCell uses SetAxis to build the _Drill view, where resetting could discard the position in the view drilled FROM, so that is a decision about effect ordering and intended UX, not a patch. KEEP THE NEGATIVE ARM: a_model_command_that_does_not_reshape_the_grid_leaves_cursors_alone uses SetNumberFormat, and without it 'reset on every model command' passes the positive test. Note improvise-2zq changed which axis a new category lands on, so re-read on_category_added before assuming AddCategory renumbers the way it used to.","status":"closed","priority":2,"issue_type":"bug","assignee":"wave2-n-cleanups","owner":"el-github@elangley.org","created_at":"2026-09-20T01:05:15Z","created_by":"Edward Langley","updated_at":"2026-09-20T17:57:44Z","started_at":"2026-09-20T17:53:45Z","closed_at":"2026-09-20T17:57:44Z","close_reason":"Closed","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-s1xw","title":"Multi-level grid labels on the wire -- the browser shows Food/Alice where the TUI shows two columns","description":"THE LAST THING KEEPING GridLayout INSIDE GridWidget, and a LIVE DIVERGENCE today, not a hypothetical. Found closing improvise-n10.\n\nGridCache::row_labels / col_labels are Vec\u003cString\u003e produced by GridLayout::row_label, which JOINS A MULTI-LEVEL TUPLE WITH A SLASH. That is exactly the rendering src/ui/grid.rs::two_row_categories_produce_cross_product_labels asserts must NOT appear (\"slash-joined labels should be gone\"). So the protocol carries a form of the labels THE TUI ITSELF CONSIDERS WRONG, and crates/improvise-wasm-client/src/render.rs renders precisely that form.\n\nConsequence: the browser grid has no multi-level headers, no repeat suppression, no groups and no collapse indicators, and shows Food/Alice where the TUI shows two header columns.\n\nSIX FACTS THE CACHE DOES NOT CARRY (exhaustive as of improvise-n10, by grep over the non-test body of src/ui/grid.rs):\n 1. number of header levels -- layout.row_cats / col_cats\n 2. multi-level label TUPLES -- layout.row_items (AxisEntry::DataItem(Vec\u003cString\u003e))\n 3. inline GROUP HEADER rows -- layout.row_items (AxisEntry::GroupHeader)\n 4. column GROUP SPANS -- layout.col_group_for(ci)\n 5. collapse indicator (down/right triangle) -- view.is_group_collapsed(cat, group)\n 6. records-mode column identity -- layout.record_col(ci). THIS ONE IS CORRECT TO KEEP OFF: it keys pending drill edits and is session-local by design.\n\nSo 1-5 are the gap.\n\nTWO CONSTRAINTS ON THE FIX:\n * IT IS A CO-ORDINATED CHANGE, NOT AN ADDITIVE ONE. row_labels/col_labels change TYPE, and improvise-wasm-client/src/render.rs consumes both fields, so the client updates in the same change.\n * DO NOT ADD row_levels BESIDE row_labels. Two answers to \"what are the labels\" is the disease this whole projection architecture exists to cure -- see improvise-edp, where the category panel was walked twice, two different ways.","status":"closed","priority":2,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T00:58:33Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:41:34Z","started_at":"2026-09-20T05:32:27Z","closed_at":"2026-09-20T07:41:34Z","close_reason":"Closed as ABSORBED BY improvise-anr9, verified explicitly rather than assumed. s1xw enumerated six facts the cache did not carry; PR #23 landed 1-5 (header levels, multi-level label tuples, inline group headers, column group spans, the collapse indicator) and 6 is record_col, which s1xw itself says is CORRECT to keep off -- session-local, keys pending drill edits. Both of its stated constraints were honoured: it was a co-ordinated type change (row_labels/col_labels changed TYPE and improvise-wasm-client/src/render.rs updated in the same change) and no row_levels was added beside row_labels. The live divergence it reported -- the browser drawing Food/Alice where the TUI draws two header columns -- is closed, and tests/grid_frame_agreement.rs now drives both front ends over one cache and compares what each draws.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-4zl7","title":"A fitted tree must route per record and aggregate the results, not route on the aggregate","description":"Successor to improvise-8xhy, which shipped remedy 1 (refuse at emit) as an explicit interim. This is the remedy that produces a formula A HUMAN WOULD HAVE WRITTEN, and it is the right end state -- but it was costed during 8xhy and it is much larger than it looks.\n\nANSWER THIS FIRST, BEFORE TOUCHING THE GRAMMAR. A fitted measure cell is evaluated at a CellKey context. In any view that aggregates over the split category -- Region on Page or None -- context.get(\"Region\") is None, and today Formula::filter falls straight through to aggregate_raw. So a categorical fit would SILENTLY DEGRADE to something else in exactly the views a pivot-table user spends most of their time in. That is a product decision about what a fit MEANS for an aggregated cell, not an emitter detail.\n\nSCOPE, once that is answered:\n * Expr::Is(Filter) evaluating to 1.0/0.0. This keeps the f64-only evaluator and avoids introducing string comparison into eval_expr. The primitive already exists in the right shape -- types.rs reads context.get(\u0026filter.category), the current cell own coordinate.\n * A grammar production plus tokenizer handling in the 1109-line parser.rs, with quoting rules for the category and item inside it (gotcha 34 -- one home, improvise_formula::quote).\n * Arms in BOTH evaluators: eval_expr and eval_expr_cached.\n * Coverage in the grammar-walking generator.\n * Thread Dataset::columns (the ColumnSpec/ColumnKind plan) into the emitter, so feature index -\u003e role is read off preprocess own plan. Do NOT reconstruct it: Spec::names is \u0026[\u0026str], and reconstructing index-\u003erole is a second implementation of preprocess expansion order, exactly the drift risk gotcha 41 names in another key.\n * .improv is free -- the writer emits Formula::raw.\n\nIT CLOSES ONLY A THIRD OF 8xhy EITHER WAY. A WHERE predicate covers the one-hots. col.missing means \"this record has no value for measure X\", a different vocabulary. The date sub-features (when.year, when.month) need date arithmetic improvise does not have at all. Those stay refused, or get their own tickets.\n\nPAIRS WITH improvise-vx3 (the fit-measure command), which is where a user would actually learn about the restriction.","acceptance_criteria":"A fitted measure evaluated at an aggregating cell routes PER RECORD and aggregates the results, rather than routing on the aggregate. fit-measure on web/demo.improv succeeds and the resulting formula means the same thing on a record row and one row up. The seed-dependence is gone: whether the command succeeds no longer depends on which features the search happened to pick. Note what this costs — evaluate_aggregated deliberately does not iterate the records behind a formula cell — and state how it interacts with gotcha 9's fixed point and Model::formula_cache. col.missing and the date sub-features remain out of scope.","notes":"USER RULING 2026-09-20: AGGREGATE THE RULE OVER THE MEMBERS. Route-then-aggregate, per record. The other three options -- Error(\"aggregated\"), refuse at fit time, and fall through to aggregate_raw -- are all rejected.\n\nTHE BEAD'S SCOPE HAS CHANGED AND THE TITLE NOW SAYS SO. It asked what a CATEGORICAL split means in a cell that aggregates over the split category. PR #25's review established the question has nothing to do with the split being categorical:\n\nA tree is fitted on individual RECORDS. A formula is evaluated at a CellKey, and in any aggregating cell evaluate_aggregated aggregates each Expr::Ref FIRST and then evaluates the IF. A linear formula survives that -- SUM(Revenue) - SUM(Cost) is SUM(Revenue - Cost) -- and a tree does not, because AGGREGATE-THEN-ROUTE IS NOT ROUTE-THEN-AGGREGATE. So EVERY fitted tree, numeric splits included, means one thing on a record row and something else one row up.\n\nIT IS NOT THEORETICAL: fit-measure Revenue on the repo's own shipped web/demo.improv is refused, naming Product=Espresso/Product=Latte. And because which features a champion picks is a property of the SEARCH, on a document with several categories the same command refuses or succeeds DEPENDING ON THE SEED. That is not a contract.\n\nWHAT THE RULING COSTS, stated plainly because it is the expensive answer and was chosen anyway: evaluate_aggregated deliberately does NOT iterate the underlying records for a formula cell -- it aggregates the refs and evaluates once. Routing per record means a formula cell must be able to ask for the records behind it and fold over them. That touches the evaluator, and it interacts with gotcha 9 (fixed-point recomputation) and with Model::formula_cache, which is where derived values live precisely so raw data and derived data stay separate.\n\nNOTE WHAT IT BUYS BESIDES CORRECTNESS: it is what a user means. A fitted rule applied to a group should say what the rule says about the members, summed -- not what the rule says about a fictional average member. The second is the thing that looks right and is wrong, which is the failure mode vx3's own ticket warned about ('a rule the user will believe').\n\nALSO NOTE: this ruling supersedes improvise-8xhy's remedy 1 (refuse at emit) as the END STATE, but not as the interim -- the refusal stays until this lands, or fit-measure silently produces the wrong number in aggregating views.\n\nTHE TWO THIRDS THIS STILL DOES NOT COVER, unchanged from the original text: col.missing means 'this record has no value for measure X', a different vocabulary; and the date sub-features (when.year, when.month) need date arithmetic improvise does not have at all. Those stay refused or get their own beads.","status":"in_progress","priority":2,"issue_type":"feature","assignee":"wave3-4zl7","owner":"el-github@elangley.org","created_at":"2026-09-20T00:45:22Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:37:13Z","started_at":"2026-09-20T05:32:41Z","labels":["area:gp"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-4zl7","title":"A fitted tree must route per record and aggregate the results, not route on the aggregate","description":"Successor to improvise-8xhy, which shipped remedy 1 (refuse at emit) as an explicit interim. This is the remedy that produces a formula A HUMAN WOULD HAVE WRITTEN, and it is the right end state -- but it was costed during 8xhy and it is much larger than it looks.\n\nANSWER THIS FIRST, BEFORE TOUCHING THE GRAMMAR. A fitted measure cell is evaluated at a CellKey context. In any view that aggregates over the split category -- Region on Page or None -- context.get(\"Region\") is None, and today Formula::filter falls straight through to aggregate_raw. So a categorical fit would SILENTLY DEGRADE to something else in exactly the views a pivot-table user spends most of their time in. That is a product decision about what a fit MEANS for an aggregated cell, not an emitter detail.\n\nSCOPE, once that is answered:\n * Expr::Is(Filter) evaluating to 1.0/0.0. This keeps the f64-only evaluator and avoids introducing string comparison into eval_expr. The primitive already exists in the right shape -- types.rs reads context.get(\u0026filter.category), the current cell own coordinate.\n * A grammar production plus tokenizer handling in the 1109-line parser.rs, with quoting rules for the category and item inside it (gotcha 34 -- one home, improvise_formula::quote).\n * Arms in BOTH evaluators: eval_expr and eval_expr_cached.\n * Coverage in the grammar-walking generator.\n * Thread Dataset::columns (the ColumnSpec/ColumnKind plan) into the emitter, so feature index -\u003e role is read off preprocess own plan. Do NOT reconstruct it: Spec::names is \u0026[\u0026str], and reconstructing index-\u003erole is a second implementation of preprocess expansion order, exactly the drift risk gotcha 41 names in another key.\n * .improv is free -- the writer emits Formula::raw.\n\nIT CLOSES ONLY A THIRD OF 8xhy EITHER WAY. A WHERE predicate covers the one-hots. col.missing means \"this record has no value for measure X\", a different vocabulary. The date sub-features (when.year, when.month) need date arithmetic improvise does not have at all. Those stay refused, or get their own tickets.\n\nPAIRS WITH improvise-vx3 (the fit-measure command), which is where a user would actually learn about the restriction.","acceptance_criteria":"A fitted measure evaluated at an aggregating cell routes PER RECORD and aggregates the results, rather than routing on the aggregate. fit-measure on web/demo.improv succeeds and the resulting formula means the same thing on a record row and one row up. The seed-dependence is gone: whether the command succeeds no longer depends on which features the search happened to pick. Note what this costs — evaluate_aggregated deliberately does not iterate the records behind a formula cell — and state how it interacts with gotcha 9's fixed point and Model::formula_cache. col.missing and the date sub-features remain out of scope.","notes":"USER RULING 2026-09-20: AGGREGATE THE RULE OVER THE MEMBERS. Route-then-aggregate, per record. The other three options -- Error(\"aggregated\"), refuse at fit time, and fall through to aggregate_raw -- are all rejected.\n\nTHE BEAD'S SCOPE HAS CHANGED AND THE TITLE NOW SAYS SO. It asked what a CATEGORICAL split means in a cell that aggregates over the split category. PR #25's review established the question has nothing to do with the split being categorical:\n\nA tree is fitted on individual RECORDS. A formula is evaluated at a CellKey, and in any aggregating cell evaluate_aggregated aggregates each Expr::Ref FIRST and then evaluates the IF. A linear formula survives that -- SUM(Revenue) - SUM(Cost) is SUM(Revenue - Cost) -- and a tree does not, because AGGREGATE-THEN-ROUTE IS NOT ROUTE-THEN-AGGREGATE. So EVERY fitted tree, numeric splits included, means one thing on a record row and something else one row up.\n\nIT IS NOT THEORETICAL: fit-measure Revenue on the repo's own shipped web/demo.improv is refused, naming Product=Espresso/Product=Latte. And because which features a champion picks is a property of the SEARCH, on a document with several categories the same command refuses or succeeds DEPENDING ON THE SEED. That is not a contract.\n\nWHAT THE RULING COSTS, stated plainly because it is the expensive answer and was chosen anyway: evaluate_aggregated deliberately does NOT iterate the underlying records for a formula cell -- it aggregates the refs and evaluates once. Routing per record means a formula cell must be able to ask for the records behind it and fold over them. That touches the evaluator, and it interacts with gotcha 9 (fixed-point recomputation) and with Model::formula_cache, which is where derived values live precisely so raw data and derived data stay separate.\n\nNOTE WHAT IT BUYS BESIDES CORRECTNESS: it is what a user means. A fitted rule applied to a group should say what the rule says about the members, summed -- not what the rule says about a fictional average member. The second is the thing that looks right and is wrong, which is the failure mode vx3's own ticket warned about ('a rule the user will believe').\n\nALSO NOTE: this ruling supersedes improvise-8xhy's remedy 1 (refuse at emit) as the END STATE, but not as the interim -- the refusal stays until this lands, or fit-measure silently produces the wrong number in aggregating views.\n\nTHE TWO THIRDS THIS STILL DOES NOT COVER, unchanged from the original text: col.missing means 'this record has no value for measure X', a different vocabulary; and the date sub-features (when.year, when.month) need date arithmetic improvise does not have at all. Those stay refused or get their own beads.","status":"open","priority":2,"issue_type":"feature","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T00:45:22Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:45:26Z","started_at":"2026-09-20T05:32:41Z","labels":["area:gp"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-hwpi","title":"The browser shell fails quietly, and never reads a key","description":"Found by the improvise-cr3 code review (2026-09-19) by READING web/app.js. There is no browser and no headless DOM in this environment, so none of this is observed -- it is what the code says will happen, and it is filed rather than fixed because each item is a judgement call about the shell rather than a defect in the Rust.\n\nFOUR THINGS, in descending order of how much they would cost a person at a demo:\n\n1. NO KEYBOARD AT ALL. #grid carries tabindex=\"0\" but the page registers no keydown handler, so Client::on_key_event -- and the whole keys.rs classifier and the lockstep machinery behind it -- is UNREACHED from the browser. Consistent with improvise-bpq2 (ClientMsg has no Key variant server-side) but larger than it: even a LOCAL key, one that needs no round trip, does nothing. improvise-1ey acceptance (3) is \"arrow keys move the cursor locally with no round-trip\", and nothing in the page can do it.\n\n2. A MALFORMED FRAME IS QUIET. client.on_frame(event.data) returns Result\u003c_, JsError\u003e, which wasm-bindgen turns into a THROWN EXCEPTION. It is thrown inside the message listener with no try, so it surfaces as an uncaught error in the devtools console and the following repaint() is skipped. Nothing in the UI says so: the grid simply stops updating. A try/catch writing to el.status would make it visible.\n\n3. NO error LISTENER ON THE SOCKET. A connection that never opens leaves #status blank -- only close writes \"disconnected\". So \"server not running\" and \"still connecting\" look the same.\n\n4. close() RUNS TWICE ON COMMIT, benignly. Enter -\u003e close(true) -\u003e td.replaceChildren(original) removes the focused \u003cinput\u003e -\u003e blur fires -\u003e close(false). editing is already null so it is harmless (one extra repaint), but there is no re-entry guard and only a real browser would show it.\n\nNOTE WHAT MAKES THIS TICKET DIFFERENT: items 2-4 are the kind of thing a test would catch instantly and we have no way to run one. Before fixing them, consider whether the right first move is a headless DOM in CI (jsdom or playwright) -- otherwise the fixes are as unverified as the bugs. improvise-xf1 (verify OpfsFs against a real browser) wants the same harness.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T00:40:36Z","created_by":"Edward Langley","updated_at":"2026-09-20T02:29:50Z","closed_at":"2026-09-20T02:29:50Z","close_reason":"All four items done, plus a fifth the ticket could not have predicted.\n\n1. KEYDOWN WIRED -- a window listener feeding Client::on_key_event. preventDefault fires only when a frame was produced and no Ctrl/Alt is held, so browser shortcuts stay the browser's; metaKey is skipped entirely, matching BrowserKey::to_key_event dropping Meta so Cmd-R cannot arrive as a bare r. The inline editor stopPropagation keeps it out of the way, with an if (editing) return as belt and braces.\n2. MALFORMED FRAME -- try/catch around on_frame writing to #status, plus console.error with the offending frame. render_model() is guarded the same way; it also returns a Result.\n3. SOCKET error LISTENER -- 'cannot reach ... -- is improvise-ws-server running?', so that no longer reads like 'still connecting'.\n4. close() RE-ENTRY GUARD -- a closed flag in edit().\n\nTHE FIFTH, AND IT IS THE INTERESTING ONE: a worker whose start() REJECTS is caught INSIDE worker.js, so it fires NO error event on the main thread. The page would have sat on 'in-page worker - demo.improv' over an empty grid forever -- THIS TICKET'S EXACT DISEASE, REBUILT IN THE NEW DEPLOYMENT ON DAY ONE. The guard is a first-frame watchdog (8s, cleared by the first frame), and it deliberately covers BOTH transports rather than each peer's own failure modes, because 'the peer never answered the handshake' is the one condition that means the same thing over a socket and over a port -- a check aimed per-peer would have missed exactly this one.\n\nKEYS ARE INERT AND THAT IS STATED, NOT HIDDEN. Host::handle answers ClientFrame::Key with an empty vec; improvise-bpq2 is the other half.\n\nSTILL UNRUN -- and this ticket predicted that would be the problem: 'consider whether the right first move is a headless DOM, otherwise the fixes are as unverified as the bugs.' That sentence is now true OF THESE FIXES. Three of the four are one-assertion Playwright tests. See the harness ticket.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-zv4m","title":"improvise-protocol should own the websocket framing, so both ends stop mirroring it","description":"Raised by improvise-cr3 (2026-09-19) as the thing to push back on, and filed rather than fixed there.\n\nTODAY: improvise-wasm-client/src/socket.rs declares ClientFrame/ServerFrame, which MIRROR improvise-ws-server/src/wire.rs ClientMsg/ServerMsg field for field. The client cannot depend on the server crate -- tokio, tungstenite, Box\u003cdyn Storage\u003e, all native -- so the duplication was the only way to speak the protocol from wasm.\n\nWHAT HOLDS THE LINE MEANWHILE: improvise-ws-server is a DEV-dependency of the client (the same argument the crate already makes for improvise-command), and the_socket_framing_matches_the_server round-trips every variant THROUGH THE SERVERS OWN TYPES in both directions; a_frame_named_the_other_envelopes_way_does_not_parse is its check-the-checker. So a drift is caught -- but only by a test whose job is to notice, rather than by a shared definition that cannot drift.\n\nTHE FIX: move the framing into improvise-protocol, which both ends already depend on and which is the crate whose charter is exactly this -- \"the wire vocabulary and the portable session slice\". Then the mirror, the dev-dependency and both compatibility tests all delete.\n\nNOTE THE THIRD ENVELOPE while you are in here: improvise-wasm-client/src/wire.rs has ClientMessage/ServerMessage framing the WORKER deployment (improvise-tm6). Three envelopes for two deployments is worth resolving in one pass -- decide whether the worker and the socket want the same vocabulary before moving either.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T00:37:29Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:37:50Z","closed_at":"2026-09-20T00:37:50Z","close_reason":"Duplicate of improvise-xks6 ('One home for the client/server frame envelopes'), filed minutes apart from the improvise-cr3 hand-back without spotting the existing ticket. Closing this one; xks6 is the survivor and its description should absorb two facts this one carried:\n\n * WHAT HOLDS THE LINE MEANWHILE: improvise-ws-server is a DEV-dependency of improvise-wasm-client, and the_socket_framing_matches_the_server round-trips every variant THROUGH THE SERVERS OWN TYPES in both directions, with a_frame_named_the_other_envelopes_way_does_not_parse as its check-the-checker. The drift is caught by a test whose job is to notice, rather than prevented by a shared definition.\n * THERE ARE THREE ENVELOPES, NOT TWO: improvise-wasm-client/src/wire.rs frames the WORKER deployment (improvise-tm6) separately again. Decide whether the worker and the socket want the same vocabulary before moving either, or the move produces two homes instead of one.\n\n(See also memory agent-issue-drift-pattern -- search before filing.)","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-8xhy","title":"[bug] A fit over a categorical feature emits an unevaluable formula","description":"Found during improvise-7na (2026-09-19), verified by probe, and pinned as a characterization test rather than fixed.\n\ngp_tree::data::preprocess one-hots a categorical column into a feature named Region=North. That is an ENCODING ARTEFACT, not an item of the document, so the emitted formula Expr::Ref fails Model::find_item_category and EVERY CELL of the fitted measure evaluates to Error(\"ref:Region=North\"). col.missing and the col.year / col.month date sub-features are in exactly the same position.\n\nSo \"fit a tree and get a working formula\" holds ONLY for a fit over numeric measures.\n\nTHE TEST THAT SHOULD HAVE CAUGHT IT WAS LYING: improvise_evaluation_matches_gp_tree_eval cannot see this, because its improvise_eval helper FABRICATES a _Measure item for every feature name, including outlook=rainy. It asserts the emitter and gp_tree::eval agree, which they do -- against a model that could not exist. Worth remembering as a shape: a helper that builds the world the code wants is not a test of whether that world exists.\n\nPinned as a_split_on_a_one_hot_feature_does_not_evaluate -- characterization, flip it when fixed.\n\nTHREE REMEDIES, pick deliberately: restrict fits to numeric features (smallest, and honest about what works); materialise one-hot columns as real _Measure items (makes the formula evaluable but pollutes the document with encoding artefacts); or teach the emitter improvise WHERE vocabulary so a categorical split becomes a real predicate over the category (largest, and the only one that produces a formula a human would have written). The third is probably right and should be costed before the first is shipped as permanent.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T00:35:05Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:45:21Z","closed_at":"2026-09-20T00:45:21Z","close_reason":"Fixed as REMEDY 1, explicitly labelled interim. fitted_formula now takes the \u0026Model the formula is to be stored in and refuses a tree whose splits it cannot resolve, listing them. formula_text is untouched -- it is the tree's own text and there is nothing else it could write. Nothing outside the crate called fitted_formula.\n\nWHY THE EMITTER AND NOT THE DATASET LAYER: dropping categorical columns before the fit is smaller, but it throws away real signal (permutation importance, and any future remedy-3 emitter) and makes the crate's halves disagree -- one-hot encoding is gp-tree's whole job. The defect is that the EMITTER spells a formula the document cannot evaluate.\n\nWHY THE CHECK ASKS THE DOCUMENT, NOT THE NAME'S SHAPE: refusing names containing '=' or matching .missing would be a syntactic proxy and a wrong one, because a measure LITERALLY NAMED Region=North is a legal document and must be accepted. The two are indistinguishable as strings; only the document can tell them apart. So it calls Model::find_item_category -- the exact precondition Expr::Ref has -- which covers .missing, the date parts, and any future sub-feature naming scheme with no second copy of preprocess's expansion rules to drift from. Both directions pinned.\n\nTHE LYING TEST IS KEPT, NOT DELETED. Quoting and threshold routing are real things improvise_evaluation_matches_gp_tree_eval checks, and outlook=rainy earns its place there as an awkward NAME TO SPELL. What changed is that it and its helper now state their scope -- a test of arithmetic and quoting against a STIPULATED model, unable to see whether such a model could exist. The property it could not state is asserted separately against a real document: a_formula_the_emitter_accepted_evaluates_everywhere -- if fitted_formula says Ok, no cell of the target is a ref: error. That test and the refusal test are each other's check; without the accepting case the refusal could pass by refusing everything. The characterization is flipped to a_split_on_a_one_hot_feature_is_refused.\n\nREMEDY 3 IS STILL RIGHT AND IS NOT THIS TICKET -- successor filed. It is much larger than it looked: improvise has NO SCALAR COORDINATE PREDICATE (Filter gates a formula or an aggregate, it is not an Expr), it would still leave col.missing and the date parts uncovered, and it must first answer what such a predicate means where context.get(cat) is None.","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -168,20 +166,13 @@ {"_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-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":"open","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-20T18:53:08Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-9iyv","title":"The route-then-aggregate fold is unbenchmarked","description":"improvise-4zl7 made `SUM(\u003cexpr\u003e)` fold over the member records rather than over\nthe raw matching cells. The fold is not cached and not memoised: for one\naggregated formula cell it walks `matching_cells(partial)`, dedupes the record\ntuples through an `IndexSet`, and evaluates the inner expression once per\nrecord. `recompute_formulas` runs that for every stem, every formula target, and\nup to `MAX_EVAL_DEPTH` (16) passes.\n\nNothing here is known to be slow. Nothing here has been measured either, and the\nwork is O(cells + members × expression size) per cell per pass where the\nprevious code was one `matching_values` scan. A routed formula over a document\nwith thousands of records is the shape to measure.\n\nNote what must NOT be \"fixed\" by memoising carelessly: `formula_cache` holds\nderived values and the fold deliberately reads *raw* data through the same\n`evaluate_depth` / `aggregate_raw` path the non-folding evaluator uses, which is\nwhy the fixed point still converges (gotcha 9, context/code/core.md 3 and 13). A\nper-record memo keyed by anything less than the whole record key would be a\nsecond answer to \"what does this cell depend on\".\n\n## Steps to Reproduce\n\n1. Build a document with several thousand records — `examples/gen-grammar.rs`\n or a CSV import will do.\n2. Add a routing formula over them, e.g.\n `Pred = SUM(IF(Cost \u003c 50, 1, 10))`, or run `fit-measure` and store the\n result.\n3. Time `Model::recompute_formulas`, and compare against the same document with\n a linear formula (`Profit = Revenue - Cost`), which takes the `Expr::Ref`\n fast path and does not fold.\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.","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-20T18:51:21Z","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":"open","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-20T18:50:51Z","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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:45:39Z","created_by":"wave2-h-frontend","updated_at":"2026-09-20T18:58:18Z","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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:33:24Z","created_by":"wave2-e-active-view","updated_at":"2026-09-20T18:33:24Z","labels":["area:active-view"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-w3bf","title":"A session mirror's active_view is written in four places, none through reduce_view","description":"Found by the adversarial review of PR #48 (improvise-jytm). Not a defect today: the\nfour writers agree. It is the SHAPE the divergence bugs in this area have taken every\ntime, filed before it becomes one.\n\n`ClientSession::view.active_view` — the server's mirror of a client's view — is\nwritten in four places, and none of them goes through `reduce_view`:\n\n1. `Sessions::apply_switch` — sets it directly from the accepted name.\n2. `Sessions::adopt_deleted_views` — sets it directly to the document default.\n3. `ClientSession::reseed` — replaces the whole slice via `fresh_view`.\n4. `Sessions::connect` — same, through `reseed`.\n\nEverywhere else in this codebase, the mirror stays in step with the client by folding\nthe SAME command the client folds (`ClientSession::fold` → `reduce_view`, the\n\"same function runs on both ends\" rule of improvise-cqq/m91). These four bypass it.\nThey are correct today only because `reduce_view`'s `SwitchView` arm is one line\n(`view.active_view = name.clone()`) and each of these reproduces it.\n\nThe day that arm grows a second statement — clearing a per-view buffer, resetting a\ncursor, anything — the mirror and the client diverge and nothing fails. That is\nexactly gotcha 36's \"folding SwitchView into the mirror alone was the bug\", arriving\nfrom the other direction.\n\nTwo shapes worth weighing:\n\n- Route all four through `reduce_view(\u0026mut session.view, \u0026Command::SwitchView(name))`.\n Cheap, and makes the bypass unrepresentable for this command.\n- A test that the two agree: fold a `SwitchView` into a bare `ViewState` and into a\n `ClientSession` and compare. Weaker (it is a compatibility test, and\n `doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)` says\n those are a definition waiting to be made) but it costs nothing.\n\n## Steps to Reproduce\n\nNot reproducible today; the four copies agree. To make it reproduce: add any second\nstatement to `reduce_view`'s `Command::SwitchView` arm in\n`crates/improvise-protocol/src/reduce.rs` — for instance clearing `status_msg` — then\nrun the suite. It stays green, and a client's own slice now carries something its\nserver-side mirror does not.\n","acceptance_criteria":"Either the four writers route through reduce_view, or a test pins that a ClientSession's mirror and a bare ViewState fold a SwitchView identically. Closing it as 'the copies agree' is not acceptable — that is the state it is filed in.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:32:43Z","created_by":"wave2-e-active-view","updated_at":"2026-09-20T18:32:43Z","labels":["area:active-view"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_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.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:23:51Z","created_by":"orchestrator","updated_at":"2026-09-20T19:08:21Z","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":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:11:01Z","created_by":"wave2-b-projection","updated_at":"2026-09-20T18:11:01Z","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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:06:03Z","created_by":"orchestrator","updated_at":"2026-09-20T18:06:03Z","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.","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-20T18:06:00Z","labels":["area:active-view"],"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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:05:51Z","created_by":"orchestrator","updated_at":"2026-09-20T18:05:51Z","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":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:04:14Z","created_by":"wave2-h-frontend","updated_at":"2026-09-20T18:04:14Z","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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:01:04Z","created_by":"wave2-p-milestones","updated_at":"2026-09-20T18:01:04Z","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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T17:57:25Z","created_by":"wave2-n-cleanups","updated_at":"2026-09-20T17:57:25Z","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-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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T17:57:25Z","created_by":"wave2-n-cleanups","updated_at":"2026-09-20T17:57:25Z","labels":["area:command"],"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-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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T07:49:56Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:57:52Z","labels":["area:protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -265,7 +256,7 @@ {"_type":"issue","id":"improvise-jjx","title":"Scrolled grid suppresses first visible row-header label","description":"src/ui/grid.rs:338-350: repeat-suppression shows a row-header sub-label only when ri == 0 or the prefix differs from data_row_items[ri-1]. ri is the absolute data-row index (starts at row_offset, grid.rs:269), so when the view is scrolled the first visible row compares against the off-screen row above and its label is blanked — the user loses the group context at the top of the viewport. Fix: compare against the previous *visible* row, i.e. always show labels for the first rendered data row. 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:58:38Z","created_by":"Edward Langley","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-byz","title":"records_display returns Some(\"\") for out-of-bounds columns instead of None","description":"crates/improvise-core/src/view/layout.rs:186-202: records_display() calls col_label(col), which returns an empty string for out-of-bounds indices; the empty string then fails the == \"Value\" check, is looked up as a category name, and the function returns Some(\"\") via unwrap_or_default(). cell_key() (~line 396) explicitly returns None for an empty col_label — records_display should be symmetric so view/data mismatches are not masked. 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:58:35Z","created_by":"Edward Langley","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-zgb","title":".improv formula loader mis-splits category suffix via rfind(\" [\")","description":"crates/improvise-io/src/persistence/mod.rs:403-411: the formulas_section walker finds the [Category] suffix with raw.rfind(\" [\") + ends_with(']'). Two failure modes: (1) a pipe-quoted identifier at the end of the formula containing ' [' (e.g. |Revenue [USD]|) is mis-split, treating part of the quoted name as the category; (2) a suffix written without a leading space (X=Y[Cat]) is not recognized and the whole line is parsed as a _Measure formula, losing the target category. Fix: move the category suffix into the pest grammar instead of string scanning. Write failing tests first per AGENTS.md. 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:58:32Z","created_by":"Edward Langley","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-drg","title":"vb4 step 5: narrow Effect::apply signatures where one-slice","description":"Optional follow-up to vb4 structural split. For each impl Effect for Foo, audit which slice(s) it mutates. If apply only touches ModelState (or only ViewState), narrow its signature from fn apply(\u0026self, app: \u0026mut App) to fn apply(\u0026self, model: \u0026mut ModelState) (or ViewState). Cross-cutting effects (read layout, change mode that triggers layout rebuild, etc.) stay on \u0026mut App. Improves type-level documentation and enables finer-grained effect tests. Lower priority — vb4's acceptance criterion (4) explicitly allows leaving cross-cutting on \u0026mut App.","design":"Hidden trait-design decision surfaced during vb4 step 4 review: the issue's\nphrasing — \"narrow its signature from fn apply(\u0026self, app: \u0026mut App) to\nfn apply(\u0026self, model: \u0026mut ModelState)\" — is not directly possible. You\ncannot change one Effect impl's signature without changing the Effect\ntrait. Three approaches, none picked:\n\n(A) Split into multiple traits — ModelEffect / ViewEffect / AppEffect.\n Each impl becomes the narrower thing it actually is. Cost: ~50\n existing impls reorganize across three traits, and Vec\u003cBox\u003cdyn Effect\u003e\u003e\n has to become an enum wrapper (or three parallel queues) to keep\n apply_effects working. Highest fidelity to the issue's literal wording.\n\n(B) Keep one trait, rebind inside the impl body — e.g.\n fn apply(\u0026self, app: \u0026mut App) { let view = \u0026mut app.view_state; view.mode = ... }\n so the slice is visible at the call site without changing the\n signature. Lowest disruption. Doesn't strictly satisfy acceptance\n criterion (1) (a \"narrower borrow\") — only the body is narrower.\n\n(C) Default-method dispatch on a single trait — Effect grows\n apply_view(\u0026mut ViewState) and apply_model(\u0026mut ModelState) with\n default impls that fall through to apply(\u0026mut App). Each impl\n overrides only the method that matches its slice. Middle ground:\n type-level narrowing for impls that opt in, \u0026mut App stays available\n for cross-cutting effects. Wraps cleanly around the existing\n apply_effects loop.\n\nPicking (A) commits to the architecture for downstream effect tagging\n(improvise-mae) and the protocol extraction (improvise-cqi). (B) and (C)\nleave that decision unmade. Recommend deciding before claiming.","acceptance_criteria":"Each Effect whose apply touches only one slice takes \u0026mut ModelState or \u0026mut ViewState; cross-cutting effects stay on \u0026mut App, which vb4's own acceptance explicitly allows. The win is type-level documentation and finer-grained effect tests, not line count. CAUTION, new since this ticket was filed: App::apply_effects now reads dirtiness, layout_version and the record fingerprints off the EffectKind (improvise-gv9r, gotcha 40) — narrowing a signature must not put an effect somewhere that loop cannot see.","notes":"DECLINED 2026-09-20 (wave 2), with counts. Reasoning in context/code/command.md,\n'Effect::apply stays on \u0026mut App'. NOTE: this bead carries the `human` label, so\nthe decline is a recommendation the user may overturn; it is recorded rather than\nsmuggled.\n\nCOUNTED over the 49 `impl Effect` blocks in ui/effect.rs:\n now (this branch) 8 view_state-only, 9 model_state-only, 32 cross-cutting\n base 52507a6 9 view_state-only, 16 model_state-only, 24 cross-cutting\nThe narrow set fell from 25 to 17 in one wave. The mover was improvise-9mo9,\nwhich made nine reshaping effects call app.reset_cursor(): a reshape lives in\nmodel_state.workbook and the cursor it invalidates lives in ViewState, so the\neffects that matter most are precisely the ones that reconcile the two slices.\nNarrowing bets against the direction the code moves in.\n\nFOUR GROUNDS\n1. The narrow set is small and shrinking, for that architectural reason.\n2. The type-level documentation already exists and is CHECKED: Effect::kind()\n has no default (gotcha 3) and apply_effects reads that one answer three times\n (gotchas 40, 45). A narrowed signature is a second statement of the same fact,\n free to disagree -- the defect, not the cure.\n3. Every offered mechanism weakens a guard. (A) three traits needs three queues\n (destroys batch order, which gotchas 21/23/43 lean on) or an enum wrapper (a\n fourth classification beside EffectKind). (C) default methods give `apply` a\n DEFAULT, exactly what kind() does without and for the same reason: overriding\n apply_view on an effect that needs the model compiles and writes the wrong\n slice silently; overriding neither compiles as a no-op. (B) does not meet the\n ticket's own acceptance criterion.\n4. \"Finer-grained effect tests\" is not blocked: effect.rs's 64 tests already call\n Effect.apply(\u0026mut app) against a one-line App::new(wb, None) fixture.\n\nWHAT IS NOT DECLINED: approach (B), rebinding the slice at the top of an apply\nbody, remains available as a per-function style choice. It is not mandated.\nUSER CONFIRMATION 2026-09-20: the decline is CONFIRMED and the bead stays closed. This bead carries the 'human' label, so the WU-N agent correctly recorded its decline as a recommendation rather than acting on it unilaterally; the user was shown the counts (8 view-only / 9 model-only / 32 cross-cutting of 49, down from 9/16/24 at the wave base) and all three approaches (A: three traits; B: rebind in the body; C: default-method dispatch), and chose to confirm the decline.\n\nThe reasoning the user accepted: the classification ALREADY EXISTS and is checked — Effect::kind has no default and apply_effects reads that one answer three times — so a narrowed signature is a second statement free to disagree with it. Three traits need three queues (destroying batch order) or an enum wrapper (a fourth classification). Default methods would give apply a DEFAULT whose wrong answer is silent, which is precisely what kind()'s no-default rule exists to prevent.\n\nThe fact worth carrying past this bead: improvise-9mo9 moved nine effects from 'touches one slice' to 'cross-cuts' INSIDE THE SAME BRANCH that declined this, and that is not a coincidence to route around. The App exists because the grid's shape lives in model_state.workbook and the cursor into it lives in ViewState, so the effects that matter most are exactly the ones that RECONCILE the two slices. A design that rewards single-slice effects would have been pulling against the repo's actual centre of gravity.\nCOUNTS CORRECTED 2026-09-20, after adversarial review of PR #49. The decline\nstands; the numbers it was argued on were wrong twice and the user confirmed it\npartly on them, so the correction is recorded loudly rather than restated.\n\nPUBLISHED (wrong): 49 effects; 8 view-only, 9 model-only, 32 cross; narrow set\nfell 25 -\u003e 17.\nCORRECT: 57 effects; 16 view-only, 9 model-only, 32 cross; narrow set\nfell 32 -\u003e 25. Base 52507a6 was 16/16/25.\n\nTwo errors. (a) It counted only the 49 `impl Effect` blocks and omitted the 8\n`simple_effect!` invocations, which also expand to an impl and are ALL view-only.\n(b) It misclassified AddItem at the base commit, because the extraction regex\ncould not see `app` followed by a newline and `.model_state` -- which is\ndoctrine(derive_then_verify) catching the person quoting it, with the exact\nfailure that doctrine was written about.\n\nWHAT THIS COSTS THE ARGUMENT: 25 of 57 is 44% of effects that could narrow, not\nthe 35% claimed; and the fall is 22%, not 32%. The direction is unchanged and is\nstill the point -- the seven movers are SetAxis, HideItem, ShowItem, ToggleGroup,\nTogglePruneEmpty, RemoveItem and RemoveCategory, every one an effect\nimprovise-9mo9 gave a reset_cursor, so the shrinkage is architectural rather than\nincidental. But the headline was overstated by about half again, and anyone\nre-opening this should argue from 32 -\u003e 25 of 57.\n\nThe other three grounds for the decline are untouched by this: the classification\nalready exists and is checked; three traits break batch order and default methods\nreintroduce a silent default; and finer-grained tests are not blocked.","status":"closed","priority":3,"issue_type":"task","assignee":"wave2-n-cleanups","owner":"el-github@elangley.org","created_at":"2026-04-26T18:16:52Z","created_by":"jeeves","updated_at":"2026-09-20T18:57:42Z","started_at":"2026-09-20T18:08:16Z","closed_at":"2026-09-20T18:11:26Z","close_reason":"Closed","labels":["area:command","human"],"dependencies":[{"issue_id":"improvise-drg","depends_on_id":"improvise-99k","type":"blocks","created_at":"2026-04-26T11:17:16Z","created_by":"jeeves","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-drg","title":"vb4 step 5: narrow Effect::apply signatures where one-slice","description":"Optional follow-up to vb4 structural split. For each impl Effect for Foo, audit which slice(s) it mutates. If apply only touches ModelState (or only ViewState), narrow its signature from fn apply(\u0026self, app: \u0026mut App) to fn apply(\u0026self, model: \u0026mut ModelState) (or ViewState). Cross-cutting effects (read layout, change mode that triggers layout rebuild, etc.) stay on \u0026mut App. Improves type-level documentation and enables finer-grained effect tests. Lower priority — vb4's acceptance criterion (4) explicitly allows leaving cross-cutting on \u0026mut App.","design":"Hidden trait-design decision surfaced during vb4 step 4 review: the issue's\nphrasing — \"narrow its signature from fn apply(\u0026self, app: \u0026mut App) to\nfn apply(\u0026self, model: \u0026mut ModelState)\" — is not directly possible. You\ncannot change one Effect impl's signature without changing the Effect\ntrait. Three approaches, none picked:\n\n(A) Split into multiple traits — ModelEffect / ViewEffect / AppEffect.\n Each impl becomes the narrower thing it actually is. Cost: ~50\n existing impls reorganize across three traits, and Vec\u003cBox\u003cdyn Effect\u003e\u003e\n has to become an enum wrapper (or three parallel queues) to keep\n apply_effects working. Highest fidelity to the issue's literal wording.\n\n(B) Keep one trait, rebind inside the impl body — e.g.\n fn apply(\u0026self, app: \u0026mut App) { let view = \u0026mut app.view_state; view.mode = ... }\n so the slice is visible at the call site without changing the\n signature. Lowest disruption. Doesn't strictly satisfy acceptance\n criterion (1) (a \"narrower borrow\") — only the body is narrower.\n\n(C) Default-method dispatch on a single trait — Effect grows\n apply_view(\u0026mut ViewState) and apply_model(\u0026mut ModelState) with\n default impls that fall through to apply(\u0026mut App). Each impl\n overrides only the method that matches its slice. Middle ground:\n type-level narrowing for impls that opt in, \u0026mut App stays available\n for cross-cutting effects. Wraps cleanly around the existing\n apply_effects loop.\n\nPicking (A) commits to the architecture for downstream effect tagging\n(improvise-mae) and the protocol extraction (improvise-cqi). (B) and (C)\nleave that decision unmade. Recommend deciding before claiming.","acceptance_criteria":"Each Effect whose apply touches only one slice takes \u0026mut ModelState or \u0026mut ViewState; cross-cutting effects stay on \u0026mut App, which vb4's own acceptance explicitly allows. The win is type-level documentation and finer-grained effect tests, not line count. CAUTION, new since this ticket was filed: App::apply_effects now reads dirtiness, layout_version and the record fingerprints off the EffectKind (improvise-gv9r, gotcha 40) — narrowing a signature must not put an effect somewhere that loop cannot see.","notes":"DECLINED 2026-09-20 (wave 2), with counts. Reasoning in context/code/command.md,\n'Effect::apply stays on \u0026mut App'. NOTE: this bead carries the `human` label, so\nthe decline is a recommendation the user may overturn; it is recorded rather than\nsmuggled.\n\nCOUNTED over the 49 `impl Effect` blocks in ui/effect.rs:\n now (this branch) 8 view_state-only, 9 model_state-only, 32 cross-cutting\n base 52507a6 9 view_state-only, 16 model_state-only, 24 cross-cutting\nThe narrow set fell from 25 to 17 in one wave. The mover was improvise-9mo9,\nwhich made nine reshaping effects call app.reset_cursor(): a reshape lives in\nmodel_state.workbook and the cursor it invalidates lives in ViewState, so the\neffects that matter most are precisely the ones that reconcile the two slices.\nNarrowing bets against the direction the code moves in.\n\nFOUR GROUNDS\n1. The narrow set is small and shrinking, for that architectural reason.\n2. The type-level documentation already exists and is CHECKED: Effect::kind()\n has no default (gotcha 3) and apply_effects reads that one answer three times\n (gotchas 40, 45). A narrowed signature is a second statement of the same fact,\n free to disagree -- the defect, not the cure.\n3. Every offered mechanism weakens a guard. (A) three traits needs three queues\n (destroys batch order, which gotchas 21/23/43 lean on) or an enum wrapper (a\n fourth classification beside EffectKind). (C) default methods give `apply` a\n DEFAULT, exactly what kind() does without and for the same reason: overriding\n apply_view on an effect that needs the model compiles and writes the wrong\n slice silently; overriding neither compiles as a no-op. (B) does not meet the\n ticket's own acceptance criterion.\n4. \"Finer-grained effect tests\" is not blocked: effect.rs's 64 tests already call\n Effect.apply(\u0026mut app) against a one-line App::new(wb, None) fixture.\n\nWHAT IS NOT DECLINED: approach (B), rebinding the slice at the top of an apply\nbody, remains available as a per-function style choice. It is not mandated.","status":"closed","priority":3,"issue_type":"task","assignee":"wave2-n-cleanups","owner":"el-github@elangley.org","created_at":"2026-04-26T18:16:52Z","created_by":"jeeves","updated_at":"2026-09-20T18:11:26Z","started_at":"2026-09-20T18:08:16Z","closed_at":"2026-09-20T18:11:26Z","close_reason":"Closed","labels":["area:command","human"],"dependencies":[{"issue_id":"improvise-drg","depends_on_id":"improvise-99k","type":"blocks","created_at":"2026-04-26T11:17:16Z","created_by":"jeeves","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-1ud","title":"Document RecordsEditing variant in repo-map.md","description":"context/repo-map.md currently lists 15 AppMode variants but does not include RecordsEditing, which is referenced at src/draw.rs:142 and src/ui/app.rs:442. Add the variant to the AppMode list in the 'Key Types' section so coding agents see it.\\n\\nTrivial doc fix.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:54:16Z","created_by":"Ed L","updated_at":"2026-06-10T04:05:13Z","closed_at":"2026-06-10T04:05:13Z","close_reason":"Done in commit 0fe04de (2026-06-09 repo-map refresh): AppMode list now reads 16 variants including RecordsNormal and RecordsEditing, and notes MinibufferConfig + the ModelState/ViewState split.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-8fy","title":"Introduce simple_effect! macro for single-field setter effects","description":"crates/improvise-command/src/ui/effect.rs has ~15 simple setter effects (SetSelected, SetRowOffset, SetColOffset, SetYanked, SetSearchQuery, SetSearchMode, SetStatus, etc.) that each implement the same 4-line apply: pub struct SetX(pub T); impl Effect { fn apply(\u0026self, app: \u0026mut App) { app.field = self.0.clone(); } }.\\n\\nAbstraction: simple_effect!(SetRowOffset, usize, row_offset) macro generates struct + Effect impl.\\n\\nCorrectness win: provides a single hook for cross-cutting concerns. If layout-affecting setters need to mark dirty or rebuild layout, one macro body covers all of them. Currently each setter independently decides whether to mark_dirty(), and silent omissions are plausible. ~50 LOC.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:54:14Z","created_by":"Ed L","updated_at":"2026-09-19T20:35:54Z","closed_at":"2026-09-19T20:35:54Z","close_reason":"simple_effect! macro added beside the Effect trait; eight single-field ViewOnly setters declared through it (-42 lines net). The macro HARD-CODES EffectKind::ViewOnly by design: an effect other clients must see is not 'simple' and keeps a hand-written impl, which is how gotcha 3's no-default-kind rule survives a macro at all. SetSelected (two fields) and SetNumberFormat (a Model change) stay hand-written. Existing effect tests already cover all eight, including view_only_effects_leave_shared_state_alone.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-1wu","title":"Extract axis label helper in view/layout.rs","description":"crates/improvise-core/crates/improvise-core/src/view/layout.rs:323-336 (row_label) and :338-351 (col_label) are character-identical except for which items list they read. Small syntactic duplication but trivial to consolidate.\\n\\nFix: fn axis_label(items: \u0026[AxisEntry], idx: usize) -\u003e String, call from both. ~14 LOC.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:54:13Z","created_by":"Ed L","updated_at":"2026-08-14T01:11:07Z","closed_at":"2026-08-14T01:11:07Z","dependencies":[{"issue_id":"improvise-1wu","depends_on_id":"improvise-ai7","type":"supersedes","created_at":"2026-08-13T18:11:07Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -291,13 +282,10 @@ {"_type":"issue","id":"improvise-l36","title":"3.2 Publish to crates.io","description":"After cargo publish --dry-run is clean and user confirms, run cargo publish. Verify crate appears at crates.io/crates/improvise and cargo install improvise works.","acceptance_criteria":"improvise is on crates.io and 'cargo install improvise' works from a clean machine. NEEDS THE USER: crates.io credentials, and their confirmation after 'cargo publish --dry-run' is clean. Note the workspace has grown to nine crates since this was filed — decide which are published and in what order, since a dependent cannot publish before its dependency, and improvise-io/protocol/command are all now real crates rather than internal modules.","notes":"DRY RUN ACTUALLY RUN, 2026-09-20 at 52507a6 (wave 2, WU-P). It is NOT clean,\nand the blocker is mechanical rather than a judgement call:\n\n error: failed to verify manifest at Cargo.toml\n all dependencies must have a version requirement specified when publishing.\n dependency `gp-tree` does not specify a version\n\ncargo stops at the FIRST offender, and gp-tree is not the only one. Every\ninter-crate dependency in this workspace is `{ path = \"...\" }` with no\n`version`, which crates.io forbids for normal dependencies:\n\n Cargo.toml (root) gp-tree, improvise-core, improvise-formula,\n improvise-io, improvise-command\n improvise-core improvise-formula\n improvise-formula (none)\n improvise-protocol improvise-core\n improvise-io improvise-core, improvise-formula\n improvise-gp gp-tree, improvise-core, improvise-formula\n improvise-command improvise-core, improvise-protocol,\n improvise-formula, improvise-io, improvise-gp\n\nDev-dependencies are exempt — cargo strips them on publish — so the root's\n`improvise-wasm-client`/`improvise-protocol` dev deps and improvise-wasm-\nclient's dev dep on the unpublishable improvise-ws-server are fine as they are.\n\nWHICH CRATES PUBLISH. `improvise-ws-server` and `improvise-worker-server` both\ncarry `publish = false` already, so the set is eight, and a dependent cannot go\nbefore its dependency. The only order that works:\n\n 1. improvise-formula (no intra-workspace deps)\n 2. improvise-core\n 3. gp-tree (no intra-workspace deps; can go any time before 4)\n 4. improvise-gp\n 5. improvise-protocol\n 6. improvise-io\n 7. improvise-command\n 8. improvise (the binary)\n\ncrates.io needs a minute or two to index each one before the next can resolve\nit, so this is eight sequential `cargo publish` calls, not one.\n\nTWO VERSION FACTS THE USER MUST DECIDE. `gp-tree` is at `0.1.0` while every\nother crate is at `0.1.0-rc2`; and a `-rc2` version requirement in a published\nmanifest pins consumers to a prerelease. If the intent is to publish 0.1.0\n(which improvise-3tj's tag implies), the version bump across ten manifests\nshould happen BEFORE the version requirements are written, or they will be\nwritten twice.\n\nNAME AVAILABILITY CHECKED: crates.io returns \"crate `improvise` does not exist\",\nso the name is free. Not checked: the other seven names.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:58Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:01:38Z","labels":["area:release","needs:user"],"dependencies":[{"issue_id":"improvise-l36","depends_on_id":"improvise-11a","type":"blocks","created_at":"2026-04-08T21:09:29Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-l36","depends_on_id":"improvise-2fr","type":"blocks","created_at":"2026-04-08T21:09:28Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":2,"comment_count":0} {"_type":"issue","id":"improvise-11a","title":"3.1 Configure cargo dist","description":"Run cargo dist init targeting x86_64-unknown-linux-gnu, aarch64-apple-darwin, x86_64-apple-darwin. Skip Windows and musl. Commit generated .github/workflows/release.yml and Cargo.toml additions. Test with v0.1.0-rc1 tag, verify builds, delete rc tag.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:55Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-11T07:49:57Z","close_reason":"Closed","dependencies":[{"issue_id":"improvise-11a","depends_on_id":"improvise-abz","type":"blocks","created_at":"2026-04-08T21:09:28Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":3,"comment_count":0} {"_type":"issue","id":"improvise-0s6","title":"Phase 3: Distribution","description":"Configure cargo dist, publish to crates.io, tag v0.1.0 release. Produces prebuilt binaries for Linux x86_64 and macOS (Intel + Apple Silicon).","acceptance_criteria":"Closes when improvise-l36 and improvise-3tj close. Both need the user; nothing here is agent work. improvise-11a (cargo dist) is already closed.","notes":"CHECKED, wave 2 WU-P: STAYS OPEN, correctly — both children need the user and\nneither was touched. Nothing was published and nothing was tagged.\n\nWhat this pass DID do is make them ready, and one of them is not as ready as\nit looked. `cargo publish --dry-run` was actually run and FAILS: every\ninter-crate dependency in the workspace is `{ path = ... }` with no `version`,\nwhich crates.io forbids. cargo reports only the first offender (gp-tree), so\nthe dry run understates it — the full list, the publish order for the eight\npublishable crates, and the gp-tree/rc2 version disagreement are on\nimprovise-l36.\n\nimprovise-3tj's premise is already partly false and its note now says so: the\nREADME prebuilt-binaries link is not a placeholder, v0.1.0-rc2 carries real\ncargo-dist artifacts for all four targets, and what is actually missing is the\nv0.1.0 tag and the manifest bump that must precede it.","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-09T04:05:51Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:05:07Z","labels":["area:release","needs:user"],"dependencies":[{"issue_id":"improvise-0s6","depends_on_id":"improvise-11a","type":"blocks","created_at":"2026-04-08T23:37:39Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-0s6","depends_on_id":"improvise-3tj","type":"blocks","created_at":"2026-04-08T23:37:41Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-0s6","depends_on_id":"improvise-l36","type":"blocks","created_at":"2026-04-08T23:37:40Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":3,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-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":"open","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-20T18:58:19Z","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":"open","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-20T18:45:36Z","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.","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-20T18:48:10Z","labels":["area:active-view"],"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":"open","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:14:57Z","created_by":"orchestrator","updated_at":"2026-09-20T18:14:57Z","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":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:13:31Z","created_by":"wave2-b-projection","updated_at":"2026-09-20T18:13:31Z","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-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":"TWO ADDITIONS, both instances of this ticket's one fact rather than new ones.\n\nFIRST, WHAT A CHECK MUST ACTUALLY DO — the part that gets lost, and the reason\nthis ticket is not a one-liner. Counting the four `.ap-player` elements PROVES\nNOTHING. `autoPlay` is false, so asciinema-player does not fetch a cast until\nthe viewer presses play; a page whose every cast path 404s constructs four\nplayers, throws no page error before the click, and is indistinguishable from a\nworking one. This was verified by injecting exactly that fault (`./casts/` -\u003e\n`./cats/`): the construction-only check passed on the broken page, and pressing\nplay returned 0 rendered lines and surfaced four 404s. So the spec must press\nplay on each of the four and assert a non-zero rendered line count, and its own\nfault injection must be recorded in `context/test-evidence/`.\n\nThe same applies to the page's one functional link. Since the user's ruling\n(improvise-kh8) the landing page IS the site root and its whole job is to reach\nthe app at `./app/`; a broken link there is a dead front door, and the page\nlooks entirely correct with one. Proved able to fail by pointing it at `./aap/`\n-- 404 while nothing on screen changes -- so the spec should resolve the\ncall-to-action href and assert the response status.\n\nSECOND, THE LINE BUDGET, which is an instance of \"asserted by nothing\" and not\na separate defect. improvise-e61's criterion is \"under 200 lines\" and\n`docs/index.html` is now 199 after the root/app restructure. A criterion met by\none line, with nothing checking it, is met by accident: the next person to touch\nthe file either trims something or silently breaks it, and no test, lint or CI\njob would say so. If this ticket's spec lands, the cheapest place for the budget\nis beside it -- a line count assertion is one statement and needs no browser.\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/` -- the landing page renders and the casts play,\n and no test in the repository would have noticed if they had not.\n4. Break a cast path in `docs/index.html`, repeat, and observe that the page is\n still structurally correct until a play button is pressed.\n5. `wc -l docs/index.html` -- 199, against a stated limit of 200, checked by\n nothing.\nWITHDRAWN FROM THIS TICKET: the 199-line budget paragraph above.\n\nIt was added here on the reasoning that \"a 200-line limit met by one line with\nnothing checking it\" is an instance of this ticket's one fact. On review that is\nthe wrong home: the better answer is to rewrite improvise-e61's criterion, since\nthe line count was only ever a proxy for single file / vanilla / no build step /\nno framework — properties nothing threatens and which a line count measures\nbadly. That has been done on improvise-e61.\n\nThis ticket is about the landing page being SERVED and ASSERTED BY NOTHING, and\nit should stay that. Read the paragraph above as superseded; the rest of the\nnotes — press play, count rendered lines, resolve the call-to-action href — is\nwhat this ticket is for.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:01:16Z","created_by":"wave2-p-milestones","updated_at":"2026-09-20T18:22:56Z","labels":["area:release"],"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":"TWO ADDITIONS, both instances of this ticket's one fact rather than new ones.\n\nFIRST, WHAT A CHECK MUST ACTUALLY DO — the part that gets lost, and the reason\nthis ticket is not a one-liner. Counting the four `.ap-player` elements PROVES\nNOTHING. `autoPlay` is false, so asciinema-player does not fetch a cast until\nthe viewer presses play; a page whose every cast path 404s constructs four\nplayers, throws no page error before the click, and is indistinguishable from a\nworking one. This was verified by injecting exactly that fault (`./casts/` -\u003e\n`./cats/`): the construction-only check passed on the broken page, and pressing\nplay returned 0 rendered lines and surfaced four 404s. So the spec must press\nplay on each of the four and assert a non-zero rendered line count, and its own\nfault injection must be recorded in `context/test-evidence/`.\n\nThe same applies to the page's one functional link. Since the user's ruling\n(improvise-kh8) the landing page IS the site root and its whole job is to reach\nthe app at `./app/`; a broken link there is a dead front door, and the page\nlooks entirely correct with one. Proved able to fail by pointing it at `./aap/`\n-- 404 while nothing on screen changes -- so the spec should resolve the\ncall-to-action href and assert the response status.\n\nSECOND, THE LINE BUDGET, which is an instance of \"asserted by nothing\" and not\na separate defect. improvise-e61's criterion is \"under 200 lines\" and\n`docs/index.html` is now 199 after the root/app restructure. A criterion met by\none line, with nothing checking it, is met by accident: the next person to touch\nthe file either trims something or silently breaks it, and no test, lint or CI\njob would say so. If this ticket's spec lands, the cheapest place for the budget\nis beside it -- a line count assertion is one statement and needs no browser.\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/` -- the landing page renders and the casts play,\n and no test in the repository would have noticed if they had not.\n4. Break a cast path in `docs/index.html`, repeat, and observe that the page is\n still structurally correct until a play button is pressed.\n5. `wc -l docs/index.html` -- 199, against a stated limit of 200, checked by\n nothing.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:01:16Z","created_by":"wave2-p-milestones","updated_at":"2026-09-20T18:14:01Z","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":"open","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-20T16:53:36Z","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":"open","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T07:50:12Z","created_by":"Edward Langley","updated_at":"2026-09-20T16:53:34Z","labels":["area:core-io"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-zx8t","title":"draw.rs::mode_style matches AppMode with a wildcard, so the repo map's exhaustiveness promise is false there","description":"Found by WU-G on PR #27 while adding AppMode::SavePrompt, and it is a correction to something context/repo-map.md currently promises.\n\nThe repo map says of AppMode: \"Per-variant UI facts live on the enum as exhaustive-match methods -- label(), hint_text(), panel(), minibuffer(), is_editing(), is_records() (improvise-2hi); ADD A VARIANT AND THE COMPILER NAMES EVERY ONE YOU MUST FILL IN.\"\n\nThat is TRUE of the five enum methods and FALSE of src/draw.rs::mode_style, which matches AppMode with a `_ =\u003e` WILDCARD. So a new variant silently inherits whatever the wildcard arm gives -- SavePrompt currently takes ExportPrompt's DarkGray by accident rather than by decision, which happens to be defensible and was nobody's choice.\n\nWHY IT IS P4 AND NOT HIGHER: the consequence is a colour, and the wrong colour is visible rather than silent. But the reason to fix it is that the repo map states a PROPERTY, and a property that holds in five places and not the sixth is worse than one nobody claimed -- the next person adding a variant will trust the sentence.\n\nTHE FIX IS ONE OF TWO, and they are not equivalent:\n * Make mode_style exhaustive, so the compiler does name it. Note mode_style stays in src/draw.rs deliberately, because Style is a ratatui type and improvise-protocol must not name one (gotcha 26, the AxisColor/MinibufColor pattern).\n * Or give AppMode a semantic colour method in the protocol crate -- the AxisColor treatment -- and let draw.rs do the one mapping. That is the established shape here and it would also give a browser something to use, which draw.rs's Style cannot.\n\nThe second is probably right and is the same question improvise-k099 asks about hint_text/label. Decide them together.\n","acceptance_criteria":"mode_style either matches AppMode exhaustively, or AppMode gains a semantic colour method in improvise-protocol that draw.rs maps to a ratatui Style (the AxisColor/MinibufColor shape). Either way, adding an AppMode variant fails the build until someone chooses its colour. The repo-map sentence promising compiler-named sites is true of every listed method, or it is corrected to say which one it is not true of.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T06:04:22Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:44:19Z","labels":["area:tui"],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -322,5 +310,5 @@ {"_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-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-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.","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:19:45Z","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 e79831eaa54aa9bd9567dd87c93f4f5e851a6266 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Sun, 20 Sep 2026 12:27:05 -0700 Subject: [PATCH 14/17] docs: two process rules the wave paid for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both are the reviewers' framings rather than mine, and both are better arguments than the ones I gave. AGENTS.md: under merge-as-soon-as-green a review round can finish AFTER its own merge, and GitHub has deleted the branch by then — so a fix push to the old name silently succeeds and accomplishes nothing. It re-creates a branch main will never see, CI runs on it and passes, and the commit is nowhere. The tell is in git's own output: `* [new branch]` where an `old..new` range was expected. Happened on #51, four minutes after the merge. design-principles §6: an argument must not live only in a commit message. I had justified the context/ rule as "context/ is what readers grep", which is weak. The real reason is that a commit message is write-once and unauditable — it cannot be corrected without rewriting merged history, and NO check in this repo can read one, because the ID audit greps source and docs and a message is in neither. A reason recorded only there is a reason nothing can ever find wrong. PR #51's 7d25762 is the worked example: a declined-test reason that was factually wrong, cited across two rounds, and correctable only by #56 saying so in context/. Co-Authored-By: Claude Opus 5 (1M context) --- .beads/issues.jsonl | 32 ++++++++++++++++++++++++-------- AGENTS.md | 11 +++++++++++ context/design-principles.md | 10 ++++++++++ 3 files changed, 45 insertions(+), 8 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 0c30e6d4..727efd09 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -22,7 +22,7 @@ {"_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.","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-20T18:19:45Z","labels":["area:epic"],"dependencies":[{"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-zkkw","type":"blocks","created_at":"2026-09-20T18:02:34Z","created_by":"wave2-p-milestones","metadata":"{}"}],"dependency_count":5,"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.","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-20T18:22:57Z","labels":["area:epic"],"dependencies":[{"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-zkkw","type":"blocks","created_at":"2026-09-20T18:02:34Z","created_by":"wave2-p-milestones","metadata":"{}"}],"dependency_count":5,"dependent_count":1,"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} @@ -37,8 +37,12 @@ {"_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-7ybz","title":"The browser category panel has no pointer affordance at all","description":"The user reported 'I can't expand categories'. Expanding works fully by KEYBOARD (Shift-C, j/k, Enter -\u003e Command::ToggleCatExpand, resolved client-side in improvise-protocol/src/resolve.rs:598). What does not work is the MOUSE: web/app.js::categoryList builds plain \u003cli\u003e elements with no click listener, so clicking the collapsed triangle does nothing. Verified in Chromium: clicking the 'Product' row leaves the panel byte-identical.\n\nThis is the whole page's pointer story, not one widget: #grid cells get a click handler ONLY when cell.editable, so there is no click-to-select either, and the tile bar and view rows added by fix/web-ui-correctness are likewise keyboard-only.\n\nThe DECISION needed before coding: does the browser get a pointer interaction model at all, and if so what does a click mean? Clicking a category row plausibly means (a) move cat_panel_cursor there, (b) also focus the panel (enter CategoryPanel mode) if it is not focused, and (c) toggle expand if the row is a header. That is three commands from one click, and app.js must stay protocol-free - so the shape would be a wasm entry point (Client::click_category(index) -\u003e Option\u003cString\u003e) mirroring typed_cell, not JS composing Commands. Same question for a grid cell click and a tile click.\n\nNot filed as part of the fix because it is a product call, not a defect in an implemented feature.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T19:17:10Z","created_by":"wave3-webui","updated_at":"2026-09-20T19:17:10Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-nynk","title":"adopt_cursor_epoch resets every client's cursor, including clients on a view nothing reshaped","description":"Found by the adversarial review of PR #49, demonstrated AND bisected. This is\nimprovise-jytm's regression, not PR #49's -- but #49 widens it.\n\nimprovise-jytm (#48) made \"which view a client is on\" per-client: apply_switch\nmoves only the asker's mirror. `Sessions::adopt_cursor_epoch`\n(ui/project.rs:904-912) was not updated and still resets EVERY mirror and returns\n`Broadcast::ToAll(Command::ResetCursor)`. So a reshape of view A throws away the\ncursor of a client sitting on view B, which nothing reshaped.\n\nTHE BISECT IS THE LOAD-BEARING PART:\n - at a3bef6e (main, post-#48) with HideItem -\u003e PASSES\n - at a3bef6e (main, post-#48) with TransposeAxes -\u003e FAILS\nSo the hole is already in main. What PR #49 (improvise-9mo9) changes is the\nNUMBER OF EFFECTS THAT REACH IT: from 2 (TransposeAxes, CycleAxis) to 12.\n\nTHE FIX IS SMALL AND THE TYPE ALREADY EXISTS. `Broadcast::To(SessionId, Command)`\nis there for exactly this shape (improvise-6c6q, improvise-jytm). adopt_cursor_epoch\nshould reset only the mirrors whose `active_view` is the view that reshaped, and\nreturn one `To(id, ResetCursor)` per affected client instead of one ToAll. Note\nthe return type becomes Vec\u003cBroadcast\u003e rather than Option\u003cBroadcast\u003e.\n\nWHY IT WAS NOT FIXED IN PR #49: changing ToAll -\u003e To for cursor invalidation is a\ndecision about broadcast semantics in a design that landed hours earlier, and\ngotcha 36 records a deliberate argument for ToAll here (\"a consequence the asker\nis owed like everybody else\"). That argument is still right for a client on the\nRESHAPED view and wrong for everyone else, which is a distinction #48 created and\nnobody has yet written down. It wants jytm's owner, not a review fix-up.\n\nimprovise-ni6k answers it for free: a per-view grid-frame fingerprint is\nper-view by construction.\n\n## Steps to Reproduce\n\n1. Two sessions on one document.\n2. Bystander sends Command::SwitchView(\"Second\"), then SetRowOffset(2).\n3. Asker sends ModelCommand::HideItem (or TransposeAxes, on main) against the\n host's \"Default\" view.\n4. The bystander's row_offset goes to 0 and the broadcast is\n [ToAll(Command::ResetCursor)] -- for a view the bystander is not on and that\n nothing reshaped.","acceptance_criteria":"adopt_cursor_epoch resets only the mirrors whose active_view is the view that reshaped, and addresses the relay per client with Broadcast::To rather than ToAll. The regression guard is a two-session test where the bystander is on a DIFFERENT view from the asker and keeps its offset -- and it must also keep the existing positive arm, where a bystander on the SAME view is still reset and still told, or 'never invalidate anyone' passes it. Note the bisect: this reproduces at a3bef6e with TransposeAxes, so it is not PR #49's to fix, but #49 takes it from 2 effects to 12.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T19:17:05Z","created_by":"wave2-n-cleanups","updated_at":"2026-09-20T19:17:05Z","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-pdvm","title":"AddItem/RemoveItem reshape the grid but declare CellData, so layout_version and cursor_epoch disagree","description":"Found in the adversarial review of PR #49 (improvise-9mo9). PRE-EXISTING, but\nthat PR is the first thing in the repo to produce evidence for it.\n\n`AddItem`, `AddItemInGroup` and `RemoveItem` declare\n`EffectKind::Model(ModelChange::CellData)`. `App::apply_effects` bumps\n`layout_version` only for `ModelChange::LayoutGeometry`, so these three do not\nmove it. Since improvise-9mo9 they DO call `app.reset_cursor()`, which bumps\n`cursor_epoch`.\n\nSo one event moves one counter and not the other, and the two counters are\nanswering the same question: \"has the grid's shape changed under you\". A client\nis told its cursor is invalid (`Command::ResetCursor`) while its `layout_version`\nbasis tier still reads current, so a batch composed against the old layout is\naccepted.\n\nTHE EVIDENCE IS THIS PR'S OWN TEST. `every_reshaping_model_command_invalidates_\nevery_cursor` now asserts, with a `grid_shape()` guard over `row_items`/\n`col_items`, that `AddItem`/`AddItemInGroup`/`RemoveItem` REALLY DO reshape the\ngrid. That is the assertion `ModelChange::CellData` contradicts.\n\nWHY IT IS NOT OBVIOUSLY \"RECLASSIFY THEM\". `EffectKind` is read by three\nconsumers off one classification (gotcha 40): `Dirty::of`, `layout_version`, and\n`model_state.dirty`. Moving these three to `LayoutGeometry` changes what every\nclient is told is dirty and widens the basis rejection surface. It also interacts\nwith gotcha 3's traps -- `AddCategory`/`RemoveCategory` are already the\n\"looks like data, is geometry\" case -- and with the `ModelCommand` derivation.\n\nRELATED, NOT DUPLICATE: improvise-75ld is the same family in the other direction\n(a refused or no-op AddCategory bumps layout_version and raises dirty when\nnothing happened). Decide them together; a single question -- \"what does an\nEffectKind entitle a counter to assume\" -- is under both.\n\n## Steps to Reproduce\n\n1. On a view with a category on Row, apply `ModelCommand::AddItem` for a new item\n in that category.\n2. `grid_shape()` (or `layout.row_count()`) changes: a row was inserted.\n3. `app.cursor_epoch()` has moved and clients receive `Command::ResetCursor`.\n4. `app.layout_version()` has NOT moved, because the effect declares CellData.\n5. A client may now compose a `Command::Model` whose `Basis.layout_version` is\n the pre-change value and have it accepted.","acceptance_criteria":"One event moves both counters or neither. Either AddItem/AddItemInGroup/RemoveItem are reclassified as LayoutGeometry -- in which case the blast radius through Dirty::of, the basis rejection surface and gotcha 3's traps is argued, not assumed -- or layout_version stops being read off EffectKind and is observed the way cursor_epoch and record_versions already are. Decide alongside improvise-75ld, which is the same question in the other direction, and alongside improvise-ni6k, whose grid-frame fingerprint would answer all three at once. The regression guard must assert the two counters agree over the same table every_reshaping_model_command_invalidates_every_cursor already drives, so it cannot pass by naming nothing.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:57:17Z","created_by":"wave2-n-cleanups","updated_at":"2026-09-20T18:57:17Z","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_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":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:22:32Z","created_by":"wave2-p-milestones","updated_at":"2026-09-20T18:22:32Z","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":"open","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-20T18:18:24Z","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-lltt","title":"[bug] A view-scoped ModelCommand names no view, so it lands on the host's","description":"Opened by improvise-jytm, which made `ViewState::active_view` authoritative per\nclient. The view-configuration half of `ModelCommand` — SetAxis, CycleAxis,\nTransposeAxes, SetPageSelection, HideItem, ShowItem, ToggleGroup, TogglePruneEmpty,\nSetNumberFormat — acts on \"the active view\", which is now an ambiguous phrase.\n\n`Sessions::apply_model` routes with no SessionId (gotcha 38, correctly: the change\nis addressed to no one session, because a `View` is document state shared by every\nclient sitting on it). But it must still name WHICH view, and today it names it\nimplicitly: `apply_command` runs the effect against the `App`, and the effect reads\n`App::view()`, which is the HOST's slice. So a connected client on another view\nmoves the host's view rather than its own.\n\nBefore jytm every client was on the same view, so the implicit answer was right.\nIt is now wrong exactly when two clients are on different views, which is the\ncapability jytm exists to add.\n\nThe honest fix is for those variants to name their view — a protocol change — so\nthe command carries the operand rather than borrowing an ambient one. Rejected\nduring jytm: retargeting `app.view_state.active_view` around `apply_command` at the\ndoor. It makes the host's own UI slice a parameter channel, and `reset_cursor`/\n`cursor_epoch` would then write the asker's key into the host's cursor map.\n\nNote the `_Drill`/`_Records` virtual views make this sharper than it looks: a host\nin records mode is on a view no client can name.\n\n## Steps to Reproduce\n\n1. Two clients, A and B, on one document with views \"Default\" and \"Second\".\n2. B sends `Command::SwitchView(\"Second\")`. Its mirror moves; the host's App stays\n on \"Default\" (correct, per improvise-jytm).\n3. B sends `Command::Model { cmd: ModelCommand::SetAxis { category: \"Region\",\n axis: Row } }`.\n4. The axis moves on \"Default\" — the view B is not on — and B's next projection\n shows nothing changed while A's shows a change A did not ask for.\n\nNot reachable from the TUI, which is the only client that drives the App's own\nslice, and not reachable from `web/app.js` today because it sends no SwitchView.\n","acceptance_criteria":"The view-configuration ModelCommand variants name the view they act on, so a connected client's SetAxis moves the view that client is on and not the host's. A test drives two sessions onto different views and asserts an axis change by one is invisible to the other. Deciding NOT to fix is also an acceptable close, but then gotcha 38 must say so.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:05:49Z","created_by":"wave2-e-active-view","updated_at":"2026-09-20T18:05:49Z","labels":["area:active-view"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-lltt","title":"[bug] A view-scoped ModelCommand names no view, so it lands on the host's","description":"Opened by improvise-jytm, which made `ViewState::active_view` authoritative per\nclient. The view-configuration half of `ModelCommand` — SetAxis, CycleAxis,\nTransposeAxes, SetPageSelection, HideItem, ShowItem, ToggleGroup, TogglePruneEmpty,\nSetNumberFormat — acts on \"the active view\", which is now an ambiguous phrase.\n\n`Sessions::apply_model` routes with no SessionId (gotcha 38, correctly: the change\nis addressed to no one session, because a `View` is document state shared by every\nclient sitting on it). But it must still name WHICH view, and today it names it\nimplicitly: `apply_command` runs the effect against the `App`, and the effect reads\n`App::view()`, which is the HOST's slice. So a connected client on another view\nmoves the host's view rather than its own.\n\nBefore jytm every client was on the same view, so the implicit answer was right.\nIt is now wrong exactly when two clients are on different views, which is the\ncapability jytm exists to add.\n\nThe honest fix is for those variants to name their view — a protocol change — so\nthe command carries the operand rather than borrowing an ambient one. Rejected\nduring jytm: retargeting `app.view_state.active_view` around `apply_command` at the\ndoor. It makes the host's own UI slice a parameter channel, and `reset_cursor`/\n`cursor_epoch` would then write the asker's key into the host's cursor map.\n\nNote the `_Drill`/`_Records` virtual views make this sharper than it looks: a host\nin records mode is on a view no client can name.\n\n## Steps to Reproduce\n\n1. Two clients, A and B, on one document with views \"Default\" and \"Second\".\n2. B sends `Command::SwitchView(\"Second\")`. Its mirror moves; the host's App stays\n on \"Default\" (correct, per improvise-jytm).\n3. B sends `Command::Model { cmd: ModelCommand::SetAxis { category: \"Region\",\n axis: Row } }`.\n4. The axis moves on \"Default\" — the view B is not on — and B's next projection\n shows nothing changed while A's shows a change A did not ask for.\n\nNot reachable from the TUI, which is the only client that drives the App's own\nslice, and not reachable from `web/app.js` today because it sends no SwitchView.\n","acceptance_criteria":"The view-configuration ModelCommand variants name the view they act on, so a connected client's SetAxis moves the view that client is on and not the host's. A test drives two sessions onto different views and asserts an axis change by one is invisible to the other. Deciding NOT to fix is also an acceptable close, but then gotcha 38 must say so.","notes":"THE TRIGGER, added after the adversarial review of PR #48 (2026-09-20). This is deferrable, but it is deferred on a CONDITION rather than on a judgement, and the condition is one line of somebody else's future work away. It is unreachable today ONLY because no shipped client emits Command::SwitchView. That is the whole of the argument. The browser already emits view-configuration model commands -- keys.rs resolves a keypress to ModelCommand::TransposeAxes locally and sends it -- so the machinery on the other side of the ambiguity is live. THE DAY ANY WAVE WIRES A VIEW SWITCH INTO THE BROWSER, THIS GOES LIVE ON THE NEXT TRANSPOSE KEYSTROKE, AND SILENTLY: a client on Second presses transpose, the axes of Default move instead, that client's grid does not change and every other client's does. Two consequences. (1) Do not close this by arguing it is unreachable -- reachability is the thing that is about to change, and gotcha 47 already records that 'unreachable today' was the argument under which the lost update CheckedBatch later fixed had shipped. (2) There is a cheap interim guard worth landing before the real fix: assert that no shipped browser client emits Command::SwitchView, a test over improvise-wasm-client's key resolver. It turns the silent failure into a loud one at exactly the moment the premise stops holding, which is the only moment anybody would otherwise not be looking.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:05:49Z","created_by":"wave2-e-active-view","updated_at":"2026-09-20T18:34:24Z","labels":["area:active-view"],"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":"CORRECTION, same session: this bead as first written read as though nobody had\ndecided the Chromium-only question. Somebody had.\n`invariant(opfs_is_run_but_bindings_is_not)` in prolog-sessions/kb.pl records\nit, and gives TWO reasons, not one:\n\n \"harness/tests/shell.spec.js runs web/app.js under CHROMIUM ONLY --\n engine-independent logic, and routeWebSocket is slower across three.\"\n\nThe description above only answers the second. The first — the logic is\nengine-independent — is a general claim covering all six specs, and against\nthe specs as a test of web/app.js it is correct: app.js is plain DOM and\nplain event wiring, and running the same assertions on a second engine buys\nvery little.\n\nWHAT IT DOES NOT COVER is the claim improvise-bck criterion (1) actually\nmakes. That criterion is not \"app.js's logic works in Firefox\", it is \"the\nstandalone deployment works in Firefox\" — and the standalone deployment is\nmodule workers, `wasm-pack` output, `createSyncAccessHandle` through\nbindings.rs and the seed fetch, none of which is app.js's logic and all of\nwhich is engine-specific. opfs.spec.js covers Firefox's OPFS through\n`OpfsFs` directly and, per the same invariant, NEVER INSTANTIATES\nbindings.rs. So the two standalone specs on Firefox would be the first\nexecution of that shim on a second engine, and near enough the first on any.\n\nSo the disagreement with the invariant is narrow and should be argued on\nthose terms: the engine-independence reason is a statement about app.js, and\nthe two standalone specs are not only a test of app.js. If that argument is\nrejected, the right outcome is to REWRITE improvise-bck criterion (1) to say\nChromium, not to leave a criterion standing that names an engine nothing\nruns on — a criterion nobody checks is the failure mode this whole pass\nexists to catch.\n\nEither way the invariant's wording is owed an update, since it currently\nreads as a blanket justification for a split that has two different reasons\nbehind its two halves.\nCORRECTION — MY OWN PREMISE WAS WRONG, found in adversarial review of PR #47.\n\nThe note above says the two standalone specs on Firefox \"would be the first\nexecution of that shim on a second engine, and near enough the first on any\".\nThe second half is false. `bindings.rs` HAS been executed, on Chromium, since\nimprovise-vvl3 landed the harness. The chain is unbroken and each link is\ncheckable:\n\n /app/index.html with no ?ws= -\u003e app.js `new Worker(\"./worker.js\")`\n worker.js `import { start } from \"./worker-pkg/improvise_worker_server.js\"`\n bindings.rs `pub async fn start()` -\u003e `boot()` -\u003e `OpfsFs::open().await`\n\nand `harness/tests/shell.spec.js`'s own comment on the standalone test has said\nso all along: \"If this draws a grid, worker.js, bindings.rs, OpfsFs and the seed\nfetch all ran.\"\n\nI RAN THAT SPEC GREEN AT 2ccd21f while verifying the /app/ move, and watched the\nbrowser fetch /app/worker.js and /app/worker-pkg/improvise_worker_server_bg.wasm\nand draw the grid with #status reading \"in-page worker\". The evidence was in my\nhand and I still repeated the claim, because I took it from kb.pl and the repo\nmap instead of from the run.\n\nWHAT SURVIVES: the argument for running the two standalone specs on Firefox is\nUNAFFECTED and is still the point of this ticket. Firefox would be a genuine\nsecond engine for the shim, and improvise-bck's criterion (1) still names an\nengine nothing runs. Only the \"first on any engine\" rhetoric goes.\n\nFIVE PLACES STATE THE FALSE VERSION and want one grep, not five edits:\nkb.pl:11, kb.pl:105 (invariant opfs_is_run_but_bindings_is_not), repo-map.md:241,\nrepo-map.md:467, and improvise-bck's own NOT-DEMONSTRATED list. Those three files\nare orchestrator-owned; the bck half is corrected in bck's notes.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:02:30Z","created_by":"wave2-p-milestones","updated_at":"2026-09-20T18:19:44Z","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":"Three PRs merged within 20 seconds on 2026-09-20 (#37, #41, #42). Each CI run built the site and uploaded a github-pages artifact, and each queued a deploy behind 'concurrency: group: pages, cancel-in-progress: false'.\n\nOnly the newest run kept its artifact:\n\n run 35526758561 (04e4e3d, #37) conclusion=failure artifacts=0\n run 35526767814 (5f871f7, #41) conclusion=success artifacts=1\n run 35526774007 (52507a6, #42) conclusion=failure artifacts=0\n\nThe two superseded deploys failed with:\n\n Fetching artifact metadata for \"github-pages\" in this workflow run\n Found 0 artifact(s)\n Error: No artifacts named \"github-pages\" were found for this workflow run.\n\nThe upload step was GREEN in all three runs, so this is not a build failure —\nthe artifact is gone by the time the losing deploy runs. The successful deploy\n(17:47:28Z) preceded the failing one (17:47:44Z) by 16 seconds.\n\nDeploying a superseded commit is pointless anyway: the tip of main is what\nshould be published. Cancelling is deliberately NOT the answer — the existing\ncomment on the concurrency block records that a deploy-pages cancelled midway\ncan leave the Pages deployment half-applied.\n\n## Steps to Reproduce\n\n1. Merge three PRs to main within about a minute.\n2. Watch the CI runs: the newest deploy succeeds, the older ones fail on an\n empty artifact fetch and mark otherwise-green runs as failed.\n\nReported by the user from the Actions log.","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-20T18:03:04Z","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":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T07:55:22Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:57:55Z","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} @@ -56,12 +60,12 @@ {"_type":"issue","id":"improvise-o8fa","title":"improvise-ws-server speaks ws:// only, so the deployed page cannot reach it","description":"Found by opening the deployed page (2026-09-20). The guidance the page and the README carried was WRONG in a way that sends someone down a dead end.\n\nFROM AN https ORIGIN A ws:// ADDRESS IS BLOCKED AS MIXED CONTENT, so the deployed GitHub Pages site needs wss://. improvise-ws-server has NO TLS AT ALL -- no rustls, no native-tls, nothing in the crate or its dependencies -- so wss:// is not obtainable from it. The text said \"note that from an https origin a ws:// address is blocked\", which correctly named the symptom and then offered an address that can never work.\n\nTEXT CORRECTED ALREADY: both web/index.html and README.md now say the address must be wss:// from the deployed page, that the server speaks plain ws:// only, and that the ws:// form works when the page is served over http://localhost. This ticket is the missing CAPABILITY.\n\nTHREE WAYS TO PROVIDE IT, and they are not equivalent:\n * TERMINATE TLS IN FRONT (nginx, caddy, cloudflared, ngrok). Zero code, works today, and is what the corrected text points at. But it means the documented path to sharing a TUI-hosted document involves a second piece of software, which is a poor answer for a tool whose selling point is that it is one binary.\n * NATIVE wss:// IN improvise-ws-server -- rustls plus a cert. Real work, and it raises the certificate question: a self-signed cert makes the browser refuse the socket with no usable error, so this is only pleasant with a real cert or an explicitly trusted local CA (mkcert).\n * DECIDE THE DEPLOYED PAGE DOES NOT PAIR WITH A LOCAL SERVER at all, and say so. Defensible: the standalone worker deployment is the point of the Pages site, and ws:// pairing is a LOCAL development convenience that works fine from the local page. Cheapest, and it makes the docs shorter rather than longer.\n\nThe third is probably right for now and the first is the escape hatch; the second only earns its place if sharing a TUI-hosted document to a remote browser becomes a real use case. Do not implement the second by default.","status":"closed","priority":2,"issue_type":"feature","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T03:53:54Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:22:10Z","started_at":"2026-09-20T05:32:35Z","closed_at":"2026-09-20T06:22:10Z","close_reason":"Closed by PR #21 (WU-I). DOCUMENTATION ONLY, per the user's ruling; native wss:// DECLINED.\n\nTHE DECLINE'S SUBSTANCE IS THE CERTIFICATE, NOT rustls. A self-signed cert makes the browser refuse the socket with no usable error, because a WebSocket handshake has no interstitial -- there is no place to click through. A real cert or mkcert is a second piece of software too, so the choice is two-either-way, with one option additionally shipping a TLS stack and a cert-loading surface in a pivot-table binary.\n\nWHAT LANDED: a worked Caddy example, the nginx difference that bites (proxy_http_version 1.1 plus the Upgrade/Connection headers), the honest cost (a second binary, a poor answer for a one-binary tool), and the framing that the standalone worker deployment is the point of the Pages site while ws:// pairing works fine from a local http://localhost page.\n\nTHIS BEAD COST SEVEN COPILOT ROUNDS AND NINE FINDINGS, EVERY ONE IN PROSE. The sequence is the value and is recorded in kb.pl:\n * the first recipe exposed an unauthenticated world-writable server -- documenting a proxy documents an exposure, because TLS termination REMOVES the loopback binding that was the only control;\n * the \"fix\" put Caddy basic_auth in front of a socket the page opens as new WebSocket(url), an API that can set no headers over a handshake that shows no prompt -- BROKEN reads as protection, which is worse than absent;\n * an ngrok --cidr-allow recipe was written without being run (deprecated-but-accepted, so the reviewer's own \"fails at startup\" was also wrong);\n * \"the browser cannot send a credential on a WebSocket handshake\" over-generalised the governing fact and contradicted the Cloudflare Access bullet three paragraphs up, which works BECAUSE the handshake carries a cookie;\n * an IP allow-list cannot see a cross-site socket at all, which is improvise-kc7n;\n * an absolute loopback claim denied a browser mitigation that exists but is mid-rollout;\n * and \"the auth above\" called an allow-list authentication in a section whose opening sentence is that the server has none.\n\nThe governing fact was ALREADY IN THE SAME FILE at every step, as the basis of this very decline. The lesson the repo did not have: a reviewed paragraph is not evidence.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-955n","title":"[bug] Host::commands applies a batch partially before rejecting","description":"Found by Copilot on PR #13 (the retro review of the unreviewed batch), DECLINED there with reasons on the thread, and filed rather than patched.\n\nADR 001 is emphatic that a stale batch is never applied, NOT EVEN PARTIALLY -- and improvise-ondj went to the trouble of a TYPE (Checked) to make the unchecked path unconstructible. But Checked is PER-COMMAND: Checked::against answers for one Command against the App AS IT STANDS. A ClientFrame::Commands is a SEQUENCE of them, and Host::commands applies in order and abandons at the first rejection. So a frame carrying SwitchView followed by a stale Command::Model has already applied the SwitchView.\n\nSO ADR 001 GUARANTEE IS CURRENTLY TRUE OF A SINGLE COMMAND AND FALSE OF A BATCH.\n\nA SECOND-ORDER EFFECT NOBODY HAD WRITTEN DOWN, found while declining it: Worker::on_frame skips persist() when ANY frame in the answer is Rejected. So a batch that DID apply a SwitchView before being refused leaves Workbook::active_view moved in memory and NOT WRITTEN BACK -- the document on disk and the document in the worker disagree, silently.\n\nWHY IT WAS DECLINED RATHER THAN PATCHED: closing it means the basis check moving from the COMMAND to the BATCH -- every command answered before any is applied. That is an ADR 001 design change with a real question inside it: WHAT DOES A BATCH BASIS MEAN WHEN ITS LATER COMMANDS WERE COMPOSED AGAINST ITS EARLIER ONES EFFECTS? A client resolving one keypress produces SetSelected plus offsets computed from the selection it just set. Answer that before writing code.\n\nREACHABILITY IS BOUNDED TODAY: no client in this workspace composes a batch containing more than one basis-carrying Command::Model -- the batches improvise-bpq2 produces are view commands, which carry no basis. Do this before the second tranche of client-side resolution creates a reason to batch two model commands.\n\nSupersedes the earlier ticket filed from bpq2 if there is one -- dedupe.","status":"closed","priority":2,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T03:35:27Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:29:48Z","started_at":"2026-09-20T05:32:29Z","closed_at":"2026-09-20T07:29:48Z","close_reason":"Closed by PR #26 (WU-C). improvise-08wb is closed as a duplicate of this one.\n\nADR 001 said a stale batch is never applied, NOT EVEN PARTIALLY, and improvise-ondj went to the trouble of a type (Checked) to make the unchecked path unconstructible -- but Checked is PER-COMMAND and a ClientFrame::Commands is a sequence of them, so Host::commands applied in order and abandoned at the first rejection. The guarantee was true of a single command and false of a batch.\n\nCheckedBatch::against now answers for EVERY command before any is applied.\n\nTHE DESIGN QUESTION THE TICKET DEMANDED WAS ANSWERED BY THE USER, not by the code, and it is larger than the ticket imagined. The ticket asked \"what does a batch's basis mean when its later commands were composed against its earlier ones' effects\". The user's ruling: \"a sequence of commands sent to the server should assume that it's played in order from start to end and that each command sees the state after the previous command runs\", as \"a single transaction from a given base\". Four clauses -- one basis per frame; checked once before anything; applied in order with data dependence EXPECTED; all or nothing. Clauses 1-3 landed here; clause 4 (rollback) is improvise-yqpd.\n\nAN EARLIER \"at most one document mutation per frame\" RULE WAS SHIPPED AND THEN REJECTED BY THE USER as forbidding the capability. It was wrong twice over: it forbids data-dependent chains, and arity is not a property of a variant anyway -- a future Command::RunScript is one variant performing unboundedly many mutations.\n\nTHE FINDING THAT MATTERED MOST came from the independent adversarial review that substituted for Copilot (quota exhausted), and it was a REGRESSION THIS WORK INTRODUCED: hoisting the basis check to the frame hoisted ALL THREE TIERS. The document and layout tiers are legitimately frame-level facts; THE RECORD TIER IS NOT AND CANNOT BE, because which records are at stake is a property of the command. So every mutation after the first had its records checked against nothing -- a silent lost update, confirmed by executing a real Host: client A's stale [SetCell(North), SetCell(South)] clobbered client B's write to South with no rejection, where the base commit refused it. Fixed: every mutation answers for its own records against the FRAME'S basis, with an exemption stated over a property -- a record that neither resolves nor is claimed is one the frame is creating. The reviewer attacked that exemption at all four corners and could not break it: the predicate is a conjunction and the client controls only one conjunct.\n\nTHE SECOND-ORDER WORKER EFFECT the ticket named is closed twice over; its test fails only when BOTH old behaviours are restored.\n\nA CAPABILITY NOBODY HAD NAMED, now guarded: a frame may write THE SAME RECORD TWICE -- two measures on one record IS one record -- and that works only because every check happens before any apply. Moving the checks into the loop breaks it, which is what stops someone closing clause 4 by taking that trade unnoticed.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-08wb","title":"[bug] A refused ClientFrame::Commands keeps the commands before the refusal","description":"Found by improvise-bpq2 (2026-09-20), stated on the variant, and currently UNREACHABLE from any client in this workspace -- filed so it is a decision rather than a discovery.\n\nClientFrame::Commands(Vec\u003cCommand\u003e) batches, because one cursor key resolved client-side is SetSelected plus the offsets viewport_effects returns, and a host that folds the selection but not the offset projects the wrong window. Host::commands applies IN ORDER and ABANDONS AT THE FIRST REJECTION.\n\nTHAT LEAVES A HOLE ADR 001 DOES NOT HAVE. ADR 001 is emphatic that a stale batch is never applied, NOT EVEN PARTIALLY -- and improvise-ondj went to the trouble of a type (Checked) to make the unchecked path unconstructible. But Checked is per-COMMAND, and a ClientFrame::Commands is a sequence of them, so a frame whose third command is refused has already applied the first two.\n\nWHY IT IS NOT LIVE: no client in this workspace composes a Commands frame containing more than one basis-carrying Command::Model -- the batches bpq2 produces are view commands (SetSelected + offsets), which carry no basis.\n\nTHE FIX IS TO MOVE THE BASIS CHECK TO THE BATCH: check every Command::Model in the frame before applying any of them. That is the shape ADR 001 already describes for an effect batch, one level up, and it would make Host::commands match the guarantee the ADR states.\n\nDo this before a client has reason to batch two model commands -- which the second tranche of client-side resolution (the ~15 bindings bpq2 left) may well create.","status":"closed","priority":2,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T02:36:42Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:29:28Z","started_at":"2026-09-20T05:32:29Z","closed_at":"2026-09-20T07:29:28Z","close_reason":"Closed as a DUPLICATE of improvise-955n, which PR #26 fixed. Both beads describe the same defect -- Host::commands applied a ClientFrame::Commands in order and abandoned at the first rejection, so a frame whose later command was refused had already applied the earlier ones, leaving ADR 001's 'never applied, not even partially' true of a single command and false of a batch. 08wb was filed from improvise-bpq2's walk and 955n from Copilot's retro review of PR #13; 955n carries the fuller reasoning and the second-order worker effect, so it is the survivor.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-ir7l","title":"[bug] A formula cell is offered as editable and silently reverts","description":"Pre-existing and consistent with the TUI, but improvise-bck just put it on the DEMO FRONT PAGE, which changes who meets it: the first thing a stranger sees is Profit beside Revenue and Cost.\n\nA formula target cell projects CellId::Record, so render.rs::editable says true, so the browser offers an editor. Type into Profit and the write lands -- then recompute_formulas overwrites it on the next pass and the value REVERTS with no explanation.\n\nWHY IT IS NOT SIMPLY A BUG IN editable: a formula target IS a stored coordinate; the cell is a record. What makes it unwritable is not its identity but that a FORMULA OWNS IT. So the fix is a fact the projection does not currently carry -- something like \"this cell is derived\" -- and it belongs beside numeric/error on CellDisplay, which already exists precisely because the SERVER is the side that knows.\n\nCHECK THE TUI BEFORE CHANGING ANYTHING: it has the same behaviour, so whatever is decided should land in both, or the divergence becomes the new bug (gotcha 44 is what that looks like). And note ADR 002 deliberately made settability a fact about the CellId VARIANT -- adding a second, independent \"but not really\" flag is exactly the two-answers-to-one-question shape this codebase keeps curing. Consider instead whether a formula-owned cell should project a different CellId at all.\n\n## Steps to Reproduce\n\n1. Open `web/demo.improv` in the browser — it ships `Profit = Revenue - Cost` beside Revenue and Cost on the front page.\n2. Click the Profit cell. It is offered as editable, because a formula target projects `CellId::Record` and `render.rs::editable` reads the variant.\n3. Type a number and commit. The write lands.\n4. `recompute_formulas` overwrites it on the next pass and the value reverts, with no explanation.\n\nThe TUI behaves the same way, so whatever is decided must land in both or the divergence becomes the new bug.\n","acceptance_criteria":"A formula-owned cell projects a non-Record CellId, so the write is unconstructible rather than flagged — the discipline ADR 002 used for aggregates and ADR 003 reaches for again for read-only sources. It lands in the TUI in the same change, or the two front ends diverge (gotcha 44 is what that looks like). Do NOT add a second 'but not really' flag beside CellId: two answers to 'can I write here' is the disease. Demonstrated on web/demo.improv, where Profit sits beside Revenue and Cost on the front page.","notes":"VERIFIED CONCRETELY during the PR #13 review (2026-09-20), and the diagnosis is sharper than when this was filed.\n\nTHE ROOT CAUSE: project::cell_id decides Record vs Aggregate purely on whether a regular category sits on Axis::None. IT NEVER ASKS WHETHER THE _Measure COORDINATE NAMES A FORMULA TARGET. So a formula cell is a Record, render.rs::editable says true, and the browser offers an editor.\n\nMEASURED AGAINST THE SHIPPED DEMO:\n col 2 label=\"Profit\" is_record=true text=\"12,200\"\n after applying SetCell(key, Number(999.0)), Profit reads \"12,200\"\n\nThe write is ACCEPTED AND STORED. Model::evaluate_aggregated checks formula_cache BEFORE raw data (model/types.rs:409), so the typed value is shadowed forever with nothing telling the user. Not a revert -- a permanent silent shadow.\n\nAND THE TEST THAT COVERS THIS AREA IS LYING BY ITS NAME. every_visible_cell_in_the_web_demo_is_settable PASSES while its name is FALSE for the Profit column -- a third of the landing-page grid. It was proved discriminating against the one thing it CAN see (the aggregate at (0,0) in examples/demo.improv) and is structurally blind to this. That is exactly the shape AGENTS.md calls this repo's characteristic find, arriving in the test written to prevent it.\n\nTHE DECISION IS STILL THE DECISION, and ADR 002 is where it goes:\n * TEACH cell_id ABOUT FORMULA TARGETS -- changes what EVERY client calls a record, and record identity is also ADR 001's unit of atomicity, so this reaches the basis check too.\n * OR GIVE editable A SECOND INPUT -- but that is the conflation gotcha 42 exists to forbid, and ADR 002 deliberately made settability a fact about the CellId VARIANT precisely so there could not be a second answer.\n\nThe first is probably right and is not small. Until it lands, the demo's front page teaches a visitor that typing into Profit does nothing.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-20T02:29:51Z","created_by":"Edward Langley","updated_at":"2026-09-20T17:53:19Z","started_at":"2026-09-20T17:53:19Z","lease_expires_at":"2026-09-20T17:58:19Z","heartbeat_at":"2026-09-20T17:53:19Z","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-ir7l","title":"[bug] A formula cell is offered as editable and silently reverts","description":"Pre-existing and consistent with the TUI, but improvise-bck just put it on the DEMO FRONT PAGE, which changes who meets it: the first thing a stranger sees is Profit beside Revenue and Cost.\n\nA formula target cell projects CellId::Record, so render.rs::editable says true, so the browser offers an editor. Type into Profit and the write lands -- then recompute_formulas overwrites it on the next pass and the value REVERTS with no explanation.\n\nWHY IT IS NOT SIMPLY A BUG IN editable: a formula target IS a stored coordinate; the cell is a record. What makes it unwritable is not its identity but that a FORMULA OWNS IT. So the fix is a fact the projection does not currently carry -- something like \"this cell is derived\" -- and it belongs beside numeric/error on CellDisplay, which already exists precisely because the SERVER is the side that knows.\n\nCHECK THE TUI BEFORE CHANGING ANYTHING: it has the same behaviour, so whatever is decided should land in both, or the divergence becomes the new bug (gotcha 44 is what that looks like). And note ADR 002 deliberately made settability a fact about the CellId VARIANT -- adding a second, independent \"but not really\" flag is exactly the two-answers-to-one-question shape this codebase keeps curing. Consider instead whether a formula-owned cell should project a different CellId at all.\n\n## Steps to Reproduce\n\n1. Open `web/demo.improv` in the browser — it ships `Profit = Revenue - Cost` beside Revenue and Cost on the front page.\n2. Click the Profit cell. It is offered as editable, because a formula target projects `CellId::Record` and `render.rs::editable` reads the variant.\n3. Type a number and commit. The write lands.\n4. `recompute_formulas` overwrites it on the next pass and the value reverts, with no explanation.\n\nThe TUI behaves the same way, so whatever is decided must land in both or the divergence becomes the new bug.\n","acceptance_criteria":"A formula-owned cell projects a non-Record CellId, so the write is unconstructible rather than flagged — the discipline ADR 002 used for aggregates and ADR 003 reaches for again for read-only sources. It lands in the TUI in the same change, or the two front ends diverge (gotcha 44 is what that looks like). Do NOT add a second 'but not really' flag beside CellId: two answers to 'can I write here' is the disease. Demonstrated on web/demo.improv, where Profit sits beside Revenue and Cost on the front page.","notes":"VERIFIED CONCRETELY during the PR #13 review (2026-09-20), and the diagnosis is sharper than when this was filed.\n\nTHE ROOT CAUSE: project::cell_id decides Record vs Aggregate purely on whether a regular category sits on Axis::None. IT NEVER ASKS WHETHER THE _Measure COORDINATE NAMES A FORMULA TARGET. So a formula cell is a Record, render.rs::editable says true, and the browser offers an editor.\n\nMEASURED AGAINST THE SHIPPED DEMO:\n col 2 label=\"Profit\" is_record=true text=\"12,200\"\n after applying SetCell(key, Number(999.0)), Profit reads \"12,200\"\n\nThe write is ACCEPTED AND STORED. Model::evaluate_aggregated checks formula_cache BEFORE raw data (model/types.rs:409), so the typed value is shadowed forever with nothing telling the user. Not a revert -- a permanent silent shadow.\n\nAND THE TEST THAT COVERS THIS AREA IS LYING BY ITS NAME. every_visible_cell_in_the_web_demo_is_settable PASSES while its name is FALSE for the Profit column -- a third of the landing-page grid. It was proved discriminating against the one thing it CAN see (the aggregate at (0,0) in examples/demo.improv) and is structurally blind to this. That is exactly the shape AGENTS.md calls this repo's characteristic find, arriving in the test written to prevent it.\n\nTHE DECISION IS STILL THE DECISION, and ADR 002 is where it goes:\n * TEACH cell_id ABOUT FORMULA TARGETS -- changes what EVERY client calls a record, and record identity is also ADR 001's unit of atomicity, so this reaches the basis check too.\n * OR GIVE editable A SECOND INPUT -- but that is the conflation gotcha 42 exists to forbid, and ADR 002 deliberately made settability a fact about the CellId VARIANT precisely so there could not be a second answer.\n\nThe first is probably right and is not small. Until it lands, the demo's front page teaches a visitor that typing into Profit does nothing.","status":"closed","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-20T02:29:51Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:22:48Z","started_at":"2026-09-20T17:53:19Z","closed_at":"2026-09-20T18:22:48Z","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-vhcz","title":"[bug] OpfsFs::list disagrees with the other backends on path shape","description":"Found by improvise-9x6 (2026-09-20) and currently PAPERED OVER by always passing the flat root.\n\nOpfsFs::list returns BARE NAMES; MemoryFs and PhysicalFs return dir-JOINED ones. So Path::new(\"\") is the ONE dir at which all three agree, and the only one at which storage_contract actually describes OpfsFs.\n\nTHE TRAP: a worker that organised documents under docs/ would get an EMPTY PICKER in a browser and a FULL ONE in every test. The harness cannot see the disagreement because it never runs OpfsFs -- improvise-xf1 is deferred -- so this is precisely the class of defect the missing browser harness exists to catch, discovered by reading instead.\n\nTWO FIXES:\n * Make OpfsFs::list join dir, matching the other two. Straightforward, and makes storage_contract describe all three at every dir.\n * Or document the flat root as PART OF THE CONTRACT and assert it in storage_contract, so the constraint is stated rather than discovered.\n\nThe first is better unless there is a reason OPFS cannot -- note gotcha 28 says backends that are not trees treat dir as a NAME PREFIX, which is already the trait doc contemplating this, so check whether the doc or the impl is the thing that is wrong.","status":"closed","priority":2,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T02:10:14Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:44:42Z","started_at":"2026-09-20T05:32:36Z","closed_at":"2026-09-20T06:44:42Z","close_reason":"Closed by PR #24 (WU-J). THE TICKET ASKED WHICH OF THE DOC OR THE IMPL WAS WRONG: IT WAS THE IMPL.\n\nThe Storage trait doc already said that backends which are not trees treat `dir` as a NAME PREFIX. OpfsFs::list ignored `dir` outbound as well as inbound, returning bare names where MemoryFs and PhysicalFs return dir-joined ones -- so Path::new(\"\") was the one dir at which all three agreed, and the only one at which storage_contract actually described OpfsFs.\n\nNow it joins. The three backends agree at EVERY dir, and storage_contract runs against OpfsFs at both \"\" and \"docs\" in a real browser. `dir` stays DECORATION rather than a filter, because entry_name reduces every path to its file name and an OPFS entry name may not contain a separator.\n\nFAULT INJECTED: reverting to bare names fails storage_contract_at_\"docs\" on `lists the one document`.\n\nTHE TRAP THE TICKET NAMED IS CLOSED: a worker that organised documents under docs/ would have got an empty picker in a browser and a full one in every test. That divergence was invisible to the harness because nothing ran OpfsFs -- which is exactly why improvise-vvl3 had to land alongside this, and why this bead could only ever have been found by reading until it did.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-hmws","title":"Storage cannot expose an async seam through Box\u003cdyn Storage\u003e","description":"Found by improvise-9x6 (2026-09-20). Every worker host will hit this, so it is worth solving once rather than in each.\n\nOpfsFs::open / attach / refresh / commit_deletes are on the STRUCT, not on the Storage trait -- deliberately, per gotcha 28: Storage stays synchronous because createSyncAccessHandle yields a blocking handle, and only four operations are genuinely async. But App::storage is a Box\u003cdyn Storage\u003e, so HANDING THE BACKEND TO THE App PUTS ALL FOUR PERMANENTLY OUT OF REACH. The App owns the only copy and the trait object cannot name them.\n\nAND THE OBVIOUS FIX IS REFUSED BY THE COMPILER: impl Storage for Rc\u003cOpfsFs\u003e is two foreign types, so the orphan rule forbids it. improvise-9x6 worked around it with a local newtype (bindings::Shared) holding an Rc\u003cOpfsFs\u003e and forwarding the four sync methods. That works but every worker must write it again.\n\nTWO CANDIDATE FIXES:\n * Ship the shared wrapper FROM improvise-io, so the newtype exists once where OpfsFs does and the orphan rule is satisfied.\n * Or have OpfsFs hand out an attachable handle -- separate the thing the App holds from the thing the message loop awaits, which is arguably the more honest modelling since they have different lifetimes and different call sites.\n\nPrefer whichever keeps Storage synchronous; that part is not negotiable (gotcha 28 is the argument).","status":"closed","priority":2,"issue_type":"task","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T02:10:13Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:44:43Z","started_at":"2026-09-20T05:32:37Z","closed_at":"2026-09-20T06:44:43Z","close_reason":"Closed by PR #24 (WU-J). NEITHER OF THE TICKET'S TWO OPTIONS WAS NEEDED, and the reason is worth more than the fix.\n\nThe ticket said `impl Storage for Rc\u003cOpfsFs\u003e` is refused by the orphan rule. That is true IN improvise-worker-server, where both the trait and Rc are foreign -- which is where improvise-9x6 hit it and wrote bindings::Shared. It is NOT true in improvise-io, where the trait is LOCAL and the impl is simply allowed.\n\nSo: improvise-io ships a blanket `impl\u003cT: Storage + ?Sized\u003e Storage for Rc\u003cT\u003e`. bindings::Shared and its _shim_is_a_storage canary are DELETED, not moved, and no future worker writes one. Storage stays synchronous, which gotcha 28 says is not negotiable.\n\nTHE GUARD IS THE SIGNATURE: a_shared_backend_is_a_backend_and_a_clone_is_the_same_one does not compile without the impl. The body asserts what a hand-written newtype could still get wrong -- that a clone addresses the same backend -- and a fault injected into the forwarded `write` (made a no-op) fails it.\n\nTHE GENERALISABLE PART: an orphan-rule refusal is a fact about WHERE YOU ARE STANDING, not about the pair of types. Before working around one, check whether the impl belongs in the crate that owns the trait.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-9mo9","title":"[bug] Geometry effects that renumber the grid but never reset the cursor","description":"Found by the code review of improvise-gvnb (2026-09-19). A PRE-EXISTING gap that gvnb inherited rather than introduced -- but gvnb is what makes it matter, because the epoch is now what tells every client their cursor is invalid.\n\nApp::reset_cursor bumps cursor_epoch; Sessions::adopt_cursor_epoch watches the COUNTER and broadcasts Command::ResetCursor to all. Nothing declares which effects renumber the grid -- deliberately, per gotcha 31 -- which means a geometry effect that forgets reset_cursor is INVISIBLE IN BOTH DIRECTIONS: the host own cursor stays stale AND no client is told.\n\nToday only TransposeAxes and CycleAxis call it. These also renumber rows or columns and do not:\n SetAxis, HideItem, ShowItem, ToggleGroup, TogglePruneEmpty, AddCategory, RemoveCategory\n (plus AddItem/RemoveItem, which are CellData rather than LayoutGeometry)\n\nSetAxis IS THE SHARPEST CASE: CycleAxis is literally SetAxis to Axis::next() and resets; SetAxis does not.\n\nWHY THE REVIEW DID NOT JUST PATCH IT: SetAxis is used by DrillIntoCell (cmd/grid.rs:365-471) to build the _Drill view, and resetting there could discard the user position in the view they drilled FROM, depending on effect order. That is a decision about effect ordering and intended UX, not a patch.\n\nKEEP THE NEGATIVE ARM. a_model_command_that_does_not_reshape_the_grid_leaves_cursors_alone uses SetNumberFormat -- a LayoutGeometry effect that must NOT reset. Without it, \"reset on every model command\" would pass the positive test. Whatever you do here, do not lose it.\n\n## Steps to Reproduce\n\n1. In a view with a cursor somewhere other than the origin, apply any of `SetAxis`, `HideItem`, `ShowItem`, `ToggleGroup`, `TogglePruneEmpty`, `AddCategory`, `RemoveCategory`, `AddItem` or `RemoveItem`.\n2. The grid renumbers and the cursor is not reset, so it addresses a position that has moved.\n3. Worse for other clients: `reset_cursor` is what bumps `cursor_epoch`, and `Sessions::adopt_cursor_epoch` watches that counter to broadcast `Command::ResetCursor`. No reset means **no client is told either**.\n\n`SetAxis` is the sharpest case: `CycleAxis` *is* `SetAxis` to `Axis::next()` and does reset.\n","acceptance_criteria":"Each of SetAxis, HideItem, ShowItem, ToggleGroup, TogglePruneEmpty, AddCategory, RemoveCategory, AddItem and RemoveItem either calls reset_cursor or carries a one-line reason why it does not. SetAxis is the sharp case — CycleAxis IS SetAxis to Axis::next() and resets, while SetAxis does not — and DrillIntoCell uses SetAxis to build the _Drill view, where resetting could discard the position in the view drilled FROM, so that is a decision about effect ordering and intended UX, not a patch. KEEP THE NEGATIVE ARM: a_model_command_that_does_not_reshape_the_grid_leaves_cursors_alone uses SetNumberFormat, and without it 'reset on every model command' passes the positive test. Note improvise-2zq changed which axis a new category lands on, so re-read on_category_added before assuming AddCategory renumbers the way it used to.","status":"closed","priority":2,"issue_type":"bug","assignee":"wave2-n-cleanups","owner":"el-github@elangley.org","created_at":"2026-09-20T01:05:15Z","created_by":"Edward Langley","updated_at":"2026-09-20T17:57:44Z","started_at":"2026-09-20T17:53:45Z","closed_at":"2026-09-20T17:57:44Z","close_reason":"Closed","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-s1xw","title":"Multi-level grid labels on the wire -- the browser shows Food/Alice where the TUI shows two columns","description":"THE LAST THING KEEPING GridLayout INSIDE GridWidget, and a LIVE DIVERGENCE today, not a hypothetical. Found closing improvise-n10.\n\nGridCache::row_labels / col_labels are Vec\u003cString\u003e produced by GridLayout::row_label, which JOINS A MULTI-LEVEL TUPLE WITH A SLASH. That is exactly the rendering src/ui/grid.rs::two_row_categories_produce_cross_product_labels asserts must NOT appear (\"slash-joined labels should be gone\"). So the protocol carries a form of the labels THE TUI ITSELF CONSIDERS WRONG, and crates/improvise-wasm-client/src/render.rs renders precisely that form.\n\nConsequence: the browser grid has no multi-level headers, no repeat suppression, no groups and no collapse indicators, and shows Food/Alice where the TUI shows two header columns.\n\nSIX FACTS THE CACHE DOES NOT CARRY (exhaustive as of improvise-n10, by grep over the non-test body of src/ui/grid.rs):\n 1. number of header levels -- layout.row_cats / col_cats\n 2. multi-level label TUPLES -- layout.row_items (AxisEntry::DataItem(Vec\u003cString\u003e))\n 3. inline GROUP HEADER rows -- layout.row_items (AxisEntry::GroupHeader)\n 4. column GROUP SPANS -- layout.col_group_for(ci)\n 5. collapse indicator (down/right triangle) -- view.is_group_collapsed(cat, group)\n 6. records-mode column identity -- layout.record_col(ci). THIS ONE IS CORRECT TO KEEP OFF: it keys pending drill edits and is session-local by design.\n\nSo 1-5 are the gap.\n\nTWO CONSTRAINTS ON THE FIX:\n * IT IS A CO-ORDINATED CHANGE, NOT AN ADDITIVE ONE. row_labels/col_labels change TYPE, and improvise-wasm-client/src/render.rs consumes both fields, so the client updates in the same change.\n * DO NOT ADD row_levels BESIDE row_labels. Two answers to \"what are the labels\" is the disease this whole projection architecture exists to cure -- see improvise-edp, where the category panel was walked twice, two different ways.","status":"closed","priority":2,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T00:58:33Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:41:34Z","started_at":"2026-09-20T05:32:27Z","closed_at":"2026-09-20T07:41:34Z","close_reason":"Closed as ABSORBED BY improvise-anr9, verified explicitly rather than assumed. s1xw enumerated six facts the cache did not carry; PR #23 landed 1-5 (header levels, multi-level label tuples, inline group headers, column group spans, the collapse indicator) and 6 is record_col, which s1xw itself says is CORRECT to keep off -- session-local, keys pending drill edits. Both of its stated constraints were honoured: it was a co-ordinated type change (row_labels/col_labels changed TYPE and improvise-wasm-client/src/render.rs updated in the same change) and no row_levels was added beside row_labels. The live divergence it reported -- the browser drawing Food/Alice where the TUI draws two header columns -- is closed, and tests/grid_frame_agreement.rs now drives both front ends over one cache and compares what each draws.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-4zl7","title":"A fitted tree must route per record and aggregate the results, not route on the aggregate","description":"Successor to improvise-8xhy, which shipped remedy 1 (refuse at emit) as an explicit interim. This is the remedy that produces a formula A HUMAN WOULD HAVE WRITTEN, and it is the right end state -- but it was costed during 8xhy and it is much larger than it looks.\n\nANSWER THIS FIRST, BEFORE TOUCHING THE GRAMMAR. A fitted measure cell is evaluated at a CellKey context. In any view that aggregates over the split category -- Region on Page or None -- context.get(\"Region\") is None, and today Formula::filter falls straight through to aggregate_raw. So a categorical fit would SILENTLY DEGRADE to something else in exactly the views a pivot-table user spends most of their time in. That is a product decision about what a fit MEANS for an aggregated cell, not an emitter detail.\n\nSCOPE, once that is answered:\n * Expr::Is(Filter) evaluating to 1.0/0.0. This keeps the f64-only evaluator and avoids introducing string comparison into eval_expr. The primitive already exists in the right shape -- types.rs reads context.get(\u0026filter.category), the current cell own coordinate.\n * A grammar production plus tokenizer handling in the 1109-line parser.rs, with quoting rules for the category and item inside it (gotcha 34 -- one home, improvise_formula::quote).\n * Arms in BOTH evaluators: eval_expr and eval_expr_cached.\n * Coverage in the grammar-walking generator.\n * Thread Dataset::columns (the ColumnSpec/ColumnKind plan) into the emitter, so feature index -\u003e role is read off preprocess own plan. Do NOT reconstruct it: Spec::names is \u0026[\u0026str], and reconstructing index-\u003erole is a second implementation of preprocess expansion order, exactly the drift risk gotcha 41 names in another key.\n * .improv is free -- the writer emits Formula::raw.\n\nIT CLOSES ONLY A THIRD OF 8xhy EITHER WAY. A WHERE predicate covers the one-hots. col.missing means \"this record has no value for measure X\", a different vocabulary. The date sub-features (when.year, when.month) need date arithmetic improvise does not have at all. Those stay refused, or get their own tickets.\n\nPAIRS WITH improvise-vx3 (the fit-measure command), which is where a user would actually learn about the restriction.","acceptance_criteria":"A fitted measure evaluated at an aggregating cell routes PER RECORD and aggregates the results, rather than routing on the aggregate. fit-measure on web/demo.improv succeeds and the resulting formula means the same thing on a record row and one row up. The seed-dependence is gone: whether the command succeeds no longer depends on which features the search happened to pick. Note what this costs — evaluate_aggregated deliberately does not iterate the records behind a formula cell — and state how it interacts with gotcha 9's fixed point and Model::formula_cache. col.missing and the date sub-features remain out of scope.","notes":"USER RULING 2026-09-20: AGGREGATE THE RULE OVER THE MEMBERS. Route-then-aggregate, per record. The other three options -- Error(\"aggregated\"), refuse at fit time, and fall through to aggregate_raw -- are all rejected.\n\nTHE BEAD'S SCOPE HAS CHANGED AND THE TITLE NOW SAYS SO. It asked what a CATEGORICAL split means in a cell that aggregates over the split category. PR #25's review established the question has nothing to do with the split being categorical:\n\nA tree is fitted on individual RECORDS. A formula is evaluated at a CellKey, and in any aggregating cell evaluate_aggregated aggregates each Expr::Ref FIRST and then evaluates the IF. A linear formula survives that -- SUM(Revenue) - SUM(Cost) is SUM(Revenue - Cost) -- and a tree does not, because AGGREGATE-THEN-ROUTE IS NOT ROUTE-THEN-AGGREGATE. So EVERY fitted tree, numeric splits included, means one thing on a record row and something else one row up.\n\nIT IS NOT THEORETICAL: fit-measure Revenue on the repo's own shipped web/demo.improv is refused, naming Product=Espresso/Product=Latte. And because which features a champion picks is a property of the SEARCH, on a document with several categories the same command refuses or succeeds DEPENDING ON THE SEED. That is not a contract.\n\nWHAT THE RULING COSTS, stated plainly because it is the expensive answer and was chosen anyway: evaluate_aggregated deliberately does NOT iterate the underlying records for a formula cell -- it aggregates the refs and evaluates once. Routing per record means a formula cell must be able to ask for the records behind it and fold over them. That touches the evaluator, and it interacts with gotcha 9 (fixed-point recomputation) and with Model::formula_cache, which is where derived values live precisely so raw data and derived data stay separate.\n\nNOTE WHAT IT BUYS BESIDES CORRECTNESS: it is what a user means. A fitted rule applied to a group should say what the rule says about the members, summed -- not what the rule says about a fictional average member. The second is the thing that looks right and is wrong, which is the failure mode vx3's own ticket warned about ('a rule the user will believe').\n\nALSO NOTE: this ruling supersedes improvise-8xhy's remedy 1 (refuse at emit) as the END STATE, but not as the interim -- the refusal stays until this lands, or fit-measure silently produces the wrong number in aggregating views.\n\nTHE TWO THIRDS THIS STILL DOES NOT COVER, unchanged from the original text: col.missing means 'this record has no value for measure X', a different vocabulary; and the date sub-features (when.year, when.month) need date arithmetic improvise does not have at all. Those stay refused or get their own beads.","status":"open","priority":2,"issue_type":"feature","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T00:45:22Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:45:26Z","started_at":"2026-09-20T05:32:41Z","labels":["area:gp"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-4zl7","title":"A fitted tree must route per record and aggregate the results, not route on the aggregate","description":"Successor to improvise-8xhy, which shipped remedy 1 (refuse at emit) as an explicit interim. This is the remedy that produces a formula A HUMAN WOULD HAVE WRITTEN, and it is the right end state -- but it was costed during 8xhy and it is much larger than it looks.\n\nANSWER THIS FIRST, BEFORE TOUCHING THE GRAMMAR. A fitted measure cell is evaluated at a CellKey context. In any view that aggregates over the split category -- Region on Page or None -- context.get(\"Region\") is None, and today Formula::filter falls straight through to aggregate_raw. So a categorical fit would SILENTLY DEGRADE to something else in exactly the views a pivot-table user spends most of their time in. That is a product decision about what a fit MEANS for an aggregated cell, not an emitter detail.\n\nSCOPE, once that is answered:\n * Expr::Is(Filter) evaluating to 1.0/0.0. This keeps the f64-only evaluator and avoids introducing string comparison into eval_expr. The primitive already exists in the right shape -- types.rs reads context.get(\u0026filter.category), the current cell own coordinate.\n * A grammar production plus tokenizer handling in the 1109-line parser.rs, with quoting rules for the category and item inside it (gotcha 34 -- one home, improvise_formula::quote).\n * Arms in BOTH evaluators: eval_expr and eval_expr_cached.\n * Coverage in the grammar-walking generator.\n * Thread Dataset::columns (the ColumnSpec/ColumnKind plan) into the emitter, so feature index -\u003e role is read off preprocess own plan. Do NOT reconstruct it: Spec::names is \u0026[\u0026str], and reconstructing index-\u003erole is a second implementation of preprocess expansion order, exactly the drift risk gotcha 41 names in another key.\n * .improv is free -- the writer emits Formula::raw.\n\nIT CLOSES ONLY A THIRD OF 8xhy EITHER WAY. A WHERE predicate covers the one-hots. col.missing means \"this record has no value for measure X\", a different vocabulary. The date sub-features (when.year, when.month) need date arithmetic improvise does not have at all. Those stay refused, or get their own tickets.\n\nPAIRS WITH improvise-vx3 (the fit-measure command), which is where a user would actually learn about the restriction.","acceptance_criteria":"A fitted measure evaluated at an aggregating cell routes PER RECORD and aggregates the results, rather than routing on the aggregate. fit-measure on web/demo.improv succeeds and the resulting formula means the same thing on a record row and one row up. The seed-dependence is gone: whether the command succeeds no longer depends on which features the search happened to pick. Note what this costs — evaluate_aggregated deliberately does not iterate the records behind a formula cell — and state how it interacts with gotcha 9's fixed point and Model::formula_cache. col.missing and the date sub-features remain out of scope.","notes":"USER RULING 2026-09-20: AGGREGATE THE RULE OVER THE MEMBERS. Route-then-aggregate, per record. The other three options -- Error(\"aggregated\"), refuse at fit time, and fall through to aggregate_raw -- are all rejected.\n\nTHE BEAD'S SCOPE HAS CHANGED AND THE TITLE NOW SAYS SO. It asked what a CATEGORICAL split means in a cell that aggregates over the split category. PR #25's review established the question has nothing to do with the split being categorical:\n\nA tree is fitted on individual RECORDS. A formula is evaluated at a CellKey, and in any aggregating cell evaluate_aggregated aggregates each Expr::Ref FIRST and then evaluates the IF. A linear formula survives that -- SUM(Revenue) - SUM(Cost) is SUM(Revenue - Cost) -- and a tree does not, because AGGREGATE-THEN-ROUTE IS NOT ROUTE-THEN-AGGREGATE. So EVERY fitted tree, numeric splits included, means one thing on a record row and something else one row up.\n\nIT IS NOT THEORETICAL: fit-measure Revenue on the repo's own shipped web/demo.improv is refused, naming Product=Espresso/Product=Latte. And because which features a champion picks is a property of the SEARCH, on a document with several categories the same command refuses or succeeds DEPENDING ON THE SEED. That is not a contract.\n\nWHAT THE RULING COSTS, stated plainly because it is the expensive answer and was chosen anyway: evaluate_aggregated deliberately does NOT iterate the underlying records for a formula cell -- it aggregates the refs and evaluates once. Routing per record means a formula cell must be able to ask for the records behind it and fold over them. That touches the evaluator, and it interacts with gotcha 9 (fixed-point recomputation) and with Model::formula_cache, which is where derived values live precisely so raw data and derived data stay separate.\n\nNOTE WHAT IT BUYS BESIDES CORRECTNESS: it is what a user means. A fitted rule applied to a group should say what the rule says about the members, summed -- not what the rule says about a fictional average member. The second is the thing that looks right and is wrong, which is the failure mode vx3's own ticket warned about ('a rule the user will believe').\n\nALSO NOTE: this ruling supersedes improvise-8xhy's remedy 1 (refuse at emit) as the END STATE, but not as the interim -- the refusal stays until this lands, or fit-measure silently produces the wrong number in aggregating views.\n\nTHE TWO THIRDS THIS STILL DOES NOT COVER, unchanged from the original text: col.missing means 'this record has no value for measure X', a different vocabulary; and the date sub-features (when.year, when.month) need date arithmetic improvise does not have at all. Those stay refused or get their own beads.","status":"in_progress","priority":2,"issue_type":"feature","assignee":"wave3-4zl7","owner":"el-github@elangley.org","created_at":"2026-09-20T00:45:22Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:37:13Z","started_at":"2026-09-20T05:32:41Z","labels":["area:gp"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-hwpi","title":"The browser shell fails quietly, and never reads a key","description":"Found by the improvise-cr3 code review (2026-09-19) by READING web/app.js. There is no browser and no headless DOM in this environment, so none of this is observed -- it is what the code says will happen, and it is filed rather than fixed because each item is a judgement call about the shell rather than a defect in the Rust.\n\nFOUR THINGS, in descending order of how much they would cost a person at a demo:\n\n1. NO KEYBOARD AT ALL. #grid carries tabindex=\"0\" but the page registers no keydown handler, so Client::on_key_event -- and the whole keys.rs classifier and the lockstep machinery behind it -- is UNREACHED from the browser. Consistent with improvise-bpq2 (ClientMsg has no Key variant server-side) but larger than it: even a LOCAL key, one that needs no round trip, does nothing. improvise-1ey acceptance (3) is \"arrow keys move the cursor locally with no round-trip\", and nothing in the page can do it.\n\n2. A MALFORMED FRAME IS QUIET. client.on_frame(event.data) returns Result\u003c_, JsError\u003e, which wasm-bindgen turns into a THROWN EXCEPTION. It is thrown inside the message listener with no try, so it surfaces as an uncaught error in the devtools console and the following repaint() is skipped. Nothing in the UI says so: the grid simply stops updating. A try/catch writing to el.status would make it visible.\n\n3. NO error LISTENER ON THE SOCKET. A connection that never opens leaves #status blank -- only close writes \"disconnected\". So \"server not running\" and \"still connecting\" look the same.\n\n4. close() RUNS TWICE ON COMMIT, benignly. Enter -\u003e close(true) -\u003e td.replaceChildren(original) removes the focused \u003cinput\u003e -\u003e blur fires -\u003e close(false). editing is already null so it is harmless (one extra repaint), but there is no re-entry guard and only a real browser would show it.\n\nNOTE WHAT MAKES THIS TICKET DIFFERENT: items 2-4 are the kind of thing a test would catch instantly and we have no way to run one. Before fixing them, consider whether the right first move is a headless DOM in CI (jsdom or playwright) -- otherwise the fixes are as unverified as the bugs. improvise-xf1 (verify OpfsFs against a real browser) wants the same harness.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T00:40:36Z","created_by":"Edward Langley","updated_at":"2026-09-20T02:29:50Z","closed_at":"2026-09-20T02:29:50Z","close_reason":"All four items done, plus a fifth the ticket could not have predicted.\n\n1. KEYDOWN WIRED -- a window listener feeding Client::on_key_event. preventDefault fires only when a frame was produced and no Ctrl/Alt is held, so browser shortcuts stay the browser's; metaKey is skipped entirely, matching BrowserKey::to_key_event dropping Meta so Cmd-R cannot arrive as a bare r. The inline editor stopPropagation keeps it out of the way, with an if (editing) return as belt and braces.\n2. MALFORMED FRAME -- try/catch around on_frame writing to #status, plus console.error with the offending frame. render_model() is guarded the same way; it also returns a Result.\n3. SOCKET error LISTENER -- 'cannot reach ... -- is improvise-ws-server running?', so that no longer reads like 'still connecting'.\n4. close() RE-ENTRY GUARD -- a closed flag in edit().\n\nTHE FIFTH, AND IT IS THE INTERESTING ONE: a worker whose start() REJECTS is caught INSIDE worker.js, so it fires NO error event on the main thread. The page would have sat on 'in-page worker - demo.improv' over an empty grid forever -- THIS TICKET'S EXACT DISEASE, REBUILT IN THE NEW DEPLOYMENT ON DAY ONE. The guard is a first-frame watchdog (8s, cleared by the first frame), and it deliberately covers BOTH transports rather than each peer's own failure modes, because 'the peer never answered the handshake' is the one condition that means the same thing over a socket and over a port -- a check aimed per-peer would have missed exactly this one.\n\nKEYS ARE INERT AND THAT IS STATED, NOT HIDDEN. Host::handle answers ClientFrame::Key with an empty vec; improvise-bpq2 is the other half.\n\nSTILL UNRUN -- and this ticket predicted that would be the problem: 'consider whether the right first move is a headless DOM, otherwise the fixes are as unverified as the bugs.' That sentence is now true OF THESE FIXES. Three of the four are one-assertion Playwright tests. See the harness ticket.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-zv4m","title":"improvise-protocol should own the websocket framing, so both ends stop mirroring it","description":"Raised by improvise-cr3 (2026-09-19) as the thing to push back on, and filed rather than fixed there.\n\nTODAY: improvise-wasm-client/src/socket.rs declares ClientFrame/ServerFrame, which MIRROR improvise-ws-server/src/wire.rs ClientMsg/ServerMsg field for field. The client cannot depend on the server crate -- tokio, tungstenite, Box\u003cdyn Storage\u003e, all native -- so the duplication was the only way to speak the protocol from wasm.\n\nWHAT HOLDS THE LINE MEANWHILE: improvise-ws-server is a DEV-dependency of the client (the same argument the crate already makes for improvise-command), and the_socket_framing_matches_the_server round-trips every variant THROUGH THE SERVERS OWN TYPES in both directions; a_frame_named_the_other_envelopes_way_does_not_parse is its check-the-checker. So a drift is caught -- but only by a test whose job is to notice, rather than by a shared definition that cannot drift.\n\nTHE FIX: move the framing into improvise-protocol, which both ends already depend on and which is the crate whose charter is exactly this -- \"the wire vocabulary and the portable session slice\". Then the mirror, the dev-dependency and both compatibility tests all delete.\n\nNOTE THE THIRD ENVELOPE while you are in here: improvise-wasm-client/src/wire.rs has ClientMessage/ServerMessage framing the WORKER deployment (improvise-tm6). Three envelopes for two deployments is worth resolving in one pass -- decide whether the worker and the socket want the same vocabulary before moving either.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T00:37:29Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:37:50Z","closed_at":"2026-09-20T00:37:50Z","close_reason":"Duplicate of improvise-xks6 ('One home for the client/server frame envelopes'), filed minutes apart from the improvise-cr3 hand-back without spotting the existing ticket. Closing this one; xks6 is the survivor and its description should absorb two facts this one carried:\n\n * WHAT HOLDS THE LINE MEANWHILE: improvise-ws-server is a DEV-dependency of improvise-wasm-client, and the_socket_framing_matches_the_server round-trips every variant THROUGH THE SERVERS OWN TYPES in both directions, with a_frame_named_the_other_envelopes_way_does_not_parse as its check-the-checker. The drift is caught by a test whose job is to notice, rather than prevented by a shared definition.\n * THERE ARE THREE ENVELOPES, NOT TWO: improvise-wasm-client/src/wire.rs frames the WORKER deployment (improvise-tm6) separately again. Decide whether the worker and the socket want the same vocabulary before moving either, or the move produces two homes instead of one.\n\n(See also memory agent-issue-drift-pattern -- search before filing.)","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-8xhy","title":"[bug] A fit over a categorical feature emits an unevaluable formula","description":"Found during improvise-7na (2026-09-19), verified by probe, and pinned as a characterization test rather than fixed.\n\ngp_tree::data::preprocess one-hots a categorical column into a feature named Region=North. That is an ENCODING ARTEFACT, not an item of the document, so the emitted formula Expr::Ref fails Model::find_item_category and EVERY CELL of the fitted measure evaluates to Error(\"ref:Region=North\"). col.missing and the col.year / col.month date sub-features are in exactly the same position.\n\nSo \"fit a tree and get a working formula\" holds ONLY for a fit over numeric measures.\n\nTHE TEST THAT SHOULD HAVE CAUGHT IT WAS LYING: improvise_evaluation_matches_gp_tree_eval cannot see this, because its improvise_eval helper FABRICATES a _Measure item for every feature name, including outlook=rainy. It asserts the emitter and gp_tree::eval agree, which they do -- against a model that could not exist. Worth remembering as a shape: a helper that builds the world the code wants is not a test of whether that world exists.\n\nPinned as a_split_on_a_one_hot_feature_does_not_evaluate -- characterization, flip it when fixed.\n\nTHREE REMEDIES, pick deliberately: restrict fits to numeric features (smallest, and honest about what works); materialise one-hot columns as real _Measure items (makes the formula evaluable but pollutes the document with encoding artefacts); or teach the emitter improvise WHERE vocabulary so a categorical split becomes a real predicate over the category (largest, and the only one that produces a formula a human would have written). The third is probably right and should be costed before the first is shipped as permanent.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T00:35:05Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:45:21Z","closed_at":"2026-09-20T00:45:21Z","close_reason":"Fixed as REMEDY 1, explicitly labelled interim. fitted_formula now takes the \u0026Model the formula is to be stored in and refuses a tree whose splits it cannot resolve, listing them. formula_text is untouched -- it is the tree's own text and there is nothing else it could write. Nothing outside the crate called fitted_formula.\n\nWHY THE EMITTER AND NOT THE DATASET LAYER: dropping categorical columns before the fit is smaller, but it throws away real signal (permutation importance, and any future remedy-3 emitter) and makes the crate's halves disagree -- one-hot encoding is gp-tree's whole job. The defect is that the EMITTER spells a formula the document cannot evaluate.\n\nWHY THE CHECK ASKS THE DOCUMENT, NOT THE NAME'S SHAPE: refusing names containing '=' or matching .missing would be a syntactic proxy and a wrong one, because a measure LITERALLY NAMED Region=North is a legal document and must be accepted. The two are indistinguishable as strings; only the document can tell them apart. So it calls Model::find_item_category -- the exact precondition Expr::Ref has -- which covers .missing, the date parts, and any future sub-feature naming scheme with no second copy of preprocess's expansion rules to drift from. Both directions pinned.\n\nTHE LYING TEST IS KEPT, NOT DELETED. Quoting and threshold routing are real things improvise_evaluation_matches_gp_tree_eval checks, and outlook=rainy earns its place there as an awkward NAME TO SPELL. What changed is that it and its helper now state their scope -- a test of arithmetic and quoting against a STIPULATED model, unable to see whether such a model could exist. The property it could not state is asserted separately against a real document: a_formula_the_emitter_accepted_evaluates_everywhere -- if fitted_formula says Ok, no cell of the target is a ref: error. That test and the refusal test are each other's check; without the accepting case the refusal could pass by refusing everything. The characterization is flipped to a_split_on_a_one_hot_feature_is_refused.\n\nREMEDY 3 IS STILL RIGHT AND IS NOT THIS TICKET -- successor filed. It is much larger than it looked: improvise has NO SCALAR COORDINATE PREDICATE (Filter gates a formula or an aggregate, it is not an Expr), it would still leave col.missing and the date parts uncovered, and it must first answer what such a predicate means where context.get(cat) is None.","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -166,13 +170,21 @@ {"_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-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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T19:17:16Z","created_by":"wave3-webui","updated_at":"2026-09-20T19:17:16Z","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":"open","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-20T18:53:08Z","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 18.1-19.2 ms 7.1-9.0 ms\n\nDebug at 18,050 cells: 132.9-135.1 ms after, against 71.7-74.6 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\nThe review measured 244 ms release / 2.75 s debug on its own fixture, which has\na different region/tag shape; the phenomenon and the direction agree, the\nabsolute number depends on members-per-group.\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\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## 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 and the scaling goes\n 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.","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:16:50Z","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":"open","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-20T18:50:51Z","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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:45:39Z","created_by":"wave2-h-frontend","updated_at":"2026-09-20T18:58:18Z","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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:33:24Z","created_by":"wave2-e-active-view","updated_at":"2026-09-20T18:33:24Z","labels":["area:active-view"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-w3bf","title":"A session mirror's active_view is written in four places, none through reduce_view","description":"Found by the adversarial review of PR #48 (improvise-jytm). Not a defect today: the\nfour writers agree. It is the SHAPE the divergence bugs in this area have taken every\ntime, filed before it becomes one.\n\n`ClientSession::view.active_view` — the server's mirror of a client's view — is\nwritten in four places, and none of them goes through `reduce_view`:\n\n1. `Sessions::apply_switch` — sets it directly from the accepted name.\n2. `Sessions::adopt_deleted_views` — sets it directly to the document default.\n3. `ClientSession::reseed` — replaces the whole slice via `fresh_view`.\n4. `Sessions::connect` — same, through `reseed`.\n\nEverywhere else in this codebase, the mirror stays in step with the client by folding\nthe SAME command the client folds (`ClientSession::fold` → `reduce_view`, the\n\"same function runs on both ends\" rule of improvise-cqq/m91). These four bypass it.\nThey are correct today only because `reduce_view`'s `SwitchView` arm is one line\n(`view.active_view = name.clone()`) and each of these reproduces it.\n\nThe day that arm grows a second statement — clearing a per-view buffer, resetting a\ncursor, anything — the mirror and the client diverge and nothing fails. That is\nexactly gotcha 36's \"folding SwitchView into the mirror alone was the bug\", arriving\nfrom the other direction.\n\nTwo shapes worth weighing:\n\n- Route all four through `reduce_view(\u0026mut session.view, \u0026Command::SwitchView(name))`.\n Cheap, and makes the bypass unrepresentable for this command.\n- A test that the two agree: fold a `SwitchView` into a bare `ViewState` and into a\n `ClientSession` and compare. Weaker (it is a compatibility test, and\n `doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)` says\n those are a definition waiting to be made) but it costs nothing.\n\n## Steps to Reproduce\n\nNot reproducible today; the four copies agree. To make it reproduce: add any second\nstatement to `reduce_view`'s `Command::SwitchView` arm in\n`crates/improvise-protocol/src/reduce.rs` — for instance clearing `status_msg` — then\nrun the suite. It stays green, and a client's own slice now carries something its\nserver-side mirror does not.\n","acceptance_criteria":"Either the four writers route through reduce_view, or a test pins that a ClientSession's mirror and a bare ViewState fold a SwitchView identically. Closing it as 'the copies agree' is not acceptable — that is the state it is filed in.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:32:43Z","created_by":"wave2-e-active-view","updated_at":"2026-09-20T18:32:43Z","labels":["area:active-view"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_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.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:23:51Z","created_by":"orchestrator","updated_at":"2026-09-20T19:17:13Z","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":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:11:01Z","created_by":"wave2-b-projection","updated_at":"2026-09-20T18:11:01Z","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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:06:03Z","created_by":"orchestrator","updated_at":"2026-09-20T18:06:03Z","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.","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-20T18:06:00Z","labels":["area:active-view"],"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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:05:51Z","created_by":"orchestrator","updated_at":"2026-09-20T18:05:51Z","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":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:04:14Z","created_by":"wave2-h-frontend","updated_at":"2026-09-20T18:04:14Z","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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:01:04Z","created_by":"wave2-p-milestones","updated_at":"2026-09-20T18:01:04Z","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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T17:57:25Z","created_by":"wave2-n-cleanups","updated_at":"2026-09-20T17:57:25Z","labels":["area:command"],"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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T17:57:25Z","created_by":"wave2-n-cleanups","updated_at":"2026-09-20T17:57:25Z","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-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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T07:49:56Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:57:52Z","labels":["area:protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -256,7 +268,7 @@ {"_type":"issue","id":"improvise-jjx","title":"Scrolled grid suppresses first visible row-header label","description":"src/ui/grid.rs:338-350: repeat-suppression shows a row-header sub-label only when ri == 0 or the prefix differs from data_row_items[ri-1]. ri is the absolute data-row index (starts at row_offset, grid.rs:269), so when the view is scrolled the first visible row compares against the off-screen row above and its label is blanked — the user loses the group context at the top of the viewport. Fix: compare against the previous *visible* row, i.e. always show labels for the first rendered data row. 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:58:38Z","created_by":"Edward Langley","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-byz","title":"records_display returns Some(\"\") for out-of-bounds columns instead of None","description":"crates/improvise-core/src/view/layout.rs:186-202: records_display() calls col_label(col), which returns an empty string for out-of-bounds indices; the empty string then fails the == \"Value\" check, is looked up as a category name, and the function returns Some(\"\") via unwrap_or_default(). cell_key() (~line 396) explicitly returns None for an empty col_label — records_display should be symmetric so view/data mismatches are not masked. 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:58:35Z","created_by":"Edward Langley","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-zgb","title":".improv formula loader mis-splits category suffix via rfind(\" [\")","description":"crates/improvise-io/src/persistence/mod.rs:403-411: the formulas_section walker finds the [Category] suffix with raw.rfind(\" [\") + ends_with(']'). Two failure modes: (1) a pipe-quoted identifier at the end of the formula containing ' [' (e.g. |Revenue [USD]|) is mis-split, treating part of the quoted name as the category; (2) a suffix written without a leading space (X=Y[Cat]) is not recognized and the whole line is parsed as a _Measure formula, losing the target category. Fix: move the category suffix into the pest grammar instead of string scanning. Write failing tests first per AGENTS.md. 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:58:32Z","created_by":"Edward Langley","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-drg","title":"vb4 step 5: narrow Effect::apply signatures where one-slice","description":"Optional follow-up to vb4 structural split. For each impl Effect for Foo, audit which slice(s) it mutates. If apply only touches ModelState (or only ViewState), narrow its signature from fn apply(\u0026self, app: \u0026mut App) to fn apply(\u0026self, model: \u0026mut ModelState) (or ViewState). Cross-cutting effects (read layout, change mode that triggers layout rebuild, etc.) stay on \u0026mut App. Improves type-level documentation and enables finer-grained effect tests. Lower priority — vb4's acceptance criterion (4) explicitly allows leaving cross-cutting on \u0026mut App.","design":"Hidden trait-design decision surfaced during vb4 step 4 review: the issue's\nphrasing — \"narrow its signature from fn apply(\u0026self, app: \u0026mut App) to\nfn apply(\u0026self, model: \u0026mut ModelState)\" — is not directly possible. You\ncannot change one Effect impl's signature without changing the Effect\ntrait. Three approaches, none picked:\n\n(A) Split into multiple traits — ModelEffect / ViewEffect / AppEffect.\n Each impl becomes the narrower thing it actually is. Cost: ~50\n existing impls reorganize across three traits, and Vec\u003cBox\u003cdyn Effect\u003e\u003e\n has to become an enum wrapper (or three parallel queues) to keep\n apply_effects working. Highest fidelity to the issue's literal wording.\n\n(B) Keep one trait, rebind inside the impl body — e.g.\n fn apply(\u0026self, app: \u0026mut App) { let view = \u0026mut app.view_state; view.mode = ... }\n so the slice is visible at the call site without changing the\n signature. Lowest disruption. Doesn't strictly satisfy acceptance\n criterion (1) (a \"narrower borrow\") — only the body is narrower.\n\n(C) Default-method dispatch on a single trait — Effect grows\n apply_view(\u0026mut ViewState) and apply_model(\u0026mut ModelState) with\n default impls that fall through to apply(\u0026mut App). Each impl\n overrides only the method that matches its slice. Middle ground:\n type-level narrowing for impls that opt in, \u0026mut App stays available\n for cross-cutting effects. Wraps cleanly around the existing\n apply_effects loop.\n\nPicking (A) commits to the architecture for downstream effect tagging\n(improvise-mae) and the protocol extraction (improvise-cqi). (B) and (C)\nleave that decision unmade. Recommend deciding before claiming.","acceptance_criteria":"Each Effect whose apply touches only one slice takes \u0026mut ModelState or \u0026mut ViewState; cross-cutting effects stay on \u0026mut App, which vb4's own acceptance explicitly allows. The win is type-level documentation and finer-grained effect tests, not line count. CAUTION, new since this ticket was filed: App::apply_effects now reads dirtiness, layout_version and the record fingerprints off the EffectKind (improvise-gv9r, gotcha 40) — narrowing a signature must not put an effect somewhere that loop cannot see.","notes":"DECLINED 2026-09-20 (wave 2), with counts. Reasoning in context/code/command.md,\n'Effect::apply stays on \u0026mut App'. NOTE: this bead carries the `human` label, so\nthe decline is a recommendation the user may overturn; it is recorded rather than\nsmuggled.\n\nCOUNTED over the 49 `impl Effect` blocks in ui/effect.rs:\n now (this branch) 8 view_state-only, 9 model_state-only, 32 cross-cutting\n base 52507a6 9 view_state-only, 16 model_state-only, 24 cross-cutting\nThe narrow set fell from 25 to 17 in one wave. The mover was improvise-9mo9,\nwhich made nine reshaping effects call app.reset_cursor(): a reshape lives in\nmodel_state.workbook and the cursor it invalidates lives in ViewState, so the\neffects that matter most are precisely the ones that reconcile the two slices.\nNarrowing bets against the direction the code moves in.\n\nFOUR GROUNDS\n1. The narrow set is small and shrinking, for that architectural reason.\n2. The type-level documentation already exists and is CHECKED: Effect::kind()\n has no default (gotcha 3) and apply_effects reads that one answer three times\n (gotchas 40, 45). A narrowed signature is a second statement of the same fact,\n free to disagree -- the defect, not the cure.\n3. Every offered mechanism weakens a guard. (A) three traits needs three queues\n (destroys batch order, which gotchas 21/23/43 lean on) or an enum wrapper (a\n fourth classification beside EffectKind). (C) default methods give `apply` a\n DEFAULT, exactly what kind() does without and for the same reason: overriding\n apply_view on an effect that needs the model compiles and writes the wrong\n slice silently; overriding neither compiles as a no-op. (B) does not meet the\n ticket's own acceptance criterion.\n4. \"Finer-grained effect tests\" is not blocked: effect.rs's 64 tests already call\n Effect.apply(\u0026mut app) against a one-line App::new(wb, None) fixture.\n\nWHAT IS NOT DECLINED: approach (B), rebinding the slice at the top of an apply\nbody, remains available as a per-function style choice. It is not mandated.","status":"closed","priority":3,"issue_type":"task","assignee":"wave2-n-cleanups","owner":"el-github@elangley.org","created_at":"2026-04-26T18:16:52Z","created_by":"jeeves","updated_at":"2026-09-20T18:11:26Z","started_at":"2026-09-20T18:08:16Z","closed_at":"2026-09-20T18:11:26Z","close_reason":"Closed","labels":["area:command","human"],"dependencies":[{"issue_id":"improvise-drg","depends_on_id":"improvise-99k","type":"blocks","created_at":"2026-04-26T11:17:16Z","created_by":"jeeves","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-drg","title":"vb4 step 5: narrow Effect::apply signatures where one-slice","description":"Optional follow-up to vb4 structural split. For each impl Effect for Foo, audit which slice(s) it mutates. If apply only touches ModelState (or only ViewState), narrow its signature from fn apply(\u0026self, app: \u0026mut App) to fn apply(\u0026self, model: \u0026mut ModelState) (or ViewState). Cross-cutting effects (read layout, change mode that triggers layout rebuild, etc.) stay on \u0026mut App. Improves type-level documentation and enables finer-grained effect tests. Lower priority — vb4's acceptance criterion (4) explicitly allows leaving cross-cutting on \u0026mut App.","design":"Hidden trait-design decision surfaced during vb4 step 4 review: the issue's\nphrasing — \"narrow its signature from fn apply(\u0026self, app: \u0026mut App) to\nfn apply(\u0026self, model: \u0026mut ModelState)\" — is not directly possible. You\ncannot change one Effect impl's signature without changing the Effect\ntrait. Three approaches, none picked:\n\n(A) Split into multiple traits — ModelEffect / ViewEffect / AppEffect.\n Each impl becomes the narrower thing it actually is. Cost: ~50\n existing impls reorganize across three traits, and Vec\u003cBox\u003cdyn Effect\u003e\u003e\n has to become an enum wrapper (or three parallel queues) to keep\n apply_effects working. Highest fidelity to the issue's literal wording.\n\n(B) Keep one trait, rebind inside the impl body — e.g.\n fn apply(\u0026self, app: \u0026mut App) { let view = \u0026mut app.view_state; view.mode = ... }\n so the slice is visible at the call site without changing the\n signature. Lowest disruption. Doesn't strictly satisfy acceptance\n criterion (1) (a \"narrower borrow\") — only the body is narrower.\n\n(C) Default-method dispatch on a single trait — Effect grows\n apply_view(\u0026mut ViewState) and apply_model(\u0026mut ModelState) with\n default impls that fall through to apply(\u0026mut App). Each impl\n overrides only the method that matches its slice. Middle ground:\n type-level narrowing for impls that opt in, \u0026mut App stays available\n for cross-cutting effects. Wraps cleanly around the existing\n apply_effects loop.\n\nPicking (A) commits to the architecture for downstream effect tagging\n(improvise-mae) and the protocol extraction (improvise-cqi). (B) and (C)\nleave that decision unmade. Recommend deciding before claiming.","acceptance_criteria":"Each Effect whose apply touches only one slice takes \u0026mut ModelState or \u0026mut ViewState; cross-cutting effects stay on \u0026mut App, which vb4's own acceptance explicitly allows. The win is type-level documentation and finer-grained effect tests, not line count. CAUTION, new since this ticket was filed: App::apply_effects now reads dirtiness, layout_version and the record fingerprints off the EffectKind (improvise-gv9r, gotcha 40) — narrowing a signature must not put an effect somewhere that loop cannot see.","notes":"DECLINED 2026-09-20 (wave 2), with counts. Reasoning in context/code/command.md,\n'Effect::apply stays on \u0026mut App'. NOTE: this bead carries the `human` label, so\nthe decline is a recommendation the user may overturn; it is recorded rather than\nsmuggled.\n\nCOUNTED over the 49 `impl Effect` blocks in ui/effect.rs:\n now (this branch) 8 view_state-only, 9 model_state-only, 32 cross-cutting\n base 52507a6 9 view_state-only, 16 model_state-only, 24 cross-cutting\nThe narrow set fell from 25 to 17 in one wave. The mover was improvise-9mo9,\nwhich made nine reshaping effects call app.reset_cursor(): a reshape lives in\nmodel_state.workbook and the cursor it invalidates lives in ViewState, so the\neffects that matter most are precisely the ones that reconcile the two slices.\nNarrowing bets against the direction the code moves in.\n\nFOUR GROUNDS\n1. The narrow set is small and shrinking, for that architectural reason.\n2. The type-level documentation already exists and is CHECKED: Effect::kind()\n has no default (gotcha 3) and apply_effects reads that one answer three times\n (gotchas 40, 45). A narrowed signature is a second statement of the same fact,\n free to disagree -- the defect, not the cure.\n3. Every offered mechanism weakens a guard. (A) three traits needs three queues\n (destroys batch order, which gotchas 21/23/43 lean on) or an enum wrapper (a\n fourth classification beside EffectKind). (C) default methods give `apply` a\n DEFAULT, exactly what kind() does without and for the same reason: overriding\n apply_view on an effect that needs the model compiles and writes the wrong\n slice silently; overriding neither compiles as a no-op. (B) does not meet the\n ticket's own acceptance criterion.\n4. \"Finer-grained effect tests\" is not blocked: effect.rs's 64 tests already call\n Effect.apply(\u0026mut app) against a one-line App::new(wb, None) fixture.\n\nWHAT IS NOT DECLINED: approach (B), rebinding the slice at the top of an apply\nbody, remains available as a per-function style choice. It is not mandated.\nUSER CONFIRMATION 2026-09-20: the decline is CONFIRMED and the bead stays closed. This bead carries the 'human' label, so the WU-N agent correctly recorded its decline as a recommendation rather than acting on it unilaterally; the user was shown the counts (8 view-only / 9 model-only / 32 cross-cutting of 49, down from 9/16/24 at the wave base) and all three approaches (A: three traits; B: rebind in the body; C: default-method dispatch), and chose to confirm the decline.\n\nThe reasoning the user accepted: the classification ALREADY EXISTS and is checked — Effect::kind has no default and apply_effects reads that one answer three times — so a narrowed signature is a second statement free to disagree with it. Three traits need three queues (destroying batch order) or an enum wrapper (a fourth classification). Default methods would give apply a DEFAULT whose wrong answer is silent, which is precisely what kind()'s no-default rule exists to prevent.\n\nThe fact worth carrying past this bead: improvise-9mo9 moved nine effects from 'touches one slice' to 'cross-cuts' INSIDE THE SAME BRANCH that declined this, and that is not a coincidence to route around. The App exists because the grid's shape lives in model_state.workbook and the cursor into it lives in ViewState, so the effects that matter most are exactly the ones that RECONCILE the two slices. A design that rewards single-slice effects would have been pulling against the repo's actual centre of gravity.\nCOUNTS CORRECTED 2026-09-20, after adversarial review of PR #49. The decline\nstands; the numbers it was argued on were wrong twice and the user confirmed it\npartly on them, so the correction is recorded loudly rather than restated.\n\nPUBLISHED (wrong): 49 effects; 8 view-only, 9 model-only, 32 cross; narrow set\nfell 25 -\u003e 17.\nCORRECT: 57 effects; 16 view-only, 9 model-only, 32 cross; narrow set\nfell 32 -\u003e 25. Base 52507a6 was 16/16/25.\n\nTwo errors. (a) It counted only the 49 `impl Effect` blocks and omitted the 8\n`simple_effect!` invocations, which also expand to an impl and are ALL view-only.\n(b) It misclassified AddItem at the base commit, because the extraction regex\ncould not see `app` followed by a newline and `.model_state` -- which is\ndoctrine(derive_then_verify) catching the person quoting it, with the exact\nfailure that doctrine was written about.\n\nWHAT THIS COSTS THE ARGUMENT: 25 of 57 is 44% of effects that could narrow, not\nthe 35% claimed; and the fall is 22%, not 32%. The direction is unchanged and is\nstill the point -- the seven movers are SetAxis, HideItem, ShowItem, ToggleGroup,\nTogglePruneEmpty, RemoveItem and RemoveCategory, every one an effect\nimprovise-9mo9 gave a reset_cursor, so the shrinkage is architectural rather than\nincidental. But the headline was overstated by about half again, and anyone\nre-opening this should argue from 32 -\u003e 25 of 57.\n\nThe other three grounds for the decline are untouched by this: the classification\nalready exists and is checked; three traits break batch order and default methods\nreintroduce a silent default; and finer-grained tests are not blocked.","status":"closed","priority":3,"issue_type":"task","assignee":"wave2-n-cleanups","owner":"el-github@elangley.org","created_at":"2026-04-26T18:16:52Z","created_by":"jeeves","updated_at":"2026-09-20T18:57:42Z","started_at":"2026-09-20T18:08:16Z","closed_at":"2026-09-20T18:11:26Z","close_reason":"Closed","labels":["area:command","human"],"dependencies":[{"issue_id":"improvise-drg","depends_on_id":"improvise-99k","type":"blocks","created_at":"2026-04-26T11:17:16Z","created_by":"jeeves","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-1ud","title":"Document RecordsEditing variant in repo-map.md","description":"context/repo-map.md currently lists 15 AppMode variants but does not include RecordsEditing, which is referenced at src/draw.rs:142 and src/ui/app.rs:442. Add the variant to the AppMode list in the 'Key Types' section so coding agents see it.\\n\\nTrivial doc fix.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:54:16Z","created_by":"Ed L","updated_at":"2026-06-10T04:05:13Z","closed_at":"2026-06-10T04:05:13Z","close_reason":"Done in commit 0fe04de (2026-06-09 repo-map refresh): AppMode list now reads 16 variants including RecordsNormal and RecordsEditing, and notes MinibufferConfig + the ModelState/ViewState split.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-8fy","title":"Introduce simple_effect! macro for single-field setter effects","description":"crates/improvise-command/src/ui/effect.rs has ~15 simple setter effects (SetSelected, SetRowOffset, SetColOffset, SetYanked, SetSearchQuery, SetSearchMode, SetStatus, etc.) that each implement the same 4-line apply: pub struct SetX(pub T); impl Effect { fn apply(\u0026self, app: \u0026mut App) { app.field = self.0.clone(); } }.\\n\\nAbstraction: simple_effect!(SetRowOffset, usize, row_offset) macro generates struct + Effect impl.\\n\\nCorrectness win: provides a single hook for cross-cutting concerns. If layout-affecting setters need to mark dirty or rebuild layout, one macro body covers all of them. Currently each setter independently decides whether to mark_dirty(), and silent omissions are plausible. ~50 LOC.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:54:14Z","created_by":"Ed L","updated_at":"2026-09-19T20:35:54Z","closed_at":"2026-09-19T20:35:54Z","close_reason":"simple_effect! macro added beside the Effect trait; eight single-field ViewOnly setters declared through it (-42 lines net). The macro HARD-CODES EffectKind::ViewOnly by design: an effect other clients must see is not 'simple' and keeps a hand-written impl, which is how gotcha 3's no-default-kind rule survives a macro at all. SetSelected (two fields) and SetNumberFormat (a Model change) stay hand-written. Existing effect tests already cover all eight, including view_only_effects_leave_shared_state_alone.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-1wu","title":"Extract axis label helper in view/layout.rs","description":"crates/improvise-core/crates/improvise-core/src/view/layout.rs:323-336 (row_label) and :338-351 (col_label) are character-identical except for which items list they read. Small syntactic duplication but trivial to consolidate.\\n\\nFix: fn axis_label(items: \u0026[AxisEntry], idx: usize) -\u003e String, call from both. ~14 LOC.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:54:13Z","created_by":"Ed L","updated_at":"2026-08-14T01:11:07Z","closed_at":"2026-08-14T01:11:07Z","dependencies":[{"issue_id":"improvise-1wu","depends_on_id":"improvise-ai7","type":"supersedes","created_at":"2026-08-13T18:11:07Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -282,10 +294,14 @@ {"_type":"issue","id":"improvise-l36","title":"3.2 Publish to crates.io","description":"After cargo publish --dry-run is clean and user confirms, run cargo publish. Verify crate appears at crates.io/crates/improvise and cargo install improvise works.","acceptance_criteria":"improvise is on crates.io and 'cargo install improvise' works from a clean machine. NEEDS THE USER: crates.io credentials, and their confirmation after 'cargo publish --dry-run' is clean. Note the workspace has grown to nine crates since this was filed — decide which are published and in what order, since a dependent cannot publish before its dependency, and improvise-io/protocol/command are all now real crates rather than internal modules.","notes":"DRY RUN ACTUALLY RUN, 2026-09-20 at 52507a6 (wave 2, WU-P). It is NOT clean,\nand the blocker is mechanical rather than a judgement call:\n\n error: failed to verify manifest at Cargo.toml\n all dependencies must have a version requirement specified when publishing.\n dependency `gp-tree` does not specify a version\n\ncargo stops at the FIRST offender, and gp-tree is not the only one. Every\ninter-crate dependency in this workspace is `{ path = \"...\" }` with no\n`version`, which crates.io forbids for normal dependencies:\n\n Cargo.toml (root) gp-tree, improvise-core, improvise-formula,\n improvise-io, improvise-command\n improvise-core improvise-formula\n improvise-formula (none)\n improvise-protocol improvise-core\n improvise-io improvise-core, improvise-formula\n improvise-gp gp-tree, improvise-core, improvise-formula\n improvise-command improvise-core, improvise-protocol,\n improvise-formula, improvise-io, improvise-gp\n\nDev-dependencies are exempt — cargo strips them on publish — so the root's\n`improvise-wasm-client`/`improvise-protocol` dev deps and improvise-wasm-\nclient's dev dep on the unpublishable improvise-ws-server are fine as they are.\n\nWHICH CRATES PUBLISH. `improvise-ws-server` and `improvise-worker-server` both\ncarry `publish = false` already, so the set is eight, and a dependent cannot go\nbefore its dependency. The only order that works:\n\n 1. improvise-formula (no intra-workspace deps)\n 2. improvise-core\n 3. gp-tree (no intra-workspace deps; can go any time before 4)\n 4. improvise-gp\n 5. improvise-protocol\n 6. improvise-io\n 7. improvise-command\n 8. improvise (the binary)\n\ncrates.io needs a minute or two to index each one before the next can resolve\nit, so this is eight sequential `cargo publish` calls, not one.\n\nTWO VERSION FACTS THE USER MUST DECIDE. `gp-tree` is at `0.1.0` while every\nother crate is at `0.1.0-rc2`; and a `-rc2` version requirement in a published\nmanifest pins consumers to a prerelease. If the intent is to publish 0.1.0\n(which improvise-3tj's tag implies), the version bump across ten manifests\nshould happen BEFORE the version requirements are written, or they will be\nwritten twice.\n\nNAME AVAILABILITY CHECKED: crates.io returns \"crate `improvise` does not exist\",\nso the name is free. Not checked: the other seven names.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:58Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:01:38Z","labels":["area:release","needs:user"],"dependencies":[{"issue_id":"improvise-l36","depends_on_id":"improvise-11a","type":"blocks","created_at":"2026-04-08T21:09:29Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-l36","depends_on_id":"improvise-2fr","type":"blocks","created_at":"2026-04-08T21:09:28Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":2,"comment_count":0} {"_type":"issue","id":"improvise-11a","title":"3.1 Configure cargo dist","description":"Run cargo dist init targeting x86_64-unknown-linux-gnu, aarch64-apple-darwin, x86_64-apple-darwin. Skip Windows and musl. Commit generated .github/workflows/release.yml and Cargo.toml additions. Test with v0.1.0-rc1 tag, verify builds, delete rc tag.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:55Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-11T07:49:57Z","close_reason":"Closed","dependencies":[{"issue_id":"improvise-11a","depends_on_id":"improvise-abz","type":"blocks","created_at":"2026-04-08T21:09:28Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":3,"comment_count":0} {"_type":"issue","id":"improvise-0s6","title":"Phase 3: Distribution","description":"Configure cargo dist, publish to crates.io, tag v0.1.0 release. Produces prebuilt binaries for Linux x86_64 and macOS (Intel + Apple Silicon).","acceptance_criteria":"Closes when improvise-l36 and improvise-3tj close. Both need the user; nothing here is agent work. improvise-11a (cargo dist) is already closed.","notes":"CHECKED, wave 2 WU-P: STAYS OPEN, correctly — both children need the user and\nneither was touched. Nothing was published and nothing was tagged.\n\nWhat this pass DID do is make them ready, and one of them is not as ready as\nit looked. `cargo publish --dry-run` was actually run and FAILS: every\ninter-crate dependency in the workspace is `{ path = ... }` with no `version`,\nwhich crates.io forbids. cargo reports only the first offender (gp-tree), so\nthe dry run understates it — the full list, the publish order for the eight\npublishable crates, and the gp-tree/rc2 version disagreement are on\nimprovise-l36.\n\nimprovise-3tj's premise is already partly false and its note now says so: the\nREADME prebuilt-binaries link is not a placeholder, v0.1.0-rc2 carries real\ncargo-dist artifacts for all four targets, and what is actually missing is the\nv0.1.0 tag and the manifest bump that must precede it.","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-09T04:05:51Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:05:07Z","labels":["area:release","needs:user"],"dependencies":[{"issue_id":"improvise-0s6","depends_on_id":"improvise-11a","type":"blocks","created_at":"2026-04-08T23:37:39Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-0s6","depends_on_id":"improvise-3tj","type":"blocks","created_at":"2026-04-08T23:37:41Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-0s6","depends_on_id":"improvise-l36","type":"blocks","created_at":"2026-04-08T23:37:40Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":3,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-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":"open","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-20T19:16:46Z","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":"open","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-20T18:58:19Z","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":"open","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-20T18:45:36Z","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.","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-20T18:48:10Z","labels":["area:active-view"],"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":"open","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:14:57Z","created_by":"orchestrator","updated_at":"2026-09-20T18:14:57Z","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":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:13:31Z","created_by":"wave2-b-projection","updated_at":"2026-09-20T18:13:31Z","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-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":"TWO ADDITIONS, both instances of this ticket's one fact rather than new ones.\n\nFIRST, WHAT A CHECK MUST ACTUALLY DO — the part that gets lost, and the reason\nthis ticket is not a one-liner. Counting the four `.ap-player` elements PROVES\nNOTHING. `autoPlay` is false, so asciinema-player does not fetch a cast until\nthe viewer presses play; a page whose every cast path 404s constructs four\nplayers, throws no page error before the click, and is indistinguishable from a\nworking one. This was verified by injecting exactly that fault (`./casts/` -\u003e\n`./cats/`): the construction-only check passed on the broken page, and pressing\nplay returned 0 rendered lines and surfaced four 404s. So the spec must press\nplay on each of the four and assert a non-zero rendered line count, and its own\nfault injection must be recorded in `context/test-evidence/`.\n\nThe same applies to the page's one functional link. Since the user's ruling\n(improvise-kh8) the landing page IS the site root and its whole job is to reach\nthe app at `./app/`; a broken link there is a dead front door, and the page\nlooks entirely correct with one. Proved able to fail by pointing it at `./aap/`\n-- 404 while nothing on screen changes -- so the spec should resolve the\ncall-to-action href and assert the response status.\n\nSECOND, THE LINE BUDGET, which is an instance of \"asserted by nothing\" and not\na separate defect. improvise-e61's criterion is \"under 200 lines\" and\n`docs/index.html` is now 199 after the root/app restructure. A criterion met by\none line, with nothing checking it, is met by accident: the next person to touch\nthe file either trims something or silently breaks it, and no test, lint or CI\njob would say so. If this ticket's spec lands, the cheapest place for the budget\nis beside it -- a line count assertion is one statement and needs no browser.\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/` -- the landing page renders and the casts play,\n and no test in the repository would have noticed if they had not.\n4. Break a cast path in `docs/index.html`, repeat, and observe that the page is\n still structurally correct until a play button is pressed.\n5. `wc -l docs/index.html` -- 199, against a stated limit of 200, checked by\n nothing.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:01:16Z","created_by":"wave2-p-milestones","updated_at":"2026-09-20T18:14:01Z","labels":["area:release"],"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":"TWO ADDITIONS, both instances of this ticket's one fact rather than new ones.\n\nFIRST, WHAT A CHECK MUST ACTUALLY DO — the part that gets lost, and the reason\nthis ticket is not a one-liner. Counting the four `.ap-player` elements PROVES\nNOTHING. `autoPlay` is false, so asciinema-player does not fetch a cast until\nthe viewer presses play; a page whose every cast path 404s constructs four\nplayers, throws no page error before the click, and is indistinguishable from a\nworking one. This was verified by injecting exactly that fault (`./casts/` -\u003e\n`./cats/`): the construction-only check passed on the broken page, and pressing\nplay returned 0 rendered lines and surfaced four 404s. So the spec must press\nplay on each of the four and assert a non-zero rendered line count, and its own\nfault injection must be recorded in `context/test-evidence/`.\n\nThe same applies to the page's one functional link. Since the user's ruling\n(improvise-kh8) the landing page IS the site root and its whole job is to reach\nthe app at `./app/`; a broken link there is a dead front door, and the page\nlooks entirely correct with one. Proved able to fail by pointing it at `./aap/`\n-- 404 while nothing on screen changes -- so the spec should resolve the\ncall-to-action href and assert the response status.\n\nSECOND, THE LINE BUDGET, which is an instance of \"asserted by nothing\" and not\na separate defect. improvise-e61's criterion is \"under 200 lines\" and\n`docs/index.html` is now 199 after the root/app restructure. A criterion met by\none line, with nothing checking it, is met by accident: the next person to touch\nthe file either trims something or silently breaks it, and no test, lint or CI\njob would say so. If this ticket's spec lands, the cheapest place for the budget\nis beside it -- a line count assertion is one statement and needs no browser.\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/` -- the landing page renders and the casts play,\n and no test in the repository would have noticed if they had not.\n4. Break a cast path in `docs/index.html`, repeat, and observe that the page is\n still structurally correct until a play button is pressed.\n5. `wc -l docs/index.html` -- 199, against a stated limit of 200, checked by\n nothing.\nWITHDRAWN FROM THIS TICKET: the 199-line budget paragraph above.\n\nIt was added here on the reasoning that \"a 200-line limit met by one line with\nnothing checking it\" is an instance of this ticket's one fact. On review that is\nthe wrong home: the better answer is to rewrite improvise-e61's criterion, since\nthe line count was only ever a proxy for single file / vanilla / no build step /\nno framework — properties nothing threatens and which a line count measures\nbadly. That has been done on improvise-e61.\n\nThis ticket is about the landing page being SERVED and ASSERTED BY NOTHING, and\nit should stay that. Read the paragraph above as superseded; the rest of the\nnotes — press play, count rendered lines, resolve the call-to-action href — is\nwhat this ticket is for.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:01:16Z","created_by":"wave2-p-milestones","updated_at":"2026-09-20T18:22:56Z","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":"open","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-20T16:53:36Z","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":"open","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T07:50:12Z","created_by":"Edward Langley","updated_at":"2026-09-20T16:53:34Z","labels":["area:core-io"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-zx8t","title":"draw.rs::mode_style matches AppMode with a wildcard, so the repo map's exhaustiveness promise is false there","description":"Found by WU-G on PR #27 while adding AppMode::SavePrompt, and it is a correction to something context/repo-map.md currently promises.\n\nThe repo map says of AppMode: \"Per-variant UI facts live on the enum as exhaustive-match methods -- label(), hint_text(), panel(), minibuffer(), is_editing(), is_records() (improvise-2hi); ADD A VARIANT AND THE COMPILER NAMES EVERY ONE YOU MUST FILL IN.\"\n\nThat is TRUE of the five enum methods and FALSE of src/draw.rs::mode_style, which matches AppMode with a `_ =\u003e` WILDCARD. So a new variant silently inherits whatever the wildcard arm gives -- SavePrompt currently takes ExportPrompt's DarkGray by accident rather than by decision, which happens to be defensible and was nobody's choice.\n\nWHY IT IS P4 AND NOT HIGHER: the consequence is a colour, and the wrong colour is visible rather than silent. But the reason to fix it is that the repo map states a PROPERTY, and a property that holds in five places and not the sixth is worse than one nobody claimed -- the next person adding a variant will trust the sentence.\n\nTHE FIX IS ONE OF TWO, and they are not equivalent:\n * Make mode_style exhaustive, so the compiler does name it. Note mode_style stays in src/draw.rs deliberately, because Style is a ratatui type and improvise-protocol must not name one (gotcha 26, the AxisColor/MinibufColor pattern).\n * Or give AppMode a semantic colour method in the protocol crate -- the AxisColor treatment -- and let draw.rs do the one mapping. That is the established shape here and it would also give a browser something to use, which draw.rs's Style cannot.\n\nThe second is probably right and is the same question improvise-k099 asks about hint_text/label. Decide them together.\n","acceptance_criteria":"mode_style either matches AppMode exhaustively, or AppMode gains a semantic colour method in improvise-protocol that draw.rs maps to a ratatui Style (the AxisColor/MinibufColor shape). Either way, adding an AppMode variant fails the build until someone chooses its colour. The repo-map sentence promising compiler-named sites is true of every listed method, or it is corrected to say which one it is not true of.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T06:04:22Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:44:19Z","labels":["area:tui"],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -310,5 +326,5 @@ {"_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-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.","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:19:45Z","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-e61","title":"4.1 Create docs/index.html landing page","description":"Vanilla HTML, single file, under 200 lines. Dark background, monospace headings. Embed asciinema-player from jsdelivr CDN. Sections: title/tagline, pivot cast, drill cast, formulas cast, import cast, install commands + GitHub link. Player config: rows 30, cols 100, theme monokai, autoPlay false, loop true.","acceptance_criteria":"docs/index.html exists: vanilla HTML, single file, under 200 lines, dark background and monospace headings, asciinema-player embedded from jsdelivr, sections for title/tagline, the pivot/drill/formulas/import casts, install commands and a GitHub link. Player config rows 30, cols 100, theme monokai, autoPlay false, loop true. The four casts already exist (improvise-d4w is closed) — check they still reflect the current UI before embedding them, since the wave changed the records columns and the status line.","notes":"CRITERION REINTERPRETED, on review of PR #47, rather than a second bead filed.\n\n\"Under 200 lines\" is a PROXY for what this ticket actually cared about — single\nfile, vanilla, no build step, no framework — and those are the properties worth\nholding. The line count is not: it is met or missed by an arbitrary margin, it is\nchecked by nothing, and at 199 lines it was \"met\" only by accident, with the next\nperson to touch the file silently breaking it.\n\n`docs/index.html` is now 209 lines. The properties the number stood for are all\nintact: one file, no build step, no framework, no dependency but the pinned\nasciinema-player from a CDN.\n\nWhat took it over was a fix, not padding. At a 390px viewport the four players\ncomputed a 4.83px terminal font — measured in Chromium against the assembled\ntree — because a 120-column cast shrunk into a phone is unreadable. That was\ntolerable when the landing page sat at /landing/; since the user's ruling it is\nthe first thing a phone visitor sees. A media query under 900px now scrolls each\nplayer sideways at a legible size instead: 11.95px, measured the same way, with\nthe PAGE's own scrollWidth still equal to its clientWidth, so the overflow is\ncontained inside the player and there is no horizontal page scroll.\nTWO CORRECTIONS TO THE NOTE ABOVE, and one thing it should not be read as saying.\n\nFIRST, THE NUMBERS IN THAT NOTE ARE NOW WRONG. It says the file is 209 lines and\nthat a media query fixes the phone rendering. A user ruling has since reverted\nthat fix — the mobile defect is filed as improvise-o31u and the page is\nunchanged — so `docs/index.html` is **213 lines**, and what took it past 200 is\nthe drill-cast caption below, not the mobile fix. The criterion reinterpretation\nstands on its own reasoning and does not depend on which change crossed the\nline: \"under 200 lines\" was a proxy for single file, vanilla, no build step, no\nframework, and all four hold.\n\nSECOND, AND MORE IMPORTANT: THE CAPTION IS A LABEL ON AN UNMET CRITERION, NOT A\nMET ONE. This ticket asked that the casts be checked against the current UI\nbefore embedding. They were, and `drill.cast` fails: it shows the narrow records\nformat with a single `Value` column, which improvise-c5v and improvise-2zq\nreplaced with a column per measure plus the `+cat` and `+` columns. The user\nruled to ship it with a visible caption rather than re-record or drop the\nsection, and to keep this ticket closed. That ruling is about what to SHIP; it\ndoes not make the criterion satisfied.\n\nSo the honest reading of this ticket's state is: delivered, with one embedded\ncast known to demo a UI the software no longer has, labelled as such on the page\nat body size and above the player rather than in a footnote, and improvise-2oxt\nopen to fix it properly. Anyone auditing \"do the casts reflect the current UI\"\nshould read improvise-2oxt, not this close reason, for the answer.","status":"closed","priority":4,"issue_type":"task","assignee":"wave2-p-milestones","owner":"el-github@elangley.org","created_at":"2026-04-09T04:08:07Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:22:56Z","started_at":"2026-09-20T18:02:40Z","closed_at":"2026-09-20T18:04:48Z","close_reason":"docs/index.html on wave/p-milestones: 194 lines, vanilla, single file, dark\nbackground, monospace headings, asciinema-player 3.10.0 pinned from jsdelivr\n(both URLs checked live, 200), all six sections, and the four improvise-d4w\ncasts. Verified by driving it in Chromium against both docs/ and the assembled\nsite/landing/: four players construct, each renders 37 terminal lines after\nplay, zero failed requests, zero page errors. That check was proved able to\nfail first — with autoPlay false the cast is not fetched until the click, so\ncounting players passes on a page whose every cast path 404s; breaking\n./casts/ to ./cats/ passes the construction check and fails the play check.\nNothing in CI asserts it yet: improvise-bgtf.\n\nTWO DEVIATIONS, both stated rather than papered over.\n\nPlayer geometry is cols 120 / rows 37, not the 100x30 this ticket and\nimprovise-d4w both name. Every cast header says 120x37, so the recordings\nnever complied with d4w's own \"Terminal: 100x30\"; a 30-row player clips the\nbottom seven rows of a 37-row cast, which is where the tile bar and the status\nline are.\n\nThe \"check they still reflect the current UI\" criterion was performed and the\nanswer is partly no. The tile bar's short_label vocabulary and the mode line\nstill match; the casts predate improvise-2zq's +cat column by five months and\nevery one opens on the recorder's shell prompt and home directory. Filed as\nimprovise-2oxt rather than embedded as if current.","labels":["area:release"],"dependencies":[{"issue_id":"improvise-e61","depends_on_id":"improvise-d4w","type":"blocks","created_at":"2026-04-08T21:09:31Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} {"_type":"issue","id":"improvise-kh8","title":"Phase 4: Landing page (optional)","description":"Create docs/index.html with embedded asciinema casts and enable GitHub Pages. Optional but recommended for launch.","acceptance_criteria":"Closes when improvise-e61 closes; improvise-3gy (enable GitHub Pages) is already closed. NOTE A COLLISION worth resolving before starting: Pages is already serving the standalone web app from the CI assemble step (improvise-d31/bck). Decide whether the landing page replaces that, sits beside it at a path, or is dropped in favour of the live app being the landing page — which is arguably the better demo and would close this as won't-fix.","notes":"CORRECTION TO THE CLOSE REASON ABOVE — THE USER OVERRULED IT, same session.\n\nThe close reason records the landing page sitting BESIDE the app at /landing/,\nwith the app keeping the root. The user ruled the other way:\n\n \"The landing page should be served at the root of the repository and link\n to the in-browser demo.\"\n\nSo the arrangement is inverted. `docs/index.html` is the site root and links\nprominently to the app, which moves to `/app/`. The argument for the other\narrangement is left standing above rather than deleted, because it is still the\nreason the alternative was considered and the collision this ticket asked to\nresolve was a genuine one — but it is OVERRULED, not merely superseded, and the\nroot is now the landing page.\n\nWHAT THE INVERSION COST, which is the part worth carrying:\n\n* The app moves under `app/` WHOLE — `index.html`, `app.js`, `style.css`,\n `worker.js`, `demo.improv` and BOTH bundle directories. Leaving `pkg/` and\n `worker-pkg/` at the root would have meant editing the wasm-pack generated\n glue; every path in app.js and worker.js is relative, so moving the bundle\n together keeps all of them correct and the glue untouched. `worker.js`'s\n `fetch(\"demo.improv\")` resolves against the WORKER's url, so the seed follows\n the move for the same reason.\n* `web/assemble.sh`'s four `stamped` guards had to follow the files. They are\n the only check that the cache-bust substitution worked, and a guard pointed at\n a path nothing writes fails in the direction that looks like success.\n* `harness/tests/shell.spec.js` navigates to `/index.html` six times, including\n the `?ws=` and `?doc=` variants. Every one of those now lands on the landing\n page, so all six were repointed at `/app/index.html`. The browser harness is a\n CI job that can fail the build, so this was not optional and not someone\n else's to discover.\n\nVERIFIED against the ASSEMBLED tree, not against docs/ directly, because a path\nmove is exactly what re-arms the trap this page already caught once: the four\ncasts play 37 lines each at the root, the primary call-to-action resolves to\n/app/ and returns 200, and there are no failed requests and no page errors. The\ncall-to-action check was proved able to fail by pointing the link at ./aap/,\nwhich returns 404 while the page still looks entirely correct.","status":"closed","priority":4,"issue_type":"feature","assignee":"wave2-p-milestones","owner":"el-github@elangley.org","created_at":"2026-04-09T04:05:53Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:10:48Z","started_at":"2026-09-20T18:02:40Z","closed_at":"2026-09-20T18:04:51Z","close_reason":"Both children terminal: improvise-3gy was already closed, improvise-e61 closes\nwith this wave.\n\nTHE COLLISION THIS TICKET ASKED TO RESOLVE FIRST, resolved: Pages serves the\nstandalone web app at the root (improvise-d31/bck), and the landing page now\nsits BESIDE it at /landing/ rather than replacing it. The reasoning is this\nticket's own — the live app is the better front door — and the third option,\ndropping the landing page in favour of the app, was declined only because the\nrecorded terminal casts show the TUI, which the browser app does not.\n\nweb/assemble.sh copies docs/index.html and docs/casts/ into the deployed tree,\nso the page is reachable and the browser harness serves the same tree the\ndeploy publishes. A landing page the deploy never copies would have satisfied\nthis ticket's letter and been a file nobody can open.\n\nREADME links both.","labels":["area:release"],"dependencies":[{"issue_id":"improvise-kh8","depends_on_id":"improvise-3gy","type":"blocks","created_at":"2026-04-08T23:37:42Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-kh8","depends_on_id":"improvise-e61","type":"blocks","created_at":"2026-04-08T23:37:41Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0} diff --git a/AGENTS.md b/AGENTS.md index f49eb61e..803c4157 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -191,6 +191,17 @@ exact-frame characterization tests that were the PR's only evidence the migration changed nothing. Two right findings, different costs. File the declined one. +**Under merge-as-soon-as-green, a review round can land after its own merge.** +GitHub deletes the branch when a PR merges, so a fix push to the old branch name +**silently succeeds and accomplishes nothing** — it re-creates a branch `main` +will never see, CI runs on it, and the commit is nowhere. The tell is the push +printing `* [new branch]` where you expected an `old..new` range. It happened on +#51: the third review round completed four minutes after the merge, and the +correction it prompted went to a re-created `wave/b-projection-gaps`. The repair +is to branch from `gh/main`, cherry-pick, open a follow-up PR, and delete the +stray re-creation. Check `gh pr view --json state` *before* pushing a fix to +a PR you have not looked at in a few minutes, rather than after. + Merge only once Copilot passes. Prefer a **merge commit** — `AGENTS.md` says merges over rebases, and a wave's commits are individually meaningful. diff --git a/context/design-principles.md b/context/design-principles.md index 1900164e..100aa83f 100644 --- a/context/design-principles.md +++ b/context/design-principles.md @@ -303,6 +303,16 @@ candidate for the store. A ``` fence in a doc comment is a doctest — a running test, not prose — and is exempt. +- **And an argument must not live only in a commit message.** A message is + write-once and unauditable: it cannot be corrected without rewriting merged + history, and no check in this repo can read one — the ID audit greps + `context`, `README.md`, `AGENTS.md`, `CLAUDE.md`, `crates`, `src`, `tests`, + `examples`, `web`, `harness` and `docs`, and a commit message is in none of + them. So a reason recorded only there is a reason nothing can ever find wrong. + When one turns out to be wrong, correct `context/` and say in `context/` that + the message is superseded; that is the fix rather than a compromise. PR #51's + `7d25762` is the worked example, corrected by #56. + --- ## 7. Testing From 9d6934604e89d5adf446ff6e56e35b0f3bfaf39f Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Sun, 20 Sep 2026 13:09:49 -0700 Subject: [PATCH 15/17] kb: scope the unrun claim by engine, rather than closing the list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding on this PR, and it is the commit's own lesson applied to the commit. The replacement invariant ended with "what is genuinely unrun: Safari/WebKit and any non-Chromium engine for the shell spec" — a CLOSED list, and an incomplete one. Verified: `WorkerServer::documents()` and `WorkerServer::refresh()` are `#[wasm_bindgen]` exports with no caller in `web/*.js` or `harness/`, so they are run by nothing. (The ten `.refresh(` hits under `harness/opfs-contract` are `OpfsFs::refresh`, a different method — I checked rather than counting greps.) So the old invariant understated coverage and my replacement overstated it. The direction flipped; the defect did not. The claim is now scoped BY ENGINE and says why: a coverage claim in prose is unfalsifiable by anything in this repo — the KB-consistency job checks the KB is consistent, not that it is true — so state what IS covered and scope what is not, rather than enumerating an absence. kb_consistent exits 0; 55 doctrines / 47 decisions / 51 invariants, unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- prolog-sessions/kb.pl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/prolog-sessions/kb.pl b/prolog-sessions/kb.pl index 0583cab8..b9b03d67 100644 --- a/prolog-sessions/kb.pl +++ b/prolog-sessions/kb.pl @@ -102,7 +102,7 @@ invariant(a_cats_change_reidentifies_every_position,'A cells identity is AxisFra invariant(the_header_width_rule_has_one_home,'improvise_protocol::header_level_widths: per level, the widest label plus a gap, clamped (improvise-1125). It lived twice -- inline in src/ui/grid.rs::render_grid and again in ui::viewport::compute_row_header_width -- and a browser sizing its own row header would have been a third. It takes a borrowed iterator rather than an AxisFrame because its other caller holds a GridLayout on the keypress path and building a frame there would clone every row tuple on every key.'). invariant(saving_is_a_frame_not_a_command,'ClientFrame::Save (improvise-368h) carries no path -- a client cannot see the hosts storage -- and is a FRAME because as a Command it put a runtime-fallible operation inside a batch ClientFrame::Commands promises is whole-or-nothing, so [Save, SetCell] reported a failed write and applied the edit anyway. It also fails the test every Command passes: it mutates no shared state and no clients slice, so reduce_view has nothing to fold and Checked has no basis to answer for. Refused as NoDocumentPath or SaveFailed to the asker alone; success is silent.'). invariant(the_persist_trigger_is_an_observation,'improvise-worker-server persists on model_state.dirty, not on a table of Command variants (touches_document is deleted) -- the same choice as watching cursor_epoch rather than matching CycleAxis, and document_generation rather than matching LoadModel. It is strictly tighter: a refused batch raises nothing, so the "was anything rejected" guard went with it.'). -invariant(the_harness_runs_the_worker_chain_under_chromium_only,'harness/ (improvise-vvl3/xf1) executes improvise_io::storage::contract::storage_contract against a real OpfsFs in a real dedicated Web Worker under Chromium and Firefox, gated by the browser job in CI; two bugs fell out of the first run. Playwrights WebKit provides no OPFS at all (getDirectory rejects with UnknownError), so xf1s three-engine criterion is met for two of three and the project is kept so it starts the day that build gains OPFS. harness/tests/shell.spec.js runs web/app.js under CHROMIUM ONLY -- engine-independent logic, and routeWebSocket is slower across three. It also runs BINDINGS.RS: its no-?ws test loads the app, which spawns web/worker.js, which imports the glue, which calls bindings.rs start() and OpfsFs::open(), and the test then asserts a grid draws. This invariant was named opfs_is_run_but_bindings_is_not and asserted the opposite in FIVE places for months (here, the crate/3 fact above, two context/repo-map.md lines and improvise-bck) -- every copy written before the harness landed and none re-read after. A never-run claim in prose is unfalsifiable by anything in this repo: the CI job checks this KB for CONSISTENCY, not truth, and the error UNDERSTATED coverage so nothing failed and nobody looked. What is genuinely unrun: Safari/WebKit (no OPFS in Playwrights build) and any non-Chromium engine for the shell spec.'). +invariant(the_harness_runs_the_worker_chain_under_chromium_only,'harness/ (improvise-vvl3/xf1) executes improvise_io::storage::contract::storage_contract against a real OpfsFs in a real dedicated Web Worker under Chromium and Firefox, gated by the browser job in CI; two bugs fell out of the first run. Playwrights WebKit provides no OPFS at all (getDirectory rejects with UnknownError), so xf1s three-engine criterion is met for two of three and the project is kept so it starts the day that build gains OPFS. harness/tests/shell.spec.js runs web/app.js under CHROMIUM ONLY -- engine-independent logic, and routeWebSocket is slower across three. It also runs BINDINGS.RS: its no-?ws test loads the app, which spawns web/worker.js, which imports the glue, which calls bindings.rs start() and OpfsFs::open(), and the test then asserts a grid draws. This invariant was named opfs_is_run_but_bindings_is_not and asserted the opposite in FIVE places for months (here, the crate/3 fact above, two context/repo-map.md lines and improvise-bck) -- every copy written before the harness landed and none re-read after. A never-run claim in prose is unfalsifiable by anything in this repo: the CI job checks this KB for CONSISTENCY, not truth, and the error UNDERSTATED coverage so nothing failed and nobody looked. What is genuinely unrun, BY ENGINE: Safari/WebKit (no OPFS in Playwrights build) and any non-Chromium engine for the shell spec. That is deliberately scoped to engines rather than written as a closed list of everything unreached, because a closed list is how the previous version of this invariant went wrong in the other direction: two wasm_bindgen exports on WorkerServer, documents() and refresh(), have NO caller in web/*.js or harness/ and so are run by nothing (the .refresh( hits in harness/opfs-contract are OpfsFs::refresh, a different method). The lesson the rename is about applies to its own replacement: a coverage claim in prose is unfalsifiable by anything in this repo, so state what IS covered and scope what is not, rather than enumerating an absence.'). doctrine(bug_first,'write a failing test demonstrating the bug before fixing it'). doctrine(unit_over_e2e,'prefer small unit tests over integration/e2e'). doctrine(suite_speed,'Two numbers, because the ambiguity between them is what let this drift: NO SINGLE TEST BINARY may exceed 2 seconds, and the WORKSPACE WALL CLOCK is the sum of them. Measured at 22d43b2: 1309 tests over 33 binaries, ~2.2s wall, longest single binary 1.42s -- so the workspace is already past 2s and the rule that bites is the per-binary one. Re-MEASURE rather than re-assert: cargo test --workspace prints a "finished in" per binary. Excludes compilation, and excludes harness/ (Playwright, minutes, its own CI job).'). From 648a5fe79c1cd891d2b4b20da93a13773c42878f Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Sun, 20 Sep 2026 13:16:36 -0700 Subject: [PATCH 16/17] docs: E2E pins the boundaries, unit tests work between them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User doctrine, 2026-09-20: 'E2E and integration tests should be rare and focus on boundary contracts. Unit tests should assume the boundary contracts tested by E2E and integration tests and test between the boundaries.' Recorded with the worked example that makes it actionable rather than aspirational. Four browser widgets had never worked and all four were ONE boundary failure — a RenderModel field with no element painting it. Per-widget specs cost four slow tests and would still miss the fifth; one contract assertion that every exposed field is painted catches all four and the next. opfs.spec.js is the shape already in the tree: it runs the storage contract rather than a feature. The corollary that is easy to miss: a unit test is cheap precisely BECAUSE it assumes the contract, which means deleting a contract test silently invalidates every unit test leaning on it. Co-Authored-By: Claude Opus 5 (1M context) --- .beads/issues.jsonl | 35 ++++++++++++++++++++++++++--------- context/design-principles.md | 31 +++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 9 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 0c30e6d4..bf13a234 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -22,7 +22,7 @@ {"_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.","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-20T18:19:45Z","labels":["area:epic"],"dependencies":[{"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-zkkw","type":"blocks","created_at":"2026-09-20T18:02:34Z","created_by":"wave2-p-milestones","metadata":"{}"}],"dependency_count":5,"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.","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-20T18:22:57Z","labels":["area:epic"],"dependencies":[{"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-zkkw","type":"blocks","created_at":"2026-09-20T18:02:34Z","created_by":"wave2-p-milestones","metadata":"{}"}],"dependency_count":5,"dependent_count":1,"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} @@ -37,8 +37,12 @@ {"_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-7ybz","title":"The browser category panel has no pointer affordance at all","description":"The user reported 'I can't expand categories'. Expanding works fully by KEYBOARD (Shift-C, j/k, Enter -\u003e Command::ToggleCatExpand, resolved client-side in improvise-protocol/src/resolve.rs:598). What does not work is the MOUSE: web/app.js::categoryList builds plain \u003cli\u003e elements with no click listener, so clicking the collapsed triangle does nothing. Verified in Chromium: clicking the 'Product' row leaves the panel byte-identical.\n\nThis is the whole page's pointer story, not one widget: #grid cells get a click handler ONLY when cell.editable, so there is no click-to-select either, and the tile bar and view rows added by fix/web-ui-correctness are likewise keyboard-only.\n\nThe DECISION needed before coding: does the browser get a pointer interaction model at all, and if so what does a click mean? Clicking a category row plausibly means (a) move cat_panel_cursor there, (b) also focus the panel (enter CategoryPanel mode) if it is not focused, and (c) toggle expand if the row is a header. That is three commands from one click, and app.js must stay protocol-free - so the shape would be a wasm entry point (Client::click_category(index) -\u003e Option\u003cString\u003e) mirroring typed_cell, not JS composing Commands. Same question for a grid cell click and a tile click.\n\nNot filed as part of the fix because it is a product call, not a defect in an implemented feature.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"wave3-webui","owner":"el-github@elangley.org","created_at":"2026-09-20T19:17:10Z","created_by":"wave3-webui","updated_at":"2026-09-20T19:30:56Z","started_at":"2026-09-20T19:30:56Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-nynk","title":"adopt_cursor_epoch resets every client's cursor, including clients on a view nothing reshaped","description":"Found by the adversarial review of PR #49, demonstrated AND bisected. This is\nimprovise-jytm's regression, not PR #49's -- but #49 widens it.\n\nimprovise-jytm (#48) made \"which view a client is on\" per-client: apply_switch\nmoves only the asker's mirror. `Sessions::adopt_cursor_epoch`\n(ui/project.rs:904-912) was not updated and still resets EVERY mirror and returns\n`Broadcast::ToAll(Command::ResetCursor)`. So a reshape of view A throws away the\ncursor of a client sitting on view B, which nothing reshaped.\n\nTHE BISECT IS THE LOAD-BEARING PART:\n - at a3bef6e (main, post-#48) with HideItem -\u003e PASSES\n - at a3bef6e (main, post-#48) with TransposeAxes -\u003e FAILS\nSo the hole is already in main. What PR #49 (improvise-9mo9) changes is the\nNUMBER OF EFFECTS THAT REACH IT: from 2 (TransposeAxes, CycleAxis) to 12.\n\nTHE FIX IS SMALL AND THE TYPE ALREADY EXISTS. `Broadcast::To(SessionId, Command)`\nis there for exactly this shape (improvise-6c6q, improvise-jytm). adopt_cursor_epoch\nshould reset only the mirrors whose `active_view` is the view that reshaped, and\nreturn one `To(id, ResetCursor)` per affected client instead of one ToAll. Note\nthe return type becomes Vec\u003cBroadcast\u003e rather than Option\u003cBroadcast\u003e.\n\nWHY IT WAS NOT FIXED IN PR #49: changing ToAll -\u003e To for cursor invalidation is a\ndecision about broadcast semantics in a design that landed hours earlier, and\ngotcha 36 records a deliberate argument for ToAll here (\"a consequence the asker\nis owed like everybody else\"). That argument is still right for a client on the\nRESHAPED view and wrong for everyone else, which is a distinction #48 created and\nnobody has yet written down. It wants jytm's owner, not a review fix-up.\n\nimprovise-ni6k answers it for free: a per-view grid-frame fingerprint is\nper-view by construction.\n\n## Steps to Reproduce\n\n1. Two sessions on one document.\n2. Bystander sends Command::SwitchView(\"Second\"), then SetRowOffset(2).\n3. Asker sends ModelCommand::HideItem (or TransposeAxes, on main) against the\n host's \"Default\" view.\n4. The bystander's row_offset goes to 0 and the broadcast is\n [ToAll(Command::ResetCursor)] -- for a view the bystander is not on and that\n nothing reshaped.","acceptance_criteria":"adopt_cursor_epoch resets only the mirrors whose active_view is the view that reshaped, and addresses the relay per client with Broadcast::To rather than ToAll. The regression guard is a two-session test where the bystander is on a DIFFERENT view from the asker and keeps its offset -- and it must also keep the existing positive arm, where a bystander on the SAME view is still reset and still told, or 'never invalidate anyone' passes it. Note the bisect: this reproduces at a3bef6e with TransposeAxes, so it is not PR #49's to fix, but #49 takes it from 2 effects to 12.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T19:17:05Z","created_by":"wave2-n-cleanups","updated_at":"2026-09-20T19:17:05Z","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-pdvm","title":"AddItem/RemoveItem reshape the grid but declare CellData, so layout_version and cursor_epoch disagree","description":"Found in the adversarial review of PR #49 (improvise-9mo9). PRE-EXISTING, but\nthat PR is the first thing in the repo to produce evidence for it.\n\n`AddItem`, `AddItemInGroup` and `RemoveItem` declare\n`EffectKind::Model(ModelChange::CellData)`. `App::apply_effects` bumps\n`layout_version` only for `ModelChange::LayoutGeometry`, so these three do not\nmove it. Since improvise-9mo9 they DO call `app.reset_cursor()`, which bumps\n`cursor_epoch`.\n\nSo one event moves one counter and not the other, and the two counters are\nanswering the same question: \"has the grid's shape changed under you\". A client\nis told its cursor is invalid (`Command::ResetCursor`) while its `layout_version`\nbasis tier still reads current, so a batch composed against the old layout is\naccepted.\n\nTHE EVIDENCE IS THIS PR'S OWN TEST. `every_reshaping_model_command_invalidates_\nevery_cursor` now asserts, with a `grid_shape()` guard over `row_items`/\n`col_items`, that `AddItem`/`AddItemInGroup`/`RemoveItem` REALLY DO reshape the\ngrid. That is the assertion `ModelChange::CellData` contradicts.\n\nWHY IT IS NOT OBVIOUSLY \"RECLASSIFY THEM\". `EffectKind` is read by three\nconsumers off one classification (gotcha 40): `Dirty::of`, `layout_version`, and\n`model_state.dirty`. Moving these three to `LayoutGeometry` changes what every\nclient is told is dirty and widens the basis rejection surface. It also interacts\nwith gotcha 3's traps -- `AddCategory`/`RemoveCategory` are already the\n\"looks like data, is geometry\" case -- and with the `ModelCommand` derivation.\n\nRELATED, NOT DUPLICATE: improvise-75ld is the same family in the other direction\n(a refused or no-op AddCategory bumps layout_version and raises dirty when\nnothing happened). Decide them together; a single question -- \"what does an\nEffectKind entitle a counter to assume\" -- is under both.\n\n## Steps to Reproduce\n\n1. On a view with a category on Row, apply `ModelCommand::AddItem` for a new item\n in that category.\n2. `grid_shape()` (or `layout.row_count()`) changes: a row was inserted.\n3. `app.cursor_epoch()` has moved and clients receive `Command::ResetCursor`.\n4. `app.layout_version()` has NOT moved, because the effect declares CellData.\n5. A client may now compose a `Command::Model` whose `Basis.layout_version` is\n the pre-change value and have it accepted.","acceptance_criteria":"One event moves both counters or neither. Either AddItem/AddItemInGroup/RemoveItem are reclassified as LayoutGeometry -- in which case the blast radius through Dirty::of, the basis rejection surface and gotcha 3's traps is argued, not assumed -- or layout_version stops being read off EffectKind and is observed the way cursor_epoch and record_versions already are. Decide alongside improvise-75ld, which is the same question in the other direction, and alongside improvise-ni6k, whose grid-frame fingerprint would answer all three at once. The regression guard must assert the two counters agree over the same table every_reshaping_model_command_invalidates_every_cursor already drives, so it cannot pass by naming nothing.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:57:17Z","created_by":"wave2-n-cleanups","updated_at":"2026-09-20T18:57:17Z","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_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":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:22:32Z","created_by":"wave2-p-milestones","updated_at":"2026-09-20T18:22:32Z","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":"open","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-20T18:18:24Z","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-lltt","title":"[bug] A view-scoped ModelCommand names no view, so it lands on the host's","description":"Opened by improvise-jytm, which made `ViewState::active_view` authoritative per\nclient. The view-configuration half of `ModelCommand` — SetAxis, CycleAxis,\nTransposeAxes, SetPageSelection, HideItem, ShowItem, ToggleGroup, TogglePruneEmpty,\nSetNumberFormat — acts on \"the active view\", which is now an ambiguous phrase.\n\n`Sessions::apply_model` routes with no SessionId (gotcha 38, correctly: the change\nis addressed to no one session, because a `View` is document state shared by every\nclient sitting on it). But it must still name WHICH view, and today it names it\nimplicitly: `apply_command` runs the effect against the `App`, and the effect reads\n`App::view()`, which is the HOST's slice. So a connected client on another view\nmoves the host's view rather than its own.\n\nBefore jytm every client was on the same view, so the implicit answer was right.\nIt is now wrong exactly when two clients are on different views, which is the\ncapability jytm exists to add.\n\nThe honest fix is for those variants to name their view — a protocol change — so\nthe command carries the operand rather than borrowing an ambient one. Rejected\nduring jytm: retargeting `app.view_state.active_view` around `apply_command` at the\ndoor. It makes the host's own UI slice a parameter channel, and `reset_cursor`/\n`cursor_epoch` would then write the asker's key into the host's cursor map.\n\nNote the `_Drill`/`_Records` virtual views make this sharper than it looks: a host\nin records mode is on a view no client can name.\n\n## Steps to Reproduce\n\n1. Two clients, A and B, on one document with views \"Default\" and \"Second\".\n2. B sends `Command::SwitchView(\"Second\")`. Its mirror moves; the host's App stays\n on \"Default\" (correct, per improvise-jytm).\n3. B sends `Command::Model { cmd: ModelCommand::SetAxis { category: \"Region\",\n axis: Row } }`.\n4. The axis moves on \"Default\" — the view B is not on — and B's next projection\n shows nothing changed while A's shows a change A did not ask for.\n\nNot reachable from the TUI, which is the only client that drives the App's own\nslice, and not reachable from `web/app.js` today because it sends no SwitchView.\n","acceptance_criteria":"The view-configuration ModelCommand variants name the view they act on, so a connected client's SetAxis moves the view that client is on and not the host's. A test drives two sessions onto different views and asserts an axis change by one is invisible to the other. Deciding NOT to fix is also an acceptable close, but then gotcha 38 must say so.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:05:49Z","created_by":"wave2-e-active-view","updated_at":"2026-09-20T18:05:49Z","labels":["area:active-view"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-lltt","title":"[bug] A view-scoped ModelCommand names no view, so it lands on the host's","description":"Opened by improvise-jytm, which made `ViewState::active_view` authoritative per\nclient. The view-configuration half of `ModelCommand` — SetAxis, CycleAxis,\nTransposeAxes, SetPageSelection, HideItem, ShowItem, ToggleGroup, TogglePruneEmpty,\nSetNumberFormat — acts on \"the active view\", which is now an ambiguous phrase.\n\n`Sessions::apply_model` routes with no SessionId (gotcha 38, correctly: the change\nis addressed to no one session, because a `View` is document state shared by every\nclient sitting on it). But it must still name WHICH view, and today it names it\nimplicitly: `apply_command` runs the effect against the `App`, and the effect reads\n`App::view()`, which is the HOST's slice. So a connected client on another view\nmoves the host's view rather than its own.\n\nBefore jytm every client was on the same view, so the implicit answer was right.\nIt is now wrong exactly when two clients are on different views, which is the\ncapability jytm exists to add.\n\nThe honest fix is for those variants to name their view — a protocol change — so\nthe command carries the operand rather than borrowing an ambient one. Rejected\nduring jytm: retargeting `app.view_state.active_view` around `apply_command` at the\ndoor. It makes the host's own UI slice a parameter channel, and `reset_cursor`/\n`cursor_epoch` would then write the asker's key into the host's cursor map.\n\nNote the `_Drill`/`_Records` virtual views make this sharper than it looks: a host\nin records mode is on a view no client can name.\n\n## Steps to Reproduce\n\n1. Two clients, A and B, on one document with views \"Default\" and \"Second\".\n2. B sends `Command::SwitchView(\"Second\")`. Its mirror moves; the host's App stays\n on \"Default\" (correct, per improvise-jytm).\n3. B sends `Command::Model { cmd: ModelCommand::SetAxis { category: \"Region\",\n axis: Row } }`.\n4. The axis moves on \"Default\" — the view B is not on — and B's next projection\n shows nothing changed while A's shows a change A did not ask for.\n\nNot reachable from the TUI, which is the only client that drives the App's own\nslice, and not reachable from `web/app.js` today because it sends no SwitchView.\n","acceptance_criteria":"The view-configuration ModelCommand variants name the view they act on, so a connected client's SetAxis moves the view that client is on and not the host's. A test drives two sessions onto different views and asserts an axis change by one is invisible to the other. Deciding NOT to fix is also an acceptable close, but then gotcha 38 must say so.","notes":"THE TRIGGER, added after the adversarial review of PR #48 (2026-09-20). This is deferrable, but it is deferred on a CONDITION rather than on a judgement, and the condition is one line of somebody else's future work away. It is unreachable today ONLY because no shipped client emits Command::SwitchView. That is the whole of the argument. The browser already emits view-configuration model commands -- keys.rs resolves a keypress to ModelCommand::TransposeAxes locally and sends it -- so the machinery on the other side of the ambiguity is live. THE DAY ANY WAVE WIRES A VIEW SWITCH INTO THE BROWSER, THIS GOES LIVE ON THE NEXT TRANSPOSE KEYSTROKE, AND SILENTLY: a client on Second presses transpose, the axes of Default move instead, that client's grid does not change and every other client's does. Two consequences. (1) Do not close this by arguing it is unreachable -- reachability is the thing that is about to change, and gotcha 47 already records that 'unreachable today' was the argument under which the lost update CheckedBatch later fixed had shipped. (2) There is a cheap interim guard worth landing before the real fix: assert that no shipped browser client emits Command::SwitchView, a test over improvise-wasm-client's key resolver. It turns the silent failure into a loud one at exactly the moment the premise stops holding, which is the only moment anybody would otherwise not be looking.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:05:49Z","created_by":"wave2-e-active-view","updated_at":"2026-09-20T18:34:24Z","labels":["area:active-view"],"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":"CORRECTION, same session: this bead as first written read as though nobody had\ndecided the Chromium-only question. Somebody had.\n`invariant(opfs_is_run_but_bindings_is_not)` in prolog-sessions/kb.pl records\nit, and gives TWO reasons, not one:\n\n \"harness/tests/shell.spec.js runs web/app.js under CHROMIUM ONLY --\n engine-independent logic, and routeWebSocket is slower across three.\"\n\nThe description above only answers the second. The first — the logic is\nengine-independent — is a general claim covering all six specs, and against\nthe specs as a test of web/app.js it is correct: app.js is plain DOM and\nplain event wiring, and running the same assertions on a second engine buys\nvery little.\n\nWHAT IT DOES NOT COVER is the claim improvise-bck criterion (1) actually\nmakes. That criterion is not \"app.js's logic works in Firefox\", it is \"the\nstandalone deployment works in Firefox\" — and the standalone deployment is\nmodule workers, `wasm-pack` output, `createSyncAccessHandle` through\nbindings.rs and the seed fetch, none of which is app.js's logic and all of\nwhich is engine-specific. opfs.spec.js covers Firefox's OPFS through\n`OpfsFs` directly and, per the same invariant, NEVER INSTANTIATES\nbindings.rs. So the two standalone specs on Firefox would be the first\nexecution of that shim on a second engine, and near enough the first on any.\n\nSo the disagreement with the invariant is narrow and should be argued on\nthose terms: the engine-independence reason is a statement about app.js, and\nthe two standalone specs are not only a test of app.js. If that argument is\nrejected, the right outcome is to REWRITE improvise-bck criterion (1) to say\nChromium, not to leave a criterion standing that names an engine nothing\nruns on — a criterion nobody checks is the failure mode this whole pass\nexists to catch.\n\nEither way the invariant's wording is owed an update, since it currently\nreads as a blanket justification for a split that has two different reasons\nbehind its two halves.\nCORRECTION — MY OWN PREMISE WAS WRONG, found in adversarial review of PR #47.\n\nThe note above says the two standalone specs on Firefox \"would be the first\nexecution of that shim on a second engine, and near enough the first on any\".\nThe second half is false. `bindings.rs` HAS been executed, on Chromium, since\nimprovise-vvl3 landed the harness. The chain is unbroken and each link is\ncheckable:\n\n /app/index.html with no ?ws= -\u003e app.js `new Worker(\"./worker.js\")`\n worker.js `import { start } from \"./worker-pkg/improvise_worker_server.js\"`\n bindings.rs `pub async fn start()` -\u003e `boot()` -\u003e `OpfsFs::open().await`\n\nand `harness/tests/shell.spec.js`'s own comment on the standalone test has said\nso all along: \"If this draws a grid, worker.js, bindings.rs, OpfsFs and the seed\nfetch all ran.\"\n\nI RAN THAT SPEC GREEN AT 2ccd21f while verifying the /app/ move, and watched the\nbrowser fetch /app/worker.js and /app/worker-pkg/improvise_worker_server_bg.wasm\nand draw the grid with #status reading \"in-page worker\". The evidence was in my\nhand and I still repeated the claim, because I took it from kb.pl and the repo\nmap instead of from the run.\n\nWHAT SURVIVES: the argument for running the two standalone specs on Firefox is\nUNAFFECTED and is still the point of this ticket. Firefox would be a genuine\nsecond engine for the shim, and improvise-bck's criterion (1) still names an\nengine nothing runs. Only the \"first on any engine\" rhetoric goes.\n\nFIVE PLACES STATE THE FALSE VERSION and want one grep, not five edits:\nkb.pl:11, kb.pl:105 (invariant opfs_is_run_but_bindings_is_not), repo-map.md:241,\nrepo-map.md:467, and improvise-bck's own NOT-DEMONSTRATED list. Those three files\nare orchestrator-owned; the bck half is corrected in bck's notes.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:02:30Z","created_by":"wave2-p-milestones","updated_at":"2026-09-20T18:19:44Z","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":"Three PRs merged within 20 seconds on 2026-09-20 (#37, #41, #42). Each CI run built the site and uploaded a github-pages artifact, and each queued a deploy behind 'concurrency: group: pages, cancel-in-progress: false'.\n\nOnly the newest run kept its artifact:\n\n run 35526758561 (04e4e3d, #37) conclusion=failure artifacts=0\n run 35526767814 (5f871f7, #41) conclusion=success artifacts=1\n run 35526774007 (52507a6, #42) conclusion=failure artifacts=0\n\nThe two superseded deploys failed with:\n\n Fetching artifact metadata for \"github-pages\" in this workflow run\n Found 0 artifact(s)\n Error: No artifacts named \"github-pages\" were found for this workflow run.\n\nThe upload step was GREEN in all three runs, so this is not a build failure —\nthe artifact is gone by the time the losing deploy runs. The successful deploy\n(17:47:28Z) preceded the failing one (17:47:44Z) by 16 seconds.\n\nDeploying a superseded commit is pointless anyway: the tip of main is what\nshould be published. Cancelling is deliberately NOT the answer — the existing\ncomment on the concurrency block records that a deploy-pages cancelled midway\ncan leave the Pages deployment half-applied.\n\n## Steps to Reproduce\n\n1. Merge three PRs to main within about a minute.\n2. Watch the CI runs: the newest deploy succeeds, the older ones fail on an\n empty artifact fetch and mark otherwise-green runs as failed.\n\nReported by the user from the Actions log.","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-20T18:03:04Z","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":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T07:55:22Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:57:55Z","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} @@ -56,12 +60,12 @@ {"_type":"issue","id":"improvise-o8fa","title":"improvise-ws-server speaks ws:// only, so the deployed page cannot reach it","description":"Found by opening the deployed page (2026-09-20). The guidance the page and the README carried was WRONG in a way that sends someone down a dead end.\n\nFROM AN https ORIGIN A ws:// ADDRESS IS BLOCKED AS MIXED CONTENT, so the deployed GitHub Pages site needs wss://. improvise-ws-server has NO TLS AT ALL -- no rustls, no native-tls, nothing in the crate or its dependencies -- so wss:// is not obtainable from it. The text said \"note that from an https origin a ws:// address is blocked\", which correctly named the symptom and then offered an address that can never work.\n\nTEXT CORRECTED ALREADY: both web/index.html and README.md now say the address must be wss:// from the deployed page, that the server speaks plain ws:// only, and that the ws:// form works when the page is served over http://localhost. This ticket is the missing CAPABILITY.\n\nTHREE WAYS TO PROVIDE IT, and they are not equivalent:\n * TERMINATE TLS IN FRONT (nginx, caddy, cloudflared, ngrok). Zero code, works today, and is what the corrected text points at. But it means the documented path to sharing a TUI-hosted document involves a second piece of software, which is a poor answer for a tool whose selling point is that it is one binary.\n * NATIVE wss:// IN improvise-ws-server -- rustls plus a cert. Real work, and it raises the certificate question: a self-signed cert makes the browser refuse the socket with no usable error, so this is only pleasant with a real cert or an explicitly trusted local CA (mkcert).\n * DECIDE THE DEPLOYED PAGE DOES NOT PAIR WITH A LOCAL SERVER at all, and say so. Defensible: the standalone worker deployment is the point of the Pages site, and ws:// pairing is a LOCAL development convenience that works fine from the local page. Cheapest, and it makes the docs shorter rather than longer.\n\nThe third is probably right for now and the first is the escape hatch; the second only earns its place if sharing a TUI-hosted document to a remote browser becomes a real use case. Do not implement the second by default.","status":"closed","priority":2,"issue_type":"feature","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T03:53:54Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:22:10Z","started_at":"2026-09-20T05:32:35Z","closed_at":"2026-09-20T06:22:10Z","close_reason":"Closed by PR #21 (WU-I). DOCUMENTATION ONLY, per the user's ruling; native wss:// DECLINED.\n\nTHE DECLINE'S SUBSTANCE IS THE CERTIFICATE, NOT rustls. A self-signed cert makes the browser refuse the socket with no usable error, because a WebSocket handshake has no interstitial -- there is no place to click through. A real cert or mkcert is a second piece of software too, so the choice is two-either-way, with one option additionally shipping a TLS stack and a cert-loading surface in a pivot-table binary.\n\nWHAT LANDED: a worked Caddy example, the nginx difference that bites (proxy_http_version 1.1 plus the Upgrade/Connection headers), the honest cost (a second binary, a poor answer for a one-binary tool), and the framing that the standalone worker deployment is the point of the Pages site while ws:// pairing works fine from a local http://localhost page.\n\nTHIS BEAD COST SEVEN COPILOT ROUNDS AND NINE FINDINGS, EVERY ONE IN PROSE. The sequence is the value and is recorded in kb.pl:\n * the first recipe exposed an unauthenticated world-writable server -- documenting a proxy documents an exposure, because TLS termination REMOVES the loopback binding that was the only control;\n * the \"fix\" put Caddy basic_auth in front of a socket the page opens as new WebSocket(url), an API that can set no headers over a handshake that shows no prompt -- BROKEN reads as protection, which is worse than absent;\n * an ngrok --cidr-allow recipe was written without being run (deprecated-but-accepted, so the reviewer's own \"fails at startup\" was also wrong);\n * \"the browser cannot send a credential on a WebSocket handshake\" over-generalised the governing fact and contradicted the Cloudflare Access bullet three paragraphs up, which works BECAUSE the handshake carries a cookie;\n * an IP allow-list cannot see a cross-site socket at all, which is improvise-kc7n;\n * an absolute loopback claim denied a browser mitigation that exists but is mid-rollout;\n * and \"the auth above\" called an allow-list authentication in a section whose opening sentence is that the server has none.\n\nThe governing fact was ALREADY IN THE SAME FILE at every step, as the basis of this very decline. The lesson the repo did not have: a reviewed paragraph is not evidence.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-955n","title":"[bug] Host::commands applies a batch partially before rejecting","description":"Found by Copilot on PR #13 (the retro review of the unreviewed batch), DECLINED there with reasons on the thread, and filed rather than patched.\n\nADR 001 is emphatic that a stale batch is never applied, NOT EVEN PARTIALLY -- and improvise-ondj went to the trouble of a TYPE (Checked) to make the unchecked path unconstructible. But Checked is PER-COMMAND: Checked::against answers for one Command against the App AS IT STANDS. A ClientFrame::Commands is a SEQUENCE of them, and Host::commands applies in order and abandons at the first rejection. So a frame carrying SwitchView followed by a stale Command::Model has already applied the SwitchView.\n\nSO ADR 001 GUARANTEE IS CURRENTLY TRUE OF A SINGLE COMMAND AND FALSE OF A BATCH.\n\nA SECOND-ORDER EFFECT NOBODY HAD WRITTEN DOWN, found while declining it: Worker::on_frame skips persist() when ANY frame in the answer is Rejected. So a batch that DID apply a SwitchView before being refused leaves Workbook::active_view moved in memory and NOT WRITTEN BACK -- the document on disk and the document in the worker disagree, silently.\n\nWHY IT WAS DECLINED RATHER THAN PATCHED: closing it means the basis check moving from the COMMAND to the BATCH -- every command answered before any is applied. That is an ADR 001 design change with a real question inside it: WHAT DOES A BATCH BASIS MEAN WHEN ITS LATER COMMANDS WERE COMPOSED AGAINST ITS EARLIER ONES EFFECTS? A client resolving one keypress produces SetSelected plus offsets computed from the selection it just set. Answer that before writing code.\n\nREACHABILITY IS BOUNDED TODAY: no client in this workspace composes a batch containing more than one basis-carrying Command::Model -- the batches improvise-bpq2 produces are view commands, which carry no basis. Do this before the second tranche of client-side resolution creates a reason to batch two model commands.\n\nSupersedes the earlier ticket filed from bpq2 if there is one -- dedupe.","status":"closed","priority":2,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T03:35:27Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:29:48Z","started_at":"2026-09-20T05:32:29Z","closed_at":"2026-09-20T07:29:48Z","close_reason":"Closed by PR #26 (WU-C). improvise-08wb is closed as a duplicate of this one.\n\nADR 001 said a stale batch is never applied, NOT EVEN PARTIALLY, and improvise-ondj went to the trouble of a type (Checked) to make the unchecked path unconstructible -- but Checked is PER-COMMAND and a ClientFrame::Commands is a sequence of them, so Host::commands applied in order and abandoned at the first rejection. The guarantee was true of a single command and false of a batch.\n\nCheckedBatch::against now answers for EVERY command before any is applied.\n\nTHE DESIGN QUESTION THE TICKET DEMANDED WAS ANSWERED BY THE USER, not by the code, and it is larger than the ticket imagined. The ticket asked \"what does a batch's basis mean when its later commands were composed against its earlier ones' effects\". The user's ruling: \"a sequence of commands sent to the server should assume that it's played in order from start to end and that each command sees the state after the previous command runs\", as \"a single transaction from a given base\". Four clauses -- one basis per frame; checked once before anything; applied in order with data dependence EXPECTED; all or nothing. Clauses 1-3 landed here; clause 4 (rollback) is improvise-yqpd.\n\nAN EARLIER \"at most one document mutation per frame\" RULE WAS SHIPPED AND THEN REJECTED BY THE USER as forbidding the capability. It was wrong twice over: it forbids data-dependent chains, and arity is not a property of a variant anyway -- a future Command::RunScript is one variant performing unboundedly many mutations.\n\nTHE FINDING THAT MATTERED MOST came from the independent adversarial review that substituted for Copilot (quota exhausted), and it was a REGRESSION THIS WORK INTRODUCED: hoisting the basis check to the frame hoisted ALL THREE TIERS. The document and layout tiers are legitimately frame-level facts; THE RECORD TIER IS NOT AND CANNOT BE, because which records are at stake is a property of the command. So every mutation after the first had its records checked against nothing -- a silent lost update, confirmed by executing a real Host: client A's stale [SetCell(North), SetCell(South)] clobbered client B's write to South with no rejection, where the base commit refused it. Fixed: every mutation answers for its own records against the FRAME'S basis, with an exemption stated over a property -- a record that neither resolves nor is claimed is one the frame is creating. The reviewer attacked that exemption at all four corners and could not break it: the predicate is a conjunction and the client controls only one conjunct.\n\nTHE SECOND-ORDER WORKER EFFECT the ticket named is closed twice over; its test fails only when BOTH old behaviours are restored.\n\nA CAPABILITY NOBODY HAD NAMED, now guarded: a frame may write THE SAME RECORD TWICE -- two measures on one record IS one record -- and that works only because every check happens before any apply. Moving the checks into the loop breaks it, which is what stops someone closing clause 4 by taking that trade unnoticed.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-08wb","title":"[bug] A refused ClientFrame::Commands keeps the commands before the refusal","description":"Found by improvise-bpq2 (2026-09-20), stated on the variant, and currently UNREACHABLE from any client in this workspace -- filed so it is a decision rather than a discovery.\n\nClientFrame::Commands(Vec\u003cCommand\u003e) batches, because one cursor key resolved client-side is SetSelected plus the offsets viewport_effects returns, and a host that folds the selection but not the offset projects the wrong window. Host::commands applies IN ORDER and ABANDONS AT THE FIRST REJECTION.\n\nTHAT LEAVES A HOLE ADR 001 DOES NOT HAVE. ADR 001 is emphatic that a stale batch is never applied, NOT EVEN PARTIALLY -- and improvise-ondj went to the trouble of a type (Checked) to make the unchecked path unconstructible. But Checked is per-COMMAND, and a ClientFrame::Commands is a sequence of them, so a frame whose third command is refused has already applied the first two.\n\nWHY IT IS NOT LIVE: no client in this workspace composes a Commands frame containing more than one basis-carrying Command::Model -- the batches bpq2 produces are view commands (SetSelected + offsets), which carry no basis.\n\nTHE FIX IS TO MOVE THE BASIS CHECK TO THE BATCH: check every Command::Model in the frame before applying any of them. That is the shape ADR 001 already describes for an effect batch, one level up, and it would make Host::commands match the guarantee the ADR states.\n\nDo this before a client has reason to batch two model commands -- which the second tranche of client-side resolution (the ~15 bindings bpq2 left) may well create.","status":"closed","priority":2,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T02:36:42Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:29:28Z","started_at":"2026-09-20T05:32:29Z","closed_at":"2026-09-20T07:29:28Z","close_reason":"Closed as a DUPLICATE of improvise-955n, which PR #26 fixed. Both beads describe the same defect -- Host::commands applied a ClientFrame::Commands in order and abandoned at the first rejection, so a frame whose later command was refused had already applied the earlier ones, leaving ADR 001's 'never applied, not even partially' true of a single command and false of a batch. 08wb was filed from improvise-bpq2's walk and 955n from Copilot's retro review of PR #13; 955n carries the fuller reasoning and the second-order worker effect, so it is the survivor.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-ir7l","title":"[bug] A formula cell is offered as editable and silently reverts","description":"Pre-existing and consistent with the TUI, but improvise-bck just put it on the DEMO FRONT PAGE, which changes who meets it: the first thing a stranger sees is Profit beside Revenue and Cost.\n\nA formula target cell projects CellId::Record, so render.rs::editable says true, so the browser offers an editor. Type into Profit and the write lands -- then recompute_formulas overwrites it on the next pass and the value REVERTS with no explanation.\n\nWHY IT IS NOT SIMPLY A BUG IN editable: a formula target IS a stored coordinate; the cell is a record. What makes it unwritable is not its identity but that a FORMULA OWNS IT. So the fix is a fact the projection does not currently carry -- something like \"this cell is derived\" -- and it belongs beside numeric/error on CellDisplay, which already exists precisely because the SERVER is the side that knows.\n\nCHECK THE TUI BEFORE CHANGING ANYTHING: it has the same behaviour, so whatever is decided should land in both, or the divergence becomes the new bug (gotcha 44 is what that looks like). And note ADR 002 deliberately made settability a fact about the CellId VARIANT -- adding a second, independent \"but not really\" flag is exactly the two-answers-to-one-question shape this codebase keeps curing. Consider instead whether a formula-owned cell should project a different CellId at all.\n\n## Steps to Reproduce\n\n1. Open `web/demo.improv` in the browser — it ships `Profit = Revenue - Cost` beside Revenue and Cost on the front page.\n2. Click the Profit cell. It is offered as editable, because a formula target projects `CellId::Record` and `render.rs::editable` reads the variant.\n3. Type a number and commit. The write lands.\n4. `recompute_formulas` overwrites it on the next pass and the value reverts, with no explanation.\n\nThe TUI behaves the same way, so whatever is decided must land in both or the divergence becomes the new bug.\n","acceptance_criteria":"A formula-owned cell projects a non-Record CellId, so the write is unconstructible rather than flagged — the discipline ADR 002 used for aggregates and ADR 003 reaches for again for read-only sources. It lands in the TUI in the same change, or the two front ends diverge (gotcha 44 is what that looks like). Do NOT add a second 'but not really' flag beside CellId: two answers to 'can I write here' is the disease. Demonstrated on web/demo.improv, where Profit sits beside Revenue and Cost on the front page.","notes":"VERIFIED CONCRETELY during the PR #13 review (2026-09-20), and the diagnosis is sharper than when this was filed.\n\nTHE ROOT CAUSE: project::cell_id decides Record vs Aggregate purely on whether a regular category sits on Axis::None. IT NEVER ASKS WHETHER THE _Measure COORDINATE NAMES A FORMULA TARGET. So a formula cell is a Record, render.rs::editable says true, and the browser offers an editor.\n\nMEASURED AGAINST THE SHIPPED DEMO:\n col 2 label=\"Profit\" is_record=true text=\"12,200\"\n after applying SetCell(key, Number(999.0)), Profit reads \"12,200\"\n\nThe write is ACCEPTED AND STORED. Model::evaluate_aggregated checks formula_cache BEFORE raw data (model/types.rs:409), so the typed value is shadowed forever with nothing telling the user. Not a revert -- a permanent silent shadow.\n\nAND THE TEST THAT COVERS THIS AREA IS LYING BY ITS NAME. every_visible_cell_in_the_web_demo_is_settable PASSES while its name is FALSE for the Profit column -- a third of the landing-page grid. It was proved discriminating against the one thing it CAN see (the aggregate at (0,0) in examples/demo.improv) and is structurally blind to this. That is exactly the shape AGENTS.md calls this repo's characteristic find, arriving in the test written to prevent it.\n\nTHE DECISION IS STILL THE DECISION, and ADR 002 is where it goes:\n * TEACH cell_id ABOUT FORMULA TARGETS -- changes what EVERY client calls a record, and record identity is also ADR 001's unit of atomicity, so this reaches the basis check too.\n * OR GIVE editable A SECOND INPUT -- but that is the conflation gotcha 42 exists to forbid, and ADR 002 deliberately made settability a fact about the CellId VARIANT precisely so there could not be a second answer.\n\nThe first is probably right and is not small. Until it lands, the demo's front page teaches a visitor that typing into Profit does nothing.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-20T02:29:51Z","created_by":"Edward Langley","updated_at":"2026-09-20T17:53:19Z","started_at":"2026-09-20T17:53:19Z","lease_expires_at":"2026-09-20T17:58:19Z","heartbeat_at":"2026-09-20T17:53:19Z","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-ir7l","title":"[bug] A formula cell is offered as editable and silently reverts","description":"Pre-existing and consistent with the TUI, but improvise-bck just put it on the DEMO FRONT PAGE, which changes who meets it: the first thing a stranger sees is Profit beside Revenue and Cost.\n\nA formula target cell projects CellId::Record, so render.rs::editable says true, so the browser offers an editor. Type into Profit and the write lands -- then recompute_formulas overwrites it on the next pass and the value REVERTS with no explanation.\n\nWHY IT IS NOT SIMPLY A BUG IN editable: a formula target IS a stored coordinate; the cell is a record. What makes it unwritable is not its identity but that a FORMULA OWNS IT. So the fix is a fact the projection does not currently carry -- something like \"this cell is derived\" -- and it belongs beside numeric/error on CellDisplay, which already exists precisely because the SERVER is the side that knows.\n\nCHECK THE TUI BEFORE CHANGING ANYTHING: it has the same behaviour, so whatever is decided should land in both, or the divergence becomes the new bug (gotcha 44 is what that looks like). And note ADR 002 deliberately made settability a fact about the CellId VARIANT -- adding a second, independent \"but not really\" flag is exactly the two-answers-to-one-question shape this codebase keeps curing. Consider instead whether a formula-owned cell should project a different CellId at all.\n\n## Steps to Reproduce\n\n1. Open `web/demo.improv` in the browser — it ships `Profit = Revenue - Cost` beside Revenue and Cost on the front page.\n2. Click the Profit cell. It is offered as editable, because a formula target projects `CellId::Record` and `render.rs::editable` reads the variant.\n3. Type a number and commit. The write lands.\n4. `recompute_formulas` overwrites it on the next pass and the value reverts, with no explanation.\n\nThe TUI behaves the same way, so whatever is decided must land in both or the divergence becomes the new bug.\n","acceptance_criteria":"A formula-owned cell projects a non-Record CellId, so the write is unconstructible rather than flagged — the discipline ADR 002 used for aggregates and ADR 003 reaches for again for read-only sources. It lands in the TUI in the same change, or the two front ends diverge (gotcha 44 is what that looks like). Do NOT add a second 'but not really' flag beside CellId: two answers to 'can I write here' is the disease. Demonstrated on web/demo.improv, where Profit sits beside Revenue and Cost on the front page.","notes":"VERIFIED CONCRETELY during the PR #13 review (2026-09-20), and the diagnosis is sharper than when this was filed.\n\nTHE ROOT CAUSE: project::cell_id decides Record vs Aggregate purely on whether a regular category sits on Axis::None. IT NEVER ASKS WHETHER THE _Measure COORDINATE NAMES A FORMULA TARGET. So a formula cell is a Record, render.rs::editable says true, and the browser offers an editor.\n\nMEASURED AGAINST THE SHIPPED DEMO:\n col 2 label=\"Profit\" is_record=true text=\"12,200\"\n after applying SetCell(key, Number(999.0)), Profit reads \"12,200\"\n\nThe write is ACCEPTED AND STORED. Model::evaluate_aggregated checks formula_cache BEFORE raw data (model/types.rs:409), so the typed value is shadowed forever with nothing telling the user. Not a revert -- a permanent silent shadow.\n\nAND THE TEST THAT COVERS THIS AREA IS LYING BY ITS NAME. every_visible_cell_in_the_web_demo_is_settable PASSES while its name is FALSE for the Profit column -- a third of the landing-page grid. It was proved discriminating against the one thing it CAN see (the aggregate at (0,0) in examples/demo.improv) and is structurally blind to this. That is exactly the shape AGENTS.md calls this repo's characteristic find, arriving in the test written to prevent it.\n\nTHE DECISION IS STILL THE DECISION, and ADR 002 is where it goes:\n * TEACH cell_id ABOUT FORMULA TARGETS -- changes what EVERY client calls a record, and record identity is also ADR 001's unit of atomicity, so this reaches the basis check too.\n * OR GIVE editable A SECOND INPUT -- but that is the conflation gotcha 42 exists to forbid, and ADR 002 deliberately made settability a fact about the CellId VARIANT precisely so there could not be a second answer.\n\nThe first is probably right and is not small. Until it lands, the demo's front page teaches a visitor that typing into Profit does nothing.","status":"closed","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-20T02:29:51Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:22:48Z","started_at":"2026-09-20T17:53:19Z","closed_at":"2026-09-20T18:22:48Z","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-vhcz","title":"[bug] OpfsFs::list disagrees with the other backends on path shape","description":"Found by improvise-9x6 (2026-09-20) and currently PAPERED OVER by always passing the flat root.\n\nOpfsFs::list returns BARE NAMES; MemoryFs and PhysicalFs return dir-JOINED ones. So Path::new(\"\") is the ONE dir at which all three agree, and the only one at which storage_contract actually describes OpfsFs.\n\nTHE TRAP: a worker that organised documents under docs/ would get an EMPTY PICKER in a browser and a FULL ONE in every test. The harness cannot see the disagreement because it never runs OpfsFs -- improvise-xf1 is deferred -- so this is precisely the class of defect the missing browser harness exists to catch, discovered by reading instead.\n\nTWO FIXES:\n * Make OpfsFs::list join dir, matching the other two. Straightforward, and makes storage_contract describe all three at every dir.\n * Or document the flat root as PART OF THE CONTRACT and assert it in storage_contract, so the constraint is stated rather than discovered.\n\nThe first is better unless there is a reason OPFS cannot -- note gotcha 28 says backends that are not trees treat dir as a NAME PREFIX, which is already the trait doc contemplating this, so check whether the doc or the impl is the thing that is wrong.","status":"closed","priority":2,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T02:10:14Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:44:42Z","started_at":"2026-09-20T05:32:36Z","closed_at":"2026-09-20T06:44:42Z","close_reason":"Closed by PR #24 (WU-J). THE TICKET ASKED WHICH OF THE DOC OR THE IMPL WAS WRONG: IT WAS THE IMPL.\n\nThe Storage trait doc already said that backends which are not trees treat `dir` as a NAME PREFIX. OpfsFs::list ignored `dir` outbound as well as inbound, returning bare names where MemoryFs and PhysicalFs return dir-joined ones -- so Path::new(\"\") was the one dir at which all three agreed, and the only one at which storage_contract actually described OpfsFs.\n\nNow it joins. The three backends agree at EVERY dir, and storage_contract runs against OpfsFs at both \"\" and \"docs\" in a real browser. `dir` stays DECORATION rather than a filter, because entry_name reduces every path to its file name and an OPFS entry name may not contain a separator.\n\nFAULT INJECTED: reverting to bare names fails storage_contract_at_\"docs\" on `lists the one document`.\n\nTHE TRAP THE TICKET NAMED IS CLOSED: a worker that organised documents under docs/ would have got an empty picker in a browser and a full one in every test. That divergence was invisible to the harness because nothing ran OpfsFs -- which is exactly why improvise-vvl3 had to land alongside this, and why this bead could only ever have been found by reading until it did.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-hmws","title":"Storage cannot expose an async seam through Box\u003cdyn Storage\u003e","description":"Found by improvise-9x6 (2026-09-20). Every worker host will hit this, so it is worth solving once rather than in each.\n\nOpfsFs::open / attach / refresh / commit_deletes are on the STRUCT, not on the Storage trait -- deliberately, per gotcha 28: Storage stays synchronous because createSyncAccessHandle yields a blocking handle, and only four operations are genuinely async. But App::storage is a Box\u003cdyn Storage\u003e, so HANDING THE BACKEND TO THE App PUTS ALL FOUR PERMANENTLY OUT OF REACH. The App owns the only copy and the trait object cannot name them.\n\nAND THE OBVIOUS FIX IS REFUSED BY THE COMPILER: impl Storage for Rc\u003cOpfsFs\u003e is two foreign types, so the orphan rule forbids it. improvise-9x6 worked around it with a local newtype (bindings::Shared) holding an Rc\u003cOpfsFs\u003e and forwarding the four sync methods. That works but every worker must write it again.\n\nTWO CANDIDATE FIXES:\n * Ship the shared wrapper FROM improvise-io, so the newtype exists once where OpfsFs does and the orphan rule is satisfied.\n * Or have OpfsFs hand out an attachable handle -- separate the thing the App holds from the thing the message loop awaits, which is arguably the more honest modelling since they have different lifetimes and different call sites.\n\nPrefer whichever keeps Storage synchronous; that part is not negotiable (gotcha 28 is the argument).","status":"closed","priority":2,"issue_type":"task","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T02:10:13Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:44:43Z","started_at":"2026-09-20T05:32:37Z","closed_at":"2026-09-20T06:44:43Z","close_reason":"Closed by PR #24 (WU-J). NEITHER OF THE TICKET'S TWO OPTIONS WAS NEEDED, and the reason is worth more than the fix.\n\nThe ticket said `impl Storage for Rc\u003cOpfsFs\u003e` is refused by the orphan rule. That is true IN improvise-worker-server, where both the trait and Rc are foreign -- which is where improvise-9x6 hit it and wrote bindings::Shared. It is NOT true in improvise-io, where the trait is LOCAL and the impl is simply allowed.\n\nSo: improvise-io ships a blanket `impl\u003cT: Storage + ?Sized\u003e Storage for Rc\u003cT\u003e`. bindings::Shared and its _shim_is_a_storage canary are DELETED, not moved, and no future worker writes one. Storage stays synchronous, which gotcha 28 says is not negotiable.\n\nTHE GUARD IS THE SIGNATURE: a_shared_backend_is_a_backend_and_a_clone_is_the_same_one does not compile without the impl. The body asserts what a hand-written newtype could still get wrong -- that a clone addresses the same backend -- and a fault injected into the forwarded `write` (made a no-op) fails it.\n\nTHE GENERALISABLE PART: an orphan-rule refusal is a fact about WHERE YOU ARE STANDING, not about the pair of types. Before working around one, check whether the impl belongs in the crate that owns the trait.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-9mo9","title":"[bug] Geometry effects that renumber the grid but never reset the cursor","description":"Found by the code review of improvise-gvnb (2026-09-19). A PRE-EXISTING gap that gvnb inherited rather than introduced -- but gvnb is what makes it matter, because the epoch is now what tells every client their cursor is invalid.\n\nApp::reset_cursor bumps cursor_epoch; Sessions::adopt_cursor_epoch watches the COUNTER and broadcasts Command::ResetCursor to all. Nothing declares which effects renumber the grid -- deliberately, per gotcha 31 -- which means a geometry effect that forgets reset_cursor is INVISIBLE IN BOTH DIRECTIONS: the host own cursor stays stale AND no client is told.\n\nToday only TransposeAxes and CycleAxis call it. These also renumber rows or columns and do not:\n SetAxis, HideItem, ShowItem, ToggleGroup, TogglePruneEmpty, AddCategory, RemoveCategory\n (plus AddItem/RemoveItem, which are CellData rather than LayoutGeometry)\n\nSetAxis IS THE SHARPEST CASE: CycleAxis is literally SetAxis to Axis::next() and resets; SetAxis does not.\n\nWHY THE REVIEW DID NOT JUST PATCH IT: SetAxis is used by DrillIntoCell (cmd/grid.rs:365-471) to build the _Drill view, and resetting there could discard the user position in the view they drilled FROM, depending on effect order. That is a decision about effect ordering and intended UX, not a patch.\n\nKEEP THE NEGATIVE ARM. a_model_command_that_does_not_reshape_the_grid_leaves_cursors_alone uses SetNumberFormat -- a LayoutGeometry effect that must NOT reset. Without it, \"reset on every model command\" would pass the positive test. Whatever you do here, do not lose it.\n\n## Steps to Reproduce\n\n1. In a view with a cursor somewhere other than the origin, apply any of `SetAxis`, `HideItem`, `ShowItem`, `ToggleGroup`, `TogglePruneEmpty`, `AddCategory`, `RemoveCategory`, `AddItem` or `RemoveItem`.\n2. The grid renumbers and the cursor is not reset, so it addresses a position that has moved.\n3. Worse for other clients: `reset_cursor` is what bumps `cursor_epoch`, and `Sessions::adopt_cursor_epoch` watches that counter to broadcast `Command::ResetCursor`. No reset means **no client is told either**.\n\n`SetAxis` is the sharpest case: `CycleAxis` *is* `SetAxis` to `Axis::next()` and does reset.\n","acceptance_criteria":"Each of SetAxis, HideItem, ShowItem, ToggleGroup, TogglePruneEmpty, AddCategory, RemoveCategory, AddItem and RemoveItem either calls reset_cursor or carries a one-line reason why it does not. SetAxis is the sharp case — CycleAxis IS SetAxis to Axis::next() and resets, while SetAxis does not — and DrillIntoCell uses SetAxis to build the _Drill view, where resetting could discard the position in the view drilled FROM, so that is a decision about effect ordering and intended UX, not a patch. KEEP THE NEGATIVE ARM: a_model_command_that_does_not_reshape_the_grid_leaves_cursors_alone uses SetNumberFormat, and without it 'reset on every model command' passes the positive test. Note improvise-2zq changed which axis a new category lands on, so re-read on_category_added before assuming AddCategory renumbers the way it used to.","status":"closed","priority":2,"issue_type":"bug","assignee":"wave2-n-cleanups","owner":"el-github@elangley.org","created_at":"2026-09-20T01:05:15Z","created_by":"Edward Langley","updated_at":"2026-09-20T17:57:44Z","started_at":"2026-09-20T17:53:45Z","closed_at":"2026-09-20T17:57:44Z","close_reason":"Closed","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-s1xw","title":"Multi-level grid labels on the wire -- the browser shows Food/Alice where the TUI shows two columns","description":"THE LAST THING KEEPING GridLayout INSIDE GridWidget, and a LIVE DIVERGENCE today, not a hypothetical. Found closing improvise-n10.\n\nGridCache::row_labels / col_labels are Vec\u003cString\u003e produced by GridLayout::row_label, which JOINS A MULTI-LEVEL TUPLE WITH A SLASH. That is exactly the rendering src/ui/grid.rs::two_row_categories_produce_cross_product_labels asserts must NOT appear (\"slash-joined labels should be gone\"). So the protocol carries a form of the labels THE TUI ITSELF CONSIDERS WRONG, and crates/improvise-wasm-client/src/render.rs renders precisely that form.\n\nConsequence: the browser grid has no multi-level headers, no repeat suppression, no groups and no collapse indicators, and shows Food/Alice where the TUI shows two header columns.\n\nSIX FACTS THE CACHE DOES NOT CARRY (exhaustive as of improvise-n10, by grep over the non-test body of src/ui/grid.rs):\n 1. number of header levels -- layout.row_cats / col_cats\n 2. multi-level label TUPLES -- layout.row_items (AxisEntry::DataItem(Vec\u003cString\u003e))\n 3. inline GROUP HEADER rows -- layout.row_items (AxisEntry::GroupHeader)\n 4. column GROUP SPANS -- layout.col_group_for(ci)\n 5. collapse indicator (down/right triangle) -- view.is_group_collapsed(cat, group)\n 6. records-mode column identity -- layout.record_col(ci). THIS ONE IS CORRECT TO KEEP OFF: it keys pending drill edits and is session-local by design.\n\nSo 1-5 are the gap.\n\nTWO CONSTRAINTS ON THE FIX:\n * IT IS A CO-ORDINATED CHANGE, NOT AN ADDITIVE ONE. row_labels/col_labels change TYPE, and improvise-wasm-client/src/render.rs consumes both fields, so the client updates in the same change.\n * DO NOT ADD row_levels BESIDE row_labels. Two answers to \"what are the labels\" is the disease this whole projection architecture exists to cure -- see improvise-edp, where the category panel was walked twice, two different ways.","status":"closed","priority":2,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T00:58:33Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:41:34Z","started_at":"2026-09-20T05:32:27Z","closed_at":"2026-09-20T07:41:34Z","close_reason":"Closed as ABSORBED BY improvise-anr9, verified explicitly rather than assumed. s1xw enumerated six facts the cache did not carry; PR #23 landed 1-5 (header levels, multi-level label tuples, inline group headers, column group spans, the collapse indicator) and 6 is record_col, which s1xw itself says is CORRECT to keep off -- session-local, keys pending drill edits. Both of its stated constraints were honoured: it was a co-ordinated type change (row_labels/col_labels changed TYPE and improvise-wasm-client/src/render.rs updated in the same change) and no row_levels was added beside row_labels. The live divergence it reported -- the browser drawing Food/Alice where the TUI draws two header columns -- is closed, and tests/grid_frame_agreement.rs now drives both front ends over one cache and compares what each draws.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-4zl7","title":"A fitted tree must route per record and aggregate the results, not route on the aggregate","description":"Successor to improvise-8xhy, which shipped remedy 1 (refuse at emit) as an explicit interim. This is the remedy that produces a formula A HUMAN WOULD HAVE WRITTEN, and it is the right end state -- but it was costed during 8xhy and it is much larger than it looks.\n\nANSWER THIS FIRST, BEFORE TOUCHING THE GRAMMAR. A fitted measure cell is evaluated at a CellKey context. In any view that aggregates over the split category -- Region on Page or None -- context.get(\"Region\") is None, and today Formula::filter falls straight through to aggregate_raw. So a categorical fit would SILENTLY DEGRADE to something else in exactly the views a pivot-table user spends most of their time in. That is a product decision about what a fit MEANS for an aggregated cell, not an emitter detail.\n\nSCOPE, once that is answered:\n * Expr::Is(Filter) evaluating to 1.0/0.0. This keeps the f64-only evaluator and avoids introducing string comparison into eval_expr. The primitive already exists in the right shape -- types.rs reads context.get(\u0026filter.category), the current cell own coordinate.\n * A grammar production plus tokenizer handling in the 1109-line parser.rs, with quoting rules for the category and item inside it (gotcha 34 -- one home, improvise_formula::quote).\n * Arms in BOTH evaluators: eval_expr and eval_expr_cached.\n * Coverage in the grammar-walking generator.\n * Thread Dataset::columns (the ColumnSpec/ColumnKind plan) into the emitter, so feature index -\u003e role is read off preprocess own plan. Do NOT reconstruct it: Spec::names is \u0026[\u0026str], and reconstructing index-\u003erole is a second implementation of preprocess expansion order, exactly the drift risk gotcha 41 names in another key.\n * .improv is free -- the writer emits Formula::raw.\n\nIT CLOSES ONLY A THIRD OF 8xhy EITHER WAY. A WHERE predicate covers the one-hots. col.missing means \"this record has no value for measure X\", a different vocabulary. The date sub-features (when.year, when.month) need date arithmetic improvise does not have at all. Those stay refused, or get their own tickets.\n\nPAIRS WITH improvise-vx3 (the fit-measure command), which is where a user would actually learn about the restriction.","acceptance_criteria":"A fitted measure evaluated at an aggregating cell routes PER RECORD and aggregates the results, rather than routing on the aggregate. fit-measure on web/demo.improv succeeds and the resulting formula means the same thing on a record row and one row up. The seed-dependence is gone: whether the command succeeds no longer depends on which features the search happened to pick. Note what this costs — evaluate_aggregated deliberately does not iterate the records behind a formula cell — and state how it interacts with gotcha 9's fixed point and Model::formula_cache. col.missing and the date sub-features remain out of scope.","notes":"USER RULING 2026-09-20: AGGREGATE THE RULE OVER THE MEMBERS. Route-then-aggregate, per record. The other three options -- Error(\"aggregated\"), refuse at fit time, and fall through to aggregate_raw -- are all rejected.\n\nTHE BEAD'S SCOPE HAS CHANGED AND THE TITLE NOW SAYS SO. It asked what a CATEGORICAL split means in a cell that aggregates over the split category. PR #25's review established the question has nothing to do with the split being categorical:\n\nA tree is fitted on individual RECORDS. A formula is evaluated at a CellKey, and in any aggregating cell evaluate_aggregated aggregates each Expr::Ref FIRST and then evaluates the IF. A linear formula survives that -- SUM(Revenue) - SUM(Cost) is SUM(Revenue - Cost) -- and a tree does not, because AGGREGATE-THEN-ROUTE IS NOT ROUTE-THEN-AGGREGATE. So EVERY fitted tree, numeric splits included, means one thing on a record row and something else one row up.\n\nIT IS NOT THEORETICAL: fit-measure Revenue on the repo's own shipped web/demo.improv is refused, naming Product=Espresso/Product=Latte. And because which features a champion picks is a property of the SEARCH, on a document with several categories the same command refuses or succeeds DEPENDING ON THE SEED. That is not a contract.\n\nWHAT THE RULING COSTS, stated plainly because it is the expensive answer and was chosen anyway: evaluate_aggregated deliberately does NOT iterate the underlying records for a formula cell -- it aggregates the refs and evaluates once. Routing per record means a formula cell must be able to ask for the records behind it and fold over them. That touches the evaluator, and it interacts with gotcha 9 (fixed-point recomputation) and with Model::formula_cache, which is where derived values live precisely so raw data and derived data stay separate.\n\nNOTE WHAT IT BUYS BESIDES CORRECTNESS: it is what a user means. A fitted rule applied to a group should say what the rule says about the members, summed -- not what the rule says about a fictional average member. The second is the thing that looks right and is wrong, which is the failure mode vx3's own ticket warned about ('a rule the user will believe').\n\nALSO NOTE: this ruling supersedes improvise-8xhy's remedy 1 (refuse at emit) as the END STATE, but not as the interim -- the refusal stays until this lands, or fit-measure silently produces the wrong number in aggregating views.\n\nTHE TWO THIRDS THIS STILL DOES NOT COVER, unchanged from the original text: col.missing means 'this record has no value for measure X', a different vocabulary; and the date sub-features (when.year, when.month) need date arithmetic improvise does not have at all. Those stay refused or get their own beads.","status":"open","priority":2,"issue_type":"feature","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T00:45:22Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:45:26Z","started_at":"2026-09-20T05:32:41Z","labels":["area:gp"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-4zl7","title":"A fitted tree must route per record and aggregate the results, not route on the aggregate","description":"Successor to improvise-8xhy, which shipped remedy 1 (refuse at emit) as an explicit interim. This is the remedy that produces a formula A HUMAN WOULD HAVE WRITTEN, and it is the right end state -- but it was costed during 8xhy and it is much larger than it looks.\n\nANSWER THIS FIRST, BEFORE TOUCHING THE GRAMMAR. A fitted measure cell is evaluated at a CellKey context. In any view that aggregates over the split category -- Region on Page or None -- context.get(\"Region\") is None, and today Formula::filter falls straight through to aggregate_raw. So a categorical fit would SILENTLY DEGRADE to something else in exactly the views a pivot-table user spends most of their time in. That is a product decision about what a fit MEANS for an aggregated cell, not an emitter detail.\n\nSCOPE, once that is answered:\n * Expr::Is(Filter) evaluating to 1.0/0.0. This keeps the f64-only evaluator and avoids introducing string comparison into eval_expr. The primitive already exists in the right shape -- types.rs reads context.get(\u0026filter.category), the current cell own coordinate.\n * A grammar production plus tokenizer handling in the 1109-line parser.rs, with quoting rules for the category and item inside it (gotcha 34 -- one home, improvise_formula::quote).\n * Arms in BOTH evaluators: eval_expr and eval_expr_cached.\n * Coverage in the grammar-walking generator.\n * Thread Dataset::columns (the ColumnSpec/ColumnKind plan) into the emitter, so feature index -\u003e role is read off preprocess own plan. Do NOT reconstruct it: Spec::names is \u0026[\u0026str], and reconstructing index-\u003erole is a second implementation of preprocess expansion order, exactly the drift risk gotcha 41 names in another key.\n * .improv is free -- the writer emits Formula::raw.\n\nIT CLOSES ONLY A THIRD OF 8xhy EITHER WAY. A WHERE predicate covers the one-hots. col.missing means \"this record has no value for measure X\", a different vocabulary. The date sub-features (when.year, when.month) need date arithmetic improvise does not have at all. Those stay refused, or get their own tickets.\n\nPAIRS WITH improvise-vx3 (the fit-measure command), which is where a user would actually learn about the restriction.","acceptance_criteria":"A fitted measure evaluated at an aggregating cell routes PER RECORD and aggregates the results, rather than routing on the aggregate. fit-measure on web/demo.improv succeeds and the resulting formula means the same thing on a record row and one row up. The seed-dependence is gone: whether the command succeeds no longer depends on which features the search happened to pick. Note what this costs — evaluate_aggregated deliberately does not iterate the records behind a formula cell — and state how it interacts with gotcha 9's fixed point and Model::formula_cache. col.missing and the date sub-features remain out of scope.","notes":"USER RULING 2026-09-20: AGGREGATE THE RULE OVER THE MEMBERS. Route-then-aggregate, per record. The other three options -- Error(\"aggregated\"), refuse at fit time, and fall through to aggregate_raw -- are all rejected.\n\nTHE BEAD'S SCOPE HAS CHANGED AND THE TITLE NOW SAYS SO. It asked what a CATEGORICAL split means in a cell that aggregates over the split category. PR #25's review established the question has nothing to do with the split being categorical:\n\nA tree is fitted on individual RECORDS. A formula is evaluated at a CellKey, and in any aggregating cell evaluate_aggregated aggregates each Expr::Ref FIRST and then evaluates the IF. A linear formula survives that -- SUM(Revenue) - SUM(Cost) is SUM(Revenue - Cost) -- and a tree does not, because AGGREGATE-THEN-ROUTE IS NOT ROUTE-THEN-AGGREGATE. So EVERY fitted tree, numeric splits included, means one thing on a record row and something else one row up.\n\nIT IS NOT THEORETICAL: fit-measure Revenue on the repo's own shipped web/demo.improv is refused, naming Product=Espresso/Product=Latte. And because which features a champion picks is a property of the SEARCH, on a document with several categories the same command refuses or succeeds DEPENDING ON THE SEED. That is not a contract.\n\nWHAT THE RULING COSTS, stated plainly because it is the expensive answer and was chosen anyway: evaluate_aggregated deliberately does NOT iterate the underlying records for a formula cell -- it aggregates the refs and evaluates once. Routing per record means a formula cell must be able to ask for the records behind it and fold over them. That touches the evaluator, and it interacts with gotcha 9 (fixed-point recomputation) and with Model::formula_cache, which is where derived values live precisely so raw data and derived data stay separate.\n\nNOTE WHAT IT BUYS BESIDES CORRECTNESS: it is what a user means. A fitted rule applied to a group should say what the rule says about the members, summed -- not what the rule says about a fictional average member. The second is the thing that looks right and is wrong, which is the failure mode vx3's own ticket warned about ('a rule the user will believe').\n\nALSO NOTE: this ruling supersedes improvise-8xhy's remedy 1 (refuse at emit) as the END STATE, but not as the interim -- the refusal stays until this lands, or fit-measure silently produces the wrong number in aggregating views.\n\nTHE TWO THIRDS THIS STILL DOES NOT COVER, unchanged from the original text: col.missing means 'this record has no value for measure X', a different vocabulary; and the date sub-features (when.year, when.month) need date arithmetic improvise does not have at all. Those stay refused or get their own beads.","status":"closed","priority":2,"issue_type":"feature","assignee":"wave3-4zl7","owner":"el-github@elangley.org","created_at":"2026-09-20T00:45:22Z","created_by":"Edward Langley","updated_at":"2026-09-20T20:03:35Z","started_at":"2026-09-20T05:32:41Z","closed_at":"2026-09-20T20:03:35Z","close_reason":"Implemented and merged in PR #54 (b06d0d0 -\u003e main 8f28477). Route-then-aggregate per the user's ruling: SUM(e) folds e over the member records rather than aggregating the refs and routing once. It needed NO new vocabulary — SUM(e) already meant this and simply did not do it: eval_agg specialised Expr::Ref and, for every other inner expression, summed the raw matching cells and DISCARDED THE EXPRESSION, so SUM(Revenue - Cost) never computed a difference at all. The fold lives in eval_agg, which both evaluators share, so unlike gotcha 3 there is one copy. Expr::Is(Filter) is the scalar coordinate predicate the language lacked. Four review rounds; findings 1 and 3 turned out to be ONE defect — the fold routed through eval_expr_cached, whose Ref could never hit a cache keyed by stems with none_cats stripped, so it fell through to aggregate_raw once per member. Fixing the correctness bug WAS the optimisation: 51ms -\u003e 17.4ms at 18,050 cells, superlinear -\u003e linear, and the 4.8x shape-dependence between two fixtures at equal cell count disappeared, which is what confirms the diagnosis rather than the smaller number. Follow-ups: improvise-wd6n, improvise-9iyv, improvise-r08u.","labels":["area:gp"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-hwpi","title":"The browser shell fails quietly, and never reads a key","description":"Found by the improvise-cr3 code review (2026-09-19) by READING web/app.js. There is no browser and no headless DOM in this environment, so none of this is observed -- it is what the code says will happen, and it is filed rather than fixed because each item is a judgement call about the shell rather than a defect in the Rust.\n\nFOUR THINGS, in descending order of how much they would cost a person at a demo:\n\n1. NO KEYBOARD AT ALL. #grid carries tabindex=\"0\" but the page registers no keydown handler, so Client::on_key_event -- and the whole keys.rs classifier and the lockstep machinery behind it -- is UNREACHED from the browser. Consistent with improvise-bpq2 (ClientMsg has no Key variant server-side) but larger than it: even a LOCAL key, one that needs no round trip, does nothing. improvise-1ey acceptance (3) is \"arrow keys move the cursor locally with no round-trip\", and nothing in the page can do it.\n\n2. A MALFORMED FRAME IS QUIET. client.on_frame(event.data) returns Result\u003c_, JsError\u003e, which wasm-bindgen turns into a THROWN EXCEPTION. It is thrown inside the message listener with no try, so it surfaces as an uncaught error in the devtools console and the following repaint() is skipped. Nothing in the UI says so: the grid simply stops updating. A try/catch writing to el.status would make it visible.\n\n3. NO error LISTENER ON THE SOCKET. A connection that never opens leaves #status blank -- only close writes \"disconnected\". So \"server not running\" and \"still connecting\" look the same.\n\n4. close() RUNS TWICE ON COMMIT, benignly. Enter -\u003e close(true) -\u003e td.replaceChildren(original) removes the focused \u003cinput\u003e -\u003e blur fires -\u003e close(false). editing is already null so it is harmless (one extra repaint), but there is no re-entry guard and only a real browser would show it.\n\nNOTE WHAT MAKES THIS TICKET DIFFERENT: items 2-4 are the kind of thing a test would catch instantly and we have no way to run one. Before fixing them, consider whether the right first move is a headless DOM in CI (jsdom or playwright) -- otherwise the fixes are as unverified as the bugs. improvise-xf1 (verify OpfsFs against a real browser) wants the same harness.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T00:40:36Z","created_by":"Edward Langley","updated_at":"2026-09-20T02:29:50Z","closed_at":"2026-09-20T02:29:50Z","close_reason":"All four items done, plus a fifth the ticket could not have predicted.\n\n1. KEYDOWN WIRED -- a window listener feeding Client::on_key_event. preventDefault fires only when a frame was produced and no Ctrl/Alt is held, so browser shortcuts stay the browser's; metaKey is skipped entirely, matching BrowserKey::to_key_event dropping Meta so Cmd-R cannot arrive as a bare r. The inline editor stopPropagation keeps it out of the way, with an if (editing) return as belt and braces.\n2. MALFORMED FRAME -- try/catch around on_frame writing to #status, plus console.error with the offending frame. render_model() is guarded the same way; it also returns a Result.\n3. SOCKET error LISTENER -- 'cannot reach ... -- is improvise-ws-server running?', so that no longer reads like 'still connecting'.\n4. close() RE-ENTRY GUARD -- a closed flag in edit().\n\nTHE FIFTH, AND IT IS THE INTERESTING ONE: a worker whose start() REJECTS is caught INSIDE worker.js, so it fires NO error event on the main thread. The page would have sat on 'in-page worker - demo.improv' over an empty grid forever -- THIS TICKET'S EXACT DISEASE, REBUILT IN THE NEW DEPLOYMENT ON DAY ONE. The guard is a first-frame watchdog (8s, cleared by the first frame), and it deliberately covers BOTH transports rather than each peer's own failure modes, because 'the peer never answered the handshake' is the one condition that means the same thing over a socket and over a port -- a check aimed per-peer would have missed exactly this one.\n\nKEYS ARE INERT AND THAT IS STATED, NOT HIDDEN. Host::handle answers ClientFrame::Key with an empty vec; improvise-bpq2 is the other half.\n\nSTILL UNRUN -- and this ticket predicted that would be the problem: 'consider whether the right first move is a headless DOM, otherwise the fixes are as unverified as the bugs.' That sentence is now true OF THESE FIXES. Three of the four are one-assertion Playwright tests. See the harness ticket.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-zv4m","title":"improvise-protocol should own the websocket framing, so both ends stop mirroring it","description":"Raised by improvise-cr3 (2026-09-19) as the thing to push back on, and filed rather than fixed there.\n\nTODAY: improvise-wasm-client/src/socket.rs declares ClientFrame/ServerFrame, which MIRROR improvise-ws-server/src/wire.rs ClientMsg/ServerMsg field for field. The client cannot depend on the server crate -- tokio, tungstenite, Box\u003cdyn Storage\u003e, all native -- so the duplication was the only way to speak the protocol from wasm.\n\nWHAT HOLDS THE LINE MEANWHILE: improvise-ws-server is a DEV-dependency of the client (the same argument the crate already makes for improvise-command), and the_socket_framing_matches_the_server round-trips every variant THROUGH THE SERVERS OWN TYPES in both directions; a_frame_named_the_other_envelopes_way_does_not_parse is its check-the-checker. So a drift is caught -- but only by a test whose job is to notice, rather than by a shared definition that cannot drift.\n\nTHE FIX: move the framing into improvise-protocol, which both ends already depend on and which is the crate whose charter is exactly this -- \"the wire vocabulary and the portable session slice\". Then the mirror, the dev-dependency and both compatibility tests all delete.\n\nNOTE THE THIRD ENVELOPE while you are in here: improvise-wasm-client/src/wire.rs has ClientMessage/ServerMessage framing the WORKER deployment (improvise-tm6). Three envelopes for two deployments is worth resolving in one pass -- decide whether the worker and the socket want the same vocabulary before moving either.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T00:37:29Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:37:50Z","closed_at":"2026-09-20T00:37:50Z","close_reason":"Duplicate of improvise-xks6 ('One home for the client/server frame envelopes'), filed minutes apart from the improvise-cr3 hand-back without spotting the existing ticket. Closing this one; xks6 is the survivor and its description should absorb two facts this one carried:\n\n * WHAT HOLDS THE LINE MEANWHILE: improvise-ws-server is a DEV-dependency of improvise-wasm-client, and the_socket_framing_matches_the_server round-trips every variant THROUGH THE SERVERS OWN TYPES in both directions, with a_frame_named_the_other_envelopes_way_does_not_parse as its check-the-checker. The drift is caught by a test whose job is to notice, rather than prevented by a shared definition.\n * THERE ARE THREE ENVELOPES, NOT TWO: improvise-wasm-client/src/wire.rs frames the WORKER deployment (improvise-tm6) separately again. Decide whether the worker and the socket want the same vocabulary before moving either, or the move produces two homes instead of one.\n\n(See also memory agent-issue-drift-pattern -- search before filing.)","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-8xhy","title":"[bug] A fit over a categorical feature emits an unevaluable formula","description":"Found during improvise-7na (2026-09-19), verified by probe, and pinned as a characterization test rather than fixed.\n\ngp_tree::data::preprocess one-hots a categorical column into a feature named Region=North. That is an ENCODING ARTEFACT, not an item of the document, so the emitted formula Expr::Ref fails Model::find_item_category and EVERY CELL of the fitted measure evaluates to Error(\"ref:Region=North\"). col.missing and the col.year / col.month date sub-features are in exactly the same position.\n\nSo \"fit a tree and get a working formula\" holds ONLY for a fit over numeric measures.\n\nTHE TEST THAT SHOULD HAVE CAUGHT IT WAS LYING: improvise_evaluation_matches_gp_tree_eval cannot see this, because its improvise_eval helper FABRICATES a _Measure item for every feature name, including outlook=rainy. It asserts the emitter and gp_tree::eval agree, which they do -- against a model that could not exist. Worth remembering as a shape: a helper that builds the world the code wants is not a test of whether that world exists.\n\nPinned as a_split_on_a_one_hot_feature_does_not_evaluate -- characterization, flip it when fixed.\n\nTHREE REMEDIES, pick deliberately: restrict fits to numeric features (smallest, and honest about what works); materialise one-hot columns as real _Measure items (makes the formula evaluable but pollutes the document with encoding artefacts); or teach the emitter improvise WHERE vocabulary so a categorical split becomes a real predicate over the category (largest, and the only one that produces a formula a human would have written). The third is probably right and should be costed before the first is shipped as permanent.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T00:35:05Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:45:21Z","closed_at":"2026-09-20T00:45:21Z","close_reason":"Fixed as REMEDY 1, explicitly labelled interim. fitted_formula now takes the \u0026Model the formula is to be stored in and refuses a tree whose splits it cannot resolve, listing them. formula_text is untouched -- it is the tree's own text and there is nothing else it could write. Nothing outside the crate called fitted_formula.\n\nWHY THE EMITTER AND NOT THE DATASET LAYER: dropping categorical columns before the fit is smaller, but it throws away real signal (permutation importance, and any future remedy-3 emitter) and makes the crate's halves disagree -- one-hot encoding is gp-tree's whole job. The defect is that the EMITTER spells a formula the document cannot evaluate.\n\nWHY THE CHECK ASKS THE DOCUMENT, NOT THE NAME'S SHAPE: refusing names containing '=' or matching .missing would be a syntactic proxy and a wrong one, because a measure LITERALLY NAMED Region=North is a legal document and must be accepted. The two are indistinguishable as strings; only the document can tell them apart. So it calls Model::find_item_category -- the exact precondition Expr::Ref has -- which covers .missing, the date parts, and any future sub-feature naming scheme with no second copy of preprocess's expansion rules to drift from. Both directions pinned.\n\nTHE LYING TEST IS KEPT, NOT DELETED. Quoting and threshold routing are real things improvise_evaluation_matches_gp_tree_eval checks, and outlook=rainy earns its place there as an awkward NAME TO SPELL. What changed is that it and its helper now state their scope -- a test of arithmetic and quoting against a STIPULATED model, unable to see whether such a model could exist. The property it could not state is asserted separately against a real document: a_formula_the_emitter_accepted_evaluates_everywhere -- if fitted_formula says Ok, no cell of the target is a ref: error. That test and the refusal test are each other's check; without the accepting case the refusal could pass by refusing everything. The characterization is flipped to a_split_on_a_one_hot_feature_is_refused.\n\nREMEDY 3 IS STILL RIGHT AND IS NOT THIS TICKET -- successor filed. It is much larger than it looked: improvise has NO SCALAR COORDINATE PREDICATE (Filter gates a formula or an aggregate, it is not an Expr), it would still leave col.missing and the date parts uncovered, and it must first answer what such a predicate means where context.get(cat) is None.","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -166,13 +170,22 @@ {"_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-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-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":"in_progress","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-20T19:30:59Z","started_at":"2026-09-20T19:30:59Z","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":"open","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-20T18:53:08Z","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-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":"open","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-20T18:50:51Z","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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:45:39Z","created_by":"wave2-h-frontend","updated_at":"2026-09-20T18:58:18Z","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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:33:24Z","created_by":"wave2-e-active-view","updated_at":"2026-09-20T18:33:24Z","labels":["area:active-view"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-w3bf","title":"A session mirror's active_view is written in four places, none through reduce_view","description":"Found by the adversarial review of PR #48 (improvise-jytm). Not a defect today: the\nfour writers agree. It is the SHAPE the divergence bugs in this area have taken every\ntime, filed before it becomes one.\n\n`ClientSession::view.active_view` — the server's mirror of a client's view — is\nwritten in four places, and none of them goes through `reduce_view`:\n\n1. `Sessions::apply_switch` — sets it directly from the accepted name.\n2. `Sessions::adopt_deleted_views` — sets it directly to the document default.\n3. `ClientSession::reseed` — replaces the whole slice via `fresh_view`.\n4. `Sessions::connect` — same, through `reseed`.\n\nEverywhere else in this codebase, the mirror stays in step with the client by folding\nthe SAME command the client folds (`ClientSession::fold` → `reduce_view`, the\n\"same function runs on both ends\" rule of improvise-cqq/m91). These four bypass it.\nThey are correct today only because `reduce_view`'s `SwitchView` arm is one line\n(`view.active_view = name.clone()`) and each of these reproduces it.\n\nThe day that arm grows a second statement — clearing a per-view buffer, resetting a\ncursor, anything — the mirror and the client diverge and nothing fails. That is\nexactly gotcha 36's \"folding SwitchView into the mirror alone was the bug\", arriving\nfrom the other direction.\n\nTwo shapes worth weighing:\n\n- Route all four through `reduce_view(\u0026mut session.view, \u0026Command::SwitchView(name))`.\n Cheap, and makes the bypass unrepresentable for this command.\n- A test that the two agree: fold a `SwitchView` into a bare `ViewState` and into a\n `ClientSession` and compare. Weaker (it is a compatibility test, and\n `doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)` says\n those are a definition waiting to be made) but it costs nothing.\n\n## Steps to Reproduce\n\nNot reproducible today; the four copies agree. To make it reproduce: add any second\nstatement to `reduce_view`'s `Command::SwitchView` arm in\n`crates/improvise-protocol/src/reduce.rs` — for instance clearing `status_msg` — then\nrun the suite. It stays green, and a client's own slice now carries something its\nserver-side mirror does not.\n","acceptance_criteria":"Either the four writers route through reduce_view, or a test pins that a ClientSession's mirror and a bare ViewState fold a SwitchView identically. Closing it as 'the copies agree' is not acceptable — that is the state it is filed in.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:32:43Z","created_by":"wave2-e-active-view","updated_at":"2026-09-20T18:32:43Z","labels":["area:active-view"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_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.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:23:51Z","created_by":"orchestrator","updated_at":"2026-09-20T19:17:13Z","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":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:11:01Z","created_by":"wave2-b-projection","updated_at":"2026-09-20T18:11:01Z","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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:06:03Z","created_by":"orchestrator","updated_at":"2026-09-20T18:06:03Z","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.","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-20T18:06:00Z","labels":["area:active-view"],"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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:05:51Z","created_by":"orchestrator","updated_at":"2026-09-20T18:05:51Z","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":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:04:14Z","created_by":"wave2-h-frontend","updated_at":"2026-09-20T18:04:14Z","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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:01:04Z","created_by":"wave2-p-milestones","updated_at":"2026-09-20T18:01:04Z","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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T17:57:25Z","created_by":"wave2-n-cleanups","updated_at":"2026-09-20T17:57:25Z","labels":["area:command"],"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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T17:57:25Z","created_by":"wave2-n-cleanups","updated_at":"2026-09-20T17:57:25Z","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-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":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T07:49:56Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:57:52Z","labels":["area:protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -202,7 +215,7 @@ {"_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-gd28","title":"Drill over the wire needs the addressed command form","description":"From the ADR 002 spine landing (2026-09-19). The RESOLVER exists and is tested -- App::resolve_aggregate, layout-only by necessity since an aggregate has no existence outside the layout that derives it -- and CellId::Aggregate is emitted. What is missing is the command.\n\nModelCommand::Drill was deliberately NOT added. Drilling creates the _Drill view and writes drill_state, which gotchas 30 and 38 rule session-local and therefore unaddressable by a NULLARY Command -- exactly the reason ApplyAndClearDrill was removed from the wire in improvise-3x3u.\n\nSo a wire Drill needs the ADDRESSED form: the variant carries a SessionId and the staging map moves off the single App onto the session. That is ADR 001 layer 2 (context/adr/001-concurrent-edits.md), so this is blocked on it rather than being independent work.\n\nThe seam is ready: emit, resolve and the type-level guarantee (Drill takes an Aggregate and nothing else) are all in place and tested.","acceptance_criteria":"Drilling works over the wire. It needs the ADDRESSED command form — the variant carries a SessionId and the staging map moves off the single App onto the session — because drilling writes drill_state, which gotchas 30 and 38 rule session-local and therefore unaddressable by a nullary Command. That is ADR 001 layer 2, so this is blocked on improvise-7b8v rather than independent. The seam is ready: App::resolve_aggregate, CellId::Aggregate emission and the type-level guarantee that Drill takes an Aggregate and nothing else all exist and are tested.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T00:18:34Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:56:23Z","labels":["area:protocol"],"dependencies":[{"issue_id":"improvise-gd28","depends_on_id":"improvise-7b8v","type":"blocks","created_at":"2026-09-20T16:20:30Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-gd28","title":"Drill over the wire needs the addressed command form","description":"From the ADR 002 spine landing (2026-09-19). The RESOLVER exists and is tested -- App::resolve_aggregate, layout-only by necessity since an aggregate has no existence outside the layout that derives it -- and CellId::Aggregate is emitted. What is missing is the command.\n\nModelCommand::Drill was deliberately NOT added. Drilling creates the _Drill view and writes drill_state, which gotchas 30 and 38 rule session-local and therefore unaddressable by a NULLARY Command -- exactly the reason ApplyAndClearDrill was removed from the wire in improvise-3x3u.\n\nSo a wire Drill needs the ADDRESSED form: the variant carries a SessionId and the staging map moves off the single App onto the session. That is ADR 001 layer 2 (context/adr/001-concurrent-edits.md), so this is blocked on it rather than being independent work.\n\nThe seam is ready: emit, resolve and the type-level guarantee (Drill takes an Aggregate and nothing else) are all in place and tested.","acceptance_criteria":"Drilling works over the wire. It needs the ADDRESSED command form — the variant carries a SessionId and the staging map moves off the single App onto the session — because drilling writes drill_state, which gotchas 30 and 38 rule session-local and therefore unaddressable by a nullary Command. That is ADR 001 layer 2, so this is blocked on improvise-7b8v rather than independent. The seam is ready: App::resolve_aggregate, CellId::Aggregate emission and the type-level guarantee that Drill takes an Aggregate and nothing else all exist and are tested.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T00:18:34Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:56:23Z","labels":["area:protocol"],"dependencies":[{"issue_id":"improvise-gd28","depends_on_id":"improvise-7b8v","type":"blocks","created_at":"2026-09-20T16:20:30Z","created_by":"Edward Langley","metadata":"{}"}],"comments":[{"id":"7fce3874-00de-5f06-a481-e66fb873f24f","issue_id":"improvise-gd28","author":"wave3-webui","text":"BROWSER EVIDENCE, gathered under Chromium rather than read. The user reported\n\"drill also doesn't work in the webui: it was never confirmed working.\" This\nticket is the answer; nothing here asks to change the design. Confirming the\nticket's own claims empirically, plus one asymmetry it does not record.\n\nWHAT WORKS, verified in the running browser and not inferred:\n - project::grid_of emits CellId::Aggregate and the client RECEIVES it. In\n render_state: {\"Aggregate\":\"750ab1f5ffa96d5e00f605b3c8e36aac\"}.\n - render.rs::editable correctly answers false for it (ADR 002), so the page\n offers no editor on an aggregate.\n - resolve.rs reaches the CellId::Aggregate arm of edit_or_drill.\n - The TUI path is intact: 12 drill tests pass across cmd/grid.rs, cmd/mode.rs,\n ui/effect.rs and ui/app.rs. So this is isolated to the client/wire and is\n NOT a general regression.\n\nTHE ONE MISSING LINK, confirmed: reduce.rs contains no `Drill` variant at all —\nneither Command nor ModelCommand. `grep -n Drill crates/improvise-protocol/src/\nreduce.rs` returns nothing. The ticket's \"what is missing is the command\" is\nexactly right, and the seam really is ready.\n\nTHE ASYMMETRY THIS TICKET DOES NOT RECORD, and the reason it reads to a user as\nbroken rather than as refused — the two entry points fail DIFFERENTLY:\n\n - `i` / `a` on an aggregate declines OUT LOUD. edit_or_drill answers the\n Aggregate arm with Command::SetStatus(\"drilling into an aggregate needs the\n host — not in the browser yet\"). The user sees why.\n\n - `\u003e` (drill-into-cell) is SILENT. It is a command name resolve::step does not\n handle, so it falls through to KeyOutcome::Upstream -\u003e ClientFrame::Key -\u003e\n Host::handle, whose Key arm is an explicit `Vec::new()`. Gotcha 47: an\n upstream key is a black hole, not a slow path. Pressing `\u003e` produces no\n status, no mode change, nothing at all.\n\n`\u003e` is the key the help hint advertises for drill (\"\u003e:drill\" in the Normal-mode\nhint line), so the advertised gesture is the silent one and the honest refusal\nis on a key the user was not told to press. Whoever picks this ticket up may\nwant to route drill-into-cell through the same declining arm in the interim, so\nthe gap SAYS something on the key that names it — that is a one-line change and\nis independent of the addressed-command design work. I have not made it: it is\nthis ticket's call, not mine.\n\nA FIXTURE FACT worth knowing before anyone tries to reproduce: web/demo.improv\nhas NO aggregate cell. Gotcha 46 puts exactly one category on each visible axis\nprecisely so every cell is a Record, and tests/web_demo_model.rs asserts it. So\non the shipped demo you cannot reach the refusal at all without first changing\nan axis — which plausibly explains \"never confirmed working\". To reproduce:\npress T, select the Quarter tile, Enter once (Page -\u003e None); every cell is then\nan aggregate.\n\nREGRESSION GUARD LANDED: harness/tests/drill.spec.js, two tests, documenting the\nrefusal honestly rather than leaving folklore. It makes the aggregate through\nthe tile bar the way a user would and ASSERTS the precondition (td.editable\ncount 0) so it cannot pass against a grid of Records. Both proved by injection:\nsilencing the aggregate arm fails both tests (the second on its\ncheck_the_checker control, which is the point — with nothing answering, the\ncontrol can no longer distinguish \"`\u003e` is silent\" from \"the page is dead\");\nmaking `\u003e` resolve fails only the second. The tests assert what is TRUE today,\nso they will fail the day drill lands over the wire — which is the correct\nsignal to whoever closes this ticket, not an obstacle.\n","created_at":"2026-09-20T20:14:35Z"}],"dependency_count":1,"dependent_count":0,"comment_count":1} {"_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-rucf","title":"Read arbitrary SQLite databases as a data source","description":"SUPERSEDED BY context/adr/003-live-backed-models.md, and rewritten 2026-09-19 to match it. The original text framed this as an open choice between import-once and live-backed, and said \"READ-ONLY for now\" as though that were a scoping convenience. ADR 003 decided both, and in the opposite direction from what this ticket assumed.\n\nWHAT THE ADR DECIDED, and this ticket now means:\n\n * LIVE-BACKED, not import-once. Import-once was explicitly REJECTED as the only answer -- it makes the model a snapshot, so source changes are invisible until re-import and the data must fit in memory. The model is a view over the source and queries go to it as the user pivots.\n * READ-ONLY IS A FEATURE, not a limitation accepted reluctantly. Pointing a pivot tool at a production database and being unable to write to it is the point. No write-through, no overlay, no reconciliation.\n * THEREFORE: Model becomes a FACADE OVER A DATA SOURCE, of which today's in-RAM DataStore is one implementation and a SQL query engine is another. That is a change to what Model IS, not to where its bytes come from.\n\nSTATUS: NOT A PRIORITY, per the ADR's own header. Deferred 2026-09-19 during the whole-backlog campaign, by user ruling, together with improvise-qddo and improvise-mjy7. Nothing here outranks the browser work. It is written down so the option stays open, because several decisions made recently could foreclose it by accident.\n\nTHE THREE THINGS THAT MAKE IT TRACTABLE, from the ADR, which should shape the design rather than be discovered late:\n 1. Aggregation is ALREADY the interface and it is ALREADY SQL. apply_agg computes Sum/Avg/Min/Max/Count over a matching cell set; those are exactly SQL's aggregates. GridLayout asks for a VALUE, not for the rows behind it. The pushdown is not a translation layer to be invented.\n 2. Formula results are ALREADY separate from stored data. Evaluation is fixed-point and derived values live in Model::formula_cache, not in DataStore. Raw data is queried; derived data stays in RAM. Formulas do not become SQL.\n 3. Clients are ALREADY insulated. A client sees CellDisplays and never learns where a value came from, so live-backing and the browser deployment compose rather than compete. A browser never needs SQLite at all.\n\nTHE CONSEQUENCE NOBODY ASKED FOR, and the reason this is three tickets rather than one: a live-backed document splits into OUR configuration and THEIR data, and .improv does not distinguish them. The format grows a SOURCE SPECIFICATION where a live model would otherwise have a ## Data section, and categories become derived (SELECT DISTINCT) rather than declared. improv.pest is the single source of truth, so the grammar comes first.\n\nCHILDREN (all filed and deferred alongside this one):\n * the .improv source specification and the grammar change\n * the SQLite source implementation, native-only and feature-gated\n * projection freshness for a source an external writer can change\n\nWHAT ADR 001 DOES AND DOES NOT OWE THIS: a read-only model has no concurrent EDITS, so the basis check simply does not apply -- the guarantee is scoped to documents improvise owns, which is what it always meant. What remains is a different and smaller question, which is the freshness child.\n\nTHE MACHINERY TO EXPRESS READ-ONLY ALREADY EXISTS, built for something else: ADR 002 makes SetCell take a CellId::Record and nothing else, so a source that projects no Record ids makes a write UNCONSTRUCTIBLE by the client -- no runtime check, no error path, no read-only dialog. One design consequence for the source trait follows: writes must NOT be in the trait a query source implements, or a read-only source has to fail at runtime for a method it structurally cannot serve.\n\nTHE DEPENDENCY CONSTRAINT THAT CARRIES OVER FROM improvise-9ix: improvise-io is in the wasm bundle (the wasm job in .github/workflows/ci.yml gates it) and libsqlite3-sys does not build for wasm32-unknown-unknown under this repo's nix cc-wrapper. Native-only and feature-gated, with the wasm job passing untouched. Verify the gate rather than assuming it.\n\nWHAT THIS IS NOT: improvise-9ix, which proposed SQLite as improvise's OWN PERSISTENCE FORMAT and was declined on measurements (22% larger than .improv, 4.7x gzipped, a second implementation of the pest grammar). Reading someone else's database has no round-trip obligation and no grammar duplication.\n","notes":"\n--- SUPERSEDED IN FRAMING by ADR 003 (user, 2026-09-19) ---\nThe user chose LIVE-BACKED over import-once, and the goal is stated as: have\nTHE OPTION of essentially using improvise as a DB CLIENT. NOT A PRIORITY --\nnothing here outranks improvise-1ey. Re-prioritised P2 -\u003e P3.\n\nSo this ticket read-only import framing is not the target shape. See\ncontext/adr/003-live-backed-models.md. The import-once seam it described\n(csv_parser -\u003e Vec\u003cValue\u003e -\u003e analyze_records) is still accurate and still\nuseful, but as ONE implementation of a source rather than as the answer.\n\nWHAT SURVIVES UNCHANGED and is worth keeping from this ticket: the type-mapping\nnotes (SQLite columns carry declared types, which is BETTER input than CSV\nstring sampling -- detect_date_format currently guesses a date by pattern-\nmatching text; dates may be TEXT, INTEGER epoch or REAL julian, so detect\nrather than assume; NULL is ABSENCE not the string \"null\", per improvise-ych),\nand the wasm constraint (improvise-io is in the bundle, libsqlite3-sys does not\nbuild for wasm32 under this repo nix cc-wrapper, so any SQLite dependency is\nfeature-gated and native-only).","status":"deferred","priority":3,"issue_type":"epic","owner":"el-github@elangley.org","created_at":"2026-09-20T00:14:11Z","created_by":"Edward Langley","updated_at":"2026-09-20T05:31:18Z","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} @@ -256,7 +269,7 @@ {"_type":"issue","id":"improvise-jjx","title":"Scrolled grid suppresses first visible row-header label","description":"src/ui/grid.rs:338-350: repeat-suppression shows a row-header sub-label only when ri == 0 or the prefix differs from data_row_items[ri-1]. ri is the absolute data-row index (starts at row_offset, grid.rs:269), so when the view is scrolled the first visible row compares against the off-screen row above and its label is blanked — the user loses the group context at the top of the viewport. Fix: compare against the previous *visible* row, i.e. always show labels for the first rendered data row. 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:58:38Z","created_by":"Edward Langley","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-byz","title":"records_display returns Some(\"\") for out-of-bounds columns instead of None","description":"crates/improvise-core/src/view/layout.rs:186-202: records_display() calls col_label(col), which returns an empty string for out-of-bounds indices; the empty string then fails the == \"Value\" check, is looked up as a category name, and the function returns Some(\"\") via unwrap_or_default(). cell_key() (~line 396) explicitly returns None for an empty col_label — records_display should be symmetric so view/data mismatches are not masked. 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:58:35Z","created_by":"Edward Langley","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-zgb","title":".improv formula loader mis-splits category suffix via rfind(\" [\")","description":"crates/improvise-io/src/persistence/mod.rs:403-411: the formulas_section walker finds the [Category] suffix with raw.rfind(\" [\") + ends_with(']'). Two failure modes: (1) a pipe-quoted identifier at the end of the formula containing ' [' (e.g. |Revenue [USD]|) is mis-split, treating part of the quoted name as the category; (2) a suffix written without a leading space (X=Y[Cat]) is not recognized and the whole line is parsed as a _Measure formula, losing the target category. Fix: move the category suffix into the pest grammar instead of string scanning. Write failing tests first per AGENTS.md. 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:58:32Z","created_by":"Edward Langley","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-drg","title":"vb4 step 5: narrow Effect::apply signatures where one-slice","description":"Optional follow-up to vb4 structural split. For each impl Effect for Foo, audit which slice(s) it mutates. If apply only touches ModelState (or only ViewState), narrow its signature from fn apply(\u0026self, app: \u0026mut App) to fn apply(\u0026self, model: \u0026mut ModelState) (or ViewState). Cross-cutting effects (read layout, change mode that triggers layout rebuild, etc.) stay on \u0026mut App. Improves type-level documentation and enables finer-grained effect tests. Lower priority — vb4's acceptance criterion (4) explicitly allows leaving cross-cutting on \u0026mut App.","design":"Hidden trait-design decision surfaced during vb4 step 4 review: the issue's\nphrasing — \"narrow its signature from fn apply(\u0026self, app: \u0026mut App) to\nfn apply(\u0026self, model: \u0026mut ModelState)\" — is not directly possible. You\ncannot change one Effect impl's signature without changing the Effect\ntrait. Three approaches, none picked:\n\n(A) Split into multiple traits — ModelEffect / ViewEffect / AppEffect.\n Each impl becomes the narrower thing it actually is. Cost: ~50\n existing impls reorganize across three traits, and Vec\u003cBox\u003cdyn Effect\u003e\u003e\n has to become an enum wrapper (or three parallel queues) to keep\n apply_effects working. Highest fidelity to the issue's literal wording.\n\n(B) Keep one trait, rebind inside the impl body — e.g.\n fn apply(\u0026self, app: \u0026mut App) { let view = \u0026mut app.view_state; view.mode = ... }\n so the slice is visible at the call site without changing the\n signature. Lowest disruption. Doesn't strictly satisfy acceptance\n criterion (1) (a \"narrower borrow\") — only the body is narrower.\n\n(C) Default-method dispatch on a single trait — Effect grows\n apply_view(\u0026mut ViewState) and apply_model(\u0026mut ModelState) with\n default impls that fall through to apply(\u0026mut App). Each impl\n overrides only the method that matches its slice. Middle ground:\n type-level narrowing for impls that opt in, \u0026mut App stays available\n for cross-cutting effects. Wraps cleanly around the existing\n apply_effects loop.\n\nPicking (A) commits to the architecture for downstream effect tagging\n(improvise-mae) and the protocol extraction (improvise-cqi). (B) and (C)\nleave that decision unmade. Recommend deciding before claiming.","acceptance_criteria":"Each Effect whose apply touches only one slice takes \u0026mut ModelState or \u0026mut ViewState; cross-cutting effects stay on \u0026mut App, which vb4's own acceptance explicitly allows. The win is type-level documentation and finer-grained effect tests, not line count. CAUTION, new since this ticket was filed: App::apply_effects now reads dirtiness, layout_version and the record fingerprints off the EffectKind (improvise-gv9r, gotcha 40) — narrowing a signature must not put an effect somewhere that loop cannot see.","notes":"DECLINED 2026-09-20 (wave 2), with counts. Reasoning in context/code/command.md,\n'Effect::apply stays on \u0026mut App'. NOTE: this bead carries the `human` label, so\nthe decline is a recommendation the user may overturn; it is recorded rather than\nsmuggled.\n\nCOUNTED over the 49 `impl Effect` blocks in ui/effect.rs:\n now (this branch) 8 view_state-only, 9 model_state-only, 32 cross-cutting\n base 52507a6 9 view_state-only, 16 model_state-only, 24 cross-cutting\nThe narrow set fell from 25 to 17 in one wave. The mover was improvise-9mo9,\nwhich made nine reshaping effects call app.reset_cursor(): a reshape lives in\nmodel_state.workbook and the cursor it invalidates lives in ViewState, so the\neffects that matter most are precisely the ones that reconcile the two slices.\nNarrowing bets against the direction the code moves in.\n\nFOUR GROUNDS\n1. The narrow set is small and shrinking, for that architectural reason.\n2. The type-level documentation already exists and is CHECKED: Effect::kind()\n has no default (gotcha 3) and apply_effects reads that one answer three times\n (gotchas 40, 45). A narrowed signature is a second statement of the same fact,\n free to disagree -- the defect, not the cure.\n3. Every offered mechanism weakens a guard. (A) three traits needs three queues\n (destroys batch order, which gotchas 21/23/43 lean on) or an enum wrapper (a\n fourth classification beside EffectKind). (C) default methods give `apply` a\n DEFAULT, exactly what kind() does without and for the same reason: overriding\n apply_view on an effect that needs the model compiles and writes the wrong\n slice silently; overriding neither compiles as a no-op. (B) does not meet the\n ticket's own acceptance criterion.\n4. \"Finer-grained effect tests\" is not blocked: effect.rs's 64 tests already call\n Effect.apply(\u0026mut app) against a one-line App::new(wb, None) fixture.\n\nWHAT IS NOT DECLINED: approach (B), rebinding the slice at the top of an apply\nbody, remains available as a per-function style choice. It is not mandated.","status":"closed","priority":3,"issue_type":"task","assignee":"wave2-n-cleanups","owner":"el-github@elangley.org","created_at":"2026-04-26T18:16:52Z","created_by":"jeeves","updated_at":"2026-09-20T18:11:26Z","started_at":"2026-09-20T18:08:16Z","closed_at":"2026-09-20T18:11:26Z","close_reason":"Closed","labels":["area:command","human"],"dependencies":[{"issue_id":"improvise-drg","depends_on_id":"improvise-99k","type":"blocks","created_at":"2026-04-26T11:17:16Z","created_by":"jeeves","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-drg","title":"vb4 step 5: narrow Effect::apply signatures where one-slice","description":"Optional follow-up to vb4 structural split. For each impl Effect for Foo, audit which slice(s) it mutates. If apply only touches ModelState (or only ViewState), narrow its signature from fn apply(\u0026self, app: \u0026mut App) to fn apply(\u0026self, model: \u0026mut ModelState) (or ViewState). Cross-cutting effects (read layout, change mode that triggers layout rebuild, etc.) stay on \u0026mut App. Improves type-level documentation and enables finer-grained effect tests. Lower priority — vb4's acceptance criterion (4) explicitly allows leaving cross-cutting on \u0026mut App.","design":"Hidden trait-design decision surfaced during vb4 step 4 review: the issue's\nphrasing — \"narrow its signature from fn apply(\u0026self, app: \u0026mut App) to\nfn apply(\u0026self, model: \u0026mut ModelState)\" — is not directly possible. You\ncannot change one Effect impl's signature without changing the Effect\ntrait. Three approaches, none picked:\n\n(A) Split into multiple traits — ModelEffect / ViewEffect / AppEffect.\n Each impl becomes the narrower thing it actually is. Cost: ~50\n existing impls reorganize across three traits, and Vec\u003cBox\u003cdyn Effect\u003e\u003e\n has to become an enum wrapper (or three parallel queues) to keep\n apply_effects working. Highest fidelity to the issue's literal wording.\n\n(B) Keep one trait, rebind inside the impl body — e.g.\n fn apply(\u0026self, app: \u0026mut App) { let view = \u0026mut app.view_state; view.mode = ... }\n so the slice is visible at the call site without changing the\n signature. Lowest disruption. Doesn't strictly satisfy acceptance\n criterion (1) (a \"narrower borrow\") — only the body is narrower.\n\n(C) Default-method dispatch on a single trait — Effect grows\n apply_view(\u0026mut ViewState) and apply_model(\u0026mut ModelState) with\n default impls that fall through to apply(\u0026mut App). Each impl\n overrides only the method that matches its slice. Middle ground:\n type-level narrowing for impls that opt in, \u0026mut App stays available\n for cross-cutting effects. Wraps cleanly around the existing\n apply_effects loop.\n\nPicking (A) commits to the architecture for downstream effect tagging\n(improvise-mae) and the protocol extraction (improvise-cqi). (B) and (C)\nleave that decision unmade. Recommend deciding before claiming.","acceptance_criteria":"Each Effect whose apply touches only one slice takes \u0026mut ModelState or \u0026mut ViewState; cross-cutting effects stay on \u0026mut App, which vb4's own acceptance explicitly allows. The win is type-level documentation and finer-grained effect tests, not line count. CAUTION, new since this ticket was filed: App::apply_effects now reads dirtiness, layout_version and the record fingerprints off the EffectKind (improvise-gv9r, gotcha 40) — narrowing a signature must not put an effect somewhere that loop cannot see.","notes":"DECLINED 2026-09-20 (wave 2), with counts. Reasoning in context/code/command.md,\n'Effect::apply stays on \u0026mut App'. NOTE: this bead carries the `human` label, so\nthe decline is a recommendation the user may overturn; it is recorded rather than\nsmuggled.\n\nCOUNTED over the 49 `impl Effect` blocks in ui/effect.rs:\n now (this branch) 8 view_state-only, 9 model_state-only, 32 cross-cutting\n base 52507a6 9 view_state-only, 16 model_state-only, 24 cross-cutting\nThe narrow set fell from 25 to 17 in one wave. The mover was improvise-9mo9,\nwhich made nine reshaping effects call app.reset_cursor(): a reshape lives in\nmodel_state.workbook and the cursor it invalidates lives in ViewState, so the\neffects that matter most are precisely the ones that reconcile the two slices.\nNarrowing bets against the direction the code moves in.\n\nFOUR GROUNDS\n1. The narrow set is small and shrinking, for that architectural reason.\n2. The type-level documentation already exists and is CHECKED: Effect::kind()\n has no default (gotcha 3) and apply_effects reads that one answer three times\n (gotchas 40, 45). A narrowed signature is a second statement of the same fact,\n free to disagree -- the defect, not the cure.\n3. Every offered mechanism weakens a guard. (A) three traits needs three queues\n (destroys batch order, which gotchas 21/23/43 lean on) or an enum wrapper (a\n fourth classification beside EffectKind). (C) default methods give `apply` a\n DEFAULT, exactly what kind() does without and for the same reason: overriding\n apply_view on an effect that needs the model compiles and writes the wrong\n slice silently; overriding neither compiles as a no-op. (B) does not meet the\n ticket's own acceptance criterion.\n4. \"Finer-grained effect tests\" is not blocked: effect.rs's 64 tests already call\n Effect.apply(\u0026mut app) against a one-line App::new(wb, None) fixture.\n\nWHAT IS NOT DECLINED: approach (B), rebinding the slice at the top of an apply\nbody, remains available as a per-function style choice. It is not mandated.\nUSER CONFIRMATION 2026-09-20: the decline is CONFIRMED and the bead stays closed. This bead carries the 'human' label, so the WU-N agent correctly recorded its decline as a recommendation rather than acting on it unilaterally; the user was shown the counts (8 view-only / 9 model-only / 32 cross-cutting of 49, down from 9/16/24 at the wave base) and all three approaches (A: three traits; B: rebind in the body; C: default-method dispatch), and chose to confirm the decline.\n\nThe reasoning the user accepted: the classification ALREADY EXISTS and is checked — Effect::kind has no default and apply_effects reads that one answer three times — so a narrowed signature is a second statement free to disagree with it. Three traits need three queues (destroying batch order) or an enum wrapper (a fourth classification). Default methods would give apply a DEFAULT whose wrong answer is silent, which is precisely what kind()'s no-default rule exists to prevent.\n\nThe fact worth carrying past this bead: improvise-9mo9 moved nine effects from 'touches one slice' to 'cross-cuts' INSIDE THE SAME BRANCH that declined this, and that is not a coincidence to route around. The App exists because the grid's shape lives in model_state.workbook and the cursor into it lives in ViewState, so the effects that matter most are exactly the ones that RECONCILE the two slices. A design that rewards single-slice effects would have been pulling against the repo's actual centre of gravity.\nCOUNTS CORRECTED 2026-09-20, after adversarial review of PR #49. The decline\nstands; the numbers it was argued on were wrong twice and the user confirmed it\npartly on them, so the correction is recorded loudly rather than restated.\n\nPUBLISHED (wrong): 49 effects; 8 view-only, 9 model-only, 32 cross; narrow set\nfell 25 -\u003e 17.\nCORRECT: 57 effects; 16 view-only, 9 model-only, 32 cross; narrow set\nfell 32 -\u003e 25. Base 52507a6 was 16/16/25.\n\nTwo errors. (a) It counted only the 49 `impl Effect` blocks and omitted the 8\n`simple_effect!` invocations, which also expand to an impl and are ALL view-only.\n(b) It misclassified AddItem at the base commit, because the extraction regex\ncould not see `app` followed by a newline and `.model_state` -- which is\ndoctrine(derive_then_verify) catching the person quoting it, with the exact\nfailure that doctrine was written about.\n\nWHAT THIS COSTS THE ARGUMENT: 25 of 57 is 44% of effects that could narrow, not\nthe 35% claimed; and the fall is 22%, not 32%. The direction is unchanged and is\nstill the point -- the seven movers are SetAxis, HideItem, ShowItem, ToggleGroup,\nTogglePruneEmpty, RemoveItem and RemoveCategory, every one an effect\nimprovise-9mo9 gave a reset_cursor, so the shrinkage is architectural rather than\nincidental. But the headline was overstated by about half again, and anyone\nre-opening this should argue from 32 -\u003e 25 of 57.\n\nThe other three grounds for the decline are untouched by this: the classification\nalready exists and is checked; three traits break batch order and default methods\nreintroduce a silent default; and finer-grained tests are not blocked.","status":"closed","priority":3,"issue_type":"task","assignee":"wave2-n-cleanups","owner":"el-github@elangley.org","created_at":"2026-04-26T18:16:52Z","created_by":"jeeves","updated_at":"2026-09-20T18:57:42Z","started_at":"2026-09-20T18:08:16Z","closed_at":"2026-09-20T18:11:26Z","close_reason":"Closed","labels":["area:command","human"],"dependencies":[{"issue_id":"improvise-drg","depends_on_id":"improvise-99k","type":"blocks","created_at":"2026-04-26T11:17:16Z","created_by":"jeeves","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-1ud","title":"Document RecordsEditing variant in repo-map.md","description":"context/repo-map.md currently lists 15 AppMode variants but does not include RecordsEditing, which is referenced at src/draw.rs:142 and src/ui/app.rs:442. Add the variant to the AppMode list in the 'Key Types' section so coding agents see it.\\n\\nTrivial doc fix.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:54:16Z","created_by":"Ed L","updated_at":"2026-06-10T04:05:13Z","closed_at":"2026-06-10T04:05:13Z","close_reason":"Done in commit 0fe04de (2026-06-09 repo-map refresh): AppMode list now reads 16 variants including RecordsNormal and RecordsEditing, and notes MinibufferConfig + the ModelState/ViewState split.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-8fy","title":"Introduce simple_effect! macro for single-field setter effects","description":"crates/improvise-command/src/ui/effect.rs has ~15 simple setter effects (SetSelected, SetRowOffset, SetColOffset, SetYanked, SetSearchQuery, SetSearchMode, SetStatus, etc.) that each implement the same 4-line apply: pub struct SetX(pub T); impl Effect { fn apply(\u0026self, app: \u0026mut App) { app.field = self.0.clone(); } }.\\n\\nAbstraction: simple_effect!(SetRowOffset, usize, row_offset) macro generates struct + Effect impl.\\n\\nCorrectness win: provides a single hook for cross-cutting concerns. If layout-affecting setters need to mark dirty or rebuild layout, one macro body covers all of them. Currently each setter independently decides whether to mark_dirty(), and silent omissions are plausible. ~50 LOC.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:54:14Z","created_by":"Ed L","updated_at":"2026-09-19T20:35:54Z","closed_at":"2026-09-19T20:35:54Z","close_reason":"simple_effect! macro added beside the Effect trait; eight single-field ViewOnly setters declared through it (-42 lines net). The macro HARD-CODES EffectKind::ViewOnly by design: an effect other clients must see is not 'simple' and keeps a hand-written impl, which is how gotcha 3's no-default-kind rule survives a macro at all. SetSelected (two fields) and SetNumberFormat (a Model change) stay hand-written. Existing effect tests already cover all eight, including view_only_effects_leave_shared_state_alone.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-1wu","title":"Extract axis label helper in view/layout.rs","description":"crates/improvise-core/crates/improvise-core/src/view/layout.rs:323-336 (row_label) and :338-351 (col_label) are character-identical except for which items list they read. Small syntactic duplication but trivial to consolidate.\\n\\nFix: fn axis_label(items: \u0026[AxisEntry], idx: usize) -\u003e String, call from both. ~14 LOC.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:54:13Z","created_by":"Ed L","updated_at":"2026-08-14T01:11:07Z","closed_at":"2026-08-14T01:11:07Z","dependencies":[{"issue_id":"improvise-1wu","depends_on_id":"improvise-ai7","type":"supersedes","created_at":"2026-08-13T18:11:07Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -282,10 +295,14 @@ {"_type":"issue","id":"improvise-l36","title":"3.2 Publish to crates.io","description":"After cargo publish --dry-run is clean and user confirms, run cargo publish. Verify crate appears at crates.io/crates/improvise and cargo install improvise works.","acceptance_criteria":"improvise is on crates.io and 'cargo install improvise' works from a clean machine. NEEDS THE USER: crates.io credentials, and their confirmation after 'cargo publish --dry-run' is clean. Note the workspace has grown to nine crates since this was filed — decide which are published and in what order, since a dependent cannot publish before its dependency, and improvise-io/protocol/command are all now real crates rather than internal modules.","notes":"DRY RUN ACTUALLY RUN, 2026-09-20 at 52507a6 (wave 2, WU-P). It is NOT clean,\nand the blocker is mechanical rather than a judgement call:\n\n error: failed to verify manifest at Cargo.toml\n all dependencies must have a version requirement specified when publishing.\n dependency `gp-tree` does not specify a version\n\ncargo stops at the FIRST offender, and gp-tree is not the only one. Every\ninter-crate dependency in this workspace is `{ path = \"...\" }` with no\n`version`, which crates.io forbids for normal dependencies:\n\n Cargo.toml (root) gp-tree, improvise-core, improvise-formula,\n improvise-io, improvise-command\n improvise-core improvise-formula\n improvise-formula (none)\n improvise-protocol improvise-core\n improvise-io improvise-core, improvise-formula\n improvise-gp gp-tree, improvise-core, improvise-formula\n improvise-command improvise-core, improvise-protocol,\n improvise-formula, improvise-io, improvise-gp\n\nDev-dependencies are exempt — cargo strips them on publish — so the root's\n`improvise-wasm-client`/`improvise-protocol` dev deps and improvise-wasm-\nclient's dev dep on the unpublishable improvise-ws-server are fine as they are.\n\nWHICH CRATES PUBLISH. `improvise-ws-server` and `improvise-worker-server` both\ncarry `publish = false` already, so the set is eight, and a dependent cannot go\nbefore its dependency. The only order that works:\n\n 1. improvise-formula (no intra-workspace deps)\n 2. improvise-core\n 3. gp-tree (no intra-workspace deps; can go any time before 4)\n 4. improvise-gp\n 5. improvise-protocol\n 6. improvise-io\n 7. improvise-command\n 8. improvise (the binary)\n\ncrates.io needs a minute or two to index each one before the next can resolve\nit, so this is eight sequential `cargo publish` calls, not one.\n\nTWO VERSION FACTS THE USER MUST DECIDE. `gp-tree` is at `0.1.0` while every\nother crate is at `0.1.0-rc2`; and a `-rc2` version requirement in a published\nmanifest pins consumers to a prerelease. If the intent is to publish 0.1.0\n(which improvise-3tj's tag implies), the version bump across ten manifests\nshould happen BEFORE the version requirements are written, or they will be\nwritten twice.\n\nNAME AVAILABILITY CHECKED: crates.io returns \"crate `improvise` does not exist\",\nso the name is free. Not checked: the other seven names.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:58Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:01:38Z","labels":["area:release","needs:user"],"dependencies":[{"issue_id":"improvise-l36","depends_on_id":"improvise-11a","type":"blocks","created_at":"2026-04-08T21:09:29Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-l36","depends_on_id":"improvise-2fr","type":"blocks","created_at":"2026-04-08T21:09:28Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":2,"comment_count":0} {"_type":"issue","id":"improvise-11a","title":"3.1 Configure cargo dist","description":"Run cargo dist init targeting x86_64-unknown-linux-gnu, aarch64-apple-darwin, x86_64-apple-darwin. Skip Windows and musl. Commit generated .github/workflows/release.yml and Cargo.toml additions. Test with v0.1.0-rc1 tag, verify builds, delete rc tag.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:55Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-11T07:49:57Z","close_reason":"Closed","dependencies":[{"issue_id":"improvise-11a","depends_on_id":"improvise-abz","type":"blocks","created_at":"2026-04-08T21:09:28Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":3,"comment_count":0} {"_type":"issue","id":"improvise-0s6","title":"Phase 3: Distribution","description":"Configure cargo dist, publish to crates.io, tag v0.1.0 release. Produces prebuilt binaries for Linux x86_64 and macOS (Intel + Apple Silicon).","acceptance_criteria":"Closes when improvise-l36 and improvise-3tj close. Both need the user; nothing here is agent work. improvise-11a (cargo dist) is already closed.","notes":"CHECKED, wave 2 WU-P: STAYS OPEN, correctly — both children need the user and\nneither was touched. Nothing was published and nothing was tagged.\n\nWhat this pass DID do is make them ready, and one of them is not as ready as\nit looked. `cargo publish --dry-run` was actually run and FAILS: every\ninter-crate dependency in the workspace is `{ path = ... }` with no `version`,\nwhich crates.io forbids. cargo reports only the first offender (gp-tree), so\nthe dry run understates it — the full list, the publish order for the eight\npublishable crates, and the gp-tree/rc2 version disagreement are on\nimprovise-l36.\n\nimprovise-3tj's premise is already partly false and its note now says so: the\nREADME prebuilt-binaries link is not a placeholder, v0.1.0-rc2 carries real\ncargo-dist artifacts for all four targets, and what is actually missing is the\nv0.1.0 tag and the manifest bump that must precede it.","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-09T04:05:51Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:05:07Z","labels":["area:release","needs:user"],"dependencies":[{"issue_id":"improvise-0s6","depends_on_id":"improvise-11a","type":"blocks","created_at":"2026-04-08T23:37:39Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-0s6","depends_on_id":"improvise-3tj","type":"blocks","created_at":"2026-04-08T23:37:41Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-0s6","depends_on_id":"improvise-l36","type":"blocks","created_at":"2026-04-08T23:37:40Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":3,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-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":"open","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-20T19:16:46Z","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":"open","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-20T18:58:19Z","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":"open","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-20T18:45:36Z","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.","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-20T18:48:10Z","labels":["area:active-view"],"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":"open","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:14:57Z","created_by":"orchestrator","updated_at":"2026-09-20T18:14:57Z","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":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:13:31Z","created_by":"wave2-b-projection","updated_at":"2026-09-20T18:13:31Z","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-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":"TWO ADDITIONS, both instances of this ticket's one fact rather than new ones.\n\nFIRST, WHAT A CHECK MUST ACTUALLY DO — the part that gets lost, and the reason\nthis ticket is not a one-liner. Counting the four `.ap-player` elements PROVES\nNOTHING. `autoPlay` is false, so asciinema-player does not fetch a cast until\nthe viewer presses play; a page whose every cast path 404s constructs four\nplayers, throws no page error before the click, and is indistinguishable from a\nworking one. This was verified by injecting exactly that fault (`./casts/` -\u003e\n`./cats/`): the construction-only check passed on the broken page, and pressing\nplay returned 0 rendered lines and surfaced four 404s. So the spec must press\nplay on each of the four and assert a non-zero rendered line count, and its own\nfault injection must be recorded in `context/test-evidence/`.\n\nThe same applies to the page's one functional link. Since the user's ruling\n(improvise-kh8) the landing page IS the site root and its whole job is to reach\nthe app at `./app/`; a broken link there is a dead front door, and the page\nlooks entirely correct with one. Proved able to fail by pointing it at `./aap/`\n-- 404 while nothing on screen changes -- so the spec should resolve the\ncall-to-action href and assert the response status.\n\nSECOND, THE LINE BUDGET, which is an instance of \"asserted by nothing\" and not\na separate defect. improvise-e61's criterion is \"under 200 lines\" and\n`docs/index.html` is now 199 after the root/app restructure. A criterion met by\none line, with nothing checking it, is met by accident: the next person to touch\nthe file either trims something or silently breaks it, and no test, lint or CI\njob would say so. If this ticket's spec lands, the cheapest place for the budget\nis beside it -- a line count assertion is one statement and needs no browser.\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/` -- the landing page renders and the casts play,\n and no test in the repository would have noticed if they had not.\n4. Break a cast path in `docs/index.html`, repeat, and observe that the page is\n still structurally correct until a play button is pressed.\n5. `wc -l docs/index.html` -- 199, against a stated limit of 200, checked by\n nothing.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:01:16Z","created_by":"wave2-p-milestones","updated_at":"2026-09-20T18:14:01Z","labels":["area:release"],"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":"TWO ADDITIONS, both instances of this ticket's one fact rather than new ones.\n\nFIRST, WHAT A CHECK MUST ACTUALLY DO — the part that gets lost, and the reason\nthis ticket is not a one-liner. Counting the four `.ap-player` elements PROVES\nNOTHING. `autoPlay` is false, so asciinema-player does not fetch a cast until\nthe viewer presses play; a page whose every cast path 404s constructs four\nplayers, throws no page error before the click, and is indistinguishable from a\nworking one. This was verified by injecting exactly that fault (`./casts/` -\u003e\n`./cats/`): the construction-only check passed on the broken page, and pressing\nplay returned 0 rendered lines and surfaced four 404s. So the spec must press\nplay on each of the four and assert a non-zero rendered line count, and its own\nfault injection must be recorded in `context/test-evidence/`.\n\nThe same applies to the page's one functional link. Since the user's ruling\n(improvise-kh8) the landing page IS the site root and its whole job is to reach\nthe app at `./app/`; a broken link there is a dead front door, and the page\nlooks entirely correct with one. Proved able to fail by pointing it at `./aap/`\n-- 404 while nothing on screen changes -- so the spec should resolve the\ncall-to-action href and assert the response status.\n\nSECOND, THE LINE BUDGET, which is an instance of \"asserted by nothing\" and not\na separate defect. improvise-e61's criterion is \"under 200 lines\" and\n`docs/index.html` is now 199 after the root/app restructure. A criterion met by\none line, with nothing checking it, is met by accident: the next person to touch\nthe file either trims something or silently breaks it, and no test, lint or CI\njob would say so. If this ticket's spec lands, the cheapest place for the budget\nis beside it -- a line count assertion is one statement and needs no browser.\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/` -- the landing page renders and the casts play,\n and no test in the repository would have noticed if they had not.\n4. Break a cast path in `docs/index.html`, repeat, and observe that the page is\n still structurally correct until a play button is pressed.\n5. `wc -l docs/index.html` -- 199, against a stated limit of 200, checked by\n nothing.\nWITHDRAWN FROM THIS TICKET: the 199-line budget paragraph above.\n\nIt was added here on the reasoning that \"a 200-line limit met by one line with\nnothing checking it\" is an instance of this ticket's one fact. On review that is\nthe wrong home: the better answer is to rewrite improvise-e61's criterion, since\nthe line count was only ever a proxy for single file / vanilla / no build step /\nno framework — properties nothing threatens and which a line count measures\nbadly. That has been done on improvise-e61.\n\nThis ticket is about the landing page being SERVED and ASSERTED BY NOTHING, and\nit should stay that. Read the paragraph above as superseded; the rest of the\nnotes — press play, count rendered lines, resolve the call-to-action href — is\nwhat this ticket is for.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:01:16Z","created_by":"wave2-p-milestones","updated_at":"2026-09-20T18:22:56Z","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":"open","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-20T16:53:36Z","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":"open","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T07:50:12Z","created_by":"Edward Langley","updated_at":"2026-09-20T16:53:34Z","labels":["area:core-io"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-zx8t","title":"draw.rs::mode_style matches AppMode with a wildcard, so the repo map's exhaustiveness promise is false there","description":"Found by WU-G on PR #27 while adding AppMode::SavePrompt, and it is a correction to something context/repo-map.md currently promises.\n\nThe repo map says of AppMode: \"Per-variant UI facts live on the enum as exhaustive-match methods -- label(), hint_text(), panel(), minibuffer(), is_editing(), is_records() (improvise-2hi); ADD A VARIANT AND THE COMPILER NAMES EVERY ONE YOU MUST FILL IN.\"\n\nThat is TRUE of the five enum methods and FALSE of src/draw.rs::mode_style, which matches AppMode with a `_ =\u003e` WILDCARD. So a new variant silently inherits whatever the wildcard arm gives -- SavePrompt currently takes ExportPrompt's DarkGray by accident rather than by decision, which happens to be defensible and was nobody's choice.\n\nWHY IT IS P4 AND NOT HIGHER: the consequence is a colour, and the wrong colour is visible rather than silent. But the reason to fix it is that the repo map states a PROPERTY, and a property that holds in five places and not the sixth is worse than one nobody claimed -- the next person adding a variant will trust the sentence.\n\nTHE FIX IS ONE OF TWO, and they are not equivalent:\n * Make mode_style exhaustive, so the compiler does name it. Note mode_style stays in src/draw.rs deliberately, because Style is a ratatui type and improvise-protocol must not name one (gotcha 26, the AxisColor/MinibufColor pattern).\n * Or give AppMode a semantic colour method in the protocol crate -- the AxisColor treatment -- and let draw.rs do the one mapping. That is the established shape here and it would also give a browser something to use, which draw.rs's Style cannot.\n\nThe second is probably right and is the same question improvise-k099 asks about hint_text/label. Decide them together.\n","acceptance_criteria":"mode_style either matches AppMode exhaustively, or AppMode gains a semantic colour method in improvise-protocol that draw.rs maps to a ratatui Style (the AxisColor/MinibufColor shape). Either way, adding an AppMode variant fails the build until someone chooses its colour. The repo-map sentence promising compiler-named sites is true of every listed method, or it is corrected to say which one it is not true of.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T06:04:22Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:44:19Z","labels":["area:tui"],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -310,5 +327,5 @@ {"_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-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.","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:19:45Z","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-e61","title":"4.1 Create docs/index.html landing page","description":"Vanilla HTML, single file, under 200 lines. Dark background, monospace headings. Embed asciinema-player from jsdelivr CDN. Sections: title/tagline, pivot cast, drill cast, formulas cast, import cast, install commands + GitHub link. Player config: rows 30, cols 100, theme monokai, autoPlay false, loop true.","acceptance_criteria":"docs/index.html exists: vanilla HTML, single file, under 200 lines, dark background and monospace headings, asciinema-player embedded from jsdelivr, sections for title/tagline, the pivot/drill/formulas/import casts, install commands and a GitHub link. Player config rows 30, cols 100, theme monokai, autoPlay false, loop true. The four casts already exist (improvise-d4w is closed) — check they still reflect the current UI before embedding them, since the wave changed the records columns and the status line.","notes":"CRITERION REINTERPRETED, on review of PR #47, rather than a second bead filed.\n\n\"Under 200 lines\" is a PROXY for what this ticket actually cared about — single\nfile, vanilla, no build step, no framework — and those are the properties worth\nholding. The line count is not: it is met or missed by an arbitrary margin, it is\nchecked by nothing, and at 199 lines it was \"met\" only by accident, with the next\nperson to touch the file silently breaking it.\n\n`docs/index.html` is now 209 lines. The properties the number stood for are all\nintact: one file, no build step, no framework, no dependency but the pinned\nasciinema-player from a CDN.\n\nWhat took it over was a fix, not padding. At a 390px viewport the four players\ncomputed a 4.83px terminal font — measured in Chromium against the assembled\ntree — because a 120-column cast shrunk into a phone is unreadable. That was\ntolerable when the landing page sat at /landing/; since the user's ruling it is\nthe first thing a phone visitor sees. A media query under 900px now scrolls each\nplayer sideways at a legible size instead: 11.95px, measured the same way, with\nthe PAGE's own scrollWidth still equal to its clientWidth, so the overflow is\ncontained inside the player and there is no horizontal page scroll.\nTWO CORRECTIONS TO THE NOTE ABOVE, and one thing it should not be read as saying.\n\nFIRST, THE NUMBERS IN THAT NOTE ARE NOW WRONG. It says the file is 209 lines and\nthat a media query fixes the phone rendering. A user ruling has since reverted\nthat fix — the mobile defect is filed as improvise-o31u and the page is\nunchanged — so `docs/index.html` is **213 lines**, and what took it past 200 is\nthe drill-cast caption below, not the mobile fix. The criterion reinterpretation\nstands on its own reasoning and does not depend on which change crossed the\nline: \"under 200 lines\" was a proxy for single file, vanilla, no build step, no\nframework, and all four hold.\n\nSECOND, AND MORE IMPORTANT: THE CAPTION IS A LABEL ON AN UNMET CRITERION, NOT A\nMET ONE. This ticket asked that the casts be checked against the current UI\nbefore embedding. They were, and `drill.cast` fails: it shows the narrow records\nformat with a single `Value` column, which improvise-c5v and improvise-2zq\nreplaced with a column per measure plus the `+cat` and `+` columns. The user\nruled to ship it with a visible caption rather than re-record or drop the\nsection, and to keep this ticket closed. That ruling is about what to SHIP; it\ndoes not make the criterion satisfied.\n\nSo the honest reading of this ticket's state is: delivered, with one embedded\ncast known to demo a UI the software no longer has, labelled as such on the page\nat body size and above the player rather than in a footnote, and improvise-2oxt\nopen to fix it properly. Anyone auditing \"do the casts reflect the current UI\"\nshould read improvise-2oxt, not this close reason, for the answer.","status":"closed","priority":4,"issue_type":"task","assignee":"wave2-p-milestones","owner":"el-github@elangley.org","created_at":"2026-04-09T04:08:07Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:22:56Z","started_at":"2026-09-20T18:02:40Z","closed_at":"2026-09-20T18:04:48Z","close_reason":"docs/index.html on wave/p-milestones: 194 lines, vanilla, single file, dark\nbackground, monospace headings, asciinema-player 3.10.0 pinned from jsdelivr\n(both URLs checked live, 200), all six sections, and the four improvise-d4w\ncasts. Verified by driving it in Chromium against both docs/ and the assembled\nsite/landing/: four players construct, each renders 37 terminal lines after\nplay, zero failed requests, zero page errors. That check was proved able to\nfail first — with autoPlay false the cast is not fetched until the click, so\ncounting players passes on a page whose every cast path 404s; breaking\n./casts/ to ./cats/ passes the construction check and fails the play check.\nNothing in CI asserts it yet: improvise-bgtf.\n\nTWO DEVIATIONS, both stated rather than papered over.\n\nPlayer geometry is cols 120 / rows 37, not the 100x30 this ticket and\nimprovise-d4w both name. Every cast header says 120x37, so the recordings\nnever complied with d4w's own \"Terminal: 100x30\"; a 30-row player clips the\nbottom seven rows of a 37-row cast, which is where the tile bar and the status\nline are.\n\nThe \"check they still reflect the current UI\" criterion was performed and the\nanswer is partly no. The tile bar's short_label vocabulary and the mode line\nstill match; the casts predate improvise-2zq's +cat column by five months and\nevery one opens on the recorder's shell prompt and home directory. Filed as\nimprovise-2oxt rather than embedded as if current.","labels":["area:release"],"dependencies":[{"issue_id":"improvise-e61","depends_on_id":"improvise-d4w","type":"blocks","created_at":"2026-04-08T21:09:31Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} {"_type":"issue","id":"improvise-kh8","title":"Phase 4: Landing page (optional)","description":"Create docs/index.html with embedded asciinema casts and enable GitHub Pages. Optional but recommended for launch.","acceptance_criteria":"Closes when improvise-e61 closes; improvise-3gy (enable GitHub Pages) is already closed. NOTE A COLLISION worth resolving before starting: Pages is already serving the standalone web app from the CI assemble step (improvise-d31/bck). Decide whether the landing page replaces that, sits beside it at a path, or is dropped in favour of the live app being the landing page — which is arguably the better demo and would close this as won't-fix.","notes":"CORRECTION TO THE CLOSE REASON ABOVE — THE USER OVERRULED IT, same session.\n\nThe close reason records the landing page sitting BESIDE the app at /landing/,\nwith the app keeping the root. The user ruled the other way:\n\n \"The landing page should be served at the root of the repository and link\n to the in-browser demo.\"\n\nSo the arrangement is inverted. `docs/index.html` is the site root and links\nprominently to the app, which moves to `/app/`. The argument for the other\narrangement is left standing above rather than deleted, because it is still the\nreason the alternative was considered and the collision this ticket asked to\nresolve was a genuine one — but it is OVERRULED, not merely superseded, and the\nroot is now the landing page.\n\nWHAT THE INVERSION COST, which is the part worth carrying:\n\n* The app moves under `app/` WHOLE — `index.html`, `app.js`, `style.css`,\n `worker.js`, `demo.improv` and BOTH bundle directories. Leaving `pkg/` and\n `worker-pkg/` at the root would have meant editing the wasm-pack generated\n glue; every path in app.js and worker.js is relative, so moving the bundle\n together keeps all of them correct and the glue untouched. `worker.js`'s\n `fetch(\"demo.improv\")` resolves against the WORKER's url, so the seed follows\n the move for the same reason.\n* `web/assemble.sh`'s four `stamped` guards had to follow the files. They are\n the only check that the cache-bust substitution worked, and a guard pointed at\n a path nothing writes fails in the direction that looks like success.\n* `harness/tests/shell.spec.js` navigates to `/index.html` six times, including\n the `?ws=` and `?doc=` variants. Every one of those now lands on the landing\n page, so all six were repointed at `/app/index.html`. The browser harness is a\n CI job that can fail the build, so this was not optional and not someone\n else's to discover.\n\nVERIFIED against the ASSEMBLED tree, not against docs/ directly, because a path\nmove is exactly what re-arms the trap this page already caught once: the four\ncasts play 37 lines each at the root, the primary call-to-action resolves to\n/app/ and returns 200, and there are no failed requests and no page errors. The\ncall-to-action check was proved able to fail by pointing the link at ./aap/,\nwhich returns 404 while the page still looks entirely correct.","status":"closed","priority":4,"issue_type":"feature","assignee":"wave2-p-milestones","owner":"el-github@elangley.org","created_at":"2026-04-09T04:05:53Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:10:48Z","started_at":"2026-09-20T18:02:40Z","closed_at":"2026-09-20T18:04:51Z","close_reason":"Both children terminal: improvise-3gy was already closed, improvise-e61 closes\nwith this wave.\n\nTHE COLLISION THIS TICKET ASKED TO RESOLVE FIRST, resolved: Pages serves the\nstandalone web app at the root (improvise-d31/bck), and the landing page now\nsits BESIDE it at /landing/ rather than replacing it. The reasoning is this\nticket's own — the live app is the better front door — and the third option,\ndropping the landing page in favour of the app, was declined only because the\nrecorded terminal casts show the TUI, which the browser app does not.\n\nweb/assemble.sh copies docs/index.html and docs/casts/ into the deployed tree,\nso the page is reachable and the browser harness serves the same tree the\ndeploy publishes. A landing page the deploy never copies would have satisfied\nthis ticket's letter and been a file nobody can open.\n\nREADME links both.","labels":["area:release"],"dependencies":[{"issue_id":"improvise-kh8","depends_on_id":"improvise-3gy","type":"blocks","created_at":"2026-04-08T23:37:42Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-kh8","depends_on_id":"improvise-e61","type":"blocks","created_at":"2026-04-08T23:37:41Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0} diff --git a/context/design-principles.md b/context/design-principles.md index 1900164e..6b9f8c1a 100644 --- a/context/design-principles.md +++ b/context/design-principles.md @@ -336,6 +336,37 @@ catches that: every `### name` heading must name a test that exists. It checks one direction only, deliberately — a test may have no evidence (most do not), but evidence naming a test nobody can find is a dangling explanation. +### E2E pins the boundaries; unit tests work between them + +**E2E and integration tests are rare and pin *boundary contracts*. Unit tests +*assume* those contracts and test between the boundaries** (user, 2026-09-20). + +So the question to ask of a slow test is never "what feature does this cover" — +it is **"which boundary is this, and what is the contract at it?"** The answer +changes what you write. Four browser widgets had never worked — the tile bar, +the view panel, the formula panel, and the category sidebar ignoring +`category_panel_open` — and all four were **one** boundary failure: a +`RenderModel` field with no element painting it. Four per-widget Playwright +specs cost four slow tests and would still have missed the fifth; a single +contract assertion that *every field the render model exposes is painted* +catches all four and the next one. `harness/tests/opfs.spec.js` is the cleanest +instance of the right shape already in the tree: it runs the **storage +contract**, not a feature. + +Three corollaries, in the order they bite: + +- **A unit test may assume the contract rather than re-establishing it.** That + assumption is what makes it cheap, and it is only sound because something + slow pins the contract — so deleting the contract test silently invalidates + every unit test that leaned on it. +- **Never weaken a contract to make it cheap.** If the honest contract needs a + real browser, a real `Worker` or real OPFS, it stays slow. Moving it to a + faster harness that cannot see the thing it names is how a suite goes green + over a broken product. +- **Each slow test must say which contract it pins.** The difference between a + contract test and a feature test is invisible from the code, so it has to be + written down or the next person adds the fifth per-widget spec. + ### Use property tests judiciously Property tests (`proptest`) are for **invariants that must hold across all From 3c9668f9e25b52c49dfe0ee9c0ce6be3df810f92 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Sun, 20 Sep 2026 13:40:08 -0700 Subject: [PATCH 17/17] ci: build the bundles once, and take the harness lint off the critical path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured on run 35535225637 (push to main, caches warm). The critical path was the browser job at 263s plus an 11s deploy; everything else finished inside it. Its decomposition: harness lint 37s, Playwright browser download 50s, `./harness/build.sh` 110s, `npx playwright test` 50s. Three wasm-pack builds ran in that 110s, and two of them — the client and the worker bundle — were the same two `pages-build` ran in its own 68s step, in a parallel job with a separate rust-cache key (`add-job-id-key` defaults on). Same work, twice per run. - One `build` job now runs `./harness/build.sh` (all three bundles plus the `harness/serve` tree) and then `./web/assemble.sh site $GITHUB_SHA` from the pkg dirs it just produced. It uploads `harness/serve`, the two raw bundles, and the Pages artifact. `pages-build` is folded into it. - `browser` downloads `harness/serve` whole and needs no Rust toolchain and no wasm-pack. Downloading the tree rather than reassembling it keeps one answer to "what does the harness serve" (improvise-vvl3): `harness/build.sh`. Verified locally that the two assemblies differ in exactly the four cache-bust-stamped files and nothing else — the .wasm bytes are identical. - `harness-lint` is its own job. It gated nothing the harness needs and cost 37s of the critical path, and `cargo fmt --all` run from inside `harness/opfs-contract` formats the WHOLE outer workspace (114 files, measured) despite that crate's empty `[workspace]` table, so a rustfmt violation anywhere in the repo failed the *Browser harness* job. `-p opfs-contract` scopes it to the one file it is about. - Playwright's browser download is cached on the resolved package version. - rust-cache in `build` and `harness-lint` covers `harness/opfs-contract`'s own target dir, which the default single-workspace config never did. No gate is weakened. `pages-deploy` still needs every check that could previously fail it; `harness-lint` is named explicitly because it used to be a step inside `browser`. `kb`, `wasm` and `test` gain no dependency on `build` — none of them consumes an artifact. On improvise-864a: the gap between the Pages artifact upload and the deploy narrows from ~182s to ~115s on this run's shape, so the window in which a superseding run can expire it is smaller. The race is not closed, and not widened. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 259 ++++++++++++++++++++++++++------------- README.md | 2 +- context/plan.md | 2 +- 3 files changed, 179 insertions(+), 84 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0015f78a..6d41924c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,107 @@ env: CARGO_TERM_COLOR: always jobs: + # --------------------------------------------------------------------------- + # The one place wasm-pack runs (improvise CI dedup). + # + # Before this job there were two: `pages-build` ran `web/build.sh`, and the + # browser harness ran `harness/build.sh`, which CALLS `web/build.sh`. The two + # wasm-pack builds were therefore done twice per run, in parallel jobs, with + # separate rust-cache keys (`add-job-id-key` is on by default, so `browser` + # and `pages-build` never shared a target dir). Measured on run 35535225637 + # (push to main, caches warm): 68s inside `pages-build`, and the same 68s + # inside the browser job's 110s build step. + # + # Everything downstream now DOWNLOADS. The invariant that buys is artifact + # identity: the harness and Pages are served bytes from one wasm-pack run, + # so "the harness runs against the tree Pages publishes" (improvise-vvl3) is + # true by construction rather than because two scripts agree. + # + # The native binary is deliberately NOT built here. Nothing downstream + # consumes one — `cargo test --workspace` in the `test` job builds its own, + # for its own target dir and its own cache key, and a `needs:` on this job + # would serialise a 109s job behind a 126s one for no artifact at all. + build: + name: Build all bundles + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + # Two workspaces, because `harness/opfs-contract` carries an empty + # `[workspace]` table and so has its own target dir, which the default + # single-workspace configuration never cached (it recompiled its whole + # dependency tree every run). + - uses: Swatinem/rust-cache@v2 + with: + workspaces: | + . -> target + harness/opfs-contract -> target + # Pinned release binary rather than `cargo install` (a three-minute + # source build) or a third-party action. wasm-pack runs its own bundled + # `wasm-opt`, which is why improvise-wasm-client's Cargo.toml names + # `--enable-bulk-memory`: without it the optimizer rejects the + # `memory.copy` LLVM emits regardless. + # v0.15.0 is what `nix develop` supplies, and the bundle was built and + # measured against it locally (346 KB, 120 KB gzipped). Pinning the same + # version is what makes that local run evidence about this one. + - name: Install wasm-pack + env: + WASM_PACK_VERSION: v0.15.0 + run: | + set -eu + url="https://github.com/rustwasm/wasm-pack/releases/download/${WASM_PACK_VERSION}/wasm-pack-${WASM_PACK_VERSION}-x86_64-unknown-linux-musl.tar.gz" + curl -sSfL "$url" | tar -xz --strip-components=1 -C /usr/local/bin \ + "wasm-pack-${WASM_PACK_VERSION}-x86_64-unknown-linux-musl/wasm-pack" + wasm-pack --version + + # The same script a developer runs locally, so there is one answer to + # "how is the bundle built" rather than two that can drift. It builds all + # three wasm bundles (client, worker, opfs-contract) and assembles + # `harness/serve` — the deployed arrangement plus /opfs/. + - name: Build the bundles and the harness tree + run: ./harness/build.sh + + # Re-assembles from the pkg dirs the step above already produced — no + # wasm-pack runs here. A second assembly rather than a copy of + # `harness/serve` because the cache-bust token differs: the harness tree + # is stamped `harness` and the published site the commit SHA, which is + # what makes a returning visitor pick up a fresh bundle (improvise-9x6). + - name: Assemble the site + run: ./web/assemble.sh site "$GITHUB_SHA" + + - run: find site -type f | sort + - run: du -sh site + + # The tree the browser harness serves. Uploaded whole rather than + # reassembled downstream: a second assembly path is a second answer to + # "what does the harness serve", which is the thing improvise-vvl3 exists + # to prevent. + - name: Upload the harness tree + uses: actions/upload-artifact@v7 + with: + name: harness-serve + path: harness/serve + retention-days: 1 + include-hidden-files: true + + # For a job that needs the raw wasm-pack output rather than the + # assembled site — the Node bundle suite under `web/tests/bundle/`. + - name: Upload the wasm bundles + uses: actions/upload-artifact@v7 + with: + name: wasm-bundles + path: | + web/pkg + web/worker-pkg + retention-days: 1 + include-hidden-files: true + + - uses: actions/upload-pages-artifact@v5 + with: + path: site + test: name: Test and lint (host) runs-on: ubuntu-latest @@ -111,6 +212,37 @@ jobs: nix shell nixpkgs#trealla --command tpl prolog-sessions/kb.pl \ -g "(kb_consistent -> halt(0) ; (kb_report, halt(1)))" + # --------------------------------------------------------------------------- + # Lints the one crate the `test` job cannot reach. + # + # `harness/opfs-contract` carries an empty `[workspace]` table, so it is its + # own root and `cargo clippy --workspace` at the repo root never sees it. + # + # Its own job rather than a step inside `browser`, for two reasons. It took + # 37s of the browser job's 263s on run 35535225637 while gating nothing the + # harness needs — a lint failure and a harness failure are different reports, + # and here the lint failure wore the harness's name. And `cargo fmt --all` + # from inside that directory formats the WHOLE outer workspace (114 files, + # measured) rather than the one crate, because cargo-fmt's `--all` walks past + # the nested workspace boundary — so a rustfmt violation anywhere in the repo + # failed the *Browser harness* job. `-p opfs-contract` scopes it to 1 file. + harness-lint: + name: Lint the harness crate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + components: clippy, rustfmt + - uses: Swatinem/rust-cache@v2 + with: + workspaces: harness/opfs-contract -> target + - working-directory: harness/opfs-contract + run: | + cargo fmt -p opfs-contract -- --check + cargo clippy --target wasm32-unknown-unknown --all-targets -- -D warnings + # --------------------------------------------------------------------------- # The browser harness (improvise-vvl3, absorbing improvise-xf1). # @@ -120,55 +252,56 @@ jobs: # by reading or by a person pressing keys, neither of which is repeatable. # # It can fail the build, deliberately: a harness nobody runs is worse than - # none, because it reads as coverage. Playwright's browser download is ~80 MB - # and takes well under a minute on ubuntu-latest, which is affordable next to - # the two wasm-pack builds this job already needs. + # none, because it reads as coverage. + # + # It now needs NO Rust toolchain: the tree it serves is downloaded from + # `build`, which is what makes "the harness runs against the tree Pages + # publishes" (improvise-vvl3) a fact about bytes rather than about two + # scripts agreeing. The download is `harness/serve` whole, so there is still + # exactly one place that arrangement is decided — `harness/build.sh`. browser: name: Browser harness (OPFS contract + shell) + needs: build runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@stable - with: - targets: wasm32-unknown-unknown - # Named rather than assumed: this job lints harness/opfs-contract, - # which the `test` job's workspace-wide fmt and clippy cannot reach. - components: clippy, rustfmt - - uses: Swatinem/rust-cache@v2 - uses: actions/setup-node@v4 with: node-version: 22 - - name: Install wasm-pack - env: - WASM_PACK_VERSION: v0.15.0 - run: | - set -eu - url="https://github.com/rustwasm/wasm-pack/releases/download/${WASM_PACK_VERSION}/wasm-pack-${WASM_PACK_VERSION}-x86_64-unknown-linux-musl.tar.gz" - curl -sSfL "$url" | tar -xz --strip-components=1 -C /usr/local/bin \ - "wasm-pack-${WASM_PACK_VERSION}-x86_64-unknown-linux-musl/wasm-pack" - wasm-pack --version - # Not a workspace member (it only builds for wasm32), so the `test` job's - # workspace-wide fmt and clippy do not reach it. - - name: Lint the harness crate - working-directory: harness/opfs-contract - run: | - cargo fmt --all -- --check - cargo clippy --target wasm32-unknown-unknown --all-targets -- -D warnings - name: Install the harness working-directory: harness run: npm ci + # Keyed on the resolved @playwright/test version, so a version bump is a + # miss and anything else is a hit. The download was 50s on run + # 35535225637 and is pure I/O — nothing about it depends on this commit. + - name: Playwright version + id: pw + working-directory: harness + run: echo "v=$(node -p "require('@playwright/test/package.json').version")" >> "$GITHUB_OUTPUT" + - uses: actions/cache@v4 + id: pw-cache + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ steps.pw.outputs.v }} # All three, because improvise-xf1's acceptance criterion names Chrome, # Firefox and Safari and `createSyncAccessHandle` is implemented # separately by each engine. WebKit is installed even though its OPFS is # absent today: the spec skips on a runtime probe, so the day that build # gains OPFS the check starts running without a CI change. + # `--with-deps` installs apt packages OUTSIDE the cached directory, so it + # runs on a cache hit too; only the browser download is skipped. - name: Install browsers working-directory: harness run: npx playwright install --with-deps chromium firefox webkit - # Builds both site bundles, the harness's own wasm bundle, and assembles - # `harness/serve` — the same tree `pages-build` publishes, plus /opfs/. - - name: Build the harness - run: ./harness/build.sh + # The tree `build` assembled, not one this job rebuilds. Same bytes as + # the Pages artifact, modulo the cache-bust token `web/assemble.sh` + # stamps — the harness tree is stamped `harness` and the site the commit + # SHA, which was already true when both jobs built their own. + - name: Download the harness tree + uses: actions/download-artifact@v7 + with: + name: harness-serve + path: harness/serve - name: Run the harness working-directory: harness run: npx playwright test @@ -182,12 +315,13 @@ jobs: # --------------------------------------------------------------------------- # GitHub Pages delivery (improvise-d31). # - # Two jobs on purpose. `pages-build` runs on every push and pull request, so - # a change that breaks `web/build.sh` or the bundle fails on the PR that made - # it — the build is a gate. `pages-deploy` runs only on a push to main and - # `needs` the check jobs above, so nothing that failed `cargo test`, the - # wasm32 gate or the browser harness can reach the published site. The deploy - # depends on those checks rather than repeating them (improvise-d31 AC 2). + # The artifact is uploaded by `build`, which runs on every push and pull + # request, so a change that breaks `web/build.sh` or the bundle fails on the + # PR that made it — the build is a gate. `pages-deploy` runs only on a push + # to main and `needs` the check jobs, so nothing that failed `cargo test`, + # the wasm32 gate or the browser harness can reach the published site. The + # deploy depends on those checks rather than repeating them (improvise-d31 + # AC 2). # # Not path-filtered, deliberately. The bundles are `improvise-wasm-client` and # `improvise-worker-server` plus, between them, most of the workspace — the @@ -196,54 +330,15 @@ jobs: # that meant "skip when the bundle cannot have changed" would have to name # most of the workspace, and the failure mode of getting it wrong is a stale # site with no signal that it is stale. A redundant build is the cheaper error. - pages-build: - name: Browser bundle + static site - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@stable - with: - targets: wasm32-unknown-unknown - - uses: Swatinem/rust-cache@v2 - # Pinned release binary rather than `cargo install` (a three-minute - # source build) or a third-party action. wasm-pack runs its own bundled - # `wasm-opt`, which is why improvise-wasm-client's Cargo.toml names - # `--enable-bulk-memory`: without it the optimizer rejects the - # `memory.copy` LLVM emits regardless. - # v0.15.0 is what `nix develop` supplies, and the bundle was built and - # measured against it locally (346 KB, 120 KB gzipped). Pinning the same - # version is what makes that local run evidence about this one. - - name: Install wasm-pack - env: - WASM_PACK_VERSION: v0.15.0 - run: | - set -eu - url="https://github.com/rustwasm/wasm-pack/releases/download/${WASM_PACK_VERSION}/wasm-pack-${WASM_PACK_VERSION}-x86_64-unknown-linux-musl.tar.gz" - curl -sSfL "$url" | tar -xz --strip-components=1 -C /usr/local/bin \ - "wasm-pack-${WASM_PACK_VERSION}-x86_64-unknown-linux-musl/wasm-pack" - wasm-pack --version - - # The same script a developer runs locally, so there is one answer to - # "how is the bundle built" rather than two that can drift. - - name: Build the wasm bundle - run: ./web/build.sh - - # `web/assemble.sh`, not an inline copy of it: the browser harness job - # below assembles the same tree, and a harness that runs against a - # different arrangement of files is testing something nobody deploys. - - name: Assemble the site - run: ./web/assemble.sh site "$GITHUB_SHA" - - - run: find site -type f | sort - - run: du -sh site - - - uses: actions/upload-pages-artifact@v5 - with: - path: site - + # + # On improvise-864a (concurrent merges racing on one Pages environment, the + # superseded run failing "Found 0 artifacts"): building once shortens the gap + # between upload and deploy — 182s before, ~115s after, on the run 35535225637 + # shape — so the window in which a superseding run can expire this run's + # artifact is smaller. It does not close the race; it does not widen it. pages-deploy: name: Deploy to GitHub Pages - needs: [test, wasm, browser, pages-build] + needs: [test, wasm, browser, harness-lint, build] if: github.event_name == 'push' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest permissions: diff --git a/README.md b/README.md index b7ee5844..a9f61f4c 100644 --- a/README.md +++ b/README.md @@ -180,7 +180,7 @@ improvise script setup.txt -f model.improv ## In the browser The browser page is at ****, -built and published from `main` by the `pages-build` / `pages-deploy` jobs in +built and published from `main` by the `build` / `pages-deploy` jobs in `.github/workflows/ci.yml`. A deploy only happens after `cargo test`, the wasm32 check and the Playwright browser harness have passed. diff --git a/context/plan.md b/context/plan.md index 2abf1f68..5c430be3 100644 --- a/context/plan.md +++ b/context/plan.md @@ -239,7 +239,7 @@ Each cast should be embedded with `AsciinemaPlayer.create()` configured with `th ### 4.2 Enable GitHub Pages -**Not branch-and-folder.** Pages is served from the GitHub Actions artifact that `pages-build` / `pages-deploy` publish in `.github/workflows/ci.yml` (Settings → Pages → Source: GitHub Actions), and the tree is assembled by `web/assemble.sh`: `docs/index.html` and `docs/casts/` at the root, the browser app under `app/`. +**Not branch-and-folder.** Pages is served from the GitHub Actions artifact that `build` / `pages-deploy` publish in `.github/workflows/ci.yml` (Settings → Pages → Source: GitHub Actions), and the tree is assembled by `web/assemble.sh`: `docs/index.html` and `docs/casts/` at the root, the browser app under `app/`. Setting the source to branch `main` / folder `/docs` instead would serve `docs/` raw. That used to fail loudly — there was no `docs/index.html`, so it was a 404 — and since improvise-e61 it fails **quietly**: the landing page renders perfectly and its "Run improvise in your browser" link 404s, because `app/` exists only in the assembled tree. A page that looks right with a dead front door is worse than a page that is missing.