ba120df4cb
The pre-commit hook that re-exported and staged .beads/issues.jsonl was removed
(adfc765 turned off git-add; the hook itself is gone), which was the right call:
the file used to ride along on every branch that did any bd work and conflicted
with every other branch that did the same — it left the index unpickable during
the PR #50 merge.
The consequence is that the export is now refreshed deliberately rather than
continuously, exactly like context/repo-map.md, and it had drifted: 314 records
committed against 338 in Dolt. Twenty-four beads filed since the hook went are
absent, including every one from today's web-UI and review rounds.
That staleness is load-bearing rather than cosmetic. The documentation site in
PR #62 links a ticket id only when it appears in this export, and renders it as
plain text otherwise — so every missing id is an unlinked citation on the public
site. The site is built to degrade rather than to assume, which is right, but
the degradation should reflect reality and not a stopped clock.
Dolt remains the source of truth; this file is a passive export of it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
339 lines
1.0 MiB
Plaintext
339 lines
1.0 MiB
Plaintext
{"_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":"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}
|
||
{"_type":"issue","id":"improvise-gv9r","title":"[bug] A wire model command never marks the document dirty","description":"Found by improvise-9x6 (2026-09-20) the hard way: the worker first persist trigger was `if dirty`, and it SILENTLY NEVER FIRED.\n\nreduce::model_effects maps ModelCommand::SetCell to effect::SetCell, which writes the cell and does NOT touch model_state.dirty. Dirtiness is raised by a SEPARATE MarkDirty effect that only the command/key path emits -- so on every transport, a client edit leaves the authoritative document reporting CLEAN.\n\nTwo consequences, and the second is the one that will bite someone:\n * \"Save if dirty\" is a trigger that never fires. Any host built on it loses every client edit, silently.\n * Anything else that reads model_state.dirty about a client-edited document gets the wrong answer -- an unsaved-changes prompt, an autosave, a UI marker.\n\nWHY IT HAPPENED: MarkDirty being a separate effect is deliberate and reasonable on the key path, where a command composes the pair. The derivation that turned effects into ModelCommands (improvise-kmgs) took the effects ONE AT A TIME, so the pairing was lost -- the wire has SetCell without its MarkDirty companion. That is the interesting shape: a derivation that is correct per element and wrong for a set.\n\nCANDIDATE FIXES, and this wants deciding alongside improvise-9x6 sibling ticket about Save: make model_effects emit the MarkDirty companion where the key path does; or fold dirtiness into the effects that change the document so it cannot be forgotten (the unrepresentable-not-validated answer, and probably right); or decide that dirty is a host-local concept the wire has no opinion on, and then say so where someone will read it.","status":"closed","priority":1,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T02:10:13Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:29:47Z","started_at":"2026-09-20T05:32:28Z","closed_at":"2026-09-20T07:29:47Z","close_reason":"Closed by PR #26 (WU-C), with the unrepresentable answer the ticket leaned toward.\n\nmodel_state.dirty is now raised by App::apply_effects for every EffectKind::Model effect, off the SAME classification the Dirty and the two lower basis tiers already read -- one classification, three readers. MarkDirty, mark_dirty() and ALL 17 CALL SITES ARE DELETED, so no path can compose a document change without it.\n\nVERIFIED RATHER THAN ASSERTED: the reviewer diffed all 19 MarkDirty call sites on main against the branch and traced each one's companion effect -- ClearCell, SetCell, TransposeAxes, AddItem, AddCategory, RemoveCategory, RemoveItem, RemoveFormula, CreateView/SwitchView, DeleteView, HideItem, ToggleGroup, SetNumberFormat, the tile axis_effect -- every one is EffectKind::Model, so apply_effects covers it. The two conditional sites reached no mark_dirty before either. Nothing was lost.\n\nTHE WORKER TRIGGER BECAME AN OBSERVATION: touches_document enumerated command variants BECAUSE dirty was unusable. With dirty trustworthy the trigger is the document itself -- the same choice cursor_epoch makes over CycleAxis and document_generation makes over LoadModel. Strictly tighter, and it retired the \"was anything rejected\" guard beside it.\n\nTHE ROOT CAUSE, which is the generalisable part and is now doctrine(a_derivation_correct_per_element_can_be_wrong_for_a_set): improvise-kmgs derived ModelCommand from the effects ONE AT A TIME and was right about every element -- but the key path never emits SetCell alone, it emits the PAIR with MarkDirty. So the wire got the mutation without its companion and no client edit ever marked the document dirty. When deriving a vocabulary from a set of primitives, ask what the existing callers COMPOSE, not only what each primitive is.\n\nTHREE BEHAVIOUR CHANGES CAME WITH IT, all recorded in the PR body:\n 1. Switching views, paging and stepping view history now mark the document dirty, so :q refuses after them. The user ruled this CORRECT -- Initial View and page selections are persisted, so discarding them silently was the bug, and :q! remains.\n 2. LoadModel leaves the document dirty, because it does not move file_path -- so the in-memory document really is a different one from the file the path names. A test pins the REASON, not just the flag.\n 3. A drill exited with no edits, and view-back, also mark it dirty -- a WRONG answer to no divergence, unlike (2). Filed as improvise-748f.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-368h","title":"[bug] No client on any transport can ask its host to save","description":"Found by improvise-9x6 (2026-09-20) while building the worker persist trigger, and it affects improvise-ws-server too -- which has shipped without anyone noticing.\n\neffect::Save is EffectKind::ViewOnly, and CORRECTLY so: it writes to THIS HOST file path and clears THIS HOST dirty flag, mutating no document state. But the wire vocabulary is DERIVED from the model effects (gotcha 38), so the derivation cannot reach it. There is no Command::Save and no ModelCommand::Save.\n\nConsequence: a connected client can edit the document and can never cause it to be written. On the socket deployment that means a ws-server session edits in memory and the file on disk never changes unless a human is at a TUI that is not there.\n\nTHIS IS A PROTOCOL DECISION, NOT A PATCH. Two coherent answers:\n * ADD Command::Save. But Save is genuinely ViewOnly and genuinely per-host -- a client asking a host to write to a path the client cannot see is a different thing from a client mutating the document, and it needs an answer for \"which path\" and \"what if there is none\" (improvise-lae already exists for the no-path case).\n * SAY EXPLICITLY THAT HOSTS OWN PERSISTENCE, and make each host trigger on its own terms. That is what improvise-9x6 did for the worker, and in a browser it is arguably RIGHT rather than a workaround: there is no file the user owns and no Ctrl-S semantics -- the document IS the OPFS entry. But then ws-server needs its own answer, and today it has none.\n\nDecide which. The gap is that neither was chosen, so the socket server silently does nothing.","status":"closed","priority":1,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T02:10:11Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:29:47Z","started_at":"2026-09-20T05:32:28Z","closed_at":"2026-09-20T07:29:47Z","close_reason":"Closed by PR #26 (WU-C). The user ruled \"add Command::Save to the wire\"; what shipped is ClientFrame::Save -- still on the wire, one layer out -- and the user accepted the deviation.\n\nWHY IT MOVED: shipped as Command::Save first, and review found it put a RUNTIME-FALLIBLE operation inside a batch documented as all-or-nothing -- [Save, SetCell] reported a failed write and then applied the edit anyway. The argument for the frame: a Command is something the shared state FOLDS, and saving mutates no shared state and no client's slice, so reduce_view had nothing to fold and Checked had no basis to answer for -- both arms were shims. As a frame it cannot be composed with a mutation BY CONSTRUCTION.\n\nTHE TWO QUESTIONS THE TICKET SAID IT WOULD FORCE, answered:\n * WHICH PATH: none. It is a REQUEST, not an instruction. A client cannot see the host's storage, so the host supplies the path.\n * WHAT IF THERE IS NONE: Rejection::NoDocumentPath, decided before anything runs, plus Rejection::SaveFailed for a write that fails. Reuses the existing refusal channel (gotcha 39, addressed to the asker alone) rather than inventing a second one. App::last_batch_aborted is the one new observable and its sole consumer is Host::save, where the batch is literally [Save].\n\nTHE GAP THE TICKET NAMED IS HALF CLOSED, and the remaining half is filed: the feature works end to end in Rust and is UNREACHABLE FROM A BROWSER -- keys.rs still lists \"save\" (Ctrl-S) as upstream, and an upstream key is a black hole (doctrine(an_upstream_key_is_a_black_hole)). One line to resolve it locally, but it owes an answer on a status-line divergence (success is silent on the wire while the host sets \"Saved to ...\"). Not wired here because keys.rs was another unit's ground.\n\nThe socket server, which the ticket noted had shipped with this hole, is covered by the same frame.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-ondj","title":"[bug] reduce_full/apply_command apply a Command::Model without the basis check","description":"Found by the code review of ADR 001 layer 1 (2026-09-19). CONFIRMED, not live today, and it should be closed BEFORE the browser worker lands.\n\nui/reduce.rs is documented as \"the one place a command is applied to the authoritative state\" and \"Every deployment mode -- the native TUI, a websocket server, a browser worker -- reaches the model through the functions here\". Both pub fn apply_command and pub fn reduce_full accept a \u0026Command including Command::Model, and:\n\n Command::Model { cmd, .. } =\u003e { // the basis is DISCARDED by `..`\n let effects = model_effects(app, cmd);\n app.apply_effects(effects)\n }\n\nThe check lives ONLY in Sessions::apply_model.\n\nWHY THIS IS THE INTERESTING SHAPE: making Command::Model a struct variant made the basis impossible to OMIT FROM THE MESSAGE. It did nothing to make it impossible to IGNORE ON ARRIVAL -- and `..` in a pattern is exactly the syntax for ignoring it, with no compile error. So the design got half of what a struct variant is usually reached for.\n\nNothing in-tree routes a client command through reduce_full (only tests), so nothing is broken now. But improvise-9x6 (the browser worker) is precisely the host that will reach for the function the module doc points it at, and it would apply client-supplied model commands unchecked.\n\nREMEDIES, pick deliberately:\n * Have apply_command take a ModelCommand from a private caller and expose only Sessions::apply for a wire Command -- makes the door unreachable rather than guarded.\n * Or have reduce_full return a Rejected-shaped result for Command::Model.\n * At absolute minimum, a compile-visible note AND a test that names the door, so the next person meets it.\n\nThe first is the one that matches this repo: invariants are made unrepresentable, not validated (doctrine(an_invariant_is_made_unrepresentable_not_validated)).","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T01:05:13Z","created_by":"Edward Langley","updated_at":"2026-09-20T01:54:29Z","closed_at":"2026-09-20T01:54:29Z","close_reason":"Fixed as option 1 -- make the door unreachable -- but as a TYPE rather than as visibility.\n\napply_command and reduce_full now take Checked\u003c'a\u003e, a private-field newtype over a \u0026Command whose ONLY constructor is Checked::against(\u0026App, \u0026Command) -\u003e Result\u003cSelf, Rejection\u003e, which is also the only caller of stale_basis. So there is no longer a spelling of 'apply this batch and ignore what it claims': the that discarded the basis is now a Rejection the caller must handle. Sessions::apply answers ONCE, before the dispatch, and hands the proof to apply_switch/apply_model -- so neither has to remember to ask, and apply_model lost its basis/model parameters entirely.\n\nWHY NOT pub(crate), which is what the ADR literally suggested: it works, but reduce_full then has NO non-test caller anywhere in the workspace, so it becomes a dead_code warning in the non-test build, and the only ways out are deleting a documented host API or cfg(test)-gating it. The newtype keeps both functions public and useful to a future host -- including the browser worker, which now gets a door that WORKS and is safe -- while making the unchecked path unconstructible. It also holds INSIDE the crate, which visibility does not: a second in-crate mutation path cannot be written without the check either.\n\nEVERYTHING THE TICKET SAID MUST SURVIVE, DOES: the check is still a property of the session door and not of every mutation (the key path, model_effects and App::apply_effects are untouched, and a host's own ModelCommand still needs no basis); caught_up(\u0026App, cmd) is unchanged and still not a bypass; the 10 basis_check tests are untouched and all pass.\n\nTHE REGRESSION GUARD IS THE SIGNATURE. The demonstration test (committed first, failed on the old code) NO LONGER COMPILES after the fix, which is the better outcome. What survives asserts that Checked::against returns Err(Rejection::LayoutMoved) and the document is untouched, with a doc-comment saying in prose that the signature is the real guard. Check-the-checker: making against() never refuse fails 8 tests -- the new one plus 7 of the 10 basis_check ones.\n\nNATIVE CLI EXERCISED FOR REAL, not merely compiled: improvise cmd and improvise script both land their changes on disk. They go through the keymap/registry path and never touched reduce_full.\n\nTHE THING THE TICKET DID NOT PREDICT, and it is the reason this defect could sit unnoticed: reduce_full HAS NO PRODUCTION CALLER IN THE ENTIRE WORKSPACE, and apply_command's view-slice arm is unreached outside tests. Every host reaches the model through Sessions::apply or reduce_key. So ui/reduce.rs's 'the signature the three hosts share' is ASPIRATIONAL -- a door pointed at by documentation and used by nobody, which is exactly the shape that hides a defect until the next host arrives. Filed as a successor.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-xks6","title":"One home for the client/server frame envelopes","description":"Two crates now define the same frames independently, which is a live divergence risk:\n improvise-wasm-client::wire -- ClientMessage / ServerMessage\n improvise-ws-server::wire -- ClientMsg / ServerMsg\nThe ws-server agent asked for one version to be landed and will delete its wire.rs and re-export; the change on its side is two type names.\n\nPUT THEM IN improvise-protocol. Both sides already depend on it, and this is the one thing there that is FRAMING rather than vocabulary -- the protocol says what a Command is, but not what distinguishes one JSON blob from another on the wire.\n\nTHE SHAPE, as the ws-server landed it and the client should adopt:\n client -\u003e server: Viewport { rows: usize, cols: usize } | Command(Command)\n server -\u003e client: Snapshot(Box\u003cSnapshot\u003e) | Commands(Vec\u003cCommand\u003e)\nNotes that matter: Snapshot is BOXED because clippy large_enum_variant fires otherwise and a Snapshot is sent once per connection while Commands is every frame. Server-\u003eclient is ONE shape carrying both projections (as Command::Project) and a peer broadcast, because on the wire they are the same thing and a client folds both -- a separate Projections variant was rejected for that reason.\n\nViewport is the only addition to the shared vocabulary and it is derived, not invented: the extent is the one session input no Command carries by construction (gotcha 40).","notes":"RAISED TO P1 AND SEQUENCED AHEAD OF improvise-9x6 (2026-09-20).\n\nThere are now THREE envelope vocabularies for TWO deployments:\n * improvise-ws-server::wire -- ClientMsg/ServerMsg, the socket, server side\n * improvise-wasm-client::socket -- ClientFrame/ServerFrame, the same socket MIRRORED, because a native crate cannot be a wasm dependency\n * improvise-wasm-client::wire -- ClientMessage/ServerMessage, the WORKER deployment (improvise-tm6), currently unused by anything\n\nimprovise-9x6 gives the worker its own Host. If it lands before this ticket, it will speak a FOURTH arrangement and consolidation gets harder rather than easier -- so this comes first, or is at least DECIDED first.\n\nThe answer is almost certainly improvise-protocol owning the framing, since both ends already depend on it and its charter is exactly 'the wire vocabulary'. The open question this ticket must actually answer is whether the WORKER and the SOCKET want the SAME vocabulary. They differ today in ways that may be essential or may be accident: the socket has Viewport and no Key; the worker envelope was written before the projection architecture landed. Decide that before moving either, or the move produces two homes instead of one.\n\nThe mirror is currently held honest by two tests (gotcha 42) -- the_socket_framing_matches_the_server and every_server_variant_is_one_this_crate_mirrors. Both DELETE when this lands, and that is the measure of success.","status":"closed","priority":1,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T23:46:23Z","created_by":"Edward Langley","updated_at":"2026-09-20T01:52:29Z","closed_at":"2026-09-20T01:52:29Z","close_reason":"Done. Three envelope vocabularies for two deployments become ONE pair: improvise-protocol::frame owns ClientFrame { Viewport, Command, Key } and ServerFrame { Snapshot, Commands(Vec\u003c_\u003e), Rejected }; improvise-ws-server and improvise-wasm-client each pub use them and neither defines one. Both wire.rs files DELETED.\n\nTHE QUESTION THE TICKET SAID TO DECIDE FIRST -- do the worker and the socket want the same vocabulary -- IS ANSWERED YES, and the test applied to each difference was whether the ARGUMENT for it names the thing they differ in. None did:\n * Viewport is deployment-independent. Its justification is that the extent is a fact about a terminal or a browser window, is not in ViewState, and no effect produces it, so no derivation reaches it. Nothing transport-shaped. Its handshake role is a property of GridCache::seed (a Snapshot seeds the VISIBLE cells), not of sockets. A worker hosting the same Sessions owes set_extent the same answer.\n * Key is deployment-independent. Its justification (gotcha 38) is that a binding names an INTENT resolved against a CmdContext a thin client does not hold -- which says nothing about what the bytes travel over. Decisive evidence: the wasm client ALREADY PRODUCES a Key frame on both paths and tests/lockstep.rs round-trips it. So the socket's 'no Key' was never a statement about the socket, it was an unfinished feature (improvise-bpq2) that the separate envelope made invisible.\n * Commands(Vec\u003c_\u003e) vs Command(_): the batching is a property of the FOLD, not the transport -- a fan-out must arrive whole and in sequence because a cell update that overtakes its labels is DROPPED (gotcha 33), and an in-process postMessage interleaves exactly as a socket does. Commands(vec![c]) expresses everything the singleton could; the reverse is false.\n\nAND THE WORKER PAIR WAS INCOHERENT, NOT MERELY DIFFERENT -- the strongest evidence its differences were staleness. Written before ADR 001, it could carry Command::Model { basis, .. } and had NO Rejected variant, so a client speaking it composed a claim that could never be answered. It was missing a variant its own payload type demands.\n\nA FIND THE TICKET DID NOT PREDICT: the worker envelope was NOT 'unused by anything'. It was the return type of Client::set_cell/set_cell_at/key/on_key_event and the input to apply/handle_message/on_message. The wasm client was carrying TWO PARALLEL FOLD SURFACES OVER ONE STATE, identical except that the worker one had no Rejected arm. Once the envelopes unified they were literal duplicates, so the worker trio is deleted; web/app.js calls neither.\n\nBOTH MIRROR TESTS DELETED, which was the stated measure of success. What they checked is now TYPE IDENTITY: a rename is a compile error at every call site in both crates, and 'a variant added on one side and not the other' is not a constructible state. Strictly stronger than a hand-written fixture list. The check-the-checker MOVES rather than dies -- the_retired_spellings_do_not_parse keeps the injected-fault discipline and aims it at the one thing still possible, the retired Bootstrap/Command spellings returning as serde ALIASES, which would be two homes again wearing one type. End-to-end compatibility is now browser_round_trip.rs handing the wasm client's own serialized frame to a real Host::handle.\n\nHost::handle gains an explicit ClientFrame::Key =\u003e Vec::new() arm. Behaviour unchanged -- the frame previously failed to parse and was skipped -- but the gap is now A LINE OF CODE WITH improvise-bpq2 ON IT rather than a variant that does not exist.\n\n1134 -\u003e 1132 passing (5 deleted, 3 added), fully accounted for. Clippy, fmt, wasm32 all clean.\n\nWHAT improvise-9x6 CAN NOW ASSUME: nothing to define and no fourth arrangement to invent; first inbound frame is ClientFrame::Viewport for the same reason; ClientFrame::Key is already on the wire and already produced by the client, and a worker holds the authoritative App so it can resolve it through reduce_key where the socket cannot -- the worker may close bpq2's mechanism first; ServerFrame::Commands must be delivered whole and in order across a MessagePort; ServerFrame::Rejected must be answered, since ADR 001 is not socket-only.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-bpq2","title":"Client-side key resolution: what must the projections carry?","description":"USER RULING (2026-09-19): keys are converted into commands IN THE BROWSER; the keymap is a client-only concept the server must not know. Follows from the standing principle -- the model is shared, the UI is not, and a keymap is UI. It also buys something real: two clients may have DIFFERENT bindings over the same document, which is impossible if the server owns the keymap.\n\nThis inverts improvise-gsw conclusion (which had keys travelling upstream unresolved) and closes the keypress-door ticket. The work is now to make client-side resolution POSSIBLE.\n\nTHE PROBLEM TO SOLVE. A binding names an INTENT resolved against a CmdContext -- Model, GridLayout, cursor, yanked cell, expanded cats, mode. A Command is the RESOLVED MUTATION, e.g. SetSelected { row, col }. A thin client holds ViewState plus the widget caches. So for each binding ask: can a client holding only that state compose the Command? Where the answer is no, the fix is to ENRICH THE PROJECTION, not to move resolution to the server.\n\nKNOWN GAPS, from the gsw agent own analysis of what it could not resolve:\n * CURSOR MOVEMENT -- GridCache is indexed by position WITHIN THE VIEWPORT, so a client cannot distinguish \"last visible row\" from \"last row\" and cannot clamp. It needs the total row/col count, or an explicit \"can I move further\" fact.\n * PANEL CURSOR MOVEMENT -- move-panel-cursor walks a flattening that depends on expanded_cats. Either the cache carries the flattened list, or the client re-derives it -- and re-deriving is how two ends drift.\n * MODIFIED CHARACTERS -- KeyPattern::AnyChar matches a Char under ANY modifiers on the host. A client mirroring that is fine now that the client owns the keymap outright; the old constraint (local resolution may only ever be a strict subset) DISSOLVES once there is no host keymap to be a subset of.\n\nMETHOD: enumerate the bindings, not the commands -- the keymap is the thing being made client-side, so walk it. doctrine(binding_dump_diff) already says most bindings have no direct test, so a green suite is not evidence here either.\n\nALSO IN SCOPE: move ClientMessage/ServerMessage into improvise-protocol. The vocabulary is there and the framing that says which a JSON blob is, is not; the ws-server will want the same envelopes and two crates should not define them.\n\nBlocks improvise-1ey.","notes":"\n--- THE COMPOSABILITY LIST (improvise-q08 agent, 2026-09-19) ---\nWhich Commands a thin client holding ViewState + the five widget caches CANNOT compose. This is the real content of this ticket.\n\nHARD BLOCKERS:\n1. SetCell { key: CellKey } and ClearCell(CellKey) -- THE BIG ONE, and it blocks\n the most basic action there is. A client sees a grid of LABEL STRINGS indexed\n by viewport position. A CellKey is the full sorted coordinate tuple: every Row\n and Column category, plus every Page AND Filter selection (gotcha 27). Nothing\n in GridCache says which category a row label belongs to; nested row axes\n flatten several categories into one label; the page/filter constraints are not\n projected at all; and records mode encodes a COLUMN INDEX in _Dim (gotcha 1).\n There is no client-side derivation. NOTE THIS BLOCKS improvise-1ey DIRECTLY --\n its stated scope is \"type a number into a cell and see the value update\".\n2. ToggleGroup { category, group } and AddItemInGroup -- CategoryRow.items is a\n flat Vec\u003cString\u003e; groups, group membership and collapsed state are not\n projected. The server does the cat_tree flattening.\n3. RemoveFormula { target, target_category } -- FormulaCache is Vec\u003cString\u003e of RAW\n text. The client would have to re-parse to get target (dragging\n improvise-formula into a thin client), and target_category is simply absent, so\n a formula written [CustomCat] cannot be addressed at all.\n4. LoadModel(PathBuf) / ImportJsonHeadless -- paths name documents in the HOSTs\n Storage (gotcha 2) and nothing projects a listing, so a client cannot compose a\n path it has reason to believe exists.\n5. ApplyAndClearDrill / AcceptWizardModel -- nullary so constructible, but nothing\n projects drill or wizard state, so a client cannot know when either is\n meaningful. (Both are already off the wire per improvise-3x3u.)\n\nCOMPOSABLE BUT TRAPPED:\n* SetSelected { row, col } is ABSOLUTE; GridCache is VIEWPORT-RELATIVE. The client\n must add row_offset/col_offset from its own ViewState -- it has them, but the two\n index spaces are not distinguished by any type and getting it wrong is silent.\n* The grids TOTAL row/column count is not projected, so a client cannot clamp its\n cursor or size a scrollbar.\n\nFINE TODAY: SetAxis/CycleAxis/TransposeAxes/TogglePruneEmpty/SortData/\nCleanEmptyRecords, AddCategory/RemoveCategory, AddItem/RemoveItem/HideItem/\nShowItem/SetPageSelection WHEN DRIVEN FROM THE CATEGORY PANEL (not from the grid\ncursor -- same reason as 1), CreateView/DeleteView/SwitchView/ViewBack/ViewForward,\nAddFormula, SetNumberFormat (though the CURRENT format is in no cache, so it cannot\nbe pre-filled), and the whole view half.\n\nSUGGESTED ORDER: item 1 first and alone -- it is what 1ey needs and it is a\nprojection design question (how does a client name a cell?), not a plumbing one.\n\n--- ADR 002 ACCEPTED (user, 2026-09-19): see context/adr/002-naming-a-cell.md ---\nA projected cell carries an OPAQUE SURROGATE KEY, and its ABSENCE means the\ncell is an aggregate:\n CellDisplay { text, numeric, error, key: Option\u003cCellId\u003e }\nSome -\u003e a stored cell this client may set, named by a token it echoes back.\nNone -\u003e an aggregate: drill, do not set.\n\nWHY THE NULLABILITY IS THE POINT: a client cannot compose SetCell for an\naggregate because there is NO KEY TO PUT IN IT. That is make-invalid-states-\nunrepresentable (design-principles 3), strictly stronger than a server-side\nrejection. It also mirrors a fork the model ALREADY makes -- EditOrDrill in\ncmd/mode.rs branches on exactly this -- so the wire projects an existing\ndistinction rather than inventing one. And it follows the precedent\nCellDisplay::numeric set, whose doc says the server decides \"because it is the\nside that knows\".\n\n_INDEX WAS CONSIDERED AND REJECTED, and the reason is worth keeping: right\nontology, unsafe representation. CategoryKind::VirtualIndex is documented as\n\"Items are 0, 1, ... N where N = number of matching cells\" -- POSITIONAL over\nthe currently matching set, not an identity. _Index = \"3\" names a different\nrecord after a filter change, an insert or a delete, and a different one again\nin another view. A client echoing a stale _Index writes to the WRONG CELL,\nSILENTLY -- worse than a surrogate that fails to resolve, and the same hazard\nclass as viewport-relative-vs-absolute and the stale cursor under a renamed key.\n\nBUT KEEP WHAT IT POINTS AT: the surrogate should name the RECORD, not the cell.\nA record is the coord tuple minus _Measure (record_row_identity), which is\nalready ADR 001 unit of atomicity and already what record_versions is keyed by.\nAddress a cell as (record surrogate, measure) and ONE IDENTITY serves both\naddressing and staleness, instead of each ADR inventing its own.\n\nOPEN, for the implementer to decide with evidence: hash vs interned id (the\ncodebase interns Symbols already); how the server resolves a surrogate back --\nnote that REGENERATING FROM THE CURRENT LAYOUT means a stale surrogate simply\nfails to resolve, which IS ADR 001 stale-basis rejection at cell granularity,\nso the two compose; and what DRILL addresses with, which this does not answer.\n\n--- SURROGATE DECIDED (user, 2026-09-19): hash of the normalized record, maintained as an index ---\nNOT a hash of the cell -- of the RECORD (the coord tuple minus _Measure), which\nis already ADR 001 unit of atomicity and already what record_versions is keyed\nby. A cell is (record hash, measure).\n\nWHY: it makes stale edits cheap to identify, three ways at once. The surrogate\na client already holds IS the lookup key for its own version, so record_versions\nbecomes HashMap\u003cRecordHash, u64\u003e -- fixed size, no allocation, no CellKey\nreconstruction on the hot path. A basis is a list of (record hash, version)\npairs: compact on the wire, O(1) to check. And a record that no longer exists\nsimply fails to resolve, so that whole class of staleness needs no version\ncomparison at all.\n\nIt is well-defined because CellKey ALREADY has a canonical form -- CellKey::new\nsorts by category name and equality/hashing rely on it (gotcha 5). Normalized\nmeans that form with _Measure removed; the normalization is a property the type\nguarantees, not a new convention.\n\nRESOLUTION IS A MAINTAINED INDEX, IN DataStore -- a hash is one-way, and\nregenerating from the layout would be O(visible cells) per resolve AND would\nonly cover the visible window, while a client may hold a surrogate for a record\nthat scrolled away. DataStore is the right home: it already interns every name\ninto a SymbolTable and already maintains HashMap\u003c(Symbol,Symbol), HashSet\u003cInternedKey\u003e\u003e\nfor hot-path lookups. The RecordHash index is the same shape, updated at the same\npoints, and that existing (Symbol,Symbol) index is what makes INVALIDATION\ntractable -- when a category or item disappears, the records mentioning it are\nfindable without a scan.\n\nTWO THINGS THAT FALL OUT: the index and record_versions want to be ONE structure\n(RecordHash -\u003e (record, version)) since both are keyed the same and invalidated\ntogether, giving both ADRs their answer in one lookup. And structural vs data\nstaleness separate cleanly: does not resolve = the record is GONE (reject,\nnothing to write to); resolves with a differing version = the record CHANGED\n(reject as stale, offer force). Regenerating from the layout would have\nconflated those, because a record scrolled out of view would look identical to\none that no longer exists.\n\nTHREE SILENT-BREAKAGE TRAPS, each producing a WRONG WRITE rather than an error:\n * NOT DefaultHasher / RandomState -- randomly seeded PER PROCESS, so hashes\n would differ across a restart and between processes.\n * NOT #[derive(Hash)] as the definition -- Rust guarantees no stability across\n compiler versions or platforms. Hash an explicit BYTE SERIALIZATION of the\n sorted (category, item) pairs.\n * Pick the WIDTH BY CONSEQUENCE: a collision is a silent write to the wrong\n record, which is exactly why _Index was rejected. 64 bits hits birthday risk\n near 2^32 records; 128 bits costs 8 more bytes per VISIBLE cell (~8KB on a\n 50x20 viewport). Argue it, do not default to u64 for convenience.\n\n--- CellId IS A TWO-VARIANT ENUM (user, 2026-09-19), not Option ---\n enum CellId { Record(RecordHash), Aggregate(AggregateHash) }\nSetCell takes a Record and nothing else; Drill takes an Aggregate and nothing\nelse. An aggregate surrogate is a hash of its COORDINATE CONSTRAINT -- the row\nand column coordinates plus page/filter selections -- so it costs one hash per\ncell, the same width and the same DataStore index as a record.\n\nWHY NOT Option\u003cCellId\u003e with None meaning aggregate: an aggregate is NOT the\nabsence of an identity, it is a different KIND of identity. Option made SetCell\nsafe and left Drill with nothing to address, which is exactly how drill\naddressing became an open question. Two variants close it by construction and\nmake BOTH operations unrepresentable-if-wrong rather than just one.\n\nDEFERRED, filed separately: shipping a bounded keyset per aggregate for hover\npreviews. The unbounded version is untenable -- the payload would scale with the\nDATA while the rest of a projection scales with the VIEWPORT, ~16MB to render\n1000 cells over a million-record document -- and drill-on-demand works without\nit.\n\n--- ADR 002 SPINE LANDED (2026-09-19). What remains here is the KEY RESOLUTION half. ---\nMerged: RecordHash/AggregateHash, the record index in DataStore, CellId on\nCellDisplay, SetCell/ClearCell taking a surrogate, and Client::set_cell /\nset_cell_at in the wasm client. 1017 tests, 16 faults injected and all fired.\n\nSO A CLIENT CAN NOW COMPOSE A WRITE -- improvise-cr3 can call set_cell_at\ntoday. What is NOT done, and is this ticket own subject: WIRING IT TO A\nKEYSTROKE. The wasm client still forwards editing keys upstream unresolved,\nwhich is exactly the thing the user ruling (keymap is client-only) says must\nchange. The composability list earlier in these notes is still the map; item 1\n(SetCell) is now answered, items 2-5 are not.\n\nDECISIONS THE IMPLEMENTER MADE THAT ARE WORTH KNOWING:\n * The wire form of a surrogate is a 32-char HEX STRING, not a number. serde_json\n writes u128 as a bare number and a JavaScript host parses every JSON number as\n f64, so anything above 2^53 comes back CHANGED. This trap was not in the ADR.\n * CellId::Record carries RecordCell { record: RecordHash, measure: Option\u003cString\u003e },\n not a bare hash. The ADR says both \"hash the record\" and \"a cell is (record\n hash, measure)\"; both cannot hold if the hash covers the measure, because then\n record_versions could not be keyed by it -- which is the reason the ADR chose\n the record over the cell. Carrying the measure beside the hash satisfies both.\n * id: None is NOT \"aggregate\" (that is Some(CellId::Aggregate)). It means no\n projected cell: a padded/invalidated GridCache slot, or a records-mode\n COORDINATE column, which renames across the whole row (gotcha 1) and is a\n different intent from SetCell.\n * A zero cell count is a TOMBSTONE, not a removal -- dropping the entry would let\n a delete-and-recreate reset the version to 0, so a client holding version 0\n against the OLD record would be told its basis is current and its write\n applied. resolve_record filters on liveness; record_version reads through.\n * Resolution is INDEX FIRST, LAYOUT SECOND. The index covers every record holding\n data on screen or not; the layout answers cells with NO DATA YET -- and typing\n into an empty cell IS improvise-1ey.","status":"closed","priority":1,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-19T23:42:49Z","created_by":"Edward Langley","updated_at":"2026-09-20T02:36:27Z","closed_at":"2026-09-20T02:36:27Z","close_reason":"Done for the cursor half, with a named list of what still needs the host so closing the rest has to come here and say so.\n\nTHE FINDING THAT REFRAMED THE TICKET: Host::handle answers ClientFrame::Key with an EXPLICIT EMPTY VEC, so an upstream key is a BLACK HOLE, not a slow path. Combined with the improvise-9x6 finding (nothing in the projection stream carries the host's ViewState, so host-side resolution DIVERGES), every KeyOutcome::Upstream was a binding that does nothing at all in a browser. That turns the ticket's measure into simply 'how much of the keymap can the client answer'.\n\nMETHOD WAS THE TICKET'S: walked KeymapSet::default_keymaps() -- all 16 modes, ~170 bindings -- via a throwaway dump test, per doctrine(binding_dump_diff). Resolved client-side now: hjkl/arrows, 0/$/Home/End, G, PageUp/Down, Ctrl-d/Ctrl-u, panel cursor, tile cursor. ~15 more need NO enrichment and were deliberately left rather than half-done.\n\nTHREE PROTOCOL ADDITIONS, each argued as the smallest thing:\n * GridExtent { rows, cols } via GridUpdate::Extent -- the ticket's gap 1. Deliberately NOT framed by the labels: it is the size of the grid the labels are a window ONTO, so fit_to_labels leaves it alone and its batch position is not load-bearing (it addresses no position, so it can neither overtake nor be overtaken). The producer reads GridLayout::row_count()/col_count(), never the projected ranges -- reading it off the ranges is INVISIBLE IN THE TUI, which builds at offsets zero, and is exactly the browser bug.\n * ClientFrame::Command -\u003e Commands(Vec\u003cCommand\u003e). FORCED: one cursor key is SetSelected plus up to two scroll offsets, and a host that folds the selection but not the offset projects the wrong window. Same argument ServerFrame::Commands already carried.\n * ClientState::window (session-local) + Client::set_viewport. NOT DERIVABLE: row_labels.len() is the window CLIPPED to the grid, which equals the window everywhere except the bottom edge -- exactly where the scroll arithmetic matters. Nothing added to ViewState; both gating tests hold; transient_prefix untouched.\n\nTWO CORRECTIONS TO THE TICKET'S OWN GAP LIST:\n * Panel cursor movement WAS NEVER A GAP. improvise-edp already made viewmodel::flatten_cat_tree the one home both ends call, so calling it is the SHARED derivation, not a second one. Nobody had noticed. Worth checking other old 'known gaps' against what has landed since.\n * The unmodified-characters rule was MIS-SCOPED, not wrong. It was about KeyPattern::AnyChar, which is bound ONLY in the text-entry modes; in Normal there is no such fallback, so Ctrl-d has an exact binding and mirroring it is a faithful subset. Kept where it was earned, dropped where it was never true.\n\na_key_resolved_on_the_host_moves_state_no_client_can_hear STILL HOLDS and was not flipped -- it states a true property of host-side resolution, which bpq2 now AVOIDS by construction rather than contradicts.\n\nEach resolver mirrors its Cmd LINE FOR LINE, including where siblings disagree about a no-op move (Move and MoveTileCursor emit anyway; MovePanelCursor does not). Unifying them would make the client a second derivation, which is what lockstep testing exists to rule out.\n\nSIX INJECTED FAULTS, ALL FIRED. The five new lockstep tests build the client's cache with the server's OWN grid_of, drive client and real reduce_key over the same keypresses, and compare ViewState. nothing_in_a_non_text_mode_resolves_locally became FALSE and was replaced by a named list of the Normal-mode keys that still need the host.\n\nSTILL NEEDS THE HOST, truthfully: all prefix keys (transient_prefix is an address, not a keymap -- gotcha 13, left intact); save/wq/commit-export (Storage by definition); : execute-command; the wizard's Any binding (WizardCache has no Projection by design); CommandMode Backspace; enter-advance (its AbortChain is a fact about the effect chain, not a cursor); records/drill toggles (session-local residue).\n\nOrchestrator applied the one line the agent flagged and could not make: web/app.js now calls client.set_viewport rather than the associated Client.viewport, without which the client never learns its window and every cursor key vanishes upstream.\n\n1172 passing, clippy/fmt clean, wasm32 clean on all seven bundle crates.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-c4er","title":"[bug] There is no server door for a keypress; the asker gets no commands back","description":"Found by improvise-gsw (2026-09-19) and it is THE seam the browser MVP depends on. Blocks improvise-1ey.\n\nA browser client cannot resolve keys locally -- a binding names an INTENT resolved against a CmdContext while a Command is the RESOLVED MUTATION (see the improvise-gxi close reason and gotcha 38). So the client sends the raw keypress upstream as ClientMessage::Key { code, modifiers }.\n\nTHE GAP: Sessions::apply(app, id, cmd) takes a Command. ui::reduce::reduce_key(app, key) exists but returns only PROJECTIONS -- not the view-slice commands the ASKER must fold into its own ViewState. With keys travelling unresolved the host owes the asker back the commands its own keypress produced, and nothing returns them.\n\nNOTE this is NOT Applied::Diff.broadcast, which is what the OTHER clients fold (gotcha 36). The asker needs its own, and there is nowhere for them to come from.\n\nSUGGESTED SHAPE (from the gsw agent): Sessions::apply_key(app, id, key) -\u003e Applied, with the askers own view-slice commands in the result. Derive it rather than inventing -- doctrine(derive_the_enum_from_the_effects). The keypress is already KeyCode + KeyModifiers, which Command::SetTransientPrefix puts on the wire today; note improvise_protocol::KeyEvent itself does NOT derive Serialize/Deserialize, only its two components do, which is worth fixing at the same time.\n\nAlso from gsw: ClientMessage/ServerMessage envelopes currently live in the client crate because the VOCABULARY is in improvise-protocol but the FRAMING that says which a JSON blob is, is not. Moving them is a one-file lift and the ws-server will want the same ones -- do it here rather than letting two crates define them.","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T23:40:37Z","created_by":"Edward Langley","updated_at":"2026-09-19T23:42:47Z","closed_at":"2026-09-19T23:42:47Z","close_reason":"WRONG PREMISE -- CLOSED AND INVERTED by a user ruling (2026-09-19): keys are converted into commands IN THE BROWSER, and the keymap is a CLIENT-ONLY concept the server must not know at all.\n\nThis ticket asked for a server door for a keypress (Sessions::apply_key). There must not be one. The reasoning follows from the standing principle -- the model is shared, the UI is not -- and A KEYMAP IS UI. If the server knows the keymap, two clients cannot have different bindings, and that is a thing this architecture should permit: a browser client with browser-native keys and a TUI with vim keys, over the same document.\n\nSo the gap this ticket named does not exist. The asker never needs its own commands handed back, because the asker COMPOSED them and already knows what it sent. ClientMessage::Key should not exist; a client sends Commands only. ui::reduce::reduce_key stays, but it belongs to the native TUI -- which is a client that happens to run in-process -- not to the server client-facing surface.\n\nTHE REAL WORK THIS DISPLACES INTO: a client must hold enough PROJECTED STATE to resolve a binding into a Command. That is a requirement on what the projections carry, not a reason to move resolution serverward. Filed separately.\n\nThe one part of this ticket that survives independently: ClientMessage/ServerMessage envelopes belong in improvise-protocol rather than the client crate -- the vocabulary is there, the framing is not. Refiled with the projection work.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-acsn","title":"[bug] A document swap over the wire leaves the client's own cursors stale; nothing tells the host a rebase is owed","description":"Wave-4 review (2026-09-19), CONFIRMED by probe. Latent -- no host wires Sessions yet -- but newly reachable from the wire.\n\nModelCommand now lets a client send LoadModel / ImportJsonHeadless / AcceptWizardModel, the WholeWorkbook commands, for the first time. The SERVER mirrors self-heal: ClientSession::project reseeds on the document-generation bump, so the server is honest. But cursors live in the CLIENT own ViewState, and that is corrected only by a Snapshot -- whose only producer, Sessions::rebase, has NO NON-TEST CALLER in the tree (grep finds one hit, a test). Applied carries only { dirty, broadcast }, neither of which tells the host a rebase is owed.\n\nProbe (two sessions, MemoryFs, Model(LoadModel) where the incoming document active view is also named Default):\n generation 0 -\u003e 1, broadcast = None\n bystander mirror cursor before project = (3, 0)\n bystander mirror cursor after project = (0, 0)\nSo the server has it at (0,0) while the bystander client keeps cursors[Default] = (3,0) from the OUTGOING document -- the gotcha-24 cursor-vs-geometry mismatch that apply_switch own doc comment says this shape exists to prevent, now arriving through another client.\n\nFIX: give Applied a way to say the document was replaced -- carry the generation, or a rebase flag -- so a host CANNOT forget. Pre-existing in kind (a keypress-driven LoadModel had the same gap), which is why the fix belongs on Applied rather than on the command.","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T22:18:02Z","created_by":"Edward Langley","updated_at":"2026-09-19T22:24:25Z","closed_at":"2026-09-19T22:24:25Z","close_reason":"Applied went from a record to an enum: Applied::Diff { dirty, broadcast } | Applied::Rebased { snapshots }. The enum IS the mechanism -- a host that wants the Dirty must match, and matching names the case where a Snapshot is owed. Changing the shape made the compiler name all five existing read sites, which is the point. Rebased carries no Dirty and no broadcast, because each Snapshot already carries a whole view slice plus the seed reproducing the new document; a diff against the old one would describe nothing that still exists. Sessions::apply_model snapshots document_generation before and after apply_command and calls rebase itself, exactly as apply_switch adopts the accepted view itself -- rebase gains its first non-test caller.\n\nTHE TRIGGER IS THE GENERATION, NOT THE VARIANT: a LoadModel on an unreadable path leaves the document alone, and reseeding on the variant would cost a client its cursors for a FAILED load. Both arms are tested per doctrine(one_arm_of_a_symmetric_rule...): a_command_that_leaves_the_document_in_place_is_not_a_rebase and a_load_that_fails_is_not_a_rebase. Injections both directions: never-rebase fails the swap test (left 3, right 0); always-rebase fails four tests including the failed-load arm.\n\nFIXTURE TRAP WORTH KNOWING, recorded in the repo-map: the first version of the test PASSED, because the existing other_document() renames the active view Default-\u003eSheet, so apply_model broadcast a SwitchView, the client folded it, and the stale cursor was read under a key nothing had written -- right by accident. A new fixture keeps the view NAME and changes only the shape, so there is nothing to hide behind.\n\nRejected: Applied { dirty, broadcast, rebase: bool } -- the host that forgets to call rebase is the host that forgets to read the flag; carrying the generation -- same problem plus it asks the host to remember the comparison; a hook on the LoadModel effect -- Sessions is unreachable from an effect, which is why the generation bump exists at all.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-oi9b","title":"[bug] 24 of 28 model_effects mappings are unguarded; a wrong mapping passes the whole suite","description":"Wave-4 review (2026-09-19), CONFIRMED by injection. Test gap, not a live bug -- the mapping is correct today.\n\nui/reduce.rs::model_effects maps each ModelCommand back to its effect. Injecting wrong-but-still-Model mappings left cargo test --workspace GREEN:\n ModelCommand::HideItem -\u003e effect::ShowItem PASSES\n ModelCommand::CreateView -\u003e effect::DeleteView PASSES\n ModelCommand::AddItem -\u003e effect::RemoveItem PASSES\n ModelCommand::ViewBack -\u003e effect::ViewForward fails (caught incidentally by the project.rs lockstep test)\n\nWhy: every_model_command_names_an_effect_that_declares_a_model_change cannot distinguish two effects of the SAME EffectKind, and a_model_command_lands_where_its_keypress_lands drives only three variants (TransposeAxes, TogglePruneEmpty, AddCategory). That leaves 24 variants whose mapping nothing checks -- and every inverse pair (Hide/Show, Add/Remove item, Create/Delete view, SetCell/ClearCell) is exactly the case the surviving tests cannot separate.\n\nFAILURE SCENARIO: a rename or re-order during a future refactor silently re-points a wire command at its INVERSE. A client sending Model(HideItem) un-hides instead, and because the Dirty is identical every other client is told a consistent lie.\n\nFIX: extend a_model_command_lands_where_its_keypress_lands to a table over every_model_command(), or -- cheaper and total -- assert the variant name equals the effect type name. The mapping is name-for-name by construction, so that assertion covers all 28 at once. This is the same class the author already fixed once, when a one-category fixture could not tell TransposeAxes from CycleAxis.","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T22:18:01Z","created_by":"Edward Langley","updated_at":"2026-09-19T22:24:24Z","closed_at":"2026-09-19T22:24:24Z","close_reason":"Guarded by name, covering all 28 at once. every_model_command_names_the_effect_of_the_same_name asserts each variant returns exactly one effect whose TYPE NAME equals the variant name -- read off the effect derived Debug, since Effect is a trait object and type_name is out of reach through it. The mapping is name-for-name by construction, so a variant whose effect must be named something else is a variant that stopped being derived: it should fail there and be argued about.\n\nThe gap was reproduced first: AddItem-\u003eRemoveItem, HideItem-\u003eShowItem and CreateView-\u003eDeleteView applied SIMULTANEOUSLY left cargo test --workspace at 918 passed, 0 failed -- three wire messages doing the exact inverse of what they say, suite fully green. Each injection was then re-run individually against the new test and each fails with the names printed.\n\nThe name READER is itself checked (the_effect_name_reader_reads_all_three_debug_shapes), because the delimiter after the identifier differs per Debug shape -- nothing, ( or space-brace -- and its own injection was verified to fail.\n\nRejected extending the behavioural test to a table over all 28: most variants have no single keypress to compare against, so you would invent one or drive the effect directly, at which point it stops being an independent yardstick. The behavioural test keeps its three genuinely key-driven cases and its two-category pivoted() fixture.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-kvyt","title":"[bug] A view switch moved only the asking session's mirror, staling every bystander's cursor","description":"Found by GitHub Copilot on PR #10 (2026-09-19) -- its second real finding on this codebase -- and fixed on the wave-2 branch in 8475b2d.\n\nSessions::apply moved the workbook active view through apply_command and then moved the mirror of the ADDRESSED session alone. But every client grid is built by grid_of from workbook.active_view(), so a bystander session left naming the outgoing view had its cursor read under a stale key: it was projected the NEW view rows positioned by the OLD view scroll. That is gotcha 24 cursor-vs-geometry mismatch, arriving through somebody else keystroke rather than its own. And the bystander client-side slice -- which folds the same commands precisely so that mirror and client cannot drift -- was never handed the switch to fold at all.\n\nFIX: the accepted view reaches every mirror, and apply returns Applied { dirty, broadcast } rather than a bare Dirty. broadcast is the command the host must forward to the other clients, whose slices live on them. It names what the document ACCEPTED, which also corrects a client that optimistically folded a view name the workbook turned down. apply_switch takes no SessionId -- the switch is addressed to no one session, and that absence is the statement.\n\nProven first: a_view_switch_moves_every_sessions_mirror_not_just_the_switchers fails on the old door, with the bystander mirror naming Default while the workbook names Second.\n\nIMPORTANT INTERACTION, do not lose this: this fix is correct for TODAY model, where active_view is shared in lockstep. The user has decided (improvise-g0f) that active_view becomes PER-CLIENT -- the model is shared, the UI is not. Under that split a view switch becomes the asker business again and this broadcast goes away or changes meaning. improvise-jytm owns the split and MUST revisit this; see the note added there.","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T21:53:03Z","created_by":"Edward Langley","updated_at":"2026-09-19T21:53:04Z","closed_at":"2026-09-19T21:53:04Z","close_reason":"Fixed on the wave-2 branch in 8475b2d, in PR #10, with the failing test written first. Found by Copilot, accepted in full, and the reasoning is in the commit message and this ticket.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-gw0m","title":"[bug] Replacing the document leaves every ClientSession mirror keyed to the old one","description":"Wave-2 review (2026-09-19), CONFIRMED by probe.\n\nGotcha 24 says 'replacing the document must clear the map' -- LoadModel/AcceptWizardModel call cursors.clear() on App's ViewState. Nothing clears ClientSession::view.cursors or ClientSession::view.active_view, and Sessions has no hook for a workbook swap.\n\nProbe: client on view Alpha scrolled to row_offset 3; install_workbook swaps in a 2-row document whose view is Beta. Fails twice on unmodified wave-2: 'the session mirror still names the OLD document's view (left Alpha, right Beta)' and 'the session still scrolls by the old document's cursor (left 3, right 0)'.\n\nUSER-VISIBLE: after ':e other.improv', a remote client is scrolled past the end of a document it has never seen. Viewport::window clamps to 3..3 and the client renders an EMPTY GRID, with no command that will ever bring it back.\n\nFIX: give Sessions a document-swap hook that resets each session the way connect does (reset mirror, re-seed with Dirty::EVERYTHING), and call it wherever the workbook is installed. The re-seed path already exists -- this is about making install_workbook reach it.","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T21:04:40Z","created_by":"Edward Langley","updated_at":"2026-09-19T21:10:57Z","closed_at":"2026-09-19T21:10:57Z","close_reason":"Fixed via a document generation, because the hook as specified is uncallable: an effect holds \u0026mut App and Sessions is the host's -- no App in the tree owns one. So App::document_generation is bumped ONLY by install_workbook (already the single writer for the cursor clear), ClientSession records the generation it was seeded at, and project() dispatches on equality: equal takes the existing diff (extracted unchanged as project_diff), unequal reseeds. seed became reseed, now also re-pointing the mirror at a fresh view, shared by connect and the swap path. Sessions::rebase(\u0026App) is the explicit client-facing half, and its doc says so: the server's mirrors are already safe via the generation, but a client's own view slice lives on the client and only a fresh Snapshot tells it to drop a cursor naming a view of a document that is gone. document_generation sits on App under the session-local residue tag, not on ViewState -- it describes the document's identity over a process lifetime, not a client's view (gotcha 30). Both faults injected and confirmed: deleting the increment fails the swap test; stubbing rebase to Vec::new fails the other. Rejected an on_document_swapped hook a host must remember to call -- that is the failure mode being fixed.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-oks8","title":"[bug] ClientSession::apply accepts SwitchView and desyncs the session mirror","description":"Wave-2 review (2026-09-19), CONFIRMED by probe. Reintroduces the gotcha-29 desync that the sibling module works to avoid.\n\nClientSession::apply is 'pub fn apply(\u0026mut self, cmd: \u0026Command) { reduce_view(\u0026mut self.view, cmd); }', documented as 'the same function the client runs, so the two cannot drift'. It is the ONLY per-session command door Sessions exposes. Meanwhile reduce.rs::apply_command goes to lengths to route SwitchView through App::switch_to_view, with a test and a gotcha-29 citation.\n\nFeed ClientSession::apply a SwitchView and the session's mirrored active_view moves while workbook.active_view and app.layout do not -- and viewport() then reads the cursor under the NEW key. The very next project() emits a grid built from the OTHER view.\n\nProbe on unmodified wave-2: 'the client that switched was sent grid rows [[\"North\",\"South\"]] built from the OTHER view's layout' -- mirror says Second, rows are Default's.\n\nThis falsifies kb.pl's derived_by_fold(view_state, command_stream, 'both ends run reduce_view over the same commands, so the server mirror cannot drift').\n\nFIX structurally, not by convention: make the session door go through apply_command, or have apply reject/route SwitchView, so a transport author cannot get this wrong by not knowing. NOTE the user has since decided (improvise-g0f) that active_view is PER-CLIENT -- coordinate with improvise-jytm, since the right shape here depends on that split.","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T21:04:40Z","created_by":"Edward Langley","updated_at":"2026-09-19T21:10:56Z","closed_at":"2026-09-19T21:10:56Z","close_reason":"Fixed by moving the door rather than adding a rule. ClientSession::apply became private fold(); the mirror's ViewState is private behind view(); a transport can no longer reach it. New Sessions::apply(app, id, cmd) is the single door: a command with a DOCUMENT HALF (SwitchView today) routes through reduce::apply_command -- the sanctioned writer per gotcha 29 -- and the mirror then takes workbook.active_view, i.e. what the document ACCEPTED rather than what was asked, so a switch to a nonexistent view leaves the mirror put. Everything else is folded into that session's mirror only.\n\nDeliberately does not hard-code lockstep anywhere: both halves move inside one call, so whichever way improvise-g0f lands there is exactly one body to change and improvise-jytm can change it without touching any transport. Second test added for the standing principle -- a_clients_view_only_command_moves_only_that_clients_mirror -- and its fault injection is the obvious wrong simplification (route EVERYTHING through apply_command), which fails it on the App's own cursor. Rejected: splitting Command in the read-only protocol crate; leaving apply public with a 'do not send SwitchView' comment (that is the convention being removed); refusing SwitchView (the door should honour it).","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-t9p6","title":"[bug] Dirty::of(LayoutGeometry) under-reports formulas; RemoveCategory strands deleted formulas on every client","description":"Wave-2 review (2026-09-19), CONFIRMED by probe against unmodified wave-2. This is the SILENT failure mode the producer's design explicitly guards against -- under-reporting, not over-reporting.\n\nproject.rs Dirty::of maps LayoutGeometry to {grid, category_tree, view_list, tile_bar} with formulas:false. RemoveCategory is LayoutGeometry (effect.rs). But Model::remove_category ends with self.formulas.retain(|f| f.target_category != name) -- deleting a category deletes every formula targeting it, and the formula panel is never told.\n\nScenario: user adds 'Total = Revenue + Cost' targeting category Region, then deletes Region. The document has 0 formulas; the client's FormulaCache still shows 1, INDEFINITELY -- nothing else in the stream will ever dirty formulas unless another formula happens to be added.\n\nProbe output on unmodified wave-2: 'UNDER-REPORT: client still shows [\"Total = Revenue + Cost\"] (dirty.formulas=false), left: 1 right: 0'.\n\nFIX: set formulas:true on LayoutGeometry. That is consistent with Dirty being 'the widgets one kind can reach', and over-reporting costs only work. Giving RemoveCategory its own classification is the alternative but multiplies kinds.\n\nSAME SITE, LOW: the doc comment justifying the mapping says 'ModelChange::CellData is worn by AddCategory'. AddCategory is LayoutGeometry in this very branch. That comment is the only record of the mapping's derivation and is already stale against its own diff.","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T21:04:39Z","created_by":"Edward Langley","updated_at":"2026-09-19T21:10:56Z","closed_at":"2026-09-19T21:10:56Z","close_reason":"Fixed: formulas:true on the LayoutGeometry arm. Failing test first -- removing_a_category_empties_the_clients_formula_panel -- which also asserts the DOCUMENT dropped the formula, so the client is shown to be wrong rather than the assertion. Pre-fix: left [\"Total = Revenue\"] right []. The arm is spelled out rather than ..NOTHING and carries a comment on why it is not written as EVERYTHING: the two coincide today but are derived from different effects. The doc comment was re-derived from the effects as they actually are, removing the stale 'CellData is worn by AddCategory' claim (untrue since the reclassification). The asymmetry is now written down: a redundant Projection::Formulas costs one message, a missed one is wrong forever. Rejected giving RemoveCategory a WholeWorkbook kind -- it would fix one site by lying about the effect's scope.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-8tk","title":"ZZ / :wq quits even when the save fails, silently discarding the model","description":"SaveAndQuit (crates/improvise-command/src/command/cmd/mode.rs) returns [Save, ChangeMode(Quit)] unconditionally. The Save effect reports failure only by setting view_state.status_msg — it never emits AbortChain — so the Quit lands regardless, and src/draw.rs:90 breaks out of the loop before another frame renders. The user never sees the error.\n\nWORST CASE — no file path, total silent loss:\nStart improvise with no file argument, do a session of work, press ZZ (bound in the normal-mode Z prefix, keymap.rs:562). Save takes the else branch, sets 'No file path — use :w \u003cpath\u003e', and the app exits. autosave_if_needed() cannot rescue it either: it early-returns unless model_state.file_path is Some. Everything the user typed is gone, with no prompt and no visible message.\n\nSECOND CASE — save errors on an existing path (read-only dir, full disk, bad permissions): same silent exit. Here a *.autosave file may exist, but only if 30s had elapsed since the last autosave, and the user is not told to look for it.\n\nNote ZZ is a vim reflex, so this is easy to hit by muscle memory.\n\nFIX SKETCH: Save needs to report failure to the effect chain, not just to the status bar — emit AbortChain (or have SaveAndQuit consult a save result) so the Quit is skipped and the error stays on screen. Decide separately what ZZ should do with no file path: most likely open the export/save prompt rather than exiting.\n\nPer AGENTS.md, write the failing test first. The command layer is pure, so assert on the emitted effects; the abort behavior itself is already covered by the AbortChain test at effect.rs:1373.\n\nBLOCKS improvise-git (replace SaveAndQuit with Binding::Sequence): a Sequence has no way to stop between steps, so converting first would cement this bug into the keymap where it is harder to fix. Fix this, then decide whether git is still worth doing.\n\nFound while grooming the backlog 2026-08-13, evaluating whether git/aaw/s13 could be combined.","status":"closed","priority":1,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-08-14T01:07:58Z","created_by":"Edward Langley","updated_at":"2026-08-29T17:34:46Z","started_at":"2026-08-29T17:31:50Z","closed_at":"2026-08-29T17:34:46Z","close_reason":"Save effect now sets abort_effects on failure (no file path, or write error), so the Quit chained by wq/ZZ is skipped and the error stays on screen. Extracted save_to_current_path() -\u003e Result\u003cString,String\u003e. Three tests in ui/effect.rs: two regression guards (no-path and failed-write both abort) plus one guaranteeing a successful save still quits.\n\nNote for improvise-git: a Binding::Sequence flattens into a single effect vec applied by one apply_effects call, so abort_effects DOES carry across sequence steps. Its stated blocker ('a Sequence has no way to stop between steps') no longer holds — it can proceed.\n\nFollow-up filed as improvise-lae for the no-file-path UX (prompt for a path rather than only erroring).","dependency_count":0,"dependent_count":1,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-1cz","title":"export_csv emits unquoted CSV — commas in values corrupt output","description":"persistence/mod.rs:581-630 builds CSV by joining labels and values with bare commas (col_labels.join(\",\"), row_values.join(\",\")) with no RFC 4180 quoting/escaping. Any comma in a row/col label, page label, or cell value shifts columns. This is guaranteed to fire when the view uses a comma-grouping number format (e.g. ,.2f): display_text() renders 1200 as |1,200.00|, so every formatted value over 999 corrupts the row. Fix: use the csv crate (already a dependency) or quote fields per RFC 4180. Found in systematic review 2026-06-09; distinct from improvise-k8i (import side).","acceptance_criteria":"A view exported with a comma-grouping number format (,.2f) and an item label containing a comma re-imports with identical column structure. Failing test first: export a grid where display_text yields |1,200.00| and a row label contains a comma; assert the CSV parses to the original shape (use the csv crate to read it back).","status":"closed","priority":1,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-06-10T03:57:49Z","created_by":"Edward Langley","updated_at":"2026-06-10T04:45:52Z","closed_at":"2026-06-10T04:45:52Z","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-4yc","title":"Malformed numbers in .improv file silently become 0.0","description":"crates/improvise-io/src/persistence/mod.rs:434 uses value_pair.as_str().parse().unwrap_or(0.0). Malformed number literals (e.g. '3.14.15', '1e999', '--5') become CellValue::Number(0.0) with no error. This is the only production unwrap/unwrap_or in the parse path and silently corrupts data. Fix: return a parse error with line context, or at minimum log a warning and use CellValue::Error.","acceptance_criteria":"Loading a .improv file with a malformed number (e.g. 12.3.4 or 1e999) surfaces a parse error or CellValue::Error naming the offending text — never silently 0.0. Regression test demonstrates the current silent-zero behavior first.","status":"closed","priority":1,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:17Z","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-k8i","title":"CSV import silently drops columns when row widths mismatch","description":"crates/improvise-io/src/import/csv_parser.rs:14-20 uses ReaderBuilder with .flexible(true). Rows with fewer columns than the header are truncated with no warning. User data is silently lost on import. Fix: either enforce .flexible(false) for strict RFC 4180 parsing, or detect mismatched row widths and surface a warning to the import wizard.","acceptance_criteria":"Importing a CSV whose rows are shorter/longer than the header either errors or surfaces a per-column warning with a count of affected rows — never a silent drop. Test feeds a 5-column header with 4-value rows and asserts the warning/error mentions the missing column.","status":"closed","priority":1,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:15Z","created_by":"Ed L","updated_at":"2026-06-10T04:46:38Z","closed_at":"2026-06-10T04:46:38Z","close_reason":"Fixed in f0b9227/77a5124 (test-first): short CSV rows padded with explicit nulls + per-column warnings threaded to status line (stderr on CLI path); wizard pre-checks 12-category limit at advance with actionable message rendered in UI; ImportJsonHeadless parses the file exactly once via extracted json_import_records helper.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-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-d31","title":"Static HTML shell + GitHub Pages deploy workflow","description":"HTML/JS shell that hosts the standalone wasm deployment. Registers the worker-server (dedicated worker for MVP, service worker as a later PWA enhancement), instantiates the MessageChannel transport, loads the main-thread wasm-client, wires them together, and presents the DOM renderer. Also publishes the whole bundle to GitHub Pages via a build + deploy workflow so anyone can open improvise in a browser with no install.","design":"docs/ or web/ directory with index.html (#app div), main.js (main-thread wasm loader + worker registration + MessageChannel setup + keyboard event wiring), worker.js (loads the worker-server wasm bundle and handles postMessage), style.css. No websocket code — transport is local MessageChannel only. GitHub Actions: on push to main, build both wasm bundles (main-thread and worker) with cargo + wasm-bindgen, run wasm-opt for size, assemble into a static site, push to gh-pages branch. Path-filtered to skip if neither bundle changed. Worker registration: start with Dedicated Worker (simpler lifecycle, same-tab scope) until PWA install requirements justify Service Worker complexity.","acceptance_criteria":"(1) Static site loads improvise in a modern browser with no backend. (2) GitHub Actions workflow builds and deploys on every main push. (3) Deployed URL is listed in the README. (4) Demo .improv file is bundled so first-time visitors see something, not an empty grid.","notes":"PROGRESS 2026-09-20 -- the DELIVERY half is done and landed; the ticket stays OPEN because the half it is named for (a static shell that works) genuinely does not, and bd is right to refuse the close while improvise-djm blocks it.\n\nDONE: pages-build/pages-deploy appended to the EXISTING ci.yml, so the deploy is a needs: edge on [test, wasm, pages-build] rather than a workflow_run dance. web/build.sh runs UNCHANGED, so there stays one answer to how the bundle is built and a local build is evidence about the CI build. wasm-pack pinned to v0.15.0 and added to the devShell -- note the wasm CI job only ever ran cargo check, so NOTHING had previously required the one tool that turns a passing check into a loadable page.\n\nMEASURED: build.sh succeeds end to end -- 346 KB wasm, 120 KB gzipped, 388 KB assembled site, every reference resolving inside it, all asset paths relative so the /improvise/ sub-path works. actionlint clean. THE WORKFLOW HAS NEVER RUN: unproven until the first main push.\n\nNOT PATH-FILTERED, deliberately. The bundle is improvise-wasm-client + protocol + core + formula, so a filter meaning 'skip when the bundle cannot have changed' would name most of the workspace, and getting it wrong leaves a STALE SITE WITH NOTHING SAYING IT IS STALE. The over-build is the cheaper error.\n\nWHAT REMAINS HERE, and one part was not predicted by this ticket. The deployed page does not work -- app.js holds no model, so it needs a server -- and the ?ws= escape hatch ALSO fails, because Pages is https and a ws:// address from an https origin is blocked as MIXED CONTENT. There is currently NO configuration in which the published URL does something useful. That is stated in STATIC HTML (#backend-notice) rather than by app.js, because a page that cannot reach a server cannot be trusted to explain why. Close this when improvise-djm makes the page self-hosting.\n\nAC 4 (bundled demo model) is MOVED TO improvise-bck: nothing in the browser reads a .improv until improvise-9x6 gives the worker an init-from-bytes entry, so the file would have no consumer.\n\nHUMAN ACTION: Pages is not enabled (the API 404s). configure-pages@v5 enablement:true should create it on the first main push; if the token may not, Settings -\u003e Pages -\u003e Source: GITHUB ACTIONS (not the branch/folder source improvise-3gy currently names).","status":"closed","priority":1,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-14T07:34:36Z","created_by":"spot","updated_at":"2026-09-20T04:20:53Z","closed_at":"2026-09-20T04:20:53Z","close_reason":"Done -- the page now works from its own URL, which is what this ticket was waiting on.\n\nThe DELIVERY half landed earlier (pages-build/pages-deploy appended to the existing ci.yml, so the deploy is a needs: edge on test+wasm rather than a workflow_run dance; build.sh runs unchanged). What was missing was that the deployed page could not DO anything: app.js held no model and the ?ws= escape hatch was blocked as mixed content. improvise-djm and improvise-9x6 closed that, and the static backend notice is gone.\n\nVERIFIED BY THE USER, 2026-09-20, Safari 27: https://fiddlerwoaroof.github.io/improvise/ renders the Corner Coffee demo from the in-page worker -- Product rows, Revenue/Cost/Profit columns, Quarter on page, and the formula column evaluated.\n\nONE DEFECT FOUND AND FIXED HERE, worth remembering because it cost two round trips: Pages serves everything max-age=600 and the wasm-pack filenames carry NO CONTENT HASH, so for ten minutes after a deploy a returning visitor runs the PREVIOUS build. That made a shipped fix look like it had not shipped, twice -- the deployed artifact was verified to be the right commit while the browser was still running the old one. The assemble step now stamps ?v=\u003csha\u003e onto BOTH the glue and the .wasm, because a fresh glue does not pull a fresh binary: the glue locates it with new URL('..._bg.wasm', import.meta.url) and a URL built against a base does not inherit the base's query.","dependencies":[{"issue_id":"improvise-d31","depends_on_id":"improvise-djm","type":"blocks","created_at":"2026-04-14T00:40:15Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-djm","title":"Standalone main-thread bundle: wasm-client + worker transport","description":"Main-thread wasm entry point for the static-web standalone deployment. Hosts the existing thin-client wasm (improvise-wasm-client) inside the browser tab, registers a worker (dedicated or service) that hosts the worker-server bundle, and wires a MessageChannel between them carrying Command messages in exactly the same shape the websocket carries in the thin-client-over-network deployment. The main-thread code does not know whether its peer is a remote server or a local worker — same protocol, different transport. Result: standalone deployment is just the thin-client architecture with a local transport.","design":"Two wasm bundles loaded by different parts of the browser runtime. (1) Main-thread bundle = improvise-wasm-client, unchanged from the network deployment. ViewState, reduce_view, keymap, DOM renderer. Small (~300 KB). (2) Worker bundle = worker-server, a separate issue. Full App + command pipeline + projection layer + VFS persistence. Heavy (~1-3 MB). Transport: MessageChannel with serde-json (or bincode) over postMessage. Main-thread Rust code is already transport-agnostic because on_message accepts serialized commands regardless of source and outgoing commands are returned to JS — the transport abstraction lives at the JS boundary. This issue is the main-thread entry point: JS bootstrap that instantiates wasm-client, spawns the worker, constructs the MessageChannel, routes messages in both directions. Worker type: start with Dedicated Worker (new Worker('worker.js')) for cleaner lifecycles and confirmed OPFS access; explore Service Worker later for PWA/offline-install scenarios.","acceptance_criteria":"(1) Crate compiles to wasm32-unknown-unknown. (2) Size budget: under 3 MB compressed (stretch: under 2 MB). (3) Initializes with a bundled demo .improv file on first load. (4) All core interactions work: cursor move, cell edit, formula entry, save, reload. (5) Can be driven end-to-end from a small JS harness without any server process running.","notes":"Depends on improvise-gsw (thin-client wasm must exist — reused literally), the new worker-server issue, improvise-cr3 (DOM renderer). Shares code with the thin-client epic to the maximum extent possible: the main-thread wasm bundle is the same artifact; only the JS bootstrap differs in how it instantiates the transport.","status":"closed","priority":1,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:34:23Z","created_by":"spot","updated_at":"2026-09-20T02:29:49Z","closed_at":"2026-09-20T02:29:49Z","close_reason":"Done. web/app.js spawns the worker: new Worker('./worker.js?doc=\u0026seed=', { type: 'module' }).\n\nTWO CORRECTIONS TO THE TICKET'S OWN DESCRIPTION, both dates rather than decisions:\n * NO MessageChannel is needed or wanted. worker.js uses self.onmessage/self.postMessage -- a dedicated worker's OWN implicit port -- so worker.postMessage / worker.onmessage is the pair.\n * type: 'module' is MANDATORY, since worker.js imports the bundle. A classic worker fails to parse it, and that failure does reach the error listener.\n\nTHE WEBSOCKET PATH IS KEPT, worker is the default: ?ws= selects the socket, its absence spawns the worker, and THE PRESENCE OF THE PARAMETER IS THE WHOLE SELECTION MECHANISM -- a second knob (?transport=) would name the same fact twice. Kept because improvise-ws-server is the only way a TUI-hosted document is shared, and deleting the page's path to it would leave a working server nothing could reach. The cost is not 'two things to keep working': the transports differ only in WHERE STRINGS COME FROM. Handshake, on_frame, repaint, typed_cell and keydown are all above the seam, so the difference is confined to two ~15-line factories returning { send, ready }. That is exactly what improvise-xks6 bought -- one envelope pair, two deployments.\n\n#status now composes TWO facts, the transport's and the document's, because a single source let a repaint silently erase a connection error.\n\nMEASURED: client 345 KB / 123 KB gzipped, worker 1.33 MB / 498 KB gzipped, total site 1.8 MB / ~620 KB gzipped -- comfortably inside the 3 MB budget. build.sh end to end under nix develop; the CI assemble step replayed locally with every relative reference checked to resolve. Every JS call confirmed against the generated .d.ts with the right arity.\n\nNOT VERIFIED, and this is the whole remaining risk: NOBODY HAS OPENED THE PAGE. No browser, no headless DOM. Every line of app.js and worker.js is read, not run -- the module-worker spawn, OPFS, ResizeObserver arithmetic, click-to-edit, keydown, the CSS. The Pages workflow has also never run.\n\nAC 4 is partly blocked: cell edit, formula entry and reload work through Commands; keyboard-driven interaction needs improvise-bpq2, and today NO KEY PRODUCES A VISIBLE CHANGE -- Host::handle answers ClientFrame::Key with an empty vec, and keys.rs resolves locally only in minibuffer modes you can only enter via an upstream key. The listener is wired and honest about doing nothing.","dependencies":[{"issue_id":"improvise-djm","depends_on_id":"improvise-9x6","type":"blocks","created_at":"2026-04-14T00:40:14Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-djm","depends_on_id":"improvise-cr3","type":"blocks","created_at":"2026-04-14T00:40:15Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-djm","depends_on_id":"improvise-gsw","type":"blocks","created_at":"2026-04-14T00:40:13Z","created_by":"spot","metadata":"{}"}],"dependency_count":3,"dependent_count":3,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-tm6","title":"Epic: Standalone static-web deployment via wasm + VFS storage","description":"Enable improvise to run entirely in the browser with no backend, suitable for GitHub Pages or any static host. Full App compiles to wasm; ratatui is stripped (automatically, by not depending on improvise-tui); persistence goes through a VFS/Storage abstraction backed by OPFS (or IndexedDB fallback) in the browser and by PhysicalFS on native. Shares the DOM renderer and Command protocol with the thin-client epic (improvise-6jk) — same code, different transport (in-process channel instead of websocket).","design":"Three deployment modes share code: (1) native TUI = today, (2) thin client over websocket = epic 6jk, (3) static web standalone = this epic. Mode 3 bundles the full App (improvise-core + improvise-formula + improvise-command + improvise-io's persistence half) into wasm. Same Command protocol and DOM renderer as mode 2; transport is an in-process command bus. Persistence abstracted behind a Storage trait (or the vfs crate) with PhysicalFS backing for native and OPFS backing for wasm. This makes sqlite/parquet formats a later orthogonal win — VFS handles bytes→medium, new format modules handle data→bytes. DOM renderer still reads ViewState + render cache even in standalone mode; an in-process projection layer updates the cache as App state changes. Ratatui falls out for free once crate-split epic completes: wasm build simply doesn't depend on improvise-tui.","acceptance_criteria":"Closes when improvise-bck does. bck is now mostly demonstrated by harness/ in CI rather than by hand; its one genuinely unmet criterion is 'add a formula', which is a missing FEATURE and is filed as improvise-fdt6. So this epic's remaining content is: fdt6, plus whatever bck's annotation lists as not-demonstrated (Safari via improvise-xf1, two tabs, bindings.rs as a code path). Do not close it on the strength of the other children being closed — check bck's own list.","notes":"Depends on crate-split epic's steps 3 (improvise-io) and 5 (improvise-command) being complete — both become critical-path under this epic. Shares protocol crate (improvise-cqi) and DOM renderer (improvise-cr3) with thin-client epic. Bundle size estimate: 1-3 MB compressed; biggest contributors are pest (formula parser) and chrono. Optimization (wee_alloc, wasm-opt, pest error trimming) is follow-on.\nCHECKED, wave 2 WU-P: STAYS OPEN, held by improvise-bck, which is itself held\nby two open children. This epic's own criteria say not to close it on the\nstrength of the other children being closed but to check bck's list, so that\nis what was done — see bck's verification annotation for the criterion-by-\ncriterion result.\n\nHolding it: improvise-fdt6 (a browser cannot commit a formula at all, so bck's\ntest-plan step 5 has no implementation) and improvise-zkkw, filed this pass\n(bck criterion (1) says \"Chrome and Firefox\"; shell.spec.js runs on Chromium\nonly, which bck's earlier annotation did not record). Two of bck's five\ncriteria are now met with measured evidence rather than assertion: the\ndeployed page reaches an editable grid in 278-1172 ms from a cold browser, and\nthe two wasm binaries are 699 KB gzipped off the live site against a 3 MB\nbudget.","status":"open","priority":1,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:33:45Z","created_by":"spot","updated_at":"2026-09-20T18:05:06Z","labels":["area:epic"],"dependencies":[{"issue_id":"improvise-tm6","depends_on_id":"improvise-6mq","type":"blocks","created_at":"2026-04-14T00:40:28Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-tm6","depends_on_id":"improvise-9x6","type":"blocks","created_at":"2026-04-14T00:40:31Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-tm6","depends_on_id":"improvise-bck","type":"blocks","created_at":"2026-04-14T00:40:33Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-tm6","depends_on_id":"improvise-d31","type":"blocks","created_at":"2026-04-14T00:40:33Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-tm6","depends_on_id":"improvise-djm","type":"blocks","created_at":"2026-04-14T00:40:32Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-tm6","depends_on_id":"improvise-i34","type":"blocks","created_at":"2026-04-14T00:40:30Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-tm6","depends_on_id":"improvise-ywd","type":"blocks","created_at":"2026-04-14T00:40:29Z","created_by":"spot","metadata":"{}"}],"dependency_count":7,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-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}
|
||
{"_type":"issue","id":"improvise-ubu","title":"Circular formula references produce ERR instead of stack overflow","description":"Added depth limit (16) to formula evaluation. Circular/self-referencing formulas return CellValue::Error(\"circular\") instead of infinite recursion. Errors propagate through expression tree via Result. Already fixed.","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-04-09T08:35:30Z","created_by":"Edward Langley","updated_at":"2026-04-09T08:36:08Z","closed_at":"2026-04-09T08:36:08Z","close_reason":"Fixed in commit 56bf736","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-di3","title":"Bug: AddFormula doesn't add target item to category","description":"When adding a formula interactively (e.g. Margin = Profit / Revenue), the formula is registered but the target item (Margin) is never added to the target category (Measure). The grid layout never creates cells for it, so the formula is invisible. Found during demo recording.","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-04-09T08:24:58Z","created_by":"Edward Langley","updated_at":"2026-04-09T08:25:15Z","closed_at":"2026-04-09T08:25:15Z","close_reason":"Fixed: AddFormula::apply now calls category_mut().add_item() before registering the formula. Regression test added.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-n1h","title":"1.5 Create examples/demo.improv and examples/demo.csv","description":"Create two synthetic example files with obviously-fake data. demo.csv: ~30-50 rows with Date, Region, Product, Customer, Revenue, Cost columns. demo.improv: result of importing demo.csv, optionally with interesting default view and sample Profit formula. Data must be obviously synthetic.","status":"closed","priority":1,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:06:53Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-09T07:16:33Z","close_reason":"Created examples/demo.csv (40 rows, obviously-fake data with fictional companies) and examples/demo.improv (generated via headless import with Profit formula and a useful default view). Added import command to README.","dependency_count":0,"dependent_count":3,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-bv1","title":"1.4 Audit CSV quote handling","description":"Verify csv_parser correctly handles RFC 4180 quoted fields: embedded commas, escaped quotes. Ensure it uses the csv crate, not manual split. Add unit test round-tripping a row with embedded comma and escaped quotes. If fundamentally broken, add Known Limitations note to README instead.","status":"closed","priority":1,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:06:51Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-09T06:58:41Z","close_reason":"Audit complete. The csv crate handles all RFC 4180 cases correctly: embedded commas, escaped quotes, embedded newlines. Added 4 regression tests. No bugs found.","dependency_count":0,"dependent_count":1,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-2fr","title":"1.3 Verify publish-readiness (cargo publish --dry-run)","description":"Run cargo publish --dry-run from inside nix develop. Fix any errors or warnings (missing license file, files too large, dependency issues). Do not actually publish.","status":"closed","priority":1,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:06:49Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-09T07:21:22Z","close_reason":"cargo publish --dry-run passes cleanly: 72 files, 952KB, no errors or warnings.","dependencies":[{"issue_id":"improvise-2fr","depends_on_id":"improvise-km8","type":"blocks","created_at":"2026-04-08T21:09:21Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0}
|
||
{"_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-lyyc","title":"check-evidence is one-directional: a test citing the evidence file can have no entry","description":"`scripts/check-comment-runs.py --check-evidence` validates one direction only: every `### name` heading in `context/test-evidence/` must name a test that exists. It never asks the reverse — that a test whose doc comment *points at* the evidence file has an entry there.\n\nThat blind spot has now been walked into for the first time. PR #59 added `the_drill_key_and_an_aggregate_edit_refuse_for_different_reasons`, whose doc comment directs the reader to `context/test-evidence/clients.md`, and that file had no entry for it. The checker was green throughout. Worse, the same file still described the behaviour #59 *ships* as one of the two injected faults that had proved the old test — so a reader following the pointer arrived at prose that contradicted the code. Both were found by human review, which is the thing the checker exists to avoid needing.\n\nThe asymmetry in the original design is deliberate and correct as far as it goes: most tests have no evidence and should not be forced to. What is missing is the narrower obligation — **a test that names the evidence file must appear in it**. That is checkable: grep test doc comments for `test-evidence`, take the enclosing `fn` name, require a matching heading.\n\nNote the second half of the incident is not covered by that check either, and probably cannot be: an entry can exist and still be *stale*. The drill entry existed and described its own subject backwards. The only defence there is the rule that a fact stated twice is changed by a grep rather than an edit (gotcha 48), which is discipline rather than a gate. Worth saying so in the ticket so nobody implements the cheap half and believes the class is closed.\n\nAcceptance: `--check-evidence` gains the second direction; it fails on a test that cites the evidence file and has no heading; proved by deleting one heading and watching it fail.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T20:40:48Z","created_by":"wave3-webui","updated_at":"2026-09-20T20:40:48Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-djoo","title":"Publish the context/ prose as a documentation site folded into GitHub Pages","description":"Generate a static documentation site from context/ (design-principles, repo-map, code/*, adr/*, test-evidence/*) plus docs/design-notes.md, assembled by web/assemble.sh under /docs/ alongside the landing page at the root and the app at /app/. No framework, no build step beyond one zero-dependency node script. Ticket ids resolve to a generated index built from .beads/issues.jsonl; source paths link to GitHub at the assembled ref. The generator link-checks its own output and exits non-zero on a break, so a dead cross-reference fails the assembly. context/plan.md and context/cloud-agents.md are deliberately not published.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T20:34:53Z","created_by":"wave3-docsite","updated_at":"2026-09-20T20:34:53Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-rmvx","title":"Six advertised keys do nothing in the browser, and the guard is scoped to one mode","description":"Found by generalising improvise-gd28's browser finding. `\u003e` was not an instance;\nit is a class. Gotcha 47 says every KeyOutcome::Upstream is a black hole —\nHost::handle answers ClientFrame::Key with an explicit empty vec — so any key\nthe app ADVERTISES and then resolves upstream does nothing at all in a browser\nand reads as broken rather than as unavailable.\n\nTHE LIST, advertised in a hint line AND unhandled by resolve::step:\n\n \u003e drill-into-cell Normal (fixed in PR #59)\n P toggle-prune-empty Normal\n R toggle-records-mode Normal\n o add-record-row RecordsNormal\n d/Del delete-formula-at-cursor FormulaPanel\n : enter-mode command Normal, RecordsNormal, FormulaPanel,\n CategoryPanel, ViewPanel, TileSelect\n\nThe `:` row is a DIFFERENT case and must not be lumped in. Its name IS handled;\nit is refused on purpose by strands_the_user, because CommandMode is a prompt\nthis client can enter but cannot commit — a dead room is worse than a locked\ndoor. So for `:` the refusal is RIGHT and only the silence is wrong. Fixing it\nmeans saying so, not implementing it.\n\nHOW THE CROSS-REFERENCE WAS MADE, because the method is reusable and the naive\nversion is wrong. help::advertised() is a table of (command, args, caption) —\nCOMMAND NAMES, not text — so the hint line can be crossed against what\nresolve::step handles exactly, with no tokenisation of the rendered string and\nno guessing which character in \"hjkl arrows :nav\" is a key. Anything that\ntokenises the hint TEXT is solving a harder problem than exists.\n\nMY FIRST PASS OVER-REPORTED AND I ALMOST BELIEVED IT. Resolving every bound key\nagainst an EMPTY ClientState flags edit-or-drill, commit-cell-edit,\ncreate-and-switch-view, page-prev/page-next and delete-formula-at-cursor as\nupstream — but most of those are handled names refused by STATE (no cell under\nthe cursor, no published basis), which is correct behaviour and not this bug.\n\"Upstream in an empty fixture\" and \"the name is unhandled\" are different\nquestions, and the first is not evidence for the second. The list above is the\nsecond, separated by checking each name against step's arms directly.\n\nAND THE NAMED LIST IS HONEST FOR ONE MODE ONLY — this is the point with reach.\nresolve.rs's `the_command_names_that_still_need_the_host_are_these` is the\nguard gotcha 47 says must be kept accurate, and it IS accurate — but it is\nbuilt with in_mode(AppMode::Normal), so it only ever asks about Normal-mode\nbindings. add-record-row (RecordsNormal) and delete-formula-at-cursor\n(FormulaPanel) are outside its reach entirely: they could never have failed it.\nA named list that silently scopes itself to one mode is a guard that LOOKS\nbroader than it is, and that is the same shape as every other defect this repo\nkeeps finding — a check passing while the thing it names is unexamined.\n\nTHE DECISION THIS TICKET WANTS, which is why nothing beyond `\u003e` was touched:\n\n (a) Do the same interim for the remaining five: each says what is true, in\n the client's existing status mechanism. Cheap, honest, reversible, and\n each needs its own TRUE wording — the `\u003e` fix was nearly routed through a\n message about aggregates, which would have been true-sounding about the\n wrong thing and worse than silence.\n (b) Implement some of them client-side instead. toggle-prune-empty and\n toggle-records-mode are view-shape changes and may be composable from\n existing ModelCommands; add-record-row and delete-formula-at-cursor\n touch the document and need checking against gotcha 38 first.\n (c) Make the class unrepresentable: a test asserting that every command named\n by help::advertised() either resolves locally or appears on a per-mode\n exemption list. That is the \"make the wrong thing impossible\" move and it\n would have caught all six at once. It is also the only option that stops\n the SEVENTH appearing.\n\nI did not pick. (c) is the one I would argue for, and it is independent of\nwhether (a) or (b) happens to the five.\n\nNot chased: whether any key is advertised in a hint for a mode whose keymap\ndoes not bind it at all. every_hint_key_resolves_in_its_mode covers that from\nthe keymap side, so it should be empty — but I did not verify it, and it is the\nmirror-image question.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T20:23:03Z","created_by":"wave3-webui","updated_at":"2026-09-20T20:23:03Z","comments":[{"id":"e5a7ccf4-843d-504a-818b-283c47115c96","issue_id":"improvise-rmvx","author":"wave3-webui","text":"Precedent note (orchestrator's instruction; do NOT build this yet).\n\nThis ticket asks for a check that every key a hint line advertises actually resolves to something in a browser, so that \"advertised but Upstream\" — a black hole (gotcha 47) — fails the build instead of being discovered by a user.\n\nThat is **the same shape as two checks this repo already has**, which makes it precedent rather than novelty, and the two are worth reading before designing it:\n\n- `every_bound_command_name_is_registered` (improvise-command/src/command/keymap.rs) walks the keymaps and requires each bound name to resolve in the registry — *and* that its args parse, because a typo'd mode name otherwise compiles into a dead key.\n- `every_hint_key_resolves_in_its_mode` (same file) splits each `AppMode::hint_text()` and requires every key it names to resolve in that mode's map. Its tokenizer knows the hint dialect explicitly and **panics on a token it cannot classify** rather than skipping it — which is the detail to copy, since a checker that silently skips what it does not understand reports success for the cases it never looked at.\n\nSo the design question is not \"how do we build such a check\" but \"what is the browser's analogue of *resolves*\". For the host, resolving means the registry answers. For a browser, it means `resolve::step` answers with `Some` rather than falling through to `KeyOutcome::Upstream`. The list of keys that legitimately still need the host already exists as `the_normal_mode_keys_that_still_need_the_host_are_these`, so the check is plausibly: every key named in a hint either resolves locally or is on that named list — which also forces closing one to come and say so.\n\nOne live instance, for scale: `\u003e` was advertised and silent, and that was one of six. The one-line interim (a `SetStatus` from `step`) closed `\u003e` only; the other five were deliberately left alone pending this ticket.\n","created_at":"2026-09-20T20:40:58Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1}
|
||
{"_type":"issue","id":"improvise-iioi","title":"The view and formula panels have no pointer affordance while the category panel does","description":"Found by adversarial review of the unreviewed pointer work (959f249), demonstrated in Chromium.\n\nweb/app.js's viewRow (~:404) and formulaRow (~:422) build their \u003cli\u003e with NO click listener, where categoryList (~:453) now has one. Click (resolve.rs:71-79) has exactly three variants and no ViewRow/FormulaRow.\n\nDemonstrated: Shift+V, then click the last row of #views -\u003e NOTHING changes. Mode stays VIEWS, the row list is byte-identical before and after, the active marker does not move, the panel cursor does not move.\n\nWHY THIS IS THE ONE A USER HITS FIRST: a user who has just learned that category rows are clickable will click a view row and read the silence as broken — which is LITERALLY the bug report that produced improvise-7ybz ('I can't expand categories' meant 'clicking does nothing'). Shipping the cure for one panel and not its two siblings reproduces the original report.\n\nIt is also the CHEAP one: the resolver already has everything. view_at_cursor, switch-view-at-cursor and delete_view_at_cursor exist, and the formula panel already has a formula_cursor in reduce_view's SetPanelCursor arm. Click::ViewRow(usize) under the commit's own stated rule is a near-copy of click_category_row.\n\nADJACENT, same class, lower confidence and not chased by the reviewer: grid GROUP HEADERS (th.group) are collapse-toggles in the TUI and are not clickable here either; row/column header \u003cth\u003es likewise.\n\nNOT covered by the drill work: improvise-gd28 is refused-by-design because no wire vocabulary exists. The view and formula panels have full wire vocabulary today, so this is a different gap.\n\n## Steps to Reproduce\n\n1. Load /app/index.html, press Shift+V.\n2. Click a row in #views.\n3. Nothing changes — no switch, no cursor move, no status.","acceptance_criteria":"Clicking a view row switches to that view; clicking a formula row moves the formula cursor — each under the same rule as click_category_row (go there, then do what Enter does there). Covered by harness/tests/pointer.spec.js assertions proved against the pre-fix bundle.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"wave3-webui","owner":"el-github@elangley.org","created_at":"2026-09-20T20:18:11Z","created_by":"orchestrator","updated_at":"2026-09-20T20:20:22Z","started_at":"2026-09-20T20:20:22Z","comments":[{"id":"c306069a-89f1-55d3-ba64-0c81b8025a7a","issue_id":"improvise-iioi","author":"wave3-webui","text":"A RULING WANTED WHILE THIS TICKET IS OPEN, raised by the reviewer and sharpened\nrather than answered, because it follows from my rule and that is exactly why my\nrule should not settle it.\n\nTHE CLICK RULE I WROTE is \"go there, then do what Enter does there\". Applied to\nthe tile bar it gives: select the tile AND cycle its axis, because Enter in\nTileSelect is cycle-axis-for-tile. So there is NO click that merely selects a\ntile. A user cannot point at a tile to inspect it, or to see which one they are\nabout to act on. It is the one widget where a single click MUTATES THE DOCUMENT\n— every other click in the page moves a cursor, opens a panel, or expands a row.\n\nThat is consistent with the rule and it may still be wrong. A pointer carries an\nexpectation a keyboard does not: `T` then arrow then Enter is three deliberate\nacts, and collapsing them into one click removes the step where the user sees\nwhat they have selected before committing. The keyboard equivalent of \"click\"\nis arguably `T`+arrow (select), not `T`+arrow+Enter (select and act).\n\nTHE OPTIONS, none of which I have taken:\n (a) Keep it. One rule, no exceptions, and cycling is reversible in four\n clicks since Axis::next() is a rotation.\n (b) A tile click SELECTS only; cycling stays on Enter/Space and r/c/p. Costs\n the single-gesture affordance and makes the tile bar the one widget where\n a click does not activate.\n (c) Single click selects, double click cycles. Introduces a second pointer\n vocabulary the page does not currently have anywhere.\n\nI lean (a) on consistency grounds and distrust that lean, because it is the\nanswer my own rule produces and I am the one who wrote the rule.\n\nALSO, A CORRECTION TO MY OWN FRAMING, same source: the \"one rule\" is honestly\nTWO. click_cell does NOT do what Enter does in the grid — Enter is\nenter-advance, and a cell click moves the selection and delegates activation to\nthe page's inline editor, which is the pointer's `i` rather than its Enter. The\ncode has always done that; the commit message overstated by one widget. The\nhonest statement is: a click navigates to what was clicked, and activates it\nwhere activation is something the client can express.\n","created_at":"2026-09-20T20:23:17Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1}
|
||
{"_type":"issue","id":"improvise-hkr8","title":"A click that cannot compose its activation keeps the prefix and drops the payload","description":"Found by adversarial review of the unreviewed pointer work (959f249), demonstrated with a probe.\n\ncrates/improvise-protocol/src/resolve.rs:98 and :121 both do:\n\n commands.extend(filter_to_item_at(state, index).unwrap_or_default());\n commands.extend(tile_axis_at(state, index, None).unwrap_or_default());\n\nBoth callees return Option\u003cVec\u003cCommand\u003e\u003e and both None arms come from model(state, cmd)? -\u003e BasisCache::claim -\u003e self.current.as_ref()?, i.e. THE SERVER HAS NOT PUBLISHED A BASIS YET (gotcha 43: claim returns None until then; a record missing from the basis is filter_map'd away rather than failing, so this is the only None trigger).\n\nThe KEYBOARD handles the same None correctly: resolve() maps step(..) == None to KeyOutcome::Upstream — 'I cannot answer, ask the host'. The CLICK instead keeps the prefix and drops the payload.\n\nProbe on ClientState::default() (no basis), clicking an item row:\n\n [ SetPanelOpen { panel: Category, open: true },\n ChangeMode(CategoryPanel),\n SetPanelCursor { panel: Category, cursor: 1 } ]\n\nThe panel opens, focuses and the cursor moves — and the FILTER, which is the entire point of the click, is gone, with the frame reported as applied. Same for click_tile: you land in TILES mode and the axis never cycles.\n\nThis is gotcha 47's clause-4 failure (partial AND silent) reproduced one layer up, and it is worse than the keyboard's answer for the same input.\n\nREACHABILITY IS LOW and that is deliberate context, not a dismissal: the basis arrives in the bootstrap Snapshot and app.js folds a whole frame before repainting, so there is normally nothing painted to click before a basis exists. The reviewer could not construct a reachable browser path. AGENTS.md's rule applies — 'reachability is a reason not to rush a fix and not a reason not to notice one' — and the lost update CheckedBatch fixed had the same reachability argument and none of the documentation.\n\n## Steps to Reproduce\n\n1. Build a ClientState with no published basis (ClientState::default()).\n2. Call click_category on an item row index.\n3. Observe the navigation commands present and the filter absent.","acceptance_criteria":"A click is all of its intents or none: when the activation half cannot be composed (no published basis), click_category_row and click_tile emit nothing rather than the navigation prefix alone. Demonstrated by probing ClientState::default() and seeing an empty command list instead of SetPanelOpen/ChangeMode/SetPanelCursor without the filter.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T20:17:59Z","created_by":"orchestrator","updated_at":"2026-09-20T20:17:59Z","dependency_count":0,"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","comments":[{"id":"89e3844a-2923-598b-87dc-5cddf6486dec","issue_id":"improvise-7ybz","author":"wave3-webui","text":"USER RULING on the tile click, recorded here because the reasoning is worth more than the behaviour: **a click SELECTS, a second click CYCLES.**\n\nThe first click on a tile moves the tile cursor and enters `TileSelect` without touching the document; cycling the axis needs a second click on the *already-selected* tile. `Enter`/`Space` keep cycling directly from the keyboard.\n\n**What it buys.** Every click in the UI is now non-mutating by default. That is a property of the whole pointer story, not a tile-specific concession, and the strongest version of the argument is a consequence the ruling was made without: a tile cycle is an `EffectKind::Model(LayoutGeometry)` change, so it bumps `App::cursor_epoch`, which `Sessions::adopt_cursor_epoch` broadcasts as `Command::ResetCursor` **to every connected client** (gotcha 24). A single stray click therefore used to cost every other viewer their cursor position. The pointer is the easiest input to trigger by accident, and it was wired to the one class of change that reaches other people.\n\n**What it cost, accepted knowingly.** Two clicks for the common action, and a **deliberate divergence from the keyboard**, where `Enter` cycles in one keystroke. Record it as a divergence chosen on purpose, not an inconsistency to tidy later — otherwise someone reads the keymap and \"fixes\" the pointer to match, which silently reinstates the broadcast-on-accident.\n\n**The implementation detail that is easy to get wrong.** \"Already selected\" must be `mode == TileSelect` **and** `tile_cat_idx == index`. The index alone is not enough: `tile_cat_idx` is 0 before anything has selected anything, so an index-only test reads tile 0 as already selected and cycles it on its very first click — the guarantee then holds for every tile but one. And \"second click\" must mean *on the already-selected tile*, not merely the second click of a session, or clicking A then B cycles B and the guarantee holds only for a session's first click. Both are asserted (`the_first_click_on_tile_zero_selects_it_like_any_other`, `clicking_a_different_tile_selects_it_rather_than_cycling_it`).\n\nEvidence discipline: the non-mutation is asserted **positively as well as negatively** — a test saying \"the axis did not change\" passes against a page where nothing happened at all. The fixture publishes a basis deliberately, so a missing `Command::Model` is a decision rather than a client that could not compose one.\n","created_at":"2026-09-20T20:41:14Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1}
|
||
{"_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.","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}
|
||
{"_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}
|
||
{"_type":"issue","id":"improvise-75ld","title":"A refused or no-op AddCategory still bumps layout_version and raises dirty","description":"Found by the post-wave code consistency review, CONFIRMED BY EXECUTION against a real App.\n\nApp::apply_effects (crates/improvise-command/src/ui/app.rs:637-650) runs `effect.apply(); dirty.union(of(kind)); dirty = true; layout_version += 1;` and ONLY THEN checks abort_effects. So the aborting effect itself is counted.\n\n## Steps to Reproduce\n\n1. Fill a document to MAX_CATEGORIES (12), then add a 13th via `:add-cat X` or the `+cat` records column. The category is NOT created — `AddCategory` now sets abort_effects on refusal (PR #27) — yet `layout_version` goes 12 -\u003e 13 and `model_state.dirty` -\u003e true.\n2. Or simply `:add-cat Region` twice. `Model::add_category` returns Ok for a duplicate (types.rs:177), so this does not even abort: a pure no-op bumps both.\n\n## Consequences\n\n * EVERY OTHER CONNECTED CLIENT'S NEXT Command::Model IS REFUSED with Rejection::LayoutMoved, for a keystroke that changed nothing.\n * The browser worker's `if app.model_state.dirty { persist() }` (worker.rs:178) rewrites the whole OPFS document — and Storage is whole-document (gotcha 41).\n\n## Why this is a contradiction and not just an over-approximation\n\nThe `dirty` half IS argued as acceptable over-approximation in the comment at app.rs:617-622. The `layout_version` half is NOT — the same comment block says \"an aborted batch must not claim the effects it never ran (gotcha 21)\", which is exactly what it does. The code and its own stated rule disagree, four lines apart.\n\n## Note before fixing\n\nMoving the two bumps below the abort check closes the MAX_CATEGORIES arm but NOT the duplicate arm, because a duplicate AddCategory does not abort at all. The reachable, silent case is the duplicate. Consider bumping on observed change instead — `apply_effects` already fingerprints records for the CellData tier (gotcha 40), so \"did anything actually change\" is machinery that exists.\n","acceptance_criteria":"A refused AddCategory (MAX_CATEGORIES) and a duplicate AddCategory both leave layout_version and model_state.dirty untouched. Note the duplicate arm does not abort, so moving the bumps below the abort check is not sufficient on its own — the reachable silent case is the duplicate. A test drives both arms through App::apply_effects and asserts neither counter moved; injecting the old order fails it. State in the commit what the rule now is: a counter is bumped on an observed change, not on a declared intent.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T07:49:41Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:49:41Z","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-yqpd","title":"Transactional rollback: a frame of mutations applies whole or not at all (ADR 001 clause 4)","description":"Clause 4 of the transaction contract the user specified 2026-09-20. Clauses 1-3 landed in PR #26; this is the half that was deliberately deferred with a costed design note.\n\nTHE CONTRACT, in the user's own words: \"a sequence of commands sent to the server should assume that it's played in order from start to end and that each command sees the state after the previous command runs\", as \"a single transaction from a given base\". Four clauses:\n\n 1. A frame states ONE basis, describing the base the client composed against. [DONE, PR #26]\n 2. Checked ONCE, before anything is applied. [DONE, PR #26]\n 3. Applied IN ORDER, each command seeing what its predecessor left. [DONE, PR #26]\n 4. ALL OR NOTHING -- a failure leaves the document exactly as the base described. [THIS BEAD]\n\nWHERE THE MECHANISM BELONGS: App::apply_effects, NOT Host::commands. A boundary drawn around a sequence of COMMANDS gets redrawn the day one command becomes a sequence -- and the user has said a command could eventually be \"run script\", which makes an unbounded number of mutations internally. So arity is not a property of a variant (doctrine(arity_is_not_a_property_of_a_variant)): a classifier can honestly say WHETHER a command changes the document, never HOW MANY TIMES. Design at the one mutation site and let both the frame case and the future script case use it.\n\nTHE CORE PROBLEM IN ONE LINE: abort_effects stops a batch and UNDOES NOTHING. That is an abort, not a rollback (gotcha 21), and clause 4 needs the latter. Two live examples sit in main today: a failed Save, and AddCategory refusing on MAX_CATEGORIES (PR #27).\n\nTHE DESIGN NOTE FROM PR #26, whose arguments should not be re-derived:\n * SNAPSHOT AND RESTORE, not speculative validation and not inverse effects. Speculation fails a concrete divergence argument: AcceptWizardModel calls take_built(), which CONSUMES the operand a real run would need. Inverse effects degenerate -- the general inverse of a document-replacing effect IS a snapshot -- so they do not avoid the clone, they multiply the surface by ~50.\n * THE FINGERPRINTS CANNOT BE AN UNDO LOG. The per-record XOR (gotcha 40) is a change DETECTOR, not invertible, and covers CellData only.\n * THE BOUNDARY IS THE DOCUMENT AND ITS TIERS, NOT ALL App STATE. AddCategory writes its refusal into view_state.status_msg and THEN aborts, so rolling that back would undo the explanation along with the damage.\n * WHY ROLLBACK IS SOUND AT ALL, structurally rather than by a flag: the three storage-WRITING effects (Save, SaveAs, ExportCsv) are all ViewOnly and unreachable from ModelCommand; LoadModel and ImportJsonHeadless only read. That is testable, and it becomes an acceptance criterion for any future RunScript -- a script language that can run :w breaks it.\n\nTWO ACCEPTANCE CRITERIA THAT CAME OUT OF THE P1 THE REVIEWER FOUND, and they are not obvious:\n * THE CHECKS MUST STAY BEFORE ANY APPLICATION. A frame that writes ONE RECORD TWICE is legitimate and likely -- two measures on one record IS one record -- and it works only because the second write is compared to the record's PRE-FRAME counter rather than the one its own sibling just bumped. Proved by injection: moving the record check into the apply loop fails that arm. `a_later_write_in_a_chain_still_answers_for_its_own_record` arm 3 is the guard.\n * CLAUSE 4'S ROLLBACK IS NOT THE RECORD TIER. They fail on DISJOINT frames, which is exactly why the P1 could not be deferred into this bead: rollback never fires on a frame where every command SUCCEEDS, and the lost update was a wrong answer fully applied.\n\nTHE TRADE NOBODY HAD NAMED, and the reason arm 3 exists: INTERLEAVING THE RECORD CHECK WITH THE APPLIES WOULD CLOSE THIS BEAD'S SILENCE FOR FREE. [RemoveItem(North), SetCell(North)] under interleaving is refused RecordGone instead of silently half-applying. So check-before-apply is not a free win -- it BUYS the legitimate double-write and SELLS the loud failure on a broken chain. Clause 4 must pay that back some other way, and arm 3 is what stops someone taking the trade without noticing they made it. Confirmed by two independent injections (the author's and the reviewer's, which differ in detail and agree on the direction).\n\nTHE BIGGEST AND LEAST OBVIOUS COST: ROLLING BACK THE App IS NOT ENOUGH. Host::command projects PER COMMAND, and apply_switch/apply_model adopt mirrors inline, so by the time a later command fails every session's mirror and believed grid cache have already moved. Deferring projection and relay to commit is the right fix and is also a bandwidth win -- but it changes Applied and restructures project.rs, so it is a merge-order problem as well as a size one.\n\nPROTOCOL MOVE THAT BELONGS HERE: clause 1 says the FRAME states one basis, but the wire still carries a Basis inside each Command::Model -- right when one command was one transaction. Later commands' Basis fields are currently IGNORED. Moving it to ClientFrame::Commands makes \"one basis per frame\" unrepresentable-otherwise instead of a convention.\n\nALSO FOLD IN: the `answered`/`frame_basis` classifier is a matches! whose failure direction is safe (a missed variant over-checks, never under-checks) -- make it exhaustive when the classifier returns, for the reason mutates_the_document was made exhaustive before it was deleted.\n\nESTIMATED COST from the design note: ~350-450 lines of production and as much again in tests, across app.rs, reduce.rs, project.rs, host.rs, improvise-protocol and an ADR section.\n\nINVERT `a_frame_whose_later_mutation_fails_is_not_yet_undone` when this lands. It currently characterizes the hole; all three of its assertions flip.\n","acceptance_criteria":"A ClientFrame::Commands whose later command fails leaves the document exactly as its stated basis described. The mechanism sits at or near App::apply_effects, not Host::commands, so a future multi-mutation command (RunScript) uses the same machinery. The checks stay BEFORE any application — a frame writing one record twice is legitimate and refuses itself otherwise, which a_later_write_in_a_chain_still_answers_for_its_own_record arm 3 guards. Rolling back the App is not enough: Host::command projects per command, so mirrors and believed grid caches must be deferred to commit. a_frame_whose_later_mutation_fails_is_not_yet_undone inverts. And the Basis moves from Command::Model to ClientFrame::Commands, making one-basis-per-frame unrepresentable-otherwise.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-20T07:29:16Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:45:26Z","labels":["area:protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-0o6x","title":"A group header first on its axis is never drawn, so a leading collapsed group has no way back","description":"Found by the independent adversarial review of PR #23, which calls it \"the most valuable thing I found that nobody named\". PRE-EXISTING and correctly characterized, but improvise-anr9 changes what it costs.\n\nTHE BEHAVIOUR: body_from(idx) starts at the first ITEM, so at row_offset == 0 a leading FrameEntry::Group is skipped. GROUPED_FRAME shows it plainly -- the group header \"Coastal\" above \"East\" is simply not drawn -- and that constant is BYTE-IDENTICAL to main, so the TUI has always behaved this way and the characterization is honest.\n\nWHY IT MATTERS MORE NOW: a COLLAPSED group that is first on its axis has NO HEADER ANYWHERE ON SCREEN, in either front end, and therefore NO WAY BACK. Its rows are hidden and the only affordance for unhiding them is the header that is not drawn.\n\nAND IT SITS EXACTLY WHERE THE WINDOWING RULE SAYS IT MUST NOT. improvise-anr9's axis_frame carries ALL headers between two in-window items specifically because \"a collapsed group's header is the only thing on screen the user can click to get its rows back\". That clause is correct and the rule is right. Position zero is the one place the rule does not reach -- not because the frame omits the header (it does not) but because body_from drops it on the way to the screen.\n\nTHE AUTHOR KNEW, WHICH IS WHY THIS IS FILED RATHER THAN REDISCOVERED: tests/grid_frame_agreement.rs deliberately orders its fixture with \"Carol\" before \"Family\" to dodge the case, and says so in a comment. It was not in the PR body and no ticket was cited, so the knowledge lived in one comment in one test.\n\nFILE THIS BEFORE ANYONE BUILDS GROUP INTERACTION IN THE BROWSER. web/app.js's group \u003cth\u003e has no click listener today and `toggle-group-under-cursor` is Upstream -- a black hole, doctrine(an_upstream_key_is_a_black_hole) -- so the affordance does not exist in the browser at all yet. The moment it does, a leading collapsed group becomes a trap: rows hidden, nothing to click.\n\nWHAT THE FIX TOUCHES, so nobody starts casually: body_from/visual_of decide what a renderer paints from a window offset. Drawing a leading header changes the row count of the painted body, which moves the exact-frame characterization buffers in src/ui/grid.rs -- so this is a deliberate re-pinning with the reason in the commit message, the same acceptance clause improvise-q0vb had. Check the interaction with the windowing rule's first clause (the nearest header ABOVE the window is carried for group_of and discarded by body_from): at offset zero there is no \"above\", so the leading header is an in-window entry that body_from is discarding, which may make the fix a one-line boundary condition rather than a redesign. Establish which before estimating.\n\nRELATED: improvise-plcx (the harness needs a grouped, multi-level fixture) is what would put this in front of a browser at all.\n\n## Steps to Reproduce\n\n1. Build a view whose row axis has a group as its FIRST entry — e.g. a category whose first item belongs to group `Coastal`.\n2. Open it in the TUI at `row_offset == 0`.\n3. The `▼ Coastal` header is not drawn at all: `body_from(0)` starts at the first ITEM and skips the leading `FrameEntry::Group`. `GROUPED_FRAME` in `src/ui/grid.rs` pins this, byte-identical to pre-anr9.\n4. Now collapse that group. Its rows disappear and its header is still not drawn — so there is nothing on screen to act on, in either front end.\n","acceptance_criteria":"A group header that is first on its axis is drawn, in the TUI and in the browser, so a collapsed group at position zero has an affordance. The exact-frame characterization buffers in src/ui/grid.rs are re-pinned DELIBERATELY, with the commit message naming which moved and why — the improvise-q0vb acceptance shape. Establish first whether this is a boundary condition in body_from rather than a redesign: at offset zero there is no 'above', so the leading header is an in-window entry body_from is discarding.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T07:26:33Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:57:12Z","labels":["area:browser"],"dependencies":[{"issue_id":"improvise-0o6x","depends_on_id":"improvise-plcx","type":"relates-to","created_at":"2026-09-20T07:26:35Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-plcx","title":"The browser harness needs a grouped, multi-level fixture — the demo has neither","description":"Named by improvise-anr9 (PR #23) as \"the highest-value follow-up in this hand-back\", and it is a gap in the NEW harness rather than in the old blind spot.\n\nmain now carries harness/tests/shell.spec.js (improvise-vvl3), which renders web/app.js in Chromium, Firefox and WebKit and asserts the grid draws, a header reads \"Revenue\", an editable cell exists, and click-to-edit commits and shows the host's value. It PASSED on PR #23's merged head, which means the DOM rewrite that PR shipped is genuinely browser-verified.\n\nBUT ONLY ON THE SIMPLE PATH. web/demo.improv has NO GROUPS AND ONE CATEGORY PER VISIBLE AXIS -- deliberately, per gotcha 46, because before improvise-anr9 a second category on an axis put the slash-join gap on the front page. That is no longer a reason, and it is now the harness's blind spot: the multi-level header, group-span, collapse-indicator and inline-group-row paths of gridTable are EXACTLY the code improvise-anr9 added, and the harness never exercises any of them.\n\nWHAT IT NEEDS: a second .improv served by the harness -- not a change to web/demo.improv, which is the shipped front page and is correct as it is -- carrying at least two categories on one axis, a group with a couple of members, a collapsed group, and enough rows to scroll. Then assertions that the DOM has real header levels rather than slash-joined labels, that a group header spans its members, that the collapse indicator renders, and that an inline group row carries no cells (RenderRow::Group exists so it cannot, which is worth asserting from the outside).\n\nWHY IT IS WORTH DOING SOON: it would be the FIRST TEST IN THIS REPOSITORY able to see that code in a browser, and improvise-anr9's own hand-back says so. It also unblocks the sibling scrollIntoView bead, which needs a document big enough to scroll.\n\nNOTE THE STALE-DOCUMENTATION LESSON THAT CAME WITH IT, recorded because it cost real caution: improvise-anr9 wrote \"web/app.js is never executed here\" into three comments and a PR body on the strength of gotcha 42 -- while a harness that renders it in three browsers had ALREADY LANDED on main and was passing on its own code. Gotcha 48 says a fact stated in several places is a grep. This is the version where the stale copy is the one telling you what you CANNOT test, and it makes you under-claim your own evidence.\n","acceptance_criteria":"The harness serves a SECOND .improv (not web/demo.improv, which is the shipped front page and correct as it is) carrying at least two categories on one axis, a group with several members, a collapsed group, and enough rows to scroll. Specs assert: real header levels rather than slash-joined labels; a group header spanning its members; the collapse indicator rendering; and an inline group row carrying no cells (RenderRow::Group makes that unconstructible, which is worth asserting from outside). Each assertion is proved discriminating before it is kept.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T07:16:28Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:45:23Z","labels":["area:browser"],"dependencies":[{"issue_id":"improvise-plcx","depends_on_id":"improvise-0o6x","type":"relates-to","created_at":"2026-09-20T07:26:35Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":2,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-sihr","title":"Every minibuffer mode entered from records mode exits to plain Normal while the layout is still records","description":"Found by Copilot on PR #27 against the new Ctrl-W save prompt, and DECLINED there with evidence because fixing the one arm the finding named would have been one arm of a three-arm rule.\n\nTHE BEHAVIOUR: every minibuffer mode entered from RecordsNormal exits to plain Normal while the LAYOUT IS STILL RECORDS. It affects `:` (CommandMode), Ctrl-E (ExportPrompt) and Ctrl-W (SavePrompt, new in PR #27). Only the last is new; the class predates it.\n\nTHE MECHANISM: the exit mode is a CONSTANT, baked into TextEntry::new(.., \"normal\") in the keymap builder and into each commit command's ChangeMode. Nothing carries where the user came FROM, so nothing can put them back.\n\nWHAT IS NOT THE MECHANISM, checked rather than reasoned (doctrine(binding_dump_diff)): the binding TABLE is fine. RecordsNormal goes 155 -\u003e 156 dumped lines across PR #27, the single addition being Char('w')+CONTROL -\u003e enter-mode [\"save-prompt\"], with nothing removed. with_parent inheritance (gotcha 13) is intact. What is lost is the RUNTIME MODE, not a binding.\n\nCHARACTERIZED, NOT FIXED: every_minibuffer_mode_entered_from_records_returns_to_plain_normal drives all three and asserts both halves -- the mode is Normal, and the layout is still records. So the current behaviour is pinned and a fix will have to update it deliberately.\n\nTHE FIX, and it is why this is its own bead: text-entry modes must carry their entry mode. That is a change to TextEntry, to AppMode (the exit is part of a mode's configuration, so it likely belongs beside MinibufferConfig -- note gotcha 11, MinibufferConfig::buffer_key is a String and not \u0026'static str precisely because ViewState must stay Deserialize-able, and the same constraint applies to anything added there), and to three commit commands. Wider than the PR that found it.\n\nWATCH TWO THINGS:\n * The exit is currently spelled in TWO places per mode -- the TextEntry spine's Esc binding and the commit command's ChangeMode. If they are made to carry an entry mode, make them read ONE source or the two will disagree on cancel versus commit.\n * SearchMode is hand-written on purpose and has no commit key (gotcha 11). It is not part of this class; do not sweep it in.\n\nRELATED: improvise-mai9 (a status message written while a minibuffer is open is never drawn) is the other half of why these modes are awkward -- both are consequences of the minibuffer owning the bottom row and nothing tracking what it displaced.\n\n## Steps to Reproduce\n\n1. Enter records mode (`R`).\n2. Press `:`, or `Ctrl-E`, or `Ctrl-W` — any minibuffer mode.\n3. Commit or cancel.\n4. The app is now in plain `Normal` while the LAYOUT is still records: the exit mode is a constant baked into `TextEntry::new(.., \"normal\")` and into each commit command's `ChangeMode`, and nothing carries where the user came from.\n\n`every_minibuffer_mode_entered_from_records_returns_to_plain_normal` drives all three and asserts both halves.\n","acceptance_criteria":"A minibuffer mode entered from RecordsNormal returns to RecordsNormal. Text-entry modes carry their entry mode rather than a constant 'normal'. The exit is currently spelled TWICE per mode — the TextEntry spine's Esc binding and the commit command's ChangeMode — so both read ONE source or they will disagree on cancel versus commit. SearchMode is hand-written on purpose and is not part of this class. every_minibuffer_mode_entered_from_records_returns_to_plain_normal is updated deliberately, not deleted.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T06:12:20Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:57:17Z","labels":["area:tui"],"dependencies":[{"issue_id":"improvise-sihr","depends_on_id":"improvise-mai9","type":"relates-to","created_at":"2026-09-20T06:12:22Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-kc7n","title":"improvise-ws-server never checks Origin, so any page a user visits can drive their session","description":"Found by Copilot on PR #21 round 5, against a reverse-proxy recipe whose IP allow-list had itself been added two rounds earlier to close a DIFFERENT hole. Verified independently by the orchestrator: `grep -rni origin crates/improvise-ws-server/src/` returns NOTHING. The server never inspects the Origin header and accepts every handshake.\n\nTHE HOLE: a WebSocket is EXEMPT FROM THE SAME-ORIGIN POLICY -- there is no CORS preflight on a handshake. So any hostile page that an allowed user visits can open a socket to the endpoint FROM THAT USER'S OWN ALLOWED ADDRESS, and read and edit every document the server holds. That is cross-site WebSocket hijacking.\n\nWHY NETWORK CONTROLS CANNOT SEE IT: an IP allow-list, a private network, loopback binding -- all answer \"may this CLIENT reach the endpoint\", and in this attack the answer is genuinely yes. The request really does come from an allowed client; it is simply being DRIVEN by a page the user did not intend to trust. Only Origin separates the two.\n\nLOOPBACK IS NOT EXEMPT, and this is the half a documentation-only fix cannot reach. In the local pairing case there is no proxy at all, so there is nothing in front to check Origin -- and loopback is not a boundary a browser enforces. While the server runs, ANY page open in that browser can reach ws://127.0.0.1:8787. So the local development path, which is the one the project actually recommends, is the least protected.\n\nDISTINCT FROM improvise-kpnr, and both are needed. kpnr is AUTHENTICATION: who is allowed to talk to this server at all. This is CSWSH: given that someone is allowed, what may put words in their mouth. An answer to either leaves the other open.\n\nTHE FIX IS CHEAP AND SERVER-SIDE: an allow-list of permitted origins checked at the handshake, defaulting to something safe for the local case (the page's own origin, and whatever --bind implies), with a flag to widen it. Refuse the upgrade rather than accepting and ignoring.\n\nTWO THINGS TO GET RIGHT:\n * A MISSING Origin header is not the same as a disallowed one. Non-browser clients (the repo's own tests, curl, a native client) send none, and the browser always does. Decide explicitly what absent means rather than letting the match arm decide by accident.\n * PR #21 has already documented Origin filtering in the PROXY as a required companion to the network control, phrased as \"in addition to, never instead of\". When this lands, the README should say the server does it too, and the proxy line becomes defence in depth rather than the only defence.\n\nTHE GENERAL LESSON, now doctrine(who_may_connect_is_two_questions): ask of any access control whether it constrains the CLIENT or what may DRIVE the client. Three consecutive rounds of that PR reasoned about the page's capabilities and none about the attacker's.\n\n## Steps to Reproduce\n\n1. Run `improvise-ws-server` on any document.\n2. From a browser that is allowed to reach it — the user's own machine, or any address an IP allow-list permits — visit an unrelated page that opens `new WebSocket(\"ws://127.0.0.1:8787\")`.\n3. The handshake succeeds. A WebSocket is exempt from the same-origin policy, so there is no CORS preflight and `grep -rni origin crates/improvise-ws-server/src/` returns nothing — the server never looks.\n4. That page can now read and edit every document the server holds, from the user's own allowed address.\n\nLoopback is not exempt and has no proxy in front of it, so the local development path is the least protected.\n","acceptance_criteria":"improvise-ws-server refuses a handshake whose Origin is not allow-listed, defaulting to something safe for the local case with a flag to widen it. A MISSING Origin is decided explicitly rather than by accident — non-browser clients (the repo's own tests, curl, a native client) send none and browsers always do. The README's proxy-side Origin filtering becomes defence in depth rather than the only defence.","status":"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":"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":"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":"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}
|
||
{"_type":"issue","id":"improvise-shyt","title":"[bug] Records-mode row order is not stable across processes","description":"Found during improvise-4s8 (2026-09-19). A real latent bug in improvise-core, out of that ticket scope, worked around there by sorting.\n\nDataStore::matching_cells returns cells in HashSet iteration order, so RecordRow::group -- and therefore RECORDS-MODE ROW ORDER IN THE LIVE UI -- is not stable across processes. The same document opened twice can list its records in different orders.\n\nWHY IT MATTERS BEYOND TIDINESS:\n * gotcha 18 says IndexMap insertion order is load-bearing for persistence, and DataStore::cells is IndexMap precisely so \"records mode can display rows in the order they were entered\" (its own doc comment). The SECONDARY INDEX walk defeats that.\n * f32 accumulation is order-dependent, so anything summing over matching_cells is reproducible only within one process. improvise-4s8 had to sort before building a feature matrix or a SEEDED FIT would not reproduce across runs -- see the known-answer vectors in gp-tree, which exist because that class of drift is silent.\n * A user watching rows reorder between sessions has no way to tell whether the document changed.\n\nFIX: make matching_cells (or its callers that care) yield in the primary IndexMap order rather than the index HashSet order. Note the index exists for O(1) LOOKUP, not for iteration, so the fix is probably to intersect the index result back against the insertion-ordered primary rather than to change the index.\n\nTEST IT THE WAY THE INSTABILITY ACTUALLY SHOWS: a single-process test cannot distinguish \"sorted\" from \"hashed that way today\". Build the same document twice with different insertion histories that produce the same set, or assert directly against the IndexMap order.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T00:35:04Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:45:20Z","closed_at":"2026-09-20T00:45:20Z","close_reason":"Fixed. A private DataStore::matching_positions replaces the duplicated prelude in matching_values and matching_cells: the secondary index still NARROWS the candidates -- that is what it is for -- but each survivor is resolved to its position in the primary IndexMap with get_index_of, positions are sorted, results read back with get_index. The index does lookup, the primary map does ordering.\n\nTHE BLAST RADIUS WAS LARGER THAN FILED -- five callers, and the one nobody knew about is worse than the headline:\n * view/layout.rs::group_records -- records-mode row order in the live UI (the filed case).\n * command/cmd/grid.rs::DrillIntoCell -- 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 keyed staged edits to DIFFERENT RECORDS: silent misdirected writes, not a cosmetic reorder. Never filed; inherits the fix.\n * eval_agg, evaluate and aggregate_raw fold f64 over the result, and f64 addition is not associative, so a formula's value could differ in the last bits between two openings of the same document. Min/Max/Count are order-free, and so is the text-unanimity rule (gotcha 7).\n * improvise-gp dataset.rs::visible_records was already sorting as a workaround (improvise-4s8). The sort is KEPT -- removing it would change the fit's row order and gp-tree's known-answer vectors are bit-level -- but its comments claimed the sort is what buys stability, which this fix makes false, so they now say it is a choice of CANONICAL order: a fit's rows should be a property of the data, not of the typing history.\n\nMEASURED, release, 18,000 cells, 2000 iterations: worst case +20% on matching_values (a sort plus one hash lookup per candidate); matching_cells is 7% FASTER, because sorted positions resolve CellKeys with better locality. Nothing became O(store) -- the alternatives (an ordered index value type, or scanning the primary) would have moved the cost onto every set or onto every query.\n\nTHE TEST SHAPE IS THE POINT: a single-process test cannot tell 'sorted' from 'hashed that way today on this machine'. So the order is compared against iter_cells -- the primary map's own order -- rather than a literal, and the_same_cells_entered_in_opposite_orders_list_in_opposite_orders builds the same logical store by two different histories and requires each to list its own.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-l4qx","title":"[bug] A text cell is invisible whenever any category is on the None axis","description":"Found during improvise-q08 (2026-09-19), PRE-EXISTING, and it is the NORMAL case rather than an edge one.\n\nModel::evaluate_aggregated takes the aggregating path when none_cats is non-empty, and that path does filter_map(as_f64) -- so a CellValue::Text aggregates to NOTHING and never becomes a CellDisplay. _Index and _Dim sit on Axis::None in a default view, so none_cats is non-empty almost always.\n\nWHY IT MATTERS: .improv has pipe-quoted text cells as a FIRST-CLASS FEATURE -- the format doc gives Region=West, Measure=Revenue = |pending| as an example -- and they do not render. A user can write a text value, save it, load it and not see it.\n\nThe q08 agent hit this while building a fixture, worked around it there, and noted it in the round-trip test comment. Costing a test iteration is how it surfaced; nothing in the suite covers it.\n\nWrite the failing test first: a text cell in a default view (so _Index/_Dim are on None) must render its text. Then decide what aggregation of text should MEAN -- filter_map(as_f64) silently dropping it is clearly wrong, but \"show the single value when there is exactly one, and something explicit when there are several\" is a product decision, not an obvious fix. Note gotcha 7: apply_agg and eval_agg already differ on empty input, so this family has precedent for being decided rather than defaulted.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T23:46:09Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:24:05Z","closed_at":"2026-09-20T00:24:05Z","close_reason":"FIXED. aggregate_cell_values in model/types.rs, shared by evaluate_aggregated and aggregate_raw (which had byte-identical bodies precisely so they could not drift -- improvise-kdp).\n\nTHE RULE IS UNANIMITY, and the ORDER is load-bearing:\n empty -\u003e None (absent, unchanged -- follows apply_agg)\n ALL NUMERIC -\u003e apply_agg -\u003e Number (unchanged; checked FIRST)\n all values equal -\u003e that value\n otherwise -\u003e Error(\"mixed\")\n\nReducing several copies of one value is the identity, so that reduction has an answer even when the values are not numbers -- which covers the case the ticket is actually about. Two different words, or a word among numbers, have no reduction, and gotcha 7 already has precedent for saying so out loud (eval_agg returns Err(\"empty\") for AVG/MIN/MAX over nothing rather than inventing a value). So this is consistent with BOTH existing rules: emptiness follows apply_agg (absent, not zero), undefined-reduction follows eval_agg (explicit error, not silence).\n\nTHE ORDERING IS THE WHOLE DIFFERENCE from the ticket own first suggestion. \"Show the single value when exactly one matches\" taken literally BREAKS Count, whose entire point is to not be the value. The all-numeric branch is tested first, so Count of a lone number is still 1. Proven by injection: swapping the branch order failed exactly one test, a_lone_number_still_counts_as_one_rather_than_passing_through, which is why that guard exists.\n\nREJECTED: \"show the first\" (depends on DataStore hash iteration order, so the displayed value would be arbitrary AND look authoritative while hiding the others -- worse than the blank); \"show a count\" (changes the type of the answer; a count rendered under a Sum measure is indistinguishable from real data); and making the display path differ from the formula path.\n\nBEHAVIOUR CHANGE TO KNOW: a formula ref over MIXED values now errors \"mixed\" instead of \"ref:name\". Still an error, but a truer one, and it no longer silently sums the numbers while dropping the text. Text-valued refs still error exactly as before. Fixed-point convergence untouched.\n\nFailing test first: a_text_cell_renders_in_a_default_view, which also asserts !none_cats.is_empty() to confirm the premise that a DEFAULT view really does put _Index/_Dim on None -- the normal path, not an edge one. 1024 tests pass; three faults injected, all fired.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-vjms","title":"Decide whether a category panel shows the virtual categories","description":"Found by improvise-35e (2026-09-19). A GENUINE BEHAVIOUR DIVERGENCE, which is why that ticket stopped rather than migrating the widget.\n\nThe TUI category panel builds from cat_tree::build_cat_tree, which walks Model::category_names() and therefore draws _Index, _Dim and _Measure. CategoryRow/category_rows carry only regular_category_names(), and CategoryTreeCache own doc says \"Only the categories a user may see -- the client does not know the _-prefixed virtual-category convention\".\n\nSo migrating the category panel to the cache would SILENTLY DROP THREE ROWS from the TUI panel. That is a behaviour change, not a relocation, so doctrine(characterization_survives_the_move) says stop and ask.\n\nTHE DECISION IS improvise-edp: either the browser panel grows the virtual categories, or the TUI panel loses them. Note gotcha 7 -- _Measure items are explicit items UNION formula targets, so a client showing _Measure needs measure_item_names semantics, not a raw item list; that is part of the cost of the first option.\n\nWhichever way it goes, the widget then also needs a tree flattener over CategoryTreeCache -- today build_cat_tree takes \u0026Model.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T23:40:37Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:06:54Z","closed_at":"2026-09-20T00:06:54Z","close_reason":"DECIDED (user, 2026-09-19): the TUI keeps the virtual categories AND the browser gets them too. CategoryTreeCache grows to carry _Index, _Dim and _Measure, so both panels render the same thing and the cache stays the single source for what a category panel shows.\n\nWHAT THIS COSTS, and it is the part to get right: the client now has to be told about the _-prefixed convention, which the caches doc currently says it does not know. In particular _MEASURE ITEMS ARE DERIVED, not a raw list -- gotcha 7: they are the explicit items UNION the formula targets, and add_formula does NOT add items to _Measure. So category_tree_of must ship membership computed through measure_item_names()/effective_item_names(), never category(\"_Measure\").items, or the panel will silently omit every formula target. That is the same trap improvise-ete was closed for proposing.\n\nConsequences to carry into improvise-edp:\n * CategoryTreeCache and its doc comment change -- the \"the client does not know the _-prefixed virtual-category convention\" line is now false and must go.\n * build_cat_tree can then migrate off \u0026Model, which is what unblocks the category panel and therefore edp.\n * A characterization test should pin that the cache carries all seven rows for a default workbook (4 regular + 3 virtual) and that _Measure includes a formula target that is not an explicit item -- one fixture that would fail under the raw-list implementation.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-pauh","title":"Make the beads flake pin actually bind in agent worktrees","description":"Two mitigations, BOTH wanted (user, 2026-09-19). Manual direnv allow is not sufficient on its own -- see the scaling problem below.\n\nTHE PROBLEM. flake.nix pins beads to github:gastownhall/beads/v1.3.0 precisely to control bd version. A git worktree does NOT load that pin: .envrc is unallowed in a fresh worktree, so a process there falls through to ~/.nix-profile/bin/bd. Verified 2026-09-19 with a scrubbed DIRENV env: \"Found RC allowed 0\". Today the profile bd is ALSO 1.3.0, so nothing breaks -- which is exactly why a careless test passes and why this is worth fixing before it bites.\n\nTHE FAILURE MODE IS SILENT. See the bd-1-0-2-dev-embedded-dolt-dependencies memory: bd VERSION SKEW SILENTLY EMPTIES THE DEPENDENCY GRAPH in .beads/issues.jsonl. No error; the loss is invisible until someone asks what blocks what. The flake pin exists to prevent this and an unallowed worktree bypasses it.\n\nWHY MANUAL ALLOW DOES NOT SUFFICE: agent worktrees are created FRESH PER DISPATCH. Allowing the ones that exist right now does not help the next one, so any manual pass is stale the moment another agent starts. Confirmed today -- allow entries were created at 16:35 and a worktree from earlier still reported allowed 0.\n\nMITIGATION 1 -- make the allow automatic. direnv allow (or an equivalent: a whitelist.prefix entry in ~/.config/direnv/direnv.toml covering the worktree root, which would cover every future worktree in one stroke and is probably the better answer) at worktree creation. Note whitelist.prefix is currently [] per direnv status.\n\nMITIGATION 2 -- assert the version rather than trust it. Have .claude/hooks/session-start.sh compare bd version against the flake pin and FAIL LOUDLY on a mismatch. doctrine(force_the_failure) applies: the whole point is that the skew is silent, so the check must be the thing that is not. Read the pin from flake.nix rather than hard-coding it, or the assertion rots the first time the pin moves.\n\nReading with a mismatched bd is safe; it is MUTATING commands that corrupt. If a cheap distinction is available, guard those specifically.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T23:39:38Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:30:40Z","closed_at":"2026-09-20T00:30:40Z","close_reason":"Half 2 landed: section 0 of .claude/hooks/session-start.sh asserts bd matches the flake pin, reading the pinned version OUT OF flake.nix so there is no second copy to rot on the next bump. Fails loudly (exit 2 + banner) on mismatch, on an unparseable pin, AND on an unreadable bd version -- an assertion that cannot answer is not a pass. Placed BEFORE the CLAUDE_CODE_REMOTE gate, because the local worktree is where the problem lives; the cloud path re-checks after installing bd and still completes tracker sync and cargo warm. It blocks nothing -- reads are safe, and the banner names the mutating commands specifically.\n\nDemonstrated both ways rather than asserted: a stub reporting 1.2.4 fires the banner; a stub reporting 1.3.0 is silent; an unparseable version fires; a flake.nix pinning a bare commit fires the cannot-read-the-pin arm. With the real binaries BOTH of today candidates pass (the flake store path and ~/.nix-profile), which is exactly the ticket point -- today pass proves nothing.\n\nHALF 1 IS THE USER TO MAKE, and is reported rather than done: create ~/.config/direnv/direnv.toml (it does not exist today) with [whitelist] prefix = [\"\u003crepo root\u003e\"]. One prefix covers the main checkout and every future worktree since worktrees live under .claude/worktrees/ inside it. Verified without touching the user file by pointing DIRENV_CONFIG at a scratch copy: same worktree went from not-allowed to allowed. There is NO repo-side alternative by design -- direnv refuses to let a repository authorize its own .envrc, and that refusal IS the security model; a .claude/settings.json env block prepending the flake store path was considered and rejected because it hard-codes a machine-specific nix hash that rots on every bump and is less trustworthy than the pin it claims to enforce.\n\nCORRECTION THIS TICKET SURFACED: the evidence in its original description read \"Found RC allowed 0\" as the broken state. It is the opposite -- direnv prints the AllowStatus enum where 0=Allowed, 1=NotAllowed. The conclusion still holds, on better evidence: this agent worktree (created after the users allow batch) reports allowed 1 and its allow file does not exist on disk.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-3al1","title":"ADR-001 c: a rejected batch keeps its values staged, and class-1 force re-composes","description":"ADR 001 layer 1, step c. Depends on step b. This is the half that makes rejection usable.\n\nTWO OBLIGATIONS, both pulled forward from layer 2 by the user force requirement:\n1. The rejected batch VALUES STAY STAGED in the session, so rejection never costs the user their typing. This is the drill staging mechanism doing what it already does; only PUBLISHING the staged keys is layer 2.\n2. FORCE, for conflict class 1 (two writers, same record): re-compose the same INTENT against the current state and submit a fresh batch with a CURRENT basis. One keystroke.\n\nFORCE IS A RE-COMPOSITION, NOT A BYPASS. Never apply a batch whose basis is stale -- a mechanism whose only purpose is to violate the invariant is the invariant deleted with extra steps.\n\nRULE: FORCE NEVER CREATES STRUCTURE. That rule is what keeps class 2 out of this ticket.\n\nTESTS: a rejected edit leaves the staged value intact; force after rejection lands the value and the basis is current at apply time; force does not re-run against the stale basis.","acceptance_criteria":"A rejected batch leaves the user's typing intact, and force re-composes the same INTENT against current state with a fresh basis — one keystroke. FORCE IS A RE-COMPOSITION, NEVER A BYPASS: a mechanism whose only purpose is to violate the invariant is the invariant deleted with extra steps. FORCE NEVER CREATES STRUCTURE, which is what keeps conflict class 2 out of this ticket. Three tests: a rejected edit leaves the staged value intact; force after rejection lands the value with a basis current AT APPLY TIME; force does not re-run against the stale basis. Note the wasm client already keeps the Rejection itself rather than a status string, precisely so force knows which record and which kind (gotcha 43).","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-19T23:27:31Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:56:21Z","labels":["area:protocol"],"dependencies":[{"issue_id":"improvise-3al1","depends_on_id":"improvise-fkfg","type":"blocks","created_at":"2026-09-19T23:27:45Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":3,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-fkfg","title":"ADR-001 b: ModelCommand carries its basis; Applied gains a rejected case","description":"ADR 001 layer 1, step b. Depends on step a.\n\nModelCommand carries the basis it was composed against -- (document_generation, layout_version, and the record_versions of the records it touches). Sessions::apply rejects a batch whose basis moved.\n\nApplied grows a THIRD case beside Diff and Rebased. An enum, not a flag: matching is what forces a host to handle it, which is exactly the reasoning gotcha 39 records for rejecting Applied { .., rebase: bool }.\n\nTHE TIERS ARE THE POINT: a CellData batch is stale if LAYOUT moved, because the shape its keys hang off changed. That is the corruption class -- A deletes category Region while B commits a key naming Region=East -- detected without any lock, and it is why a single counter is not enough.\n\nRejection is a NORMAL OUTCOME, not an error. The client already has the new state via projections.\n\nTESTS, both arms per doctrine(one_arm_of_a_symmetric_rule...): a stale CellData batch after a layout change is rejected; a fresh one is not; a batch touching an untouched record is not rejected by another record moving; a WholeWorkbook bump invalidates everything. Beware doctrine(a_rename_can_hide_a_stale_key) -- hold identifiers constant and vary only what makes the basis stale.","status":"closed","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-19T23:27:30Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:58:04Z","closed_at":"2026-09-20T00:58:04Z","close_reason":"Done. ADR 001 layer 1, the correctness floor.\n\nTHE THREE THINGS THE ADR IS EMPHATIC ABOUT:\n * NO BYPASS, AND NONE DESIGNED IN. Basis has NO Default and NO public constructor -- a zero basis is not 'no claim', it is the claim that the document is fresh, which in a new App is TRUE, so a default would be forgeable. BasisCache::claim is the only door a client has and it withholds until the server has published one. There is no force flag anywhere; re_composing_against_the_republished_basis_is_accepted walks the actual force path (refused -\u003e fold the republished basis -\u003e re-compose -\u003e accepted) with nothing that skips the check.\n * STRUCTURAL AND DATA KEPT APART. RecordGone (nothing resolves, nothing to write to, nothing coherent to force -- force never creates structure) vs RecordMoved (resolves at another version, force is one keystroke). The RecordGone test deliberately brings the LAYOUT claim up to date first so the tier above cannot mask it.\n * COUNTERS ON THE App. Nothing added to ViewState; cursor_epoch went beside document_generation for the same reason.\n\nWHERE THE ADR MET THE CODE AND WAS WRONG -- the important part. ADR 001 says 'a batch carries the basis it was composed against' and NEVER SAYS HOW A CLIENT COMES TO KNOW ONE. Nothing in the projection stream carried any of the three tiers, so the check as specified was satisfiable only by a client FORGING a basis: the invariant deleted with extra steps, arriving through the front door. Closed with Projection::Basis + BasisCache -- the server publishes the two document tiers plus the write counters for the records THIS CLIENT'S GRID NAMES (exactly the set it can address a write to, per ADR 002), diffed against what that client was last told and seeded in the bootstrap. Emitted AFTER the grid updates in a batch: the counters describe cells those updates carry, so a basis arriving first would let a client claim a version for a value it had not been shown. Mirror image of gotcha 33. The ADR is amended.\n\nTHREE SMALLER UNDER-SPECIFICATIONS, all now decided:\n * 'Any record the batch touches' -- WHOSE LIST? If the client's, a client that omits a record has a FORCE BY OMISSION: the record tier skipped, no flag to remove, nothing in the message saying so. records_named is the server's own exhaustive read of the ModelCommand (one or-pattern for the 24 that name none, so a new record-writing variant fails the build), and a claim that omits a record the server knows is written gets Rejection::RecordUnstated -- its own case because 'you lost a race' and 'you were never in one' are different things to tell a user.\n * Command::SwitchView carries no basis -- it is LayoutGeometry but sits outside Command::Model, so apply_switch is unchecked. Not a corruption risk (the workbook refuses a view it does not have, and every mirror takes what was ACCEPTED), but a hole in the rule. Filed.\n * reduce_full/apply_command do not check, and that is right: the check is a property of the SESSION DOOR, not of the mutation path, because a host holding the App is the truth rather than a claimant. ui::reduce::caught_up(\u0026App, cmd) serves that host and is not a bypass -- it takes \u0026App, so nothing reachable from a wire can call it.\n\nClient side: the wasm client folds Projection::Basis and BasisCache::claim gates both compose sites, so a client with no basis composes nothing.","dependencies":[{"issue_id":"improvise-fkfg","depends_on_id":"improvise-3x3u","type":"blocks","created_at":"2026-09-19T23:27:47Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-fkfg","depends_on_id":"improvise-5a5y","type":"blocks","created_at":"2026-09-19T23:27:44Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-5a5y","title":"ADR-001 a: version tiers on App (layout_version, record_version)","description":"ADR 001 layer 1, step a -- the foundation. No wire change, no UX; testable alone.\n\nAdd beside App::document_generation (which already exists and is bumped by install_workbook):\n * layout_version -- bumped by any effect declaring ModelChange::LayoutGeometry\n * record_version -- per RECORD, bumped by a CellData effect touching that record\nA record is the coordinate tuple EXCLUDING _Measure -- record_row_identity(wide, coord_group_excluding_measure), already in kb.pl. NOT the cell: gotcha 1 says editing a category column moves every measure cell in the row, so a cell-level unit is violated by a single legal edit. The unit must be at least as coarse as the coarsest legal write.\n\nThese are SHARED DOCUMENT STATE, so they live on App next to document_generation, never on ViewState (gotcha 30).\n\nBump them from the same place Dirty is computed, so the taxonomy has one reader: App::apply_effects already does dirty.union(Dirty::of(effect.kind())). Do not add a second classification table -- gotcha 35.\n\nTESTS: a LayoutGeometry effect bumps layout_version and not record_version; a CellData effect bumps only the records it touched; an aborted batch (gotcha 21) bumps nothing it did not run. Prove each by injection.","status":"closed","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-19T23:27:28Z","created_by":"Edward Langley","updated_at":"2026-09-19T23:34:34Z","closed_at":"2026-09-19T23:34:34Z","close_reason":"App::layout_version and App::record_versions added beside document_generation, with accessors and a free record_of(\u0026CellKey) that makes record_row_identity a function. 933 tests passing.\n\nTHE DESIGN PROBLEM WORTH KEEPING: an EffectKind says THAT a batch writes cells, never WHICH. So layout_version reads cleanly off the taxonomy in the apply_effects loop, but record_versions cannot. Rejected adding a per-effect \"which records\" method -- a 50-effect burden and exactly the second table gotcha 35 warns against -- in favour of OBSERVING THE DATA: a per-record XOR fingerprint taken when the batch declares a CellData effect, diffed after the loop. The O(cells) pass is bounded by the O(cells) rebuild_layout() that already ends every batch and is paid only on editing batches.\n\nTwo documented semantic consequences: a write landing the value already there bumps nothing (correct -- no basis was invalidated, so rejecting for it would be a rejection for nothing); and a LayoutGeometry effect that drops cells bumps their records when sharing a batch with a CellData one (an over-bump, the safe direction, the same asymmetry Dirty::of is deliberately sloppy in).\n\nA REAL BUG THE TESTS CAUGHT DURING DEVELOPMENT: the first bump_changed_records chained before.keys() with after.keys() and incremented per name, so any REWRITTEN record counted as two writes. It is a HashSet now. Precisely the class of error a weaker fixture would have hidden -- the fixture is deliberately two categories, two measures, two records, because a one-record fixture cannot tell \"the record it touched\" from \"every record\" and a one-measure fixture cannot tell a record from a cell.\n\n7 tests including a_coordinate_rename_is_one_write_to_each_record_not_one_per_measure (the test that earns the record unit) and the_version_tiers_are_not_in_the_view_state. Five faults injected, each fired.","dependency_count":0,"dependent_count":1,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-3x3u","title":"Resolve the two unaddressed nullary wire variants before concurrency lands","description":"ADR 001 names this as a prerequisite. ApplyAndClearDrill and AcceptWizardModel are routed by Sessions::apply with NO SessionId, while drill_state and wizard live on the single authoritative App. So client B sending Model(ApplyAndClearDrill) commits client A uncommitted staged edits, and Model(AcceptWizardModel) installs the workbook A half-finished wizard built -- replacing the document under everyone.\n\nThat already collides with the user ruling on improvise-tom1 (staged edits are private to whoever staged them); committing them on someone else behalf is the stronger version of the same violation. ADR 001 makes it untenable outright, because layer 2 turns staged keys into reservations held BY a session -- and a command that commits them cannot be unaddressed.\n\nTWO HONEST OPTIONS:\n (a) make them addressed -- carry a SessionId, and the effect acts only on that session staged state, which means the staging map moves off the single App and onto the session;\n (b) remove them from the wire vocabulary and keep them key-path only.\n(b) is cheaper and consistent with tom1. (a) is what layer 2 eventually wants if staged state becomes per-session anyway. Decide deliberately -- note that (b) is not a permanent answer if multi-client drill is ever wanted.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T23:09:55Z","created_by":"Edward Langley","updated_at":"2026-09-19T23:34:33Z","closed_at":"2026-09-19T23:34:33Z","close_reason":"DECIDED: option (b) -- ApplyAndClearDrill and AcceptWizardModel removed from ModelCommand (28 -\u003e 26 variants). Their effects are unchanged and still reached by the key path.\n\nThe bug was DEMONSTRATED before the fix, not assumed: a temporary test against Sessions::apply -- client A stages a drill edit, client B sends Model(ApplyAndClearDrill) -- PASSED on the pre-change code, committing A uncommitted 99 into the document and clearing A drill session.\n\nRejected (a), carrying a SessionId: it is the honest long-term form but requires the staging map to move off the single App onto the session, which IS ADR 001 layer 2. Landing it inside the correctness floor would make the branch unreviewable. The doc comment says so, so (b) is not mistaken for permanent.\n\nTHE MORE DURABLE PART IS THE CORRECTED CRITERION. Gotcha 38 kept these on the grounds that \"the command is portable even where the operand is not\". Portability was the wrong question. Gotcha 36 already states the right one: an unaddressed command is correct exactly when the change is addressed to NO ONE session -- and these are addressed to precisely one, which is never the sender.\n\nThe demonstration cannot survive its own fix (the variant it sends is gone), so the surviving guard is structural: no_model_command_commits_another_clients_session_local_state walks every_model_command() and forbids either effect, plus a companion asserting both effects still exist and are still model changes, so the first cannot pass by naming nothing. Both proven by injection.","dependency_count":0,"dependent_count":1,"comment_count":0}
|
||
{"_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":"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}
|
||
{"_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}
|
||
{"_type":"issue","id":"improvise-hzoh","title":"simple_effect! reintroduces the EffectKind default that gotcha 3 exists to prevent","description":"From the wave-1 code review (2026-09-19), CONFIRMED by probe.\n\nsimple_effect! hard-codes EffectKind::ViewOnly and takes an unconstrained $place:expr. This probe compiles, is a textbook LayoutGeometry change (SetNumberFormat right next door declares exactly that), reports ViewOnly, and the full improvise-command suite passes 294/0 with it present:\n\n simple_effect!(AbusiveSetNumberFormat(String) =\u003e |app| app.model_state.workbook.active_view_mut().number_format);\n\ninvariant(effect_kind_no_default) exists because a mis-declared kind is SILENT -- ViewOnly never reaches other clients. kind() having no default forces a declaration; the macro reintroduces the default under one name, for a category defined by SYNTAX ('one field, one place') rather than SEMANTICS ('per-client'). The doc comment states the intended boundary honestly, but a comment is not a constraint. The only pin, view_only_effects_leave_shared_state_alone, is a hand-maintained Vec that nothing forces you to extend -- and SetYanked, one of the eight macro'd effects, is already missing from it.\n\nThe eight effects currently declared through the macro were each checked and ARE genuinely ViewOnly, so there is no live defect -- this is about the shape.\n\nFIX (cheapest that keeps the macro): give it a mandatory kind argument, e.g. simple_effect!(Name(T) =\u003e |app| place, ViewOnly), so the classification is written per call site even when the answer is always the same -- the same reasoning that gave kind() no default. Also add SetYanked to the pin list.","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:40:49Z","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}
|
||
{"_type":"issue","id":"improvise-2v7t","title":"Protocol fold contract: headline tests are vacuous, ordering contract unenforced","description":"From the wave-1 code review (2026-09-19), CONFIRMED by fault injection.\n\n(A1) splitting_a_stream_anywhere_gives_the_same_cache and a_snapshot_seed_is_just_the_head_of_the_stream both assert fold(prefix)+fold(rest) == fold(whole). Since folded()'s only body IS default() + a loop of apply(), that identity is the DEFINITION of a left fold and holds for any apply() whatsoever. Proof: replacing GridCache::apply's cells.get_mut(row).get_mut(col) with get_mut(0).get_mut(0) -- every cell written to (0,0) -- left BOTH tests passing while the two real tests failed.\nThe property the architecture rests on is ORDER SENSITIVITY: a Cells update arriving before its framing RowLabels/ColLabels is silently dropped, so the fold is not commutative and nothing tests reordering. Also, the snapshot test uses the literal prefix stream[..2] as the seed; a real server sends a COMPACTED seed (current labels + cells), and that compaction-vs-replay equivalence -- what 'seeded then follows the stream' means in production -- is untested.\n\n(A2) A shape change without Invalidated tears the grid. fit_to_labels truncates/extends by POSITION, so removing a middle column leaves surviving cells under the wrong labels: ColLabels[A,B,C]+Cells, then ColLabels[A,C] =\u003e the column labelled C shows B's value. GridUpdate::Invalidated exists to prevent this and its doc states the ordering contract, but the contract is PROSE ONLY -- nothing in the types requires it, and shrinking_the_labels_drops_the_cells_that_no_longer_fit blesses a bare RowLabels shrink (the one case where preserve-by-position is right). There is no producer anywhere in the tree, so the first from_app author has only a doc comment to go on.\n\n(A3) col_widths is never re-fitted: ColLabels[A]+ColWidths[8] then ColLabels[A,B] leaves labels len 2, widths len 1. Cache stays total, but a renderer zipping widths with labels indexes out of range.\n\n(A5) populate_before_roundtrip regression: every_command_round_trips_through_json previously covered CellsUpdated with a populated CellDisplay, and now covers only Command::Project(Projection::Grid(GridUpdate::Invalidated)) -- the ONE variant in the new vocabulary carrying no payload. Also CellDisplay::numeric (the field deciding alignment) is never round-tripped at a non-default value, and Snapshot is never round-tripped at all and derives neither Clone nor PartialEq.\n\nFIX: make the tests prove order sensitivity and compaction-equivalence rather than the fold axiom; enforce or type-encode the Invalidated ordering contract; re-fit col_widths; restore populated round-trips.","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:40:48Z","created_by":"Edward Langley","updated_at":"2026-09-19T21:50:32Z","started_at":"2026-09-19T21:15:00Z","closed_at":"2026-09-19T21:50:32Z","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-l3at","title":"AddCategory declares ModelChange::CellData but changes grid geometry","description":"Found while fixing improvise-djn (2026-09-19). The AddCategory effect declares EffectKind::Model(ModelChange::CellData), but adding a category changes grid GEOMETRY, not cell data.\n\nPer repo-map gotcha 3 this class of mistake is silent: EffectKind drives what reaches other clients, there is no default, and a wrong classification produces no compile error and no test failure today -- it only shows up as a connected client whose layout never updates. That makes it exactly the kind of defect the 'no default kind' rule exists to catch, and it slipped through anyway because the rule only forces you to WRITE a kind, not to write the right one.\n\nCheck the sibling effects at the same time (AddItem, RemoveItem, RemoveCategory, anything else touching category membership) -- if one is wrong the others likely are too.\n\nSuggested test: assert the declared kind for each membership-changing effect, so the classification is pinned rather than assumed.","notes":"\n--- CONFIRMED by the wave-1 review (2026-09-19), and it is TWO effects, not one ---\nAddCategory (effect.rs:96-98) AND RemoveCategory (effect.rs:314-316) both\ndeclare Model(ModelChange::CellData). Both go through Workbook, which mutates\nEVERY view's axis map:\n workbook.rs:59-61 add_category -\u003e for view in views { view.on_category_added(\u0026name) }\n workbook.rs:80-82 remove_category -\u003e view.on_category_removed(name)\nThat is view_config_fingerprint's territory and matches LayoutGeometry's own\ndoc ('Axis assignment ... the grid re-lays out'), not CellData's. Probe\nconfirmed each changes serde_json::to_string(\u0026workbook.views).\n\nSIBLINGS CHECKED AND CORRECT -- do not 'fix' these: AddItem, AddItemInGroup,\nRemoveItem go through model only and leave view config untouched; SetCell,\nClearCell, SortData, AddFormula, RemoveFormula likewise. SetAxis,\nTransposeAxes, CreateView, SetNumberFormat, TogglePruneEmpty already declare\nLayoutGeometry correctly. The defect is confined to the two CATEGORY effects.\n\nFailure scenario once a projector exists: a client told CellData emits\nGridUpdate::Cells but no Invalidated + fresh label pair, so a new category's\nrow never appears, or a removed category leaves a column whose label is gone\nfrom every view.\n\nLATENT TODAY: kind() has no production caller yet -- it is read only by\neffect.rs's classification tests. That is the argument for fixing now, while\nthere is no behaviour to regress.\n\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:36:44Z","created_by":"Edward Langley","updated_at":"2026-09-19T21:50:36Z","started_at":"2026-09-19T21:15:03Z","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-6eyg","title":"beads git hooks strip memories from .beads/issues.jsonl","description":"The beads-installed git hooks (.beads/hooks/post-checkout etc.) run bd's plain auto-export, which omits 'bd remember' memories. Observed 2026-09-19: a 'git checkout prolog-sessions/kb.pl' silently rewrote and staged .beads/issues.jsonl with all 15 memory records removed (-180 lines). Interactive 'bd remember' correctly refuses via its shrink guard ('refusing to overwrite ... contains 14 record(s) outside auto-export scope'), so the guard exists but the hook path bypasses it.\n\nWorkaround in place: always regenerate with 'bd export --include-memories -o .beads/issues.jsonl' and check 'grep -c \"_type\\\":\\\"memory\\\"' before committing.\n\nFix options: (a) configure the hook/auto-export to include memories, (b) report upstream to gastownhall/beads, (c) add a pre-commit check in this repo that fails if issues.jsonl memory count drops. Recorded in kb.pl under beads-maintenance-2026-09-19.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T20:11:36Z","created_by":"Edward Langley","updated_at":"2026-09-19T21:15:51Z","closed_at":"2026-09-19T21:15:51Z","close_reason":"RESOLVED by the tracker policy change earlier in this session, not by a code fix. The hooks strip memories from .beads/issues.jsonl only if memories are IN it. export.include-memories is now false (bd config set), which matches bd 1.3.0's auto-export scope, and the tracked file carries issues only -- so the plain export the git hooks run now produces exactly the shape the file is supposed to have, and there is nothing left to strip. Verified: several bd writes since the change have auto-exported and auto-staged the file with no shrink-guard warning.\n\nThe underlying bd oddity is unchanged and worth knowing if memories are ever tracked again: export.include-memories is honoured by an explicit 'bd export' but NOT by the auto-export path, whose guard calls memories 'outside auto-export scope' regardless of the config. That contradiction is recorded in the never-hand-edit-issues-jsonl and autoexport-owns-issues-jsonl memories. Filing it upstream to gastownhall/beads is still open if anyone wants memories in the tracked export.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_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.\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}
|
||
{"_type":"issue","id":"improvise-8uz","title":"Cursor and scroll are per-client state stored in the shared View","description":"Found while classifying effects for improvise-mae.\n\nSetSelected, SetRowOffset and SetColOffset write View::selected / row_offset / col_offset, which live inside model_state.workbook and are PERSISTED in .improv. But they are a per-client concern: in the multi-client server architecture, one client scrolling or moving its cursor must not move another client's viewport.\n\nI classified all three EffectKind::ViewOnly for improvise-mae, on the grounds that projection must not broadcast them. That is the right call for projection, but it leaves a genuine inconsistency: they are the only ViewOnly effects that mutate state stored in the shared workbook, and view_only_effects_leave_shared_state_alone has to special-case them for exactly that reason (crates/improvise-command/src/ui/effect.rs).\n\nOptions:\n(a) Move selected/row_offset/col_offset out of View into ViewState, and stop persisting them. Cleanest, but changes the .improv format — a saved file would no longer restore the cursor where you left it, which may be a feature worth keeping.\n(b) Keep them in View as the saved STARTING position, and track the live per-client cursor in ViewState. The server then never projects cursor moves, and only the owning session writes back on save.\n(c) Leave as is and rely on the projection layer to filter.\n\n(b) looks right but is the most work. Decide before improvise-cqq (server-side projection emission) builds on the classification.","notes":"PRIORITY RAISED P3-\u003eP2 (groomed 2026-08-30): recorded improvise-cqq as depending on this, per this ticket's own 'decide before improvise-cqq builds on the classification'. That made it a P3 gating a P2.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-08-29T20:02:08Z","created_by":"Edward Langley","updated_at":"2026-09-01T05:24:59Z","started_at":"2026-09-01T05:17:59Z","closed_at":"2026-09-01T05:24:59Z","close_reason":"Cursor moved from View to ViewState::cursors: HashMap\u003cview_name, ViewCursor\u003e, via App::cursor/cursor_mut/reset_cursor. None of the ticket's three options was right, because its premise was false: the cursor was never persisted in .improv (no grammar production, format_md emits none), verified by a characterization test before choosing. (a) drops the per-view cursor restore, (b) rests on the non-existent persistence, (c) isn't per-client. Keying by view name satisfies per-client + behaviour-preserving + no-dead-rationale together. Cursor resets moved from View::transpose_axes/cycle_axis into the TransposeAxes/CycleAxis effects; Workbook::normalize_view_state deleted as vacuous, replaced by cursors.clear() in LoadModel/AcceptWizardModel. The ticket's symptom is gone: view_only_effects_leave_shared_state_alone no longer exempts the three setters.","dependency_count":0,"dependent_count":1,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-907","title":"Import wizard step machine: validate early, allow going back, make the terminal step real","description":"Supersedes improvise-gfq, improvise-o1c and improvise-c6j. All three are defects in one mechanism — the WizardStep transition table in crates/improvise-io/src/import/wizard.rs plus the per-step key tables in crates/improvise-command/src/command/cmd/wizard.rs — and they interlock badly enough that fixing any one alone makes the wizard worse.\n\n(1) from improvise-gfq — build_model raises 'At least one category must be accepted' only on Enter at NameModel, the final step. An import with no category-classified field walks the user through ReviewProposals, DefineFormulas and NameModel before refusing. improvise-mzv already added an early pre-check for the opposite failure (too many categories) at the step where the user can still act; this wants the same treatment.\n\n(2) from improvise-o1c — every browsing step binds Esc to close-the-wizard and no step binds a back key. Reaching ConfigureDates or NameModel and wanting to change a field kind means Esc, losing every proposal toggle, formula and name, then restarting the import.\n\n(3) from improvise-c6j — advance() maps NameModel -\u003e Done, but the key layer never calls advance() at NameModel: Enter there runs BuildWizardModel, which closes the wizard outright. Done is reachable only from unit tests that force it. Either drop the variant and its close-on-any-key branch, or route a successful build through it as a real confirmation step.\n\nWHY TOGETHER, NOT SEPARATELY: (1) adds a gate that refuses to advance, and (2) is the observation that there is no way back once gated — shipping (1) alone strictly worsens the dead end that (2) describes. (3) decides whether the machine even has a terminal state, which determines what 'advance' means at the last step and therefore where (1)'s gate belongs. One coherent redesign of advance/previous/validate, one set of characterization tests.\n\nScope note: improvise-ych (JSON null becoming the literal item 'null') is deliberately NOT folded in — that is a data bug in build_model_with_warnings, not the step machine, and shares no code with these.\n\nSuggested shape: WizardStep::previous(), a validate(step) gate consulted by advance(), a back key (Backspace or Left) bound on every browsing step, and a decision recorded on Done. Characterization tests exist from improvise-2wm — extend them rather than starting fresh.","status":"closed","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-08-14T01:08:54Z","created_by":"Edward Langley","updated_at":"2026-08-29T17:48:49Z","started_at":"2026-08-29T17:37:14Z","closed_at":"2026-08-29T17:48:49Z","close_reason":"Three parts, one redesign.\n\n(1) validation_error() gate consulted by advance(), checked on the step where the user can act. Adds the no-category message at ReviewProposals alongside improvise-mzv's category-limit check.\n\n(2) previous() via a history stack: advance() pushes the step it leaves, previous() pops. Retraces the path actually taken rather than re-deriving the reverse of each conditional transition, so it can't drift from the forward table. Bound to Left on every step (Backspace is taken by the name and formula buffers; h is printable and NameModel needs it).\n\n(3) Done made a real confirm step, per user decision over dropping the variant. ImportWizard holds built: Option\u003c(Workbook, Vec\u003cString\u003e)\u003e; NameModel Enter -\u003e build() -\u003e Done showing the model summary and warnings in the panel; Enter accepts, Left/Esc go back and discard the build. BuildWizardModel renamed AcceptWizardModel since it now installs an already-built workbook. move_cursor's _ =\u003e 0 wildcard made exhaustive while there.\n\nTests: 8 new in improvise-io (previous reverses each advance, retraces a visited optional step, no-op at first step, clears message/cursor; build parks on Done; take_built hands over once; build failure stays on NameModel; previous from Done discards built), 5 in improvise-command (no-category gate end-to-end with recovery via c, Left walks back, Done Esc returns and drops the build, Done ignores unbound keys, two-step confirm installs the model), plus key-table tests that every step binds Left and that Done confirms on Enter. Three characterization tests from improvise-2wm updated to the new behavior.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-5j9","title":"ImportPipeline silently skips records missing category values","description":"crates/improvise-io/src/import/wizard.rs:189-197: build_model() skips any record lacking a value for an accepted Category field, with no count or warning surfaced to the user. A 1000-row import with 5 incomplete rows silently loses 5 rows (behavior is documented only in a test comment ~line 1043). Fix: count skipped records and surface in the wizard summary/status line. Related to improvise-k8i (CSV column drops) but at the pipeline level. Found in systematic review 2026-06-09.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-06-10T03:58:22Z","created_by":"Edward Langley","updated_at":"2026-07-31T06:04:20Z","closed_at":"2026-07-31T06:04:20Z","close_reason":"build_model_with_warnings tallies skipped records and surfaces them in the wizard status line, the import effect, and the headless CLI.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-ehz","title":"EnterEditAtCursor effect calls rebuild_layout and builds a CmdContext inside apply","description":"src/ui/effect.rs:137-149: the effect calls app.rebuild_layout() and then constructs a fresh CmdContext just to read display_value. Effects should be pure mutations with inputs decided by the command; rebuilding layout mid-batch couples effect semantics to apply order (rebuild_layout is also called by apply_effects afterwards). Fix: command computes/passes the display value, or a preceding RebuildLayout effect is emitted explicitly. Found in systematic review 2026-06-09.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-06-10T03:58:21Z","created_by":"Edward Langley","updated_at":"2026-07-31T06:44:04Z","closed_at":"2026-07-31T06:44:04Z","close_reason":"RebuildLayout effect emitted explicitly by EnterEditAtCursorCmd and CommitAndAdvance; display value read via App::display_value_at_cursor() instead of constructing a CmdContext inside apply.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-2wm","title":"WizardKey effect embeds the import-wizard state machine inside Effect::apply","description":"src/ui/effect.rs:587-712: the WizardKey effect (~126 lines) pattern-matches on wizard.step, mutates wizard state, and conditionally changes view_state.mode — all inside apply(\u0026mut App). Per design-principles, decision logic belongs in the command layer; effects should be discrete mutations. Refactor: a command inspects the wizard step from CmdContext and emits fine-grained effects. Found in systematic review 2026-06-09.","notes":"Groomed 2026-06-09: coordinate with improvise-764 (migrate import wizard widget/state to RenderCache under the ltq epic) — both restructure the same wizard code; doing 2wm first gives 764 a cleaner command-shaped wizard to migrate.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-06-10T03:58:19Z","created_by":"Edward Langley","updated_at":"2026-07-31T07:22:13Z","closed_at":"2026-07-31T07:22:13Z","close_reason":"Step/key decisions moved to command/cmd/wizard.rs (per-step key tables + shared cursor fallback); effects reduced to WizardApply(WizardOp), CloseWizard, BuildWizardModel. CmdContext gained a read-only wizard ref. 10 handle_key characterization tests pin behavior unchanged.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-97o","title":"vb4 step 6: write field-by-field audit report into vb4 notes","description":"Closes vb4 acceptance criterion (5). After steps 1–4 land, write a complete audit of every original App field into improvise-vb4's notes via bd update improvise-vb4 --notes. Each entry: field name, slice (ModelState / ViewState / App-derived / App-runtime / App-config), one-line rationale. Then close improvise-vb4.","acceptance_criteria":"(1) vb4 notes contain a field-by-field classification table. (2) improvise-vb4 is closed.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-26T18:17:09Z","created_by":"jeeves","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-97o","depends_on_id":"improvise-99k","type":"blocks","created_at":"2026-04-26T11:17:17Z","created_by":"jeeves","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-99k","title":"vb4 step 4: document App-resident runtime/derived fields","description":"After steps 1–3, App owns model_state + view_state plus a residue of fields that don't belong to either slice: term_width, term_height, layout (derived cache from model+view), last_autosave (runtime instant), keymap_set (config), abort_effects (transient apply-loop flag). Add a doc-comment block on App that classifies each remaining field — 'derived cache', 'runtime metadata', 'config', 'transient' — with a one-line rationale. Satisfies vb4 acceptance criterion (2).","acceptance_criteria":"(1) App struct has a doc comment classifying every remaining direct field. (2) No behavior change. (3) Tests pass.","status":"closed","priority":2,"issue_type":"task","assignee":"jeeves","owner":"el-github@elangley.org","created_at":"2026-04-26T18:16:50Z","created_by":"jeeves","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-30T00:35:59Z","close_reason":"Step 4 complete in commit 7249fac. App struct has a top-level doc explaining the slice structure; each of the 5 residue fields (last_autosave, term_width, term_height, layout, abort_effects, keymap_set) is tagged derived cache / runtime metadata / transient / config with a one-line rationale. No behavior change; tests still pass.","dependencies":[{"issue_id":"improvise-99k","depends_on_id":"improvise-ew0","type":"blocks","created_at":"2026-04-26T11:17:16Z","created_by":"jeeves","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-ew0","title":"vb4 step 3: move UI session fields into ViewState","description":"Third slice of improvise-vb4. Move into App.view_state: ViewState the following UI session fields: mode, status_msg, wizard, search_query, search_mode, formula_panel_open, category_panel_open, view_panel_open, cat_panel_cursor, view_panel_cursor, formula_cursor, yanked, tile_cat_idx, view_back_stack, view_forward_stack, drill_state, help_page, expanded_cats, buffers, transient_keymap. Update every read/write site. Effect::apply signature stays \u0026mut App for now (narrowing happens in step 5).","acceptance_criteria":"(1) App has a view_state: ViewState field that owns all listed fields. (2) None of the listed fields are direct fields of App. (3) All tests pass. (4) cargo clippy --workspace --tests clean.","status":"closed","priority":2,"issue_type":"task","assignee":"jeeves","owner":"el-github@elangley.org","created_at":"2026-04-26T18:16:49Z","created_by":"jeeves","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-30T00:23:54Z","close_reason":"Step 3 complete in commit 9ad8abd. ViewState owns 20 UI session fields; App accesses them via app.view_state.X. 227 tests in main crate pass (+1 structural test). Clippy clean.","dependencies":[{"issue_id":"improvise-ew0","depends_on_id":"improvise-x2c","type":"blocks","created_at":"2026-04-26T11:17:15Z","created_by":"jeeves","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-x2c","title":"vb4 step 2: move workbook/file_path/dirty into ModelState","description":"Second slice of improvise-vb4. Move App.workbook, App.file_path, App.dirty into App.model_state: ModelState. Update every read/write site (commands, effects, draw, persistence, tests). ModelState wraps Workbook + IO bookkeeping; Workbook stays in improvise-core and remains IO-agnostic. Effect::apply signature stays \u0026mut App for now.","design":"ModelState { workbook: Workbook, file_path: Option\u003cPathBuf\u003e, dirty: bool } is intentionally not the same as Workbook — Workbook is pure document semantics in improvise-core, ModelState is the UI-layer wrapper that adds IO bookkeeping (file_path, dirty). Keeps the crate boundary clean.","acceptance_criteria":"(1) App has a model_state: ModelState field. (2) workbook, file_path, dirty are NOT direct fields of App. (3) All tests pass. (4) cargo clippy --workspace --tests clean.","status":"closed","priority":2,"issue_type":"task","assignee":"jeeves","owner":"el-github@elangley.org","created_at":"2026-04-26T18:16:47Z","created_by":"jeeves","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-29T05:12:12Z","close_reason":"Step 2 complete in commit 917b928. ModelState owns workbook/file_path/dirty; App accesses them via app.model_state.X. 226 tests in main crate pass (+1 structural test). Clippy clean.","dependencies":[{"issue_id":"improvise-x2c","depends_on_id":"improvise-3vr","type":"blocks","created_at":"2026-04-26T11:17:14Z","created_by":"jeeves","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-3vr","title":"vb4 step 1: scaffold ModelState and ViewState types","description":"First slice of improvise-vb4. Define pub struct ModelState and pub struct ViewState in src/ui/app.rs as empty (or stub-field) types, exported. No App field is moved yet — this isolates the type-introduction diff so subsequent steps are pure field migrations. Establishes the named boundary the rest of the refactor fills in.","acceptance_criteria":"(1) ModelState and ViewState defined and re-exported from ui::app. (2) cargo build --workspace succeeds. (3) All existing tests pass unchanged.","status":"closed","priority":2,"issue_type":"task","assignee":"jeeves","owner":"el-github@elangley.org","created_at":"2026-04-26T18:16:14Z","created_by":"jeeves","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-26T18:31:58Z","close_reason":"Step 1 complete in commit f11d79f. ModelState and ViewState defined as empty pub structs in src/ui/app.rs with doc-comments pointing at follow-up issues. Structural test model_state_and_view_state_are_constructible passes (225 tests in main crate, +1).","dependency_count":0,"dependent_count":1,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-rml","title":"Consolidate Axis display into a method on Axis","description":"Axis display logic is duplicated:\\n- src/ui/tile_bar.rs:30-36 — axis_display() match (4 arms)\\n- src/ui/category_panel.rs:14-19 — exact copy of the same match\\n- src/ui/view_panel.rs:40-44 — inline match\\n- crates/improvise-command/src/command/cmd/tile.rs:112, 131, 143 — axis cycling and conversion matches\\n\\nAdd methods on Axis (crates/improvise-core/crates/improvise-core/src/view/axis.rs):\\n- display_short() -\u003e (\u0026'static str, Color)\\n- display_long() -\u003e \u0026'static str\\n- next() -\u003e Self (cycle Row -\u003e Col -\u003e Page -\u003e None -\u003e Row)\\n- is_data_axis() -\u003e bool\\n\\nCorrectness win: eliminates the tile_bar/category_panel copy-paste — currently a change to axis colors or short labels must be made in both places.\\n\\n~25 LOC.","notes":"PRIORITY RAISED P3-\u003eP2 (groomed 2026-08-30): this gates P2 work (improvise-dz8 ), 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-04-16T19:02:21Z","created_by":"Ed L","updated_at":"2026-09-01T05:30:17Z","started_at":"2026-09-01T05:26:41Z","closed_at":"2026-09-01T05:30:17Z","close_reason":"Axis gained next/color/plain_label/glyph/short_label/initial; Display is plain_label+glyph. Four duplicate sites replaced (tile_bar, category_panel, view_panel, cmd/tile.rs) and View::cycle_axis now calls Axis::next. Two corrections to the ticket: (1) display_short() -\u003e (\u0026'static str, Color) is a layering violation — Color is ratatui, Axis is improvise-core — so it split into color() -\u003e AxisColor (semantic palette) with draw.rs::axis_fg resolving it, mirroring minibuf_fg/MinibufColor; (2) category_panel was NOT an exact copy of tile_bar — same colors, different labels — so only the color table was real duplication, and the four label vocabularies are preserved as four methods with a characterization test pinning them verbatim. is_data_axis() omitted: no caller. Unblocks improvise-dz8.","dependency_count":0,"dependent_count":1,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-tvt","title":"Route mutations through Workbook facade instead of app.workbook.model chains","description":"40+ sites follow the pattern 'if let Some(cat) = app.workbook.model.category_mut(\u0026name) { cat.add_item(\u0026item); }' (src/ui/effect.rs:38, 54, 99 and many test setups in src/command/cmd/mod.rs, src/ui/grid.rs).\\n\\nSimilarly .views.get().unwrap().axis_of() appears in 11 sites (workbook.rs:167-177, model/types.rs:1868-1890, persistence:943-946,1165) even though Workbook::active_view() / active_view_mut() already exist and are used correctly in some places.\\n\\nFix: add facade methods on Workbook: add_item(cat, item), remove_item(cat, item), view_axis(view_name, cat), sort_cells(), cell_count(). Migrate the existing chains to use them.\\n\\nCorrectness win: (a) Workbook becomes a real facade with the option to enforce invariants (e.g., notify views when items change); (b) Tests and effects stop reaching through three layers; (c) future refactors to Model/DataStore internals don't cascade into 40+ call sites.\\n\\n~60 LOC in production + simplification of many tests.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-16T19:02:18Z","created_by":"Ed L","updated_at":"2026-08-01T17:58:24Z","started_at":"2026-08-01T17:37:40Z","closed_at":"2026-08-01T17:58:24Z","close_reason":"Workbook gains add_item / add_item_in_group / remove_item; 165 model.category_mut chains migrated across production (effect.rs, import/wizard.rs) and tests (layout, persistence, app, cmd, grid). Net -125 lines, 706 tests green.\n\nDeliberately NOT done here:\n- .views.get(..).unwrap().axis_of(..) migration → covered by improvise-ont.\n- sort_cells() / cell_count() facade methods → zero call sites in the tree; speculative API, skipped per simplicity-first.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-2hi","title":"Extract AppMode dispatch methods (mode_label, mode_style, mode_key)","description":"AppMode is matched in ~52 sites across 8 files to derive UI labels, styles, and mode keys. Each match arm is a small piece of per-variant logic that should live on the variant itself.\\n\\nHot spots:\\n- src/draw.rs:119-149 — mode_name() and mode_style() match blocks (16+ arms)\\n- src/command/keymap.rs:69-89 — ModeKey::from_app_mode match (16 arms)\\n- src/ui/{formula,category,view}_panel.rs — matches!(mode, AppMode::X) checks\\n- src/ui/tile_bar.rs, src/ui/effect.rs:898-900, src/command/cmd/registry.rs:376\\n- src/ui/app.rs: 13 scattered matches!() checks\\n\\nFix: add methods on AppMode — mode_label() -\u003e \u0026'static str, mode_style() -\u003e Style, mode_key() -\u003e Option\u003cModeKey\u003e, panel_mode() -\u003e Option\u003cPanel\u003e, is_text_entry_mode() -\u003e bool (partially present via minibuffer()). Also extend the existing minibuffer() with buffer_key() convenience.\\n\\nCorrectness win: adding a new AppMode variant requires updating the enum's methods (one file), not chasing 8 files with match blocks. Exhaustive match inside the method body still forces the compiler to flag every new variant.\\n\\n~80 LOC collapsed and 7 files depend on fewer concerns of AppMode.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-16T19:02:09Z","created_by":"Ed L","updated_at":"2026-08-01T19:31:52Z","started_at":"2026-08-01T18:17:47Z","closed_at":"2026-08-01T19:31:52Z","close_reason":"Added AppMode::label(), hint_text(), panel() — all exhaustive matches. Moved Panel next to AppMode (re-exported from effect.rs, zero call-site churn) since Panel::mode and AppMode::panel are inverses. Collapsed the 'is this mode focused in panel X' duplication from 4 sites (registry.rs + 3 PanelContent impls) into one method; PanelContent::is_active(mode) became PanelContent::panel().\n\nTwo wildcards that hid gaps are now exhaustive: ModeKey::from_app_mode (_ =\u003e None would have silently swallowed all keys for a new variant) and hint_text (_ =\u003e \"\" hid ExportPrompt having no hint → filed improvise-1c9). 4 new tests, 710 green.\n\nNOT done: mode_style() stays in src/draw.rs. Style/Color are ratatui types and improvise-command carries no rendering-framework dependency; its wildcard arm is the intended default chrome, not a hidden gap. buffer_key() convenience skipped — only 2 call sites, both already holding the MinibufferConfig.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-2lh","title":"Migrate name-string comparisons to CategoryKind enum checks","description":"8 sites compare category names as strings against virtual-category prefixes ('_Measure', '_Index', '_Dim') instead of checking the typed CategoryKind enum. Adding a new CategoryKind variant won't produce a compile error at any of these sites.\\n\\nSites:\\n- crates/improvise-core/src/model/types.rs:215, 226, 429, 636 (target_category == '_Measure', cat_name == '_Measure')\\n- crates/improvise-io/src/persistence/mod.rs:213, 226 (formula target comparison)\\n- crates/improvise-core/src/view/layout.rs:108 (c == '_Index' \u0026\u0026 c == '_Dim')\\n- src/command/cmd/grid.rs:306 (target_category == '_Measure')\\n\\nFix: add methods on CategoryKind: is_virtual(), is_measure(), is_persisted(), is_index_or_dim(). Look up the Category by name once, dispatch on its .kind.\\n\\nCorrectness win: the compiler flags every site when a new variant is added. Also removes the hardcoded-name dependency — if the underscore prefix convention ever changes, one place to edit.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-16T19:01:53Z","created_by":"Ed L","updated_at":"2026-08-01T17:00:11Z","started_at":"2026-08-01T00:47:52Z","closed_at":"2026-08-01T17:00:11Z","close_reason":"CategoryKind::category_name() is now the single exhaustive name\u003c-\u003ekind mapping; of_name() plus is_measure/is_index/is_dim/is_index_or_dim/is_virtual replace all 10 string comparisons across core, io and command.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-6sq","title":"Merge EditOrDrill and EnterEditAtCursorCmd into EnterEdit{mode}","description":"command/cmd/mode.rs:254-307 has two commands (EditOrDrill, EnterEditAtCursorCmd) that each thread an edit_mode parameter. The drill-vs-edit decision is mode-independent; merging eliminates the fork.\\n\\nAbstraction: EnterEdit { mode: AppMode } — single command that checks aggregation state, pre-fills the buffer if editing, then enters the supplied mode.\\n\\nMatches the parameterization precedent from commit 30383f2 ('parameterize mode-related commands and effects'). ~50 LOC collapsed.","notes":"ANALYSIS: the proposed merge is not safe as specified. Do not merge these two commands without addressing the three points below.\n\nThe ticket says the two commands 'each thread an edit_mode parameter' and that the fork is mode-related. They actually differ in WHEN the cell value is read:\n\n- EditOrDrill (bound to i/a) runs standalone with a fresh CmdContext, so it pre-fills from ctx.display_value.\n- EnterEditAtCursorCmd runs ONLY as the tail of the records 'o' Binding::Sequence, after add-record-row has invalidated the layout. It emits RebuildLayout + the EnterEditAtCursor effect, which re-reads App::display_value_at_cursor() AFTER the rebuild. That is improvise-ehz / repo-map gotcha 13, not incidental duplication.\n\nThree concrete blockers for a merged EnterEdit { mode }:\n\n1. Empty records grid. ToggleRecordsMode puts every regular category on Axis::None, so EditOrDrill's regular_none is always true in records mode and the edit branch depends entirely on is_synthetic. With no records yet, ctx.cell_key() is None -\u003e is_synthetic false -\u003e is_aggregated true -\u003e 'cannot drill - no cell at cursor' instead of entering edit. Today 'o' works there because it does not run the drill check.\n\n2. The drill check would run against the stale pre-rebuild ctx on the 'o' path, since the check is a Cmd-time decision and RebuildLayout is an apply-time effect.\n\n3. The two paths want opposite no-cell behavior: i on no cell should report 'cannot drill', o after adding a row should edit.\n\nA merged command needs a rebuild_first / skip_drill flag, which is the same fork under a new name.\n\nDELIVERED instead: the investigation turned up a real bug. EnterEditAtCursor set view_state.mode without overriding changes_mode(), so ':enter-edit-at-cursor \u003cmode\u003e' was immediately snapped back to Normal by ExecuteCommand. Fixed with a regression test in b685857. Filed improvise-toc for ViewBack/ViewForward/StartImportWizard, which have the same symptom but change the mode conditionally.\n\nRECOMMEND: close as wont-do, or rescope to 'give EditOrDrill and EnterEditAtCursorCmd a shared helper for the edit branch' (blocked on improvise-toc, since delegating would also have hit the changes_mode bug).\n\n--- Groomed 2026-08-13: labelled 'human' — this needs a decision, not more analysis. ---\nInvestigated in full on 2026-08-01 (see the three blockers above) and the conclusion has not changed: the merge as specified would regress the empty-records-grid case. The ticket has now been skipped twice at the top of 'bd ready' because it cannot be actioned as written.\n\nThe decision is binary:\n (a) close as wont-do — the fork EditOrDrill/EnterEditAtCursorCmd is load-bearing, not incidental; or\n (b) rescope to 'give EditOrDrill and EnterEditAtCursorCmd a shared helper for the edit branch', which is a real but much smaller cleanup, and add a dependency on improvise-toc (delegating today would re-hit the changes_mode bug fixed in improvise-6sq's sibling work).\n\nUntil one is chosen this sits at the head of the ready queue blocking nothing and being picked up by nobody.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-16T18:53:39Z","created_by":"Ed L","updated_at":"2026-08-29T17:53:06Z","started_at":"2026-08-01T20:08:01Z","closed_at":"2026-08-29T17:53:06Z","close_reason":"WON'T DO — verified the NOTES analysis against the code; all three blockers hold.\n\nThe two commands do not 'each thread an edit_mode parameter' over a shared decision. They differ in WHEN the cell value is read. EditOrDrill (i/a, keymap.rs:507/575) runs standalone and pre-fills from ctx.display_value at Cmd time. EnterEditAtCursorCmd runs only as the tail of the records 'o' Binding::Sequence (keymap.rs:589) and emits RebuildLayout + EnterEditAtCursor, which re-reads App::display_value_at_cursor() AFTER the rebuild — repo-map gotcha 16, not incidental duplication.\n\nConfirmed in code:\n1. ToggleRecordsMode (command/cmd/grid.rs:432) puts every regular category on Axis::None, so EditOrDrill's regular_none is always true in records mode and the edit branch rests entirely on is_synthetic.\n2. RebuildLayout is apply-time while the drill check is Cmd-time, so a merged command would run the check against the stale pre-rebuild ctx on the 'o' path.\n3. The paths want opposite no-cell behavior.\n\nMerging needs a rebuild_first/skip_drill flag, which is the same fork under a new name, and would ADD lines rather than collapse ~50.\n\nDeliverable instead of the merge: ui/app.rs::tests::i_and_o_diverge_on_an_empty_records_grid turns the analysis into executable evidence — on an empty records grid i yields RecordsNormal + 'cannot drill' while o creates a row and opens an edit (already guarded by add_record_row_in_empty_records_view_creates_first_row). No single command produces both, so a future attempt at this merge fails a test rather than shipping.\n\nNoticed while proving it: 'cannot drill' leaks a pivot-mode concept into records mode. Filed as improvise-ct5 (P4).","labels":["human"],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-6t3","title":"Unify Commit{Formula,CategoryAdd,ItemAdd} into CommitBuffer(kind)","description":"command/cmd/commit.rs:349-439 has three commands with identical shape: read buffer named X, if empty abort, else emit AddY effect, mark dirty, optionally exit mode. Only the buffer name and the target effect type differ.\\n\\nAbstraction: CommitBuffer { kind: CommitKind } where CommitKind is Formula | Category | Item.\\n\\nCorrectness win: minibuffer commit semantics live in one place. New minibuffer modes (rename-view, rename-item) get correct semantics for free. Pairs with improvise-{text-entry keymap template} — once the commit is generic, the keymap template composes Sequence [commit-buffer X, clear-buffer X, enter-mode Normal] uniformly across all text-entry modes.\\n\\n~40 LOC collapsed.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-16T18:53:33Z","created_by":"Ed L","updated_at":"2026-08-01T23:23:37Z","started_at":"2026-08-01T20:45:46Z","closed_at":"2026-08-01T23:23:37Z","close_reason":"Replaced CommitFormula/CommitCategoryAdd/CommitItemAdd with a single CommitBuffer { kind: CommitKind }, registered as 'commit-buffer \u003cformula|category|item\u003e'; the 5 keymap bindings now pass the kind as an arg, so improvise-w3q's text-entry template can compose Sequence [commit-buffer X, clear-buffer X, ...] uniformly.\n\nCORRECTION TO THE TICKET: this does NOT collapse ~40 LOC — it costs ~+24. The three commands were not 'identical shape'; they differed in five dimensions (buffer name, empty-buffer landing mode, post-commit landing mode, status text, add effect), and two of those were genuinely semantic, not incidental:\n - formula returns to FormulaPanel on success; category/item stay in the minibuffer for the next entry (batch entry). Collapsing that would have been a UX regression either way, so CommitKind::on_commit() -\u003e Option\u003cAppMode\u003e keeps it.\n - CommitFormula did not trim or check for empty at all — it emitted AddFormula(\"\") and let the parser error. Unified, an empty formula buffer now just returns to FormulaPanel.\nThe win is real but it is 'one home for minibuffer commit semantics', not line count.\n\nBUG FOUND AND FIXED: the commit pushed set_status(\"Formula added\") AFTER AddFormula, clobbering the parse error AddFormula::apply writes to the status bar — an unparseable formula was dropped while the UI reported success. Regression test invalid_formula_reports_the_parse_error_not_success confirmed FAILING before the fix ('Formula added'). Fix: the add effect is emitted last, so the only party that knows whether it worked gets the last word.\n\nDESIGN NOTE: CommitKind::Item carries its target category rather than reading ctx.mode inside execute. parse_commit_kind(arg, mode) resolves it at registry time, which (a) keeps execute a pure function of kind+buffer, (b) removes the Option\u003cBox\u003cdyn Effect\u003e\u003e the old shared helper threaded, and (c) turns the old silent 'return vec![] if not in ItemAdd mode' no-op into a real error message on the ':' line.\n\nAlso added keymap test minibuffer_enter_dispatches_to_a_registered_commit: Keymap::dispatch swallows an unresolvable command name with .ok()?, so the existing lookup-only smoke tests would have passed even if the rename had missed the registry. Verified it fails when the bound name is corrupted.\n\nFiled improvise-djn: AddCategory still swallows its Result, so the same status-masking class of bug survives there.\n\n713 tests green, clean workspace build.","dependency_count":0,"dependent_count":1,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-k8h","title":"Tie MinibufferConfig.buffer_key to the keymap's buffer name","description":"Groomed 2026-08-13 — the original ask is already done; this is what is left.\n\nALREADY DONE: all seven constructors the ticket proposed exist on AppMode (crates/improvise-command/src/ui/app.rs:222-296): editing(), records_editing(), formula_edit(), command_mode(), category_add(), item_add(category), export_prompt(). No caller hand-builds a MinibufferConfig any more.\n\nSTILL BROKEN — the correctness win the ticket was actually after: the buffer name is written twice inside improvise-command, on opposite sides of the crate, with nothing connecting them.\n\n 1. crates/improvise-command/src/ui/app.rs:225-292 — buffer_key: \"edit\" / \"formula\" / \"command\" / \"category\" / \"item\" / \"export\"\n 2. crates/improvise-command/src/command/keymap.rs — TextEntry::new(\"edit\", ...), TextEntry::new(\"formula\", ...), etc., which is what append-char / pop-char / clear-buffer actually address\n\nIf those two disagree the mode renders a prompt over one buffer while every keystroke edits another — silent, and no test would catch it today.\n\nThis got much more tractable with improvise-w3q: TextEntry is now the single place the keymap names a buffer, so there is exactly one join to make. Options:\n (a) a Buffer enum (or \u0026'static str consts) that both AppMode::minibuffer() and TextEntry take, so the two cannot drift;\n (b) have TextEntry read the name off the AppMode it is building the keymap for.\n\nCheap interim guard regardless of which: a test asserting that for every text-entry AppMode, mode.minibuffer().buffer_key equals the buffer that mode's keymap binds append-char to. That is a direct extension of the existing text_entry_modes_clear_the_buffer_they_type_into test in keymap.rs.\n\nSuperseded scope note: the original '~40 LOC collapsed' no longer applies — the collapsing already happened. This is now a pure correctness ticket, not a size one.","notes":"Groomed 2026-06-09: the constructor consolidation has LANDED (AppMode::editing/records_editing/formula_edit/command_mode/category_add/item_add/export_prompt, crates/improvise-command/src/ui/app.rs:110-185; no hand-built minibuffer variants remain). Remaining scope is only the enforcement half: buffer_key literals are still duplicated between the constructors and command-side lookups (e.g. text_buffer.rs:187,230 hardcode \"command\"; commit.rs hardcodes \"formula\"/\"category\"/\"item\"/\"export\"). Narrow this issue to: shared buffer-key constants (or accessor via ctx.mode.minibuffer()) so a typo cannot silently desync. Related: improvise-gdl.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-16T18:53:25Z","created_by":"Ed L","updated_at":"2026-08-29T18:00:31Z","started_at":"2026-08-29T17:56:25Z","closed_at":"2026-08-29T18:00:31Z","close_reason":"Closed the join with option (b): TextEntry::new/with_parent now take the AppMode and read the buffer off its MinibufferConfig via buffer_of(). The keymap no longer spells a buffer name anywhere — call sites read TextEntry::new(\u0026AppMode::formula_edit(), \"formula-panel\"). The name is written exactly once, in the AppMode constructor.\n\nChose (b) over (a): a Buffer enum would still have each side naming a variant, so the two could still disagree — it closes the set of names but not the drift. Reading the name off the mode removes the second write entirely.\n\nGuards added in keymap.rs:\n- every_text_entry_mode_types_into_the_buffer_its_prompt_names: for all seven text-entry AppModes, mode.minibuffer().buffer_key equals the buffer that mode's keymap binds append-char to. Verified it bites by temporarily misspelling FormulaEdit's buffer_key — fails with 'FORMULA prompts over \"formyula\" but types elsewhere'.\n- every_typing_keymap_belongs_to_a_text_entry_mode: any keymap binding append-char must belong to a mode in the list, so an eighth text-entry mode cannot be added without coverage. SearchMode is exempt by name — it is Normal + a flag and has no AppMode variant.\n\nAlso reworked text_entry_modes_clear_the_buffer_they_type_into, which was hardcoding the buffer names a third time; it now derives them and shares one (mode, exit-keys) table with the new tests.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-4pg","title":"Extract panel mode keymap template","description":"command/keymap.rs:554-713 has three nearly-identical panel keymaps (FormulaPanel, CategoryPanel, ViewPanel) sharing Esc/Tab/j/k/F/C/V/: bindings. Only panel name varies.\\n\\nAbstraction: fn panel_keymap(panel: Panel) -\u003e Keymap\\n\\nCorrectness win: adding a 4th panel becomes a one-line call; prevents per-panel binding drift. Also helps keep CyclePanelFocus keybinding consistent once improvise-dqn is fixed.\\n\\n~80 LOC collapsed.","notes":"Groomed 2026-08-13 — still valid, and there is now a precedent to copy.\n\nLine range in the description is stale: the three panel keymaps are now at crates/improvise-command/src/command/keymap.rs ~600-780 (161 lines), not 554-713. Re-verify before starting.\n\nPRECEDENT: improvise-w3q landed a TextEntry builder in the same file that does exactly this job for the seven minibuffer modes — a struct that binds the shared spine in its constructor and takes the per-mode variation through chained methods. panel_keymap(panel: Panel) should follow that shape rather than inventing a second style in one file.\n\nTwo cautions learned from w3q:\n 1. Check what actually varies before committing to a signature. w3q's ticket claimed only three things varied; Tab and Backspace varied too, and the fixed-arity fn the ticket proposed would not have fitted. Diff the three panel blocks key by key first.\n 2. Verify behavior preservation by dumping every (mode, key, modifier) lookup for all 16 modes before and after and diffing the tables — most of these bindings have no direct test, so a green suite is not evidence. That is how w3q was checked.\n\nExpect the real saving to be well under the '~80 LOC collapsed' estimate once the builder's own cost is counted; w3q netted -39 against a claimed -150.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-16T18:53:24Z","created_by":"Ed L","updated_at":"2026-08-29T18:58:20Z","started_at":"2026-08-29T18:53:55Z","closed_at":"2026-08-29T18:58:20Z","close_reason":"Extracted PanelKeymap following the TextEntry precedent, as the notes asked.\n\nCaution 1 (diff the blocks first) paid off: a fixed-arity panel_keymap(panel: Panel) would NOT have fitted. The spine is genuinely shared — Esc/Tab to Normal, arrows and j/k to move-panel-cursor for that panel, every panel's toggle key, : for command mode — but the content keys vary in SHAPE, not just command name: Category's n takes an argument (enter-mode category-add) while Formula's and View's do not, View has no 'a', Formula has no Enter, Category alone binds Space. So the variation comes through chained .bind/.bind_args/.delete, same as TextEntry's .commit.\n\nCaution 2 (dump and diff, a green suite is not evidence): dumped all 4065 (mode, key, modifier) lookups across all 16 modes before and after — byte-identical. That was the whole verification; the pre-existing suite would not have caught a dropped panel binding.\n\nAlso added Panel::ALL/name()/key() in ui/app.rs and routed Normal mode's F/C/V through the same bind_panel_toggles helper — a small deliberate step past the three panel keymaps, because otherwise 'adding a 4th panel is a one-line call' would still have needed a manual Normal-mode binding. Now one Panel::ALL entry reaches all four keymaps.\n\nThree permanent guards replace the throwaway dump: every_panel_keymap_shares_the_same_spine (verified it bites by rebinding Tab), every_mode_with_panel_toggles_binds_all_of_them, and panel_toggle_keys_dispatch_to_real_effects (name() must spell what parse_panel accepts; a lookup-only assertion would miss it since dispatch swallows unresolvable commands with .ok()?).\n\nLOC note: the description claimed ~80 collapsed. Production keymap.rs went 953 -\u003e 899 lines, so 54 net once the builder is counted — the win is drift-prevention, not line count.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-9cn","title":"Introduce register_cmd! macro to prevent name drift","description":"crates/improvise-command/src/command/cmd/registry.rs has 20+ register_pure(\u0026SomeCmd(vec![]), SomeCmd::parse) sites where the registered name string must match SomeCmd::name(). Nothing enforces this match — a typo produces a silent dispatch failure.\\n\\nAbstraction: register_cmd!(r, SomeCmd) macro that derives the name from the Cmd impl at compile time.\\n\\nCorrectness win: compile-time guarantee that the registered name matches Cmd::name(). Subsumes improvise-61f (the runtime invariant test); keeping 61f as a belt-and-braces check is fine but may be unnecessary once this lands.\\n\\n~40 LOC collapsed.","notes":"Groomed 2026-06-09: see improvise-61f (invariant test for registry name mapping) — same drift problem from the test side. Sequence together; the macro makes the test trivial.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-16T18:53:22Z","created_by":"Ed L","updated_at":"2026-08-29T19:10:26Z","started_at":"2026-08-29T19:03:25Z","closed_at":"2026-08-29T19:10:26Z","close_reason":"Modelled the registry in Prolog (85 registration sites x 80 Cmd::name impls, facts extracted mechanically from source) and let it classify the drift risk rather than eyeballing. Verdict:\n\n- 70 of 85 sites already safe: register/register_pure/register_nullary all read the name off a prototype INSTANCE, not a string. The description's '20+ sites where the registered name string must match Cmd::name()' was wrong — there were 8.\n- 8 sites used NamedCmd(\"literal\") as a stand-in prototype. Prolog confirmed all 8 strings currently match what their closures build (literal_bad/3 had no solutions), so no live bug — but nothing tied them.\n- 7 sites carry a risk the ticket never mentions: ViewNavigate, TileAxisOp, ToggleGroupAtCursor and CommitAndAdvance name themselves off a FIELD, so prototype and constructed value share a type and can differ in variant.\n\nSo the macro is unnecessary. All 8 NamedCmd sites take a real prototype instead (EditOrDrill { edit_mode: AppMode::Normal }, TileAxisOp { axis: Some(Axis::Row) }, etc.), which removes the literal entirely — simpler than a macro and gives the same compile-time guarantee. NamedCmd itself is deleted; registry.rs now contains no command-name literal.\n\nThe variant class cannot be fixed by types at all, which is what improvise-61f is for (closed alongside). Note for anyone revisiting: 61f as specified — 'assert registry_name == command.name()' — is INSUFFICIENT. I injected the exact bug (ViewNavigate prototype flipped to forward: false while the closure still builds true) and that test passed: the wrong-variant entry collides with its sibling under 'view-back', so every surviving entry still agrees with its own name and 'view-forward' simply disappears. Uniqueness is what reaches it.\n\nFour guards, each verified by injecting the fault it targets:\n- every_entry_constructs_a_command_with_its_registered_name (both parse and interactive paths; asserts no entry is unreachable by both, so nothing is silently skipped)\n- registered_names_are_unique (catches the wrong-variant prototype)\n- every_bound_command_name_is_registered in keymap.rs (walks every binding in all 16 modes; dispatch resolves names with .ok()?, so an unregistered name is a silent dead key — verified by renaming toggle-records-mode)\n- every_alias_resolves_to_a_registered_command\n\nThe reasoning model is kept in prolog-sessions/kb.pl as rules (variant_named_cmd/2, catches/2, insufficient_for/2), not prose, so the 'roundtrip test does not reach wrong_variant_prototype' conclusion is re-derivable.","dependency_count":0,"dependent_count":1,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-w3q","title":"Extract text-entry keymap template for minibuffer modes","description":"command/keymap.rs:770-966 hand-binds Enter/Esc/Tab/Backspace/AnyChar for 8 modes (Editing, RecordsEditing, FormulaEdit, CategoryAdd, ItemAdd, ExportPrompt, CommandMode, SearchMode). Only buffer name, commit command, and exit mode vary between them.\\n\\nAbstraction: fn text_entry_keymap(buffer: \u0026str, commit: \u0026str, exit: AppMode, parent: Option\u003cArc\u003cKeymap\u003e\u003e) -\u003e Keymap\\n\\nCorrectness win: impossible to omit clear-buffer in one mode's Esc sequence (the exact class of bug fixed in a recent refactor). New text modes inherit semantics for free. Pairs naturally with generic CommitBuffer(kind) — once the commit primitive is unified, the template composes Sequence [commit-buffer X, clear-buffer X, enter-mode Normal] uniformly.\\n\\n~150 LOC collapsed.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-16T18:53:14Z","created_by":"Ed L","updated_at":"2026-08-14T00:00:26Z","started_at":"2026-08-02T18:15:40Z","closed_at":"2026-08-14T00:00:26Z","close_reason":"Added a TextEntry builder in keymap.rs; the seven text-entry modes (Editing, RecordsEditing, FormulaEdit, CategoryAdd, ItemAdd, ExportPrompt, CommandMode) are now built from it. It binds the shared spine (AnyChar -\u003e append-char \u003cbuf\u003e, Backspace -\u003e pop-char \u003cbuf\u003e, Esc -\u003e [clear-buffer \u003cbuf\u003e, enter-mode \u003cexit\u003e]) and .commit(key, cmd, args) welds each commit key to its clear-buffer, so the two halves cannot come apart.\n\nCORRECTION TO THE TICKET: ~39 production lines collapsed, not ~150. The ticket said 'only buffer name, commit command, and exit mode vary'. Tab and Backspace vary too: Tab is absent in FormulaEdit/ExportPrompt/CommandMode, duplicates Enter in CategoryAdd/ItemAdd, and runs commit-and-advance-right in Editing/RecordsEditing; CommandMode's Backspace is command-mode-backspace (exits on an empty line) rather than pop-char. The builder itself costs ~62 lines. Net production 978 -\u003e 939.\n\nSearchMode deliberately NOT converted: no commit key, and its query is owned by exit-search-mode rather than clear-buffer. Forcing it into the template would have meant adding opt-outs for both Esc and Enter, i.e. the template describing a mode it does not fit.\n\nVERIFICATION: dumped every (mode, key, modifier) lookup across all 16 modes from HEAD (in a throwaway git worktree) and from the refactor, 416 rows each — the tables are byte-identical. That is a stronger guarantee than the test suite alone, since most of these bindings have no direct test.\n\nAdded test text_entry_modes_clear_the_buffer_they_type_into: for each text-entry mode, asserts AnyChar appends into buffer B and every commit/exit key's Sequence contains clear-buffer B. NOTE: it PASSES against the pre-refactor code — there was no live missing-clear-buffer bug, contrary to the ticket's implied 'exact class of bug fixed in a recent refactor'. It is a regression guard, not a bug fix. Confirmed it bites by deleting the clear-buffer from ExportPrompt's Esc (fails with 'ExportPrompt: Esc must clear \"export\"').\n\n714 tests green; cargo fmt and cargo clippy --workspace --all-targets both clean.","dependencies":[{"issue_id":"improvise-w3q","depends_on_id":"improvise-6t3","type":"blocks","created_at":"2026-04-16T11:54:32Z","created_by":"Ed L","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-0bf","title":"Inconsistent float-equality tolerance in formula eval","description":"crates/improvise-core/src/model/types.rs uses different float-equality strategies: division checks 'rv == 0.0' exactly (:674), while = and != comparisons use '1e-10' epsilon (:555, :749). A formula like IF(x = 0, ..., y/x) with x = 1e-11 treats x as zero in the condition but divides without error. Either unify on a single FLOAT_EQ_EPSILON constant or document the design choice explicitly in comments.","status":"closed","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:35Z","created_by":"Ed L","updated_at":"2026-06-10T04:46:25Z","closed_at":"2026-06-10T04:46:25Z","close_reason":"Fixed in 45bfe2c (test-first): shared FLOAT_EQ_EPSILON constant governs Eq/Ne and div-by-zero guards in both eval paths (regression tests for each); stem dedup now IndexSet (order-preserving), removing the O(n^2) scan.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-kdp","title":"Duplicated aggregation dispatch block","description":"crates/improvise-core/src/model/types.rs:305-312 and :598-604 contain identical Sum/Avg/Min/Max/Count match blocks against AggFunc. If business logic changes (NaN handling, default agg), updates must be made in both sites. Fix: extract fn apply_agg(values: \u0026[f64], func: \u0026AggFunc) -\u003e f64 as a pure helper.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:29Z","created_by":"Ed L","updated_at":"2026-08-01T00:45:47Z","started_at":"2026-07-31T17:48:34Z","closed_at":"2026-08-01T00:45:47Z","close_reason":"Extracted apply_agg, eval_agg and Model::find_item_category; all four duplicated blocks across the two eval paths now route through one definition. Direct unit tests added.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-nrs","title":"Duplicated find_item_category helper in eval paths","description":"crates/improvise-core/src/model/types.rs has identical nested find_item_category helpers at lines 420 and 627 (inside eval_formula_with_cache and eval_formula_depth). Both search all categories for an item, then fall back to formula targets. Fix: extract as Model::find_item_category(\u0026self, item: \u0026str) -\u003e Option\u003c\u0026str\u003e so bug fixes apply in one place.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:26Z","created_by":"Ed L","updated_at":"2026-08-01T00:45:47Z","started_at":"2026-07-31T17:48:35Z","closed_at":"2026-08-01T00:45:47Z","close_reason":"Extracted apply_agg, eval_agg and Model::find_item_category; all four duplicated blocks across the two eval paths now route through one definition. Direct unit tests added.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-6os","title":"O(n^2) stem deduplication in recompute_formulas","description":"crates/improvise-core/src/model/types.rs:346-359 accumulates unique formula stems using 'if !stems.contains(\u0026stripped) { stems.push(stripped); }'. This is O(n^2) over every cell for every formula category. recompute_formulas runs at startup and after every data mutation. Large workbooks will show visible slowdown. Fix: collect into HashSet\u003cCellKey\u003e, convert to Vec once for iteration order.","status":"closed","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:24Z","created_by":"Ed L","updated_at":"2026-06-10T04:46:26Z","closed_at":"2026-06-10T04:46:26Z","close_reason":"Fixed in 45bfe2c (test-first): shared FLOAT_EQ_EPSILON constant governs Eq/Ne and div-by-zero guards in both eval paths (regression tests for each); stem dedup now IndexSet (order-preserving), removing the O(n^2) scan.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-dqn","title":"CyclePanelFocus does not actually cycle between panels","description":"The CyclePanelFocus command at src/command/cmd/panel.rs:299-313 is documented as 'Tab through open panels' but the if/else if chain always resolves to the first open panel (formula \u003e category \u003e view). Pressing the bound key repeatedly never rotates. Test at panel.rs:84 only asserts a panel mode is entered, not rotation. Fix: either implement real rotation using ctx.mode to determine 'next' open panel, or rename to FocusFirstOpenPanel. Also replace the three bool fields with an enum of open panel states.","acceptance_criteria":"With two or more panels open, repeated cycle-panel-focus visits each open panel in order and wraps around; with one panel open it is a no-op (stays focused). Unit test builds CmdContext with formula+category panels open in CategoryPanel mode and asserts the returned effect targets FormulaPanel (not first-open-wins).","notes":"Groomed 2026-06-09: downgraded P1→P2 — broken command but cosmetic (focus lands on first open panel instead of cycling); the remaining P1s are all data-integrity bugs. Verified still present at src/command/cmd/panel.rs:294-313 (first-open-wins if/else chain, ignores current mode).","status":"closed","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:07Z","created_by":"Ed L","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-dwe","title":"Split App into AppState + App wrapper (in-place, no crate change)","description":"Prerequisite to improvise-3mm (extract improvise-command). Refactor App into two parts without moving files across crates: (a) AppState, the pure semantic state that effects mutate (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); (b) App, a thin wrapper in ui/app.rs that owns { state: AppState, term_width, term_height, layout: GridLayout, last_autosave, keymap_set }. Change Effect::apply's signature from \u0026mut App to \u0026mut AppState. For the small number of effects that today read App-level state at apply time (EnterEditAtCursor reads display_value from layout, etc.), pre-compute the derived value in the command's Cmd::execute (where CmdContext has layout access) and pass it through the effect struct's own fields (self). Apply bodies become pure AppState writes.","design":"AppState holds everything currently mutated by Effect::apply, minus rendering caches. App keeps term_width, term_height, layout, last_autosave. apply_effects loop: for e in effects { e.apply(\u0026mut self.state); } then self.rebuild_layout(). cmd_context() is built from both App (for visible_rows/cols via layout + term dims) and AppState (for model/view/mode/buffers). For effects that need layout-derived values at apply time: compute in execute (ctx has layout), bake into self. Example: EnterEditAtCursor { target_mode, initial_value: String } — initial_value is computed pre-effect from ctx.layout and baked in; apply does state.buffers.insert(\"edit\", self.initial_value.clone()); state.mode = self.target_mode.clone();","acceptance_criteria":"(1) AppState struct exists; App wraps it. (2) Effect::apply takes \u0026mut AppState, not \u0026mut App. (3) CmdContext.workbook still resolves to \u0026AppState.workbook. (4) rebuild_layout / term-dim tracking remain on App. (5) All existing tests pass. (6) cargo clippy --workspace --tests clean. (7) No behavior change.","notes":"Do this in-place BEFORE the crate-split (improvise-3mm). Keeps the diff isolated from packaging churn. Audit every impl Effect for Foo — most already only touch fields that belong in AppState; the ones that don't need pre-compute refactors in their associated Cmd.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T06:28:47Z","created_by":"spot","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-26T18:08:24Z","dependencies":[{"issue_id":"improvise-dwe","depends_on_id":"improvise-vb4","type":"supersedes","created_at":"2026-04-16T16:02:31Z","created_by":"Ed L","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-3zq","title":"Records-mode Enter/Tab at bottom-right: persist empty record, re-enter editing","description":"Two related bugs at the bottom-right cell of records view:\n\n1. Enter on bottom-right cell re-enters editing at the same cell (because EnterAdvance stays put at bottom-right and EnterEditAtCursor then re-edits). Expected: exit edit mode.\n\n2. Tab on bottom-right cell in records mode calls AddRecordRow, which SetCells with an empty CellKey when no page filters are active (the default records view has _Measure/foo on None axis, not Page). This writes a nonsense ' = 0' cell to the model that gets persisted (see sample.improv line 26: ' = 0'). Re-toggling records mode (RR) shows the empty record at the top of the table.\n\nRoot causes:\n- CommitAndAdvance (Down) always appends EnterEditAtCursor, even when no forward motion is possible.\n- AddRecordRow::execute unconditionally produces SetCell(empty_key, 0) when coords vec is empty, rather than bailing out.\n\nTests should be added in crates/improvise-persistence (or wherever) that demonstrate ' = 0' is not persisted, plus command-level tests that:\n- CommitAndAdvance(Down) at bottom-right produces a non-editing mode change.\n- AddRecordRow with no page filters produces no SetCell effect.","acceptance_criteria":"1. AddRecordRow with empty coords emits only a status message, no SetCell.\n2. commit-cell-edit (Enter) at bottom-right exits editing (lands in Normal/RecordsNormal).\n3. Tab at bottom-right of records view does not create an empty cell in the model.\n4. All new tests fail on main before the fix; pass after.","notes":"Bug #1 (Enter at bottom-right re-enters editing) — fixed via effect-chain abort mechanism:\n- Added App::abort_effects flag; apply_effects short-circuits and resets per batch\n- New AbortChain effect sets the flag\n- EnterAdvance at bottom-right now emits only AbortChain\n- CommitAndAdvance pushes change_mode(exit_mode_for(edit_mode)) BEFORE advance; trailing EnterEditAtCursor lifts back to editing only if advance succeeded\n\nBug #2 (Tab persists empty record across RR) — fixed by cleanup-on-leave:\n- New CleanEmptyRecords effect removes cells with empty CellKey\n- ToggleRecordsMode on leave emits [CleanEmptyRecords, ViewBack, status] — inverse of the SortData on entry\n\n5 new tests (2 integration demonstrating the bugs, 3 unit for the new primitives). 366 tests pass; clippy clean.","status":"closed","priority":2,"issue_type":"bug","assignee":"fido","owner":"el-github@elangley.org","created_at":"2026-04-16T06:01:20Z","created_by":"fido","updated_at":"2026-04-16T06:26:44Z","closed_at":"2026-04-16T06:26:44Z","close_reason":"Both bugs fixed; see notes for implementation summary. All 5 new tests pass; 366 workspace tests green; clippy clean.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-4ju","title":"Make EditOrDrill mode-agnostic; delete EnterEditMode","description":"EnterEditMode::execute currently checks ctx.mode.is_records() to decide between editing() and records_editing(). Per the keymap design principle (commands/effects must be mode-agnostic; modes switch keymaps), this is wrong. EditOrDrill (the only caller) should take an edit_mode parameter, and the records-mode keymap should pass records-editing while the normal keymap passes editing. Delete EnterEditMode; replace its registration with a parameterized binding for edit-or-drill.","notes":"Refactored both:\n- EditOrDrill (i/a) — now takes edit_mode: AppMode; keymap supplies it\n- EnterEditAtCursor effect + Cmd — now take target_mode: AppMode\n- CommitAndAdvance (Enter/Tab in editing modes) — now takes edit_mode: AppMode\n- Records-normal keymap overrides i/a to pass records-editing\n- Records-editing keymap overrides Enter/Tab to pass records-editing\n- Records-normal o sequence now passes records-editing\n- Shared parse_mode_name helper in registry.rs\n\nNo is_records() checks remain in any of these commands/effects.\nTests added for each parameterization (5 new tests).\n551 tests pass; clippy clean.","status":"closed","priority":2,"issue_type":"task","assignee":"fido","owner":"el-github@elangley.org","created_at":"2026-04-16T05:04:39Z","created_by":"fido","updated_at":"2026-04-16T05:35:11Z","closed_at":"2026-04-16T05:35:11Z","close_reason":"EditOrDrill, EnterEditAtCursor (effect + cmd), and CommitAndAdvance all parameterized with mode args from keymap; no runtime is_records() checks remain in those paths; 5 new tests cover the contract.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-kos","title":"_Dim and _Index should work as pivot axes outside Records/Drill view; must default to None axis on non-records views including empty models","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-15T11:15:40Z","created_by":"Edward Langley","updated_at":"2026-04-15T11:37:10Z","closed_at":"2026-04-15T11:37:10Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-60z","title":"Virtual views _Records and _Drill should not be persisted to .improv files","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-15T11:13:29Z","created_by":"Edward Langley","updated_at":"2026-07-31T16:04:38Z","started_at":"2026-07-31T07:26:39Z","closed_at":"2026-07-31T16:04:38Z","close_reason":"format_md skips views where View::is_virtual(); Initial View falls back to the first real view; parse_md drops stale virtual views so older files self-heal. Three regression tests in persistence.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-c5v","title":"Improve records view data entry by making each measure a normal value column (wide format instead of synthetic Value column)","description":"Current records view uses long format with synthetic 'Value' column. Editing values requires being on the 'Value' column; editing measure column renames the coordinate. This is awkward for typical data entry. Switch to wide format: one column per measure with direct value editing. Update build_records_mode, records_display, cell_key, resolve_display, DrillState pending edits, tests, and related effects. Red-green-refactor cycle. Extends/ reopens improvise-rbv.","status":"closed","priority":2,"issue_type":"feature","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-15T10:34:46Z","created_by":"Edward Langley","updated_at":"2026-08-29T19:29:18Z","started_at":"2026-08-29T19:13:28Z","closed_at":"2026-08-29T19:29:18Z","close_reason":"Records mode switched from long format (synthetic 'Value' column + a _Measure column) to wide format: a row is one coordinate tuple, each raw measure is its own editable column, and a trailing '+' column creates new measures.\n\nDerived the case matrix in Prolog rather than guessing it, which surfaced the load-bearing case the description does not mention: in wide format a row GROUPS one cell per measure, so editing a category column must move every one of those cells. The long-format code moved only the cell under the cursor, which would have torn rows in half — half the measures under the old coordinate, half under the new. Both edit paths (plain records and staged drill edits) implement it; both are tested.\n\nDesign decisions:\n- _Dim in the synthetic CellKey now carries the column INDEX, not a label. A category and a measure item can share a name, so a label cannot say which column an edit landed on. Resolved through GridLayout::record_col -\u003e RecordCol {Category, Measure, NewMeasure}. This rippled through synthetic_record_info, CmdContext::synthetic_record_at_cursor, DrillState.pending_edits (now keyed by (row, RecordCol), which also removes ApplyAndClearDrill's dependency on the layout still being the drill layout), and the grid renderer.\n- Measure columns are the raw _Measure items only. Formula targets are computed, so there is nothing to type into them.\n- The '+' column was the user's call over two alternatives, because a blank model has no measures and wide format would otherwise leave nowhere to enter a number — that is the onboarding path pinned by the old edit_record_row_in_blank_model_persists_value test. Adding a measure is a schema change, so it applies immediately even inside a drill rather than being staged.\n\nVerified end-to-end, not just by unit tests: tests/records_wide_smoke.rs imports a real CSV, loads the .improv, and asserts the records grid is two rows with both measures side by side. My first two expectations there were wrong (measure order and row order come from the file/store, not the source CSV) — the code was right both times.\n\nSix existing tests encoded long-format behavior and were rewritten, not deleted: the two-row helper now uses two Region items rather than two measures on one, and the blank-model test drives the new '+' flow.\n\nAlso removed prune_empty's records branch, which was unreachable behind the function's own early return; filed improvise-2yk in case the early return is itself the bug.","dependency_count":0,"dependent_count":1,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-edp","title":"Establish ViewModel layer: derived data between RenderCache and widgets","description":"Add a classical MVVM ViewModel layer between the RenderCache (wire-format data sent from server) and the widgets (pure renderers). The ViewModel is computed from RenderCache + ViewState + client-local concerns (terminal size, fonts, theme), memoized on state change, and consumed by widgets. Decouples widgets entirely from cache shape — they only know about the ViewModel they consume. Shared concept across native ratatui and browser DOM rendering; both consume viewmodels rather than caches directly.","design":"For each rendering surface, a pair of types: (1) SurfaceCache — the data the server sends over the wire (raw cells, labels, indices). (2) SurfaceViewModel — the render-ready derived data (styled cell display with highlighting, pre-computed visible row range, column positions in terminal coordinates, cursor-region flags). Paired with a pure function fn compute_surface_viewmodel(cache: \u0026SurfaceCache, view: \u0026ViewState, render_env: \u0026RenderEnv) -\u003e SurfaceViewModel. render_env holds client-local concerns: terminal dimensions for native, window dimensions + device pixel ratio for browser, color theme, unicode width handling. Memoization: recompute viewmodel when cache or view state changes; skip when only render_env changes trivially. Widgets read \u0026SurfaceViewModel — entirely decoupled from cache shape. Benefits: (a) widgets are unit-testable with hand-crafted viewmodels, (b) viewmodel logic is testable without any rendering, (c) ratatui and DOM renderers can share the same viewmodel derivation code, (d) memoization prevents redundant recomputation.","acceptance_criteria":"(1) At least one pair (GridCache, GridViewModel) defined with compute function. (2) GridWidget and DOM grid renderer both consume GridViewModel. (3) Unit tests for viewmodel derivation covering edge cases (empty grid, wide cells, highlights). (4) Memoization hook so recomputation is skipped when neither cache nor view state has changed.","notes":"Sits between improvise-35e (cache adapter) and the widget migration issues (improvise-n10, -pca, -jb3, -764). Each widget migration issue should read its viewmodel, not its cache directly. Shared with the browser epic: improvise-cr3 (DOM renderer) should also consume viewmodels. This is the refinement the user asked for — 'the viewmodel is probably a good idea as well'.\n--- DECIDED 2026-09-19 (user) ---\nRenderCache is PER-WIDGET, and state synchronizes by EVENTS, not snapshots.\n\nThis is shape (C) from improvise-rkeq, with per-widget caches:\n * No flat grid-only wire type and no fat aggregate container. Each widget\n owns its own cache type (GridCache, CategoryTreeCache, FormulaCache,\n ViewCache, TileBarCache, WizardCache, HelpCache).\n * Each widget subscribes to and FOLDS its own stream of projection\n Commands. There is no aggregate RenderCache on the wire.\n * The ONE exception is the initial state transfer: a newly connected\n client gets a Snapshot to seed from, then follows the event stream.\n Snapshot stays bootstrap-only (wire_carries(snapshot,bootstrap_only)).\n\nConsequence for this ticket: read \"RenderCache\" as \"that widget's own cache\ntype\", and expect updates to arrive as folded projection commands rather\nthan as a rebuilt snapshot.\n\n--- UNBLOCKED: improvise-vjms decided (user, 2026-09-19) ---\nThe category panel question is answered: the TUI KEEPS the virtual categories\nand the BROWSER GETS THEM TOO. CategoryTreeCache grows to carry _Index, _Dim\nand _Measure so both panels render the same thing from one source.\n\nTHE TRAP TO AVOID when implementing: _Measure items are DERIVED, not stored --\ngotcha 7 says they are the explicit items UNION the formula targets, and\nadd_formula does not add items to _Measure. So category_tree_of must compute\nmembership through measure_item_names()/effective_item_names(), NEVER through\ncategory(\"_Measure\").items, or the panel silently omits every formula target.\nimprovise-ete was closed for proposing exactly that raw-list getter.\n\nAlso: CategoryTreeCache doc comment says \"the client does not know the\n_-prefixed virtual-category convention\" -- that is now false and must be\nremoved rather than left to mislead.\n\nThen build_cat_tree can migrate off \u0026Model (it takes \u0026Model today), which is\nwhat actually unblocks the category panel and this ticket. improvise-35e\nmigrated the other three panels already; this is the fourth.","status":"closed","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:53:04Z","created_by":"spot","updated_at":"2026-09-20T00:25:56Z","closed_at":"2026-09-20T00:25:56Z","close_reason":"PREMISE LARGELY FALSE; the one real piece was built and the rest should not be.\n\nThe ticket asked for a SurfaceViewModel per SurfaceCache plus compute_*(cache, view, render_env). Evidence it does not apply: (a) gotcha 32 already removed the aggregate RenderCache the ticket names, and (b) after improvise-35e the widget seam is ALREADY one cache plus \u0026AppMode -- FormulaContent::new(\u0026FormulaCache, \u0026AppMode), ViewContent::new(\u0026ViewListCache, \u0026str). A per-widget cache built by project.rs is already the render-ready shape: CellDisplay carries formatted strings, GridCache carries col_widths. There is no cache-shape to decouple a widget from. render_env is likewise handled -- the terminal extent enters through ClientSession::set_extent, and inventing a Command for it is forbidden by decision(improvise-cqi, command_set_derived_not_invented).\n\nWHAT WAS REAL AND GOT BUILT: the category panel flattening, which genuinely sits between a cache and a renderer because it mixes CategoryTreeCache with per-client ViewState::expanded_cats. It lives in improvise-protocol::viewmodel (CatTreeEntry + flatten_cat_tree), NOT improvise-command, because improvise-bpq2 cannot reach improvise-command -- the wasm client Cargo.toml excludes it deliberately -- and move-panel-cursor walks this flattening. Both expanded_cats and cat_panel_cursor already live in protocol ViewState, so the derivation inputs and its consumer index are all in that crate. Putting it in improvise-command would have guaranteed bpq2 re-derives it client-side and the two ends drift.\n\nALSO DELIVERED: improvise-vjms. CategoryTreeCache now carries every category; the stale \"the client does not know the _-prefixed convention\" doc is gone; CatTreeEntry carries its Axis so CategoryContent takes a cache and an expanded set and no View -- the FOURTH AND LAST panel off \u0026Model. It also removes a latent panic: the widget called view.axis_of(name), which panics for a category the view has never seen.\n\nA LIVE SHIPPED BUG WAS FIXED, not a hypothetical one: build_cat_tree took \u0026Model and read cat.items.len()/ordered_item_names() -- the RAW list -- so the TUI category panel has been showing _Measure with the WRONG ITEM COUNT and OMITTING EVERY FORMULA TARGET, on main, today. That is the improvise-ete failure mode already shipped. Injecting it back fails exactly ONE test against 1023 green.\n\nNOT BUILT AND NOT WORTH A TICKET AS SPECCED: viewmodels for grid/formula/view/tile-bar. 1031 tests pass.","dependencies":[{"issue_id":"improvise-edp","depends_on_id":"improvise-35e","type":"blocks","created_at":"2026-04-14T00:53:35Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":6,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-e0u","title":"Split native binary into in-process client + server, delete \u0026App adapter","description":"The architectural payoff: once every widget consumes RenderCache + ViewState, restructure the native binary so the ratatui UI is a proper client of an in-process server. Main loop has two halves: server holds the full App + projection layer + VFS persistence; client holds ViewState + RenderCache + ratatui widgets. Commands flow through a direct-call channel (zero-serialization in-process transport). Delete the temporary RenderCache::from_app adapter.","design":"Main loop restructure: fn main() spawns (a) a server loop that owns an App + projection layer + VFS storage (via improvise-6mq Storage trait backed by PhysicalFS for native), and (b) a client loop that owns a ViewState + RenderCache and renders ratatui. Communication via a tokio mpsc channel (or plain std::sync::mpsc since native is single-threaded) — no serialization because both sides hold the same Command enum in memory. Client captures keys → resolves via keymap → sends Command upstream → server calls reduce_full → projection commands come back down → client applies them via reduce_view → widgets redraw. Importantly, this is exactly the same message flow as the browser modes; the transport is just a function call. Delete crates/improvise-command/src/ui/app.rs's RenderCache::from_app adapter (issue 2). Native TUI's App now lives on the server side and is never read directly by widgets.","acceptance_criteria":"(1) Native binary has distinct client and server halves with a Command channel between them. (2) Ratatui widgets never read \u0026App. (3) RenderCache::from_app adapter is deleted. (4) All existing tests + integration tests pass. (5) Performance: frame latency unchanged from current native TUI (the projection work is now on the server side, the rendering work on the client side, but it's all the same process). (6) A structural lint: nothing in src/ui/ can import from src/model/ or src/command/ (only from improvise-protocol's cache types).","notes":"Terminal node of the required work in this epic. Depends on issues 1 (reduce_full), 2 (adapter), 3-6 (all widget migrations). After this lands, the native TUI and the browser modes share an identical architecture — only the transport and persistence backend differ.\nDEFERRED 2026-09-19 by user ruling during the whole-backlog campaign.\n\nDeferred as a campaign of its own, not as a rejection. e0u restructures the native binary around an in-process client/server split and deletes the RenderCache::from_app adapter -- it touches draw.rs, every widget, App and the projection layer, so it would conflict with essentially every other work unit in the backlog and would have to go last with everything rebasing onto it.\n\nThe cheap members of its cluster ARE being done now: improvise-2ud (CmdContext borrows the slices), improvise-drg (narrow Effect::apply where one-slice), improvise-s17o (decide reduce_full's fate), improvise-i5tk (benchmark the whole-grid projection first). Those are the parts that do not require the split.\n\nUn-defer when the browser/protocol backlog has settled and e0u can have a wave to itself.","status":"deferred","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:50:54Z","created_by":"spot","updated_at":"2026-09-20T05:31:01Z","dependencies":[{"issue_id":"improvise-e0u","depends_on_id":"improvise-35e","type":"blocks","created_at":"2026-04-14T00:53:39Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-e0u","depends_on_id":"improvise-764","type":"blocks","created_at":"2026-04-14T00:53:42Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-e0u","depends_on_id":"improvise-gxi","type":"blocks","created_at":"2026-04-14T00:53:38Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-e0u","depends_on_id":"improvise-jb3","type":"blocks","created_at":"2026-04-14T00:53:41Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-e0u","depends_on_id":"improvise-n10","type":"blocks","created_at":"2026-04-14T00:53:40Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-e0u","depends_on_id":"improvise-pca","type":"blocks","created_at":"2026-04-14T00:53:40Z","created_by":"spot","metadata":"{}"}],"dependency_count":6,"dependent_count":2,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-764","title":"Migrate import wizard widget and state to consume RenderCache","description":"Rewrite ui/import_wizard_ui.rs (347 lines) to consume a WizardCache instead of the ImportWizard struct directly. Also decide where the import wizard state itself lives in the new crate layout — probably needs to stay in improvise-command (not improvise-io) so it can be referenced from App without creating a dep cycle.","design":"WizardCache mirrors the ImportWizard state: current step, per-field decisions, preview rows, current validation errors. The widget reads cache + ViewState and draws. The ImportWizard state machine stays wherever it ends up living after the crate-split epic step 3 resolves its placement — either improvise-command (portable state machine) or improvise-io (tangled with CSV parsing). The separation between wizard state (session/view-ish) and import pipeline (pure data processing) is worth getting right because it affects the worker-server's ability to run imports.","acceptance_criteria":"(1) import_wizard_ui.rs no longer reads the wizard state directly — takes WizardCache. (2) ImportWizard is split into state + pipeline; state lives in an appropriate crate that doesn't cycle. (3) Native TUI wizard rendering unchanged. (4) Unit tests with fixture WizardCaches for each step.","notes":"Depends on improvise-edp (ViewModel layer). Wizard widget consumes WizardViewModel derived from WizardCache + ViewState. The wizard state machine itself (step tracking, field decisions) lives in the ImportWizard struct on the server side; the cache is a snapshot of its renderable state; the viewmodel adds any styling/layout derivation the widget needs.\nPRIORITY RAISED P3-\u003eP2 (groomed 2026-08-30): this gates P2 work (improvise-ltq improvise-e0u ), so leaving it P3 made 'bd ready' unworkable in priority order — the P2 cannot be reached without it. Priority should be non-decreasing along dependency edges. Revert if the intent was that the downstream P2 is genuinely not wanted yet.\n--- DECIDED 2026-09-19 (user) ---\nRenderCache is PER-WIDGET, and state synchronizes by EVENTS, not snapshots.\n\nThis is shape (C) from improvise-rkeq, with per-widget caches:\n * No flat grid-only wire type and no fat aggregate container. Each widget\n owns its own cache type (GridCache, CategoryTreeCache, FormulaCache,\n ViewCache, TileBarCache, WizardCache, HelpCache).\n * Each widget subscribes to and FOLDS its own stream of projection\n Commands. There is no aggregate RenderCache on the wire.\n * The ONE exception is the initial state transfer: a newly connected\n client gets a Snapshot to seed from, then follows the event stream.\n Snapshot stays bootstrap-only (wire_carries(snapshot,bootstrap_only)).\n\nConsequence for this ticket: read \"RenderCache\" as \"that widget's own cache\ntype\", and expect updates to arrive as folded projection commands rather\nthan as a rebuilt snapshot.","status":"closed","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:50:47Z","created_by":"spot","updated_at":"2026-09-20T00:58:17Z","closed_at":"2026-09-20T00:58:17Z","close_reason":"Done, and the ticket's open design question is answered.\n\nThe widget is rewritten against a new WizardCache (+ WizardStepKind, WizardFieldKind, WizardProposal, WizardDateField) in improvise-protocol::render, built by project::wizard_cache / wizard_of. ImportWizard, ImportPipeline, FieldKind, DateComponent and Workbook all stop at the projection.\n\nTHE ANSWER TO 'WHERE DOES THE WIZARD STATE LIVE': THERE IS DELIBERATELY NO Projection::Wizard. A Projection is addressed to a widget on EVERY client, which is correct exactly when the change is addressed to NO ONE session. A half-open import dialogue belongs to precisely the session that opened it. This follows settled doctrine rather than re-deciding it -- ImportWizard is on App and not ViewState (gotcha 30), and AcceptWizardModel was cut from ModelCommand for exactly this reason (gotcha 38 / improvise-tom1). Broadcasting the wizard would re-create that bug on the RENDERING side: every client drawing one client's dialogue.\n\nSo: a cache WITHOUT a projection is the shape session-local residue takes when it still has to be drawn by two different front ends. This resolves the open question gotcha 32 parks on this ticket.\n\nThe type still lives in improvise-protocol rather than improvise-command for the flatten_cat_tree reason: the wasm client reaches only the protocol, and when it grows a wizard it must draw THESE rows, not a second derivation.\n\nMulti-client import wants the ADDRESSED form, which is ADR 001 layer 2.\n\nONE GAP: WizardStep::Preview is unreachable from both fixtures -- it needs a document that parses to zero records without needing path selection. Filed.","dependencies":[{"issue_id":"improvise-764","depends_on_id":"improvise-edp","type":"blocks","created_at":"2026-04-14T00:53:38Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-jb3","title":"Migrate help and which_key widgets to consume RenderCache + ViewState","description":"Rewrite the small static-content widgets: ui/help.rs (5-page help overlay, 617 lines but mostly static text) and ui/which_key.rs (prefix-key hint popup). Lowest-risk widgets in the migration because their content is almost entirely derived from static data plus a small amount of ViewState (help_page index, active transient_keymap).","design":"HelpCache: largely static; just the page-index state and the content pages (constant). Could even be stateless if the renderer reads help_page directly from ViewState. WhichKeyCache: derived from the active transient_keymap — a flat list of (key, binding description) entries. Each widget becomes a pure render function taking its cache + ViewState for minimal dynamic bits.","acceptance_criteria":"(1) help.rs and which_key.rs no longer import App. (2) Unit-testable with fixture caches (or no cache for help). (3) Native TUI rendering unchanged.","notes":"Depends on improvise-edp (ViewModel layer). Help widget consumes HelpViewModel (derived from the static help content + current help_page); which_key consumes WhichKeyViewModel (derived from the active transient_keymap). Low-complexity widgets, but go through the viewmodel layer for consistency with the rest of the migration.\nPRIORITY RAISED P3-\u003eP2 (groomed 2026-08-30): this gates P2 work (improvise-e0u improvise-ltq ), so leaving it P3 made 'bd ready' unworkable in priority order — the P2 cannot be reached without it. Priority should be non-decreasing along dependency edges. Revert if the intent was that the downstream P2 is genuinely not wanted yet.\nSTALE ASSUMPTION (groomed 2026-08-30): this design refers three times to 'the active transient_keymap' as a ViewState field. That field is gone (improvise-jc8). ViewState now holds transient_prefix: Option\u003c(KeyCode, KeyModifiers)\u003e — the key that OPENED the prefix — and the sub-keymap is resolved on demand via KeymapSet::prefix_keymap(mode, key, mods), which walks parent keymaps.\n\nConsequence for the WhichKeyCache described here: it cannot be derived from ViewState alone any more. It needs ViewState (for the armed prefix) plus the keymap set (to resolve it and call binding_hints()). App::armed_prefix_keymap() does exactly that today and is the natural source. This matters because the whole point of jb3 is that widgets stop reading \u0026App — so decide whether the cache is populated server-side from the resolved keymap, or whether the hint list itself is what crosses to the client. The latter is probably right: hints are (label, command-name) pairs, which are portable, whereas a Keymap is not.\n--- DECIDED 2026-09-19 (user) ---\nRenderCache is PER-WIDGET, and state synchronizes by EVENTS, not snapshots.\n\nThis is shape (C) from improvise-rkeq, with per-widget caches:\n * No flat grid-only wire type and no fat aggregate container. Each widget\n owns its own cache type (GridCache, CategoryTreeCache, FormulaCache,\n ViewCache, TileBarCache, WizardCache, HelpCache).\n * Each widget subscribes to and FOLDS its own stream of projection\n Commands. There is no aggregate RenderCache on the wire.\n * The ONE exception is the initial state transfer: a newly connected\n client gets a Snapshot to seed from, then follows the event stream.\n Snapshot stays bootstrap-only (wire_carries(snapshot,bootstrap_only)).\n\nConsequence for this ticket: read \"RenderCache\" as \"that widget's own cache\ntype\", and expect updates to arrive as folded projection commands rather\nthan as a rebuilt snapshot.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-14T07:50:23Z","created_by":"spot","updated_at":"2026-09-20T00:58:16Z","closed_at":"2026-09-20T00:58:16Z","close_reason":"The widget half was already done, and the ticket's own premise was stale -- its grooming note said so and was right.\n\nHelpWidget::new(help_page: usize) and WhichKeyWidget::new(\u0026[(String, \u0026'static str)]) were NEVER \u0026App-coupled; the git history shows no migration because none was needed. The which-key derivation happens in draw.rs, not in the widget: App::armed_prefix_keymap() + Keymap::binding_hints(). The widget is already a pure renderer of the hint list.\n\nWHAT REMAINS IS A PROTOCOL GAP, NOT A WIDGET PROBLEM, and it is filed as its own ticket: ViewState::transient_prefix is portable, but THE KEYMAP SET IS NOT ON THE CLIENT, so a browser which-key cannot be derived from ViewState alone. Either the resolved hint list crosses (a projection or a ViewState field) or the client gets the keymaps. That is a real decision and it was never this ticket's.","dependencies":[{"issue_id":"improvise-jb3","depends_on_id":"improvise-edp","type":"blocks","created_at":"2026-04-14T00:53:37Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-pca","title":"Migrate panel widgets (category/formula/view/tile_bar) to consume RenderCache","description":"Rewrite the smaller ratatui panel widgets so each one takes its relevant sub-cache slice instead of \u0026App. Targets: ui/category_panel.rs + cat_tree.rs, ui/formula_panel.rs, ui/view_panel.rs, ui/tile_bar.rs, ui/panel.rs (generic frame). Each becomes a pure renderer of its own cache structure.","design":"Four sub-cache types: CategoryTreeCache (flattened cat/item/group tree with expand state, current cursor index), FormulaListCache (raw strings + target info), ViewListCache (names + active flag), TileBarCache (per-category axis assignment). Each widget's render takes \u0026SubCache and \u0026ViewState (for mode / cursor). Widget signatures: fn draw_category_panel(frame, area, cache: \u0026CategoryTreeCache, view: \u0026ViewState); same shape for the others. The flattening logic that's currently in ui/cat_tree.rs (build_cat_tree) moves into cache construction rather than happening at render time. Handcrafted fixture caches for unit tests. Panels become entirely render-only; no App dependency. Shared with the browser/standalone clients later if they ever add panel rendering (post-MVP for those).","acceptance_criteria":"(1) Each of the four panel widgets no longer imports App. (2) Each is unit-testable with a fixture sub-cache. (3) Native TUI renders identically to before. (4) Sub-cache types live in improvise-protocol so they can be serialized for remote clients later.","notes":"Depends on improvise-edp (ViewModel layer). Each panel widget consumes its own sub-viewmodel (CategoryTreeViewModel, FormulaListViewModel, ViewListViewModel, TileBarViewModel), computed from the corresponding sub-cache + ViewState. Panels become pure renderers of viewmodels; all flattening, ordering, and highlight logic lives in the compute_*_viewmodel functions.\n--- DECIDED 2026-09-19 (user) ---\nRenderCache is PER-WIDGET, and state synchronizes by EVENTS, not snapshots.\n\nThis is shape (C) from improvise-rkeq, with per-widget caches:\n * No flat grid-only wire type and no fat aggregate container. Each widget\n owns its own cache type (GridCache, CategoryTreeCache, FormulaCache,\n ViewCache, TileBarCache, WizardCache, HelpCache).\n * Each widget subscribes to and FOLDS its own stream of projection\n Commands. There is no aggregate RenderCache on the wire.\n * The ONE exception is the initial state transfer: a newly connected\n client gets a Snapshot to seed from, then follows the event stream.\n Snapshot stays bootstrap-only (wire_carries(snapshot,bootstrap_only)).\n\nConsequence for this ticket: read \"RenderCache\" as \"that widget's own cache\ntype\", and expect updates to arrive as folded projection commands rather\nthan as a rebuilt snapshot.","status":"closed","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:49:06Z","created_by":"spot","updated_at":"2026-09-20T00:58:16Z","closed_at":"2026-09-20T00:58:16Z","close_reason":"Already done -- delivered by improvise-35e (92fd3a8) and improvise-vjms (986af9e), both already on main. No work was needed and none was invented.\n\nEVIDENCE: CategoryContent::new(\u0026CategoryTreeCache, \u0026HashSet\u003cString\u003e), FormulaContent::new(\u0026FormulaCache, \u0026AppMode), ViewContent::new(\u0026ViewListCache, \u0026str), TileBar::new(\u0026TileBarCache, \u0026AppMode, usize). None takes \u0026App, \u0026Model, \u0026View or \u0026Workbook outside its test module.\n\nONE RESIDUAL DERIVATION, filed separately rather than fixed: ViewContent::axis_summary hard-codes !cat.starts_with('_') to filter virtual categories out of the axis summary, while ViewSummary::axes carries EVERY category. That is the same convention improvise-vjms deliberately pushed onto the client for the category panel, still applied locally by the view panel. Not a bug today -- there is no browser view panel -- but it is a second, divergent answer waiting.","dependencies":[{"issue_id":"improvise-pca","depends_on_id":"improvise-edp","type":"blocks","created_at":"2026-04-14T00:53:36Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-n10","title":"Migrate GridWidget to consume RenderCache + ViewState (not \u0026App)","description":"Rewrite ui/grid.rs so that GridWidget::render takes \u0026GridCache + \u0026ViewState instead of reaching into \u0026App. The grid is the largest and most important widget — 1036 lines, mixes layout calc + ratatui drawing + direct App reads. The migration separates 'what to draw' (data in GridCache) from 'how to draw it' (ratatui primitives). Also aligns with improvise-cr3 (browser DOM renderer), which renders the same GridCache shape to DOM — share the GridCache type between them.","design":"pub struct GridCache { pub col_widths: Vec\u003cu16\u003e, pub row_labels: Vec\u003cString\u003e, pub col_labels: Vec\u003cString\u003e, pub cells: Vec\u003cVec\u003cCellDisplay\u003e\u003e, pub cursor: (usize, usize), pub highlights: Vec\u003cHighlight\u003e, pub mode_indicator: ModeIndicator, ... }. GridWidget becomes: fn draw_grid(frame: \u0026mut Frame, area: Rect, cache: \u0026GridCache, view: \u0026ViewState). All current dynamic calculations (col width from content, layout metrics, records mode detection) move to GridCache construction in the projection layer, not at render time. Widget is pure drawing. The temporary RenderCache::from_app adapter (issue 2) populates GridCache from App+View+GridLayout; later the projection layer computes it server-side for remote clients. During this migration, the widget is tested against a handcrafted GridCache fixture — no App needed. Share GridCache type with improvise-cr3 by placing it in improvise-protocol.","acceptance_criteria":"(1) GridWidget no longer imports App or reads anything outside GridCache + ViewState. (2) Widget is unit-testable with a fixture GridCache. (3) Native TUI renders identically to before for every .improv file in tests/. (4) GridCache type is shared with improvise-cr3 (both rasterize the same data structure). (5) Performance: per-frame render time unchanged (the projection layer work happens outside the widget, not inside).","notes":"Depends on issue improvise-edp (ViewModel layer) — the grid widget consumes GridViewModel (client-computed from GridCache + ViewState + render env), not GridCache directly. Share design work with improvise-cr3 (browser DOM renderer) — both target the same GridViewModel type, differing only in the rendering backend.\n--- DECIDED 2026-09-19 (user) ---\nRenderCache is PER-WIDGET, and state synchronizes by EVENTS, not snapshots.\n\nThis is shape (C) from improvise-rkeq, with per-widget caches:\n * No flat grid-only wire type and no fat aggregate container. Each widget\n owns its own cache type (GridCache, CategoryTreeCache, FormulaCache,\n ViewCache, TileBarCache, WizardCache, HelpCache).\n * Each widget subscribes to and FOLDS its own stream of projection\n Commands. There is no aggregate RenderCache on the wire.\n * The ONE exception is the initial state transfer: a newly connected\n client gets a Snapshot to seed from, then follows the event stream.\n Snapshot stays bootstrap-only (wire_carries(snapshot,bootstrap_only)).\n\nConsequence for this ticket: read \"RenderCache\" as \"that widget's own cache\ntype\", and expect updates to arrive as folded projection commands rather\nthan as a rebuilt snapshot.","status":"closed","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:49:03Z","created_by":"spot","updated_at":"2026-09-20T00:58:15Z","closed_at":"2026-09-20T00:58:15Z","close_reason":"Done, partially -- and the part left undone is the finding.\n\nGridWidget no longer holds a \u0026Model. Three derivations moved:\n * COLUMN TOTALS -\u003e GridCache::totals + project::column_totals. The widget was summing every row of each visible column through evaluate_aggregated_f64. This MUST be projected, not client-derived: the sum runs over every row INCLUDING THE ONES A VIEWPORT SCROLLED PAST, so a client holding only its window cannot compute it. An EMPTY strip means 'no totals' (records mode / empty grid) -- deliberately not a row of blanks, and fit_to_labels pads it only once non-empty. Invalidated blanks the totals: they are contents, and a stale sum under fresh cells is the one number a user takes at face value.\n * PAGE BAR -\u003e GridCache::page_coords, framed by nothing (it names categories, not positions) so it survives a reshape.\n * THE ITALIC-AGGREGATE RULE -\u003e read off CellId::Aggregate (ADR 002), which already carried the fact. The widget had been walking none_cats x Model::category(..).kind.is_regular() -- a second, hand-rolled copy of what project::cell_id decides.\n\nNOT DONE, DELIBERATELY: THE GRID'S FRAME. Six facts still come off GridLayout + View -- header level count (row_cats/col_cats), multi-level label TUPLES and inline GROUP HEADERS (row_items), column group spans (col_group_for), the collapse indicator (is_group_collapsed), and record_col (session-local by design, since it keys pending drill edits -- that one is CORRECT to keep off).\n\nTHE HEADLINE, AND IT IS A LIVE DIVERGENCE TODAY: GridCache::row_labels is GridLayout::row_label, which JOINS A MULTI-LEVEL TUPLE WITH '/'. That is exactly the rendering src/ui/grid.rs::two_row_categories_produce_cross_product_labels asserts must NOT appear. So the protocol carries a form of the labels the TUI considers wrong, and the wasm client renders precisely that form: the browser grid shows Food/Alice where the TUI shows two header columns, with no multi-level headers, no repeat suppression, no groups and no collapse indicators. Successor ticket filed -- it is a co-ordinated change (row_labels/col_labels change TYPE and improvise-wasm-client consumes them), and an additive row_levels beside row_labels is the wrong fix, because two answers to 'what are the labels' is the disease.\n\nUNVERIFIED: project::grid_of now evaluates every cell per frame where GridWidget evaluated only the visible ones. The totals loop was already O(rows x visible cols) and compute_col_widths was already whole-grid, so this WIDENS an existing cost rather than introducing one -- but on a wide grid it is a real widening and there is no benchmark. The fix would be to give the TUI a real viewport, which needs the width/window chicken-and-egg untangled first.","dependencies":[{"issue_id":"improvise-n10","depends_on_id":"improvise-edp","type":"blocks","created_at":"2026-04-14T00:53:36Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-35e","title":"Temporary adapter: RenderCache::from_app bridge for incremental widget migration","description":"Build a function that constructs a complete RenderCache from an \u0026App on every frame. This is a temporary bridge that lets ratatui widgets migrate from '\u0026App direct reads' to 'RenderCache consumer' one at a time, without breaking the build. Each migrated widget reads from the adapter's output; unmigrated widgets keep reading \u0026App. When every widget has been migrated and the binary split is complete, this adapter is deleted.","design":"pub fn RenderCache::from_app(app: \u0026App, subs: SubscriptionSet) -\u003e RenderCache. Walks the full App state and populates every subscribed-to sub-cache: grid cells/labels/widths, category tree, formula list, view list, tile bar axes, wizard state, etc. SubscriptionSet is the full set initially (native TUI subscribes to everything); later issues refine it per client. Called on every frame from run_tui before drawing. Unperformant by construction (rebuilds everything per frame) but correct, and it's throwaway code. Cache shape: pub struct RenderCache { grid: Option\u003cGridCache\u003e, category_tree: Option\u003cCategoryTreeCache\u003e, formulas: Option\u003cFormulaListCache\u003e, views: Option\u003cViewListCache\u003e, tile_bar: Option\u003cTileBarCache\u003e, wizard: Option\u003cWizardCache\u003e, help: Option\u003cHelpCache\u003e, ... }. Each field is Some if subscribed and populated from App data.","acceptance_criteria":"(1) RenderCache struct exists with optional sub-caches for every rendering surface. (2) from_app populates all subscribed sub-caches from current App state. (3) run_tui calls from_app once per frame before drawing. (4) Tests demonstrate the cache contents match what the corresponding widgets would read from App directly.","notes":"Throwaway code explicitly marked as such. Deleted after issue 7 (binary split) lands. Depends on issue 1 (reduce_full) only because it needs to know what the cache shape should be, which is determined by what widgets read.\nGROOMED 2026-09-19: the RenderCache that actually landed in improvise-cqi is grid-only (row_labels/col_labels/col_widths/cells) — not the 'grid + category_tree + formulas + views + tile_bar + wizard + help' shape this ticket's design describes. Blocked on improvise-rkeq until the shape is decided; writing from_app against the wrong one is the expensive mistake.\n--- DECIDED 2026-09-19 (user) ---\nRenderCache is PER-WIDGET, and state synchronizes by EVENTS, not snapshots.\n\nThis is shape (C) from improvise-rkeq, with per-widget caches:\n * No flat grid-only wire type and no fat aggregate container. Each widget\n owns its own cache type (GridCache, CategoryTreeCache, FormulaCache,\n ViewCache, TileBarCache, WizardCache, HelpCache).\n * Each widget subscribes to and FOLDS its own stream of projection\n Commands. There is no aggregate RenderCache on the wire.\n * The ONE exception is the initial state transfer: a newly connected\n client gets a Snapshot to seed from, then follows the event stream.\n Snapshot stays bootstrap-only (wire_carries(snapshot,bootstrap_only)).\n\nConsequence for this ticket: read \"RenderCache\" as \"that widget's own cache\ntype\", and expect updates to arrive as folded projection commands rather\nthan as a rebuilt snapshot.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-14T07:48:38Z","created_by":"spot","updated_at":"2026-09-19T23:40:22Z","closed_at":"2026-09-19T23:40:22Z","close_reason":"PREMISE CHECK FIRST, and the ticket as written is DEAD: RenderCache::from_app(app, subs) -\u003e RenderCache cannot be written because that aggregate does not exist and will not (improvise-rkeq, gotcha 32). It was not written. The builders it asked for already existed and were already pub -- grid_of, category_rows, formula_texts, view_summaries, tile_entries -- and Dirty already answers the SubscriptionSet, so no parallel type was invented.\n\nTWO THINGS WERE GENUINELY MISSING, both real:\n1. THE ROOT CRATE COULD NOT NAME A CACHE TYPE. improvise had no improvise-protocol dependency and improvise-command re-exported core/formula/io but NOT protocol, so no ratatui widget in src/ could write fn new(cache: \u0026FormulaCache). That was the hard blocker and nothing complained because nobody had tried. Fixed by re-exporting the protocol through improvise-command (and the root crate), so a host names the caches through the crate that builds them and the root takes no new dependency.\n2. THE BUILDERS RETURNED PAYLOADS, NOT CACHES. Added category_tree_of/formulas_of/view_list_of/tile_bar_of, each one line over the existing payload fn so there is no second derivation. document_projections and the tests server_truth now both derive FROM the caches, so what a widget holds and what a client folds cannot disagree.\n\nThree widgets migrated off \u0026Model/\u0026View: formula panel, view panel, tile bar. No aggregate type and no adapter type -- a caller wanting several caches calls several builders, and no widget names the seam, so deleting the temporary half is deleting call sites.\n\n6 tests, each an EQUIVALENCE between the cache and what the widget used to read off the model (plus pinned render output where drawing has layout logic). Three faults injected into the builders, all fired. 970 workspace tests pass.","dependencies":[{"issue_id":"improvise-35e","depends_on_id":"improvise-gxi","type":"blocks","created_at":"2026-04-14T00:53:34Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-35e","depends_on_id":"improvise-rkeq","type":"blocks","created_at":"2026-09-19T12:49:24Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":3,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-gxi","title":"Define unified reduce_full(\u0026mut App, \u0026Command) -\u003e Vec\u003cCommand\u003e","description":"Extract the single shared reducer function that all three deployment modes use to apply a Command to the authoritative state. Replaces today's App::handle_key → Cmd::execute → Effect::apply chain with a named function that takes a Command (data, not trait object), runs the command through the existing registry + effect pipeline, and returns any outbound projection commands. Load-bearing for the entire unified architecture — native TUI, ws-server, and worker-server all import this same function.","design":"Signature: pub fn reduce_full(app: \u0026mut App, cmd: \u0026Command) -\u003e Vec\u003cCommand\u003e. Internally: (1) resolve the Command to a Cmd trait object via the registry (or match directly on the Command enum), (2) build a CmdContext from the current App state, (3) run execute() to produce effects, (4) apply each effect to App, (5) walk the effect list and compute outbound projection Commands for each subscribing client (empty if no subscribers). The function lives in improvise-command (which requires App to move there too — see epic notes). Command enum is defined in improvise-protocol (improvise-cqi). The native TUI's existing App::handle_key becomes a thin wrapper: resolve the key to a Command via the keymap, call reduce_full, drop the returned projections (single-session native has no remote subscribers). The ws-server and worker-server call reduce_full directly on received Commands and dispatch the returned projections back over their respective transports.","acceptance_criteria":"(1) pub fn reduce_full exists in improvise-command. (2) App::handle_key is rewritten to resolve key → Command via keymap, then call reduce_full, then drop projections. (3) All existing native TUI behavior is preserved — integration and unit tests pass unchanged. (4) Keymap lookup returns Command values (data) rather than Box\u003cdyn Cmd\u003e trait objects. (5) CmdRegistry's dispatch API becomes 'dispatch_command(cmd: \u0026Command, ctx: \u0026CmdContext) -\u003e Vec\u003cEffect\u003e' or equivalent. (6) A unit test demonstrates reduce_full applied to a Command produces the same final App state as the old path.","notes":"Depends on improvise-cqi (Command enum defined in improvise-protocol) and on App having moved to improvise-command (a crate-epic step 5 detail that needs updating — see improvise-3mm notes). Cross-epic: improvise-cqq (browser projection layer) formally depends on this issue since it needs reduce_full to exist.\nGROOMED 2026-09-19 — PREREQUISITES DISSOLVED. The notes said this depends on \"App having moved to improvise-command (a crate-epic step 5 detail that needs updating — see improvise-3mm notes)\". improvise-3mm is closed and App already lives at crates/improvise-command/src/ui/app.rs, so that prerequisite is satisfied, not pending. improvise-cqi is closed too: Command, ViewState and reduce_view are in improvise-protocol, and improvise-command depends on it. Nothing blocks this now — it is in bd ready.\n\n--- PARTIALLY LANDED 2026-09-19; ACs 4-5 BLOCKED, deliberately not forced ---\nDONE: reduce_full(\u0026mut App, \u0026Command) -\u003e Vec\u003cCommand\u003e (AC 1), apply_command\n(the inner half a server uses to drive Sessions::project_all), reduce_key,\nand App::handle_key is now a wrapper over App::dispatch_key(key) -\u003e Dirty\n(AC 2 in spirit). All existing behaviour preserved (ACs 3, 6).\n\nNOT DONE, and not doable as written: ACs 4 and 5 ask that keymap lookup\nreturn Command values and that CmdRegistry gain dispatch_command(\u0026Command).\nThe Command enum's client-\u003eserver half is exactly the ViewOnly portable-slice\nmutations -- cqi derived it from those effects and nothing else -- so it has\nno SetCell, AddCategory, SetAxis or Save. A keymap cannot return a Command for\nmost bindings, and reduce_full over a Command cannot drive a model mutation.\nForcing it would mean inventing variants ahead of the mutations, which\ndecision(improvise-cqi, command_set_derived_not_invented) and\ndoctrine(derive_the_enum_from_the_effects) both forbid.\n\nBLOCKED ON: the new ticket to derive the model half of Command from the 29\nEffectKind::Model effects. Until then reduce_full and reduce_key are two\ndoors and ui/reduce.rs's module doc explains why.\n\n--- AC 4 IS UNREACHABLE BY DESIGN, not for want of vocabulary (2026-09-19) ---\nimprovise-kmgs landed ModelCommand, so the \"the vocabulary is missing\" reason\nis gone. AC 4 still cannot be met, and the sharper reason is worth recording:\n\na keymap binding names an INTENT, resolved against CmdContext -- \"move-cursor\ndown\". A Command is the RESOLVED MUTATION -- SetSelected { row, col } -- which\nonly exists once the cursor and layout have been read. So a keymap LOOKUP can\nnever return a Command; only dispatch, after CmdContext, can. That is a\nproperty of the Cmd/Command split itself, not a gap.\n\nRECOMMEND: rewrite AC 4 to \"dispatch (not lookup) yields a Command where the\nbinding resolves to a model mutation\", or drop it. Do not force the original\nwording.\n\nAC 5 is satisfied in substance: model_effects(\u0026ModelCommand) -\u003e Vec\u003cBox\u003cdyn\nEffect\u003e\u003e in ui/reduce.rs is the \"or equivalent\". It was deliberately NOT hung\noff CmdRegistry as dispatch_command, because the mapping needs neither the\nregistry nor a CmdContext and a method ignoring \u0026self would misdescribe it.","status":"closed","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:48:36Z","created_by":"spot","updated_at":"2026-09-19T23:29:48Z","started_at":"2026-09-19T21:15:06Z","closed_at":"2026-09-19T23:29:48Z","close_reason":"CLOSED, re-scoped: ACs 1-3 and 6 delivered; ACs 4-5 resolved as written-against-a-false-premise.\n\nDELIVERED (wave 2, merged): reduce_full(\u0026mut App, \u0026Command) -\u003e Vec\u003cCommand\u003e, apply_command (the inner half a server uses to drive Sessions::project_all), reduce_key, and App::handle_key as a wrapper over App::dispatch_key(key) -\u003e Dirty. All existing behaviour preserved.\n\nAC 5 satisfied in substance: model_effects(\u0026ModelCommand) -\u003e Vec\u003cBox\u003cdyn Effect\u003e\u003e in ui/reduce.rs is the \"or equivalent\". Deliberately NOT hung off CmdRegistry as dispatch_command, because the mapping needs neither the registry nor a CmdContext and a method ignoring \u0026self would misdescribe it.\n\nAC 4 IS UNREACHABLE BY DESIGN, and the reason is sharper than \"the vocabulary is missing\" (improvise-kmgs supplied the vocabulary and it is still unreachable): a keymap binding names an INTENT resolved against CmdContext -- \"move-cursor down\" -- while a Command is the RESOLVED MUTATION -- SetSelected { row, col } -- which only exists once the cursor and layout have been read. So a keymap LOOKUP can never return a Command; only dispatch, after CmdContext, can. That is a property of the Cmd/Command split itself. Forcing it would mean either inventing intent-shaped Command variants (which decision(improvise-cqi, command_set_derived_not_invented) forbids) or dissolving the split.\n\nClosing rather than leaving open: it blocks improvise-cqq and the 35e -\u003e edp -\u003e cr3 chain to improvise-1ey, which is now the priority, and there is no remaining work here that a future ticket would not state better. If dispatch-yields-a-Command is wanted, file it as its own ticket against the real question.","dependencies":[{"issue_id":"improvise-gxi","depends_on_id":"improvise-cqi","type":"blocks","created_at":"2026-08-29T12:37:17Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":5,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-ltq","title":"Epic: Native TUI as in-process client of the unified server architecture","description":"Restructure the native binary so the ratatui UI is a client of an in-process server, exactly the way the DOM renderer is a client of the service worker in the standalone deployment and of the ws-server in the network deployment. One binary process, two halves: a 'server' holding the full App + projection layer, and a 'client' holding ViewState + render cache + ratatui widgets. Commands flow through a direct in-process channel (same shape as websocket/postMessage protocols, zero serialization overhead). The server emits projection commands back to the client's cache. Result: the ratatui path is architecturally identical to the browser paths — same reduce_full, same Command vocabulary, same cache-based rendering. Widgets stop reading \u0026App directly.","design":"Four deployment modes all share one architecture: (1) native TUI = in-process client + server with direct-call transport, (2) network thin-client = browser main thread + ws-server with websocket transport, (3) standalone web = browser main thread + worker with postMessage transport, (4) hybrid = in-process native client + server PLUS remote ws-server subscriber = native TUI with remote browser observer. Under this epic, mode (1) gets restructured to match (2) and (3). The shared pieces: reduce_full, projection layer with per-client subscriptions, Command vocabulary, render cache shape. The difference per mode is only the transport + which clients subscribe to which projection kinds. Native TUI subscribes to every projection kind because it renders everything (grid, panels, help, wizard); browser thin-client MVP subscribes to grid only. Staging: introduce a temporary RenderCache::from_app adapter, migrate widgets one at a time with the adapter bridging, split the binary and delete the adapter at the end. Every widget migration is individually landable.","notes":"Implications for other epics: (A) improvise-cqq (browser epic projection layer) gains a prerequisite on issue 1 of this epic (unified reduce_full). (B) improvise-3mm (crate-epic step 5) needs its design updated to put App in improvise-command, not improvise-tui, so reduce_full can live alongside App without pulling ratatui into ws-server or worker-server. (C) improvise-cr3 (browser DOM renderer) and issue 3 of this epic (ratatui grid cache consumer) share the RenderCache grid shape and can share the GridViewModel type — coordinate the two to avoid duplication. Stretch issues cover hybrid mode (native TUI + remote browser subscriber) and native undo/replay via command logging.\nGROOMED 2026-09-19: implication (B) in the description is done — improvise-3mm closed with App in improvise-command, which is where this epic wanted it. Implication (A) is recorded as a real edge (cqq depends on gxi), so it no longer needs to live in prose.\nDEFERRED 2026-09-19: the epic closes when improvise-e0u closes, and e0u is deferred by user ruling this session. All of ltq's other children are already closed. Nothing to do here until e0u gets its own campaign.","status":"deferred","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:48:34Z","created_by":"spot","updated_at":"2026-09-20T05:31:01Z","dependencies":[{"issue_id":"improvise-ltq","depends_on_id":"improvise-35e","type":"blocks","created_at":"2026-04-14T00:53:49Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-ltq","depends_on_id":"improvise-764","type":"blocks","created_at":"2026-04-14T00:53:52Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-ltq","depends_on_id":"improvise-e0u","type":"blocks","created_at":"2026-04-14T00:53:52Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-ltq","depends_on_id":"improvise-edp","type":"blocks","created_at":"2026-04-14T00:53:49Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-ltq","depends_on_id":"improvise-gxi","type":"blocks","created_at":"2026-04-14T00:53:48Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-ltq","depends_on_id":"improvise-jb3","type":"blocks","created_at":"2026-04-14T00:53:51Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-ltq","depends_on_id":"improvise-n10","type":"blocks","created_at":"2026-04-14T00:53:50Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-ltq","depends_on_id":"improvise-pca","type":"blocks","created_at":"2026-04-14T00:53:50Z","created_by":"spot","metadata":"{}"}],"dependency_count":8,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-i34","title":"OPFS-backed Storage implementation for wasm target","description":"Implement the Storage trait (or vfs backend) against the browser's Origin Private File System. Provides read/write/list operations from wasm that work across Chrome, Firefox, and Safari. Falls back to an in-memory or IndexedDB-backed implementation if OPFS is unavailable in the target environment.","design":"Use web-sys to access navigator.storage.getDirectory() and the File System Access API. OPFS is async-only, so either (a) make the Storage trait async-compatible (preferred if we switch to an async trait in the abstraction issue) or (b) wrap OPFS calls in a queued executor so the sync trait API can block on completion via a yield loop. Option (a) is cleaner; the abstraction issue (improvise-6mq) should probably commit to async up front if OPFS is the target. Directory layout: one root directory under OPFS for improvise, with subdirectories per purpose (autosave, user files, session state). File picker integration: when the user says 'open a file' in the browser, use the native file picker and either copy the file into OPFS or open it directly via the File System Access API's handle-based API. Fallback: if OPFS is unavailable (older Safari, some privacy modes), provide an IndexedDB-backed implementation using the idb crate — exposes the same trait.","acceptance_criteria":"(1) Storage implementation for wasm target exists, keyed off a compile-time target check. (2) Round-trips: write bytes, read bytes, list files, delete file. (3) Manual test: load a .improv file via file picker, edit in the DOM renderer, save to OPFS, reload page, file is still there. (4) Unit tests using a memory-backed stub where OPFS is unavailable (CI).","notes":"Depends on improvise-6mq (Storage abstraction must exist first). May reveal a need to change the abstraction from sync to async, in which case update 6mq before starting this.\nUnblocked by improvise-6mq (Storage trait landed 2026-08-29: crates/improvise-io/src/storage.rs, two methods read/write on opaque document paths, PhysicalFs + MemoryFs). NOTE: still not startable in the current dev environment — no wasm32-unknown-unknown std in the Nix toolchain and no rustup. See improvise-ywd notes.\nToolchain unblocked 2026-08-30: wasm32-unknown-unknown added to the flake (improvise-ywd), and all four bundle crates check clean. See improvise-fuj first — the two effects that still call std::fs directly need a decision on how a wasm build injects its Storage backend, which is the same decision this ticket needs.\nSYNC-VS-ASYNC RESOLVED (2026-08-30) — the abstraction does NOT need to become async, so improvise-6mq stands as built.\n\nThe DESIGN here says 'OPFS is async-only' and the NOTES say this 'may reveal a need to change the abstraction from sync to async, in which case update 6mq before starting'. That premise is incomplete. OPFS exposes two APIs:\n\n - the promise-based one (getDirectory, getFileHandle, createWritable) — async\n - FileSystemFileHandle.createSyncAccessHandle() -\u003e FileSystemSyncAccessHandle,\n whose read/write/truncate/flush/close are SYNCHRONOUS, and which is available\n ONLY inside a dedicated Web Worker. It exists precisely so wasm can do\n blocking file I/O; it is the mechanism SQLite's WASM build uses for its OPFS\n VFS.\n\nimprovise-9x6 puts 'the full App + command pipeline + projection layer + VFS persistence inside a browser worker'. So the persistence layer never runs on the main thread, and the sync path is available to it.\n\nShape that falls out:\n - ASYNC, once, at worker init(): navigator.storage.getDirectory(), getFileHandle(), createSyncAccessHandle().\n - SYNC, thereafter: the Storage impl holds the access handles; read/write are ordinary blocking calls, satisfying the existing two-method trait unchanged.\n\nThis avoids an invasive async migration through persistence::save/load and every caller, which is the outcome 6mq's Option-B choice was already hedging toward.\n\nCAVEAT: asserted from API knowledge, not tested — there is no browser here. Confirm createSyncAccessHandle availability against the target browsers (it is the weakest on Safari) before relying on it. The IndexedDB fallback this ticket already calls for is the hedge, and IndexedDB IS async-only — so the fallback, not OPFS, is what would force the async question. Decide whether the fallback is in scope before starting.\n\nPREREQUISITE: improvise-fuj — how a wasm build injects its Storage instance. App holds no Storage today; persistence::save/load hardwire PhysicalFs, and two effects bypass the trait entirely. That plumbing is host-verifiable and should land first.","status":"closed","priority":2,"issue_type":"feature","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-14T07:34:08Z","created_by":"spot","updated_at":"2026-09-01T06:11:09Z","started_at":"2026-09-01T06:06:19Z","closed_at":"2026-09-01T06:11:09Z","close_reason":"Storage gained list/delete (PhysicalFs + MemoryFs, shared storage_contract test). OpfsFs added for wasm32 using sync access handles, keeping the trait synchronous — createSyncAccessHandle's read/write/truncate/flush/close are blocking, worker-only, which suits improvise-9x6. Async work is four explicit methods: open/attach/refresh/commit_deletes. delete is deferred (index updated immediately, removeEntry on commit). No IndexedDB fallback per user decision — verified Safari 16.4+ has sync handle methods, so it isn't needed. AC(1)(2)(4) met; AC(3) is a browser test that cannot run here, tracked as improvise-xf1 and blocking improvise-9x6.","dependencies":[{"issue_id":"improvise-i34","depends_on_id":"improvise-6mq","type":"blocks","created_at":"2026-04-14T00:40:10Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-i34","depends_on_id":"improvise-fuj","type":"blocks","created_at":"2026-08-30T09:57:13Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":2,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-ywd","title":"Wasm compatibility audit and fixes across core/formula/command/io","description":"Verify every crate that belongs in the wasm bundle builds for wasm32-unknown-unknown and runs correctly. Fix anything that doesn't: feature-gate wall-clock time, swap Instant for web_time, enable chrono wasmbind, isolate dirs crate usage, verify pest/serde/indexmap/flate2/enum_dispatch all compile. Ship a cargo-check invocation per crate as a CI gate.","design":"Systematic audit: for each crate (improvise-core, improvise-formula, improvise-command, improvise-io-persistence), run cargo check --target wasm32-unknown-unknown and fix what breaks. Known hazards: (1) dirs crate — not wasm-compatible, isolate behind a trait function. (2) chrono::Local::now() — needs wasmbind feature, used in import analyzer for date parsing. (3) std::time::Instant — swap for web_time crate which falls back to js performance.now() in wasm. (4) std::fs in persistence — handled by the VFS abstraction issue. (5) std::env and std::process — probably none in core, verify. (6) flate2 — verify rust_backend feature works in wasm (should). Strategy: don't feature-gate everything individually, instead do the minimal isolation needed so each crate's public API is portable, and let the downstream bundle crate (improvise-web-standalone) opt in to what it actually needs.","acceptance_criteria":"(1) cargo check --target wasm32-unknown-unknown -p improvise-core passes. (2) Same for improvise-formula, improvise-command, and improvise-io (or improvise-persistence if split). (3) No new feature flags required to get these to build — the core path is wasm-compatible by default. (4) CI job added that runs the wasm checks on every push.","notes":"Depends on the full crate split (epics 1-5) being done — the audit needs each crate to exist independently. Does not depend on the VFS abstraction issue (std::fs concerns are handled there). These two can proceed in parallel.\nTOOLCHAIN-BLOCKED in this dev environment (checked 2026-08-29). The deliverable is 'cargo check --target wasm32-unknown-unknown' per crate plus a CI gate, but the Nix toolchain here ships no wasm32 std and there is no rustup to add it:\n\n $ cargo check -p improvise-core --target wasm32-unknown-unknown\n error[E0463]: can't find crate for 'std'\n note: the wasm32-unknown-unknown target may not be installed\n $ which rustup -\u003e not found\n\nSource changes could be written speculatively (Instant -\u003e web_time, chrono wasmbind, isolating dirs) but none could be VERIFIED, which is the whole point of an audit. Adding wasm32-unknown-unknown to the flake's rust toolchain is the actual first step of this ticket.\n\nSame constraint hits improvise-cqi acceptance criterion 5 ('builds for both host target and wasm32-unknown-unknown').","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-14T07:33:49Z","created_by":"spot","updated_at":"2026-08-30T16:51:24Z","started_at":"2026-08-30T16:47:28Z","closed_at":"2026-08-30T16:51:24Z","close_reason":"AC 1-3 turned out to need NO source changes; the blocker was the toolchain, exactly as the notes I left on 2026-08-29 predicted.\n\nAdded targets = [\"wasm32-unknown-unknown\"] to the flake's rustToolchain (flake.nix:26) and verified wasm32 std lands in the sysroot. Then all four bundle crates check clean, first try, with no feature flags:\n\n improvise-core ok\n improvise-formula ok\n improvise-io ok\n improvise-command ok\n\nThat satisfies AC 1, AC 2 and AC 3 (AC 3 explicitly: 'no new feature flags required — the core path is wasm-compatible by default'). The root improvise crate does NOT build for wasm because crossterm does not; that is correct — it is the terminal binary, not part of the bundle — so the CI job checks the four bundle crates by name rather than the workspace.\n\nAC 4: .github/workflows/ci.yml adds a wasm job plus a host job (fmt, clippy -D warnings, test). There was no test/lint CI before this — only cargo-dist's release.yml and the Claude bots. Verified clippy -D warnings and fmt pass locally so the job is not red on arrival, and validated the workflow YAML parses.\n\nTHE AUDIT'S REAL VALUE WAS THE HAZARD LIST, because a cargo check gate cannot catch anything that merely panics at runtime. Going through the DESIGN section's named hazards one by one:\n\n (1) dirs NON-ISSUE — declared at root Cargo.toml:36, used nowhere (already noted on improvise-v9w)\n (2) chrono Local::now NON-ISSUE — never called\n (3) std::time::Instant REAL — App::last_autosave; Instant::now() panics on wasm32-unknown-unknown yet compiles fine. FIXED: improvise-command now uses web-time, which re-exports std::time off-wasm and performance.now() on it. Verified both targets build and all 14 native suites pass.\n (4) std::fs REAL, NOT FIXED — StartImportWizard (effect.rs:931) and LoadModel (effect.rs:1052) still call std::fs::read_to_string directly, bypassing the Storage trait from improvise-6mq. Compiles on wasm, fails in a browser. Filed as improvise-fuj, because the fix needs a decision on how a wasm build injects its Storage backend — which also determines how improvise-i34 plugs in, so it should not be guessed at here.\n (5) std::env/process NON-ISSUE — std::env appears only in a test\n (6) flate2 NON-ISSUE — compiles for wasm as-is\n\nCaveat worth stating plainly: 'compiles for wasm32' is all that has been verified. Nothing here has been RUN on a wasm host — there is no browser or wasm runtime in this environment — so the web-time fix is verified by dual-target compilation and unchanged native behaviour, not by execution. Actual runtime verification arrives with improvise-i34 / improvise-tm6.","dependencies":[{"issue_id":"improvise-ywd","depends_on_id":"improvise-3mm","type":"blocks","created_at":"2026-04-14T00:40:09Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-6mq","title":"Introduce Storage/VFS abstraction in persistence layer","description":"Refactor persistence/mod.rs to go through a Storage trait instead of std::fs directly. Native build wires PhysicalFS; later issues wire OPFS for wasm. Behavior-preserving refactor — .improv files still save and load identically on native. Foundation for the standalone web deployment and for alternative formats (sqlite, parquet) that can target any backend.","design":"Option A: adopt the vfs crate directly (vfs::VfsPath, vfs::PhysicalFS). Pros: batteries included, filesystem-path-based API, open-source implementations. Cons: sync-only; wasm backends may need async. Option B: define our own narrow Storage trait with read_bytes/write_bytes/list/stat methods, sync for native and async-wrapped for wasm. Pros: tailored to our needs. Cons: more code to maintain. Recommend starting with Option A (vfs crate) and falling back to Option B if OPFS's async-only nature forces the issue. Refactor persistence::{save_md, load_md, save_json, load_json} to take \u0026dyn Storage (or VfsPath) as the target parameter. Autosave path resolution (currently uses the dirs crate) goes behind the abstraction too — native resolves to a dirs path, wasm resolves to a fixed OPFS path.","acceptance_criteria":"(1) Persistence functions take a storage parameter rather than reading/writing files directly. (2) Native build wires PhysicalFS (or equivalent) and all existing persistence tests pass unchanged. (3) dirs crate usage is isolated behind a function that can be stubbed for wasm. (4) Round-trip tests still pass. (5) A memory-backed test fixture exists for unit testing persistence without touching disk.","notes":"Depends on crate-split epic step 3 (improvise-8zh — improvise-io extraction) so the refactor happens in the right crate. Valuable independently of wasm: enables testing persistence without tempfile, simplifies fuzzing.","status":"closed","priority":2,"issue_type":"feature","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-14T07:33:47Z","created_by":"spot","updated_at":"2026-08-29T19:40:58Z","started_at":"2026-08-29T19:37:37Z","closed_at":"2026-08-29T19:40:58Z","close_reason":"persistence::save/load now go through a Storage trait; save/load remain thin PhysicalFs wrappers so every caller and all 83 persistence tests are untouched (AC 2).\n\nCHOSE OPTION B OVER THE RECOMMENDED OPTION A. The design section recommends starting with the vfs crate. I measured the actual surface first: persistence touches the filesystem in exactly two places — write the whole document in save, read the whole document in load. autosave_path is pure path manipulation with no I/O. That does not justify a dependency plus a VfsPath abstraction. The ticket's own con for Option A is decisive: it is sync-only while OPFS, the actual wasm target, is async — adopting A would mean doing B later anyway. The trait is two methods:\n\n fn read(\u0026self, path: \u0026Path) -\u003e Result\u003cVec\u003cu8\u003e\u003e\n fn write(\u0026self, path: \u0026Path, bytes: \u0026[u8]) -\u003e Result\u003c()\u003e\n\nPaths are opaque document names, not a directory tree, so an OPFS or map-backed backend implements it without pretending to have directories, metadata or seeking.\n\nAC 3 IS VACUOUS: 'dirs crate usage is isolated behind a function that can be stubbed for wasm' — there is no dirs usage. dirs = \"6\" is declared at root Cargo.toml:36 and referenced nowhere under src/ or crates/*/src/. It is simply an unused dependency; noted on improvise-v9w, which already covers removing unused root deps.\n\nGzip moved from streaming File+GzEncoder to encode-then-write. The .gz suffix still selects it, which makes gzip a property of the document name rather than of the backend. physical_and_memory_backends_write_identical_bytes pins that swapping backends does not change the bytes.\n\nAC 5 (memory-backed fixture): MemoryFs, plus three persistence tests that round-trip a workbook with categories, items and data through it — no tempfile, no disk. That is the shape a browser backend plugs into.\n\nVerified beyond unit tests: import to .improv, headless add-cat against it, and the .gz path all still work through the CLI.","dependencies":[{"issue_id":"improvise-6mq","depends_on_id":"improvise-8zh","type":"blocks","created_at":"2026-04-14T00:40:08Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":5,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-rbv","title":"Include _Measure in _Dim columns for records mode","description":"In records mode, _Dim columns are generated from regular (non-virtual) categories plus a synthetic 'Value' column. _Measure is excluded because it starts with '_'. This means records show a generic 'Value' column instead of showing which measure (Revenue, Cost, etc.) each record belongs to. _Measure items should appear as _Dim columns in records mode so the measure name is visible per-record.","status":"closed","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-14T07:30:40Z","created_by":"spot","updated_at":"2026-04-15T11:16:12Z","closed_at":"2026-04-15T11:16:12Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-dz8","title":"Add Axis::Filter for drill-fixed coordinates","description":"Drill mode sets fixed coordinates (e.g. Product=Widgets, Region=North) as Axis::Page, but these should be immutable constraints, not user-navigable page dimensions. Add Axis::Filter variant that: (1) excludes categories from tile bar in tile select mode, (2) excludes from page cycling via [ and ], (3) visually distinguished from Page in the UI. Drill uses Filter instead of Page for its fixed coordinates.","status":"closed","priority":2,"issue_type":"feature","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-14T07:30:04Z","created_by":"spot","updated_at":"2026-09-01T05:45:01Z","started_at":"2026-09-01T05:38:46Z","closed_at":"2026-09-01T05:45:01Z","close_reason":"Axis::Filter added. DrillIntoCell fixes coordinates on Filter instead of Page. All three ticket requirements met: excluded from tile select (View::tileable_cats, used by tile_bar + MoveTileCursor + TileAxisOp), excluded from [/] page cycling (page_cat_data stays on categories_on(Axis::Page)), and visually distinct (Fix ⚲, yellow, via Axis::color -\u003e AxisColor::Filter). Data filtering preserved via View::filtering_cats (Page ∪ Filter), used by GridLayout and add-record-row. Axis::next maps Filter to itself so nothing can rotate into it. .improv grammar gains a 'filter' keyword with round-trip test — defensive, since Filter only occurs in the never-persisted _Drill view. Note: adding the variant caused exactly ONE compile error outside axis.rs, because improvise-rml had already consolidated the display matches.","dependencies":[{"issue_id":"improvise-dz8","depends_on_id":"improvise-rml","type":"blocks","created_at":"2026-04-16T16:02:39Z","created_by":"Ed L","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-1ey","title":"Browser frontend MVP: end-to-end working demo","description":"Wire everything together into a working browser demo: start the ws-server, open the HTML shell in a browser, it loads the wasm client, connects to the websocket, receives the initial snapshot, renders the grid, accepts keystrokes, and round-trips commands to the server. This is the milestone where the architecture proves itself end-to-end. Scope: open an existing .improv file server-side, view it in the browser, type a number into a cell, see the server-side value update and the browser's projection arrive with the new value.","design":"HTML shell: minimal index.html with a \u003cdiv\u003e for the grid and a \u003cscript\u003e tag bootstrapping the wasm module. JS bootstrap: loads wasm, opens websocket connection, wires on_message from the socket into wasm.on_message, wires wasm.on_key_event output into the socket send. Reconnection: on socket close, show a status indicator; on reconnect, request a fresh snapshot. No auth, no TLS, localhost only. Test by pointing at examples/demo.improv or web/demo.improv.\n\n(Design field corrected 2026-09-19: it named bank-info.improv, which PR #22 deleted as unloadable -- hand-damaged during the v2025-04-09 migration and unreadable since April. roadmap.org line 194 is generated from this field, so the fix belongs here and the file is regenerated, never hand-edited. Note 'no auth, no TLS' is still an accurate description of improvise-ws-server and is now tracked as improvise-kpnr and improvise-kc7n rather than being merely a scope note.)","acceptance_criteria":"(1) ws-server serves a file from CLI arg, accepts a websocket connection. (2) Browser loads page, wasm initializes, grid renders. (3) Arrow keys move the cursor locally (view effect) with no round-trip. (4) Typing a number + Enter in a cell round-trips to the server and the new value appears. (5) Scrolling fetches cells for the new viewport. (6) Reload recovers cleanly from current server state. (7) Video or screenshot of the demo attached to the issue.","notes":"Final milestone of the browser epic. Depends on projection layer (improvise-cqq), ws-server (improvise-q08), wasm client (wasm-client id), DOM renderer (dom-renderer id). No new logic — just glue and testing.\nGROOMED 2026-09-20 after wave 1. The acceptance criteria are met and the evidence is better than the one the ticket asked for.\n\nCriterion (7) was 'video or screenshot of the demo attached to the issue'. What exists instead: crates/improvise-wasm-client/tests/browser_round_trip.rs drives the client's own serialized frames through a REAL Host over a REAL App, and harness/tests/shell.spec.js renders web/app.js under Chromium and asserts the grid draws, a header reads Revenue, an editable cell exists, and click-to-edit commits and shows the host's value. A screenshot proves a moment; these prove the path, in CI.\n\n(1)-(4) and (6) are covered by those two. (5), 'scrolling fetches cells for the new viewport', is covered in principle by Client::set_viewport and the GridExtent work in PR #23, but NOT by an executed test -- web/demo.improv is too small to scroll (improvise-plcx).\n\nThe design field's reference to bank-info.improv was corrected 2026-09-20; that file was deleted as unloadable (improvise-odbc) and roadmap.org:194 is generated from this field.\nVERIFICATION PASS, wave 2 WU-P, 2026-09-20 at 52507a6, criterion by criterion.\nConfirms the earlier annotation on (5) and SHARPENS (2) and (6), which it\nrecorded as met by evidence that does not in fact reach them.\n\n(1) ws-server SERVES A FILE FROM A CLI ARG AND ACCEPTS A WEBSOCKET — MET.\n crates/improvise-ws-server/tests/socket.rs is the one test on a real\n socket: handshake, JSON framing, SessionId routing and which document a URL\n asked for. crates/improvise-ws-server/src/main.rs is the clap entry.\n\n(2) BROWSER LOADS THE PAGE, WASM INITIALISES, GRID RENDERS — MET, but note\n WHICH DEPLOYMENT. harness/tests/shell.spec.js's \"the page draws the demo\n document out of the worker\" runs web/app.js under Chromium and asserts a\n table, a \"Revenue\" header and an editable cell — over the IN-PAGE WORKER,\n not over a ws-server. The four `?ws=` specs use mocked peers\n (`routeWebSocket`), so no test in this repository points a real browser at\n a real improvise-ws-server. Since improvise-xks6 both deployments speak one\n frame pair and `Host::handle` is one implementation, so the untested\n difference is the transport alone — but that is an argument, not a run, and\n the earlier annotation did not say so.\n\n(3) ARROW KEYS MOVE THE CURSOR LOCALLY WITH NO ROUND-TRIP — MET.\n crates/improvise-wasm-client/tests/lockstep.rs::\n a_locally_resolved_cursor_move_lands_where_the_host_would_put_it, plus\n a_locally_resolved_jump_to_the_last_row_agrees_with_the_host and\n a_client_with_no_window_answers_no_cursor_key for the edges. `resolve`\n returns KeyOutcome::Local, which is by construction the no-host path.\n\n(4) TYPING A NUMBER + ENTER ROUND-TRIPS AND THE NEW VALUE APPEARS — MET twice,\n at two levels. crates/improvise-wasm-client/tests/browser_round_trip.rs::\n a_browser_client_seeds_types_a_number_and_sees_it_come_back drives the\n client's own serialized frames through a real Host over a real App;\n shell.spec.js's \"committing an edit sends one frame and shows the host's\n value\" does it in Chromium and asserts the HOST's formatting (\"20,000\"),\n not the string typed.\n\n(5) SCROLLING FETCHES CELLS FOR THE NEW VIEWPORT — NOT MET BY AN EXECUTED\n TEST, and this is what keeps the ticket open. browser_round_trip.rs::\n a_resize_reprojects_the_grid_at_the_new_extent covers a RESIZE, which is\n the extent changing at a fixed offset; scrolling is the offset moving,\n which is where gotcha 47's \"the window is the client's own fact\" and the\n GridExtent arithmetic actually bite. web/demo.improv is too small to\n scroll, so the fixture is the blocker: improvise-plcx, an open dependency\n of this ticket, is what closes it.\n\n(6) RELOAD RECOVERS CLEANLY FROM CURRENT SERVER STATE — PARTIALLY MET, and\n less than the earlier annotation claimed. What is tested is the SEED: a\n fresh client's handshake and Snapshot, in browser_round_trip.rs. What is\n not tested anywhere is a browser actually reloading and coming back onto a\n document that has moved since — the reload half of the OPFS story is\n covered at the STORAGE layer by harness/tests/opfs.spec.js, which drives\n the storage contract inside a real Worker and never goes through app.js.\n\n(7) VIDEO OR SCREENSHOT ATTACHED TO THE ISSUE — NOT MEETABLE AS WRITTEN (bd\n has no attachments) and superseded, on the same argument as improvise-bck's\n criterion 4.\n\nSO: this ticket's remaining content is (5) via improvise-plcx. (2) and (6) are\nmet well enough to ship and their gaps are named above rather than rounded off.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:24:44Z","created_by":"spot","updated_at":"2026-09-20T18:02:18Z","labels":["area:epic"],"dependencies":[{"issue_id":"improvise-1ey","depends_on_id":"improvise-cqq","type":"blocks","created_at":"2026-08-30T10:08:33Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-1ey","depends_on_id":"improvise-cr3","type":"blocks","created_at":"2026-04-14T00:25:43Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-1ey","depends_on_id":"improvise-plcx","type":"blocks","created_at":"2026-09-20T16:20:27Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-1ey","depends_on_id":"improvise-q08","type":"blocks","created_at":"2026-04-14T00:25:43Z","created_by":"spot","metadata":"{}"}],"dependency_count":4,"dependent_count":1,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-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}
|
||
{"_type":"issue","id":"improvise-cqq","title":"Server-side projection emission layer + per-client viewport tracking","description":"After each command is applied on the server, walk the resulting effect list, identify model-touching effects, compute which connected clients have the affected cells in their viewport, and enqueue projection commands (CellsUpdated, ColumnLabelsChanged, etc.) for dispatch to those clients. Server must track per-client viewport state (visible row/col range) and mirror each client's ViewState for this to work.","design":"Server holds HashMap\u003cSessionId, ClientSession\u003e where ClientSession { view: ViewState, visible_region: (row_range, col_range) }. When a client dispatches a command, update its session's view state via the shared reduce_view. When the command affects Model, walk the effect list: for each ModelTouching effect, compute which cells changed, intersect with each client session's visible_region, emit CellsUpdated commands into that session's outbound queue. ScrollTo commands update visible_region and trigger a fresh CellsUpdated for the newly-visible region. Viewport intersection logic: a cell is visible if its row/col position (computed from layout) lies within the client's visible range.","acceptance_criteria":"(1) Server struct ClientSession tracks view state and visible region per connected client. (2) After effect application, a projection step computes Vec\u003c(SessionId, Command)\u003e to dispatch. (3) ScrollTo updates visible region and triggers a fresh render-cache fill. (4) Unit tests: dispatching a SetCell produces CellsUpdated only for clients whose viewport includes the cell. (5) Unit tests: ScrollTo produces a CellsUpdated covering the new visible region.","notes":"Depends on improvise-mae (effect classification) and improvise-protocol (shared types). Does not yet wire up the actual websocket transport — that's a separate issue. This is the logical projection layer.\n--- DECIDED 2026-09-19 (user) ---\nRenderCache is PER-WIDGET, and state synchronizes by EVENTS, not snapshots.\n\nThis is shape (C) from improvise-rkeq, with per-widget caches:\n * No flat grid-only wire type and no fat aggregate container. Each widget\n owns its own cache type (GridCache, CategoryTreeCache, FormulaCache,\n ViewCache, TileBarCache, WizardCache, HelpCache).\n * Each widget subscribes to and FOLDS its own stream of projection\n Commands. There is no aggregate RenderCache on the wire.\n * The ONE exception is the initial state transfer: a newly connected\n client gets a Snapshot to seed from, then follows the event stream.\n Snapshot stays bootstrap-only (wire_carries(snapshot,bootstrap_only)).\n\nConsequence for this ticket: read \"RenderCache\" as \"that widget's own cache\ntype\", and expect updates to arrive as folded projection commands rather\nthan as a rebuilt snapshot.\n\n--- IMPLEMENTATION LANDED 2026-09-19; left OPEN because it is blocked by improvise-g0f ---\nAll five ACs are met in crates/improvise-command/src/ui/project.rs (861 lines,\n9 tests). Not closed: bd reports this blocked by g0f (may two clients view\ndifferent views?) and gxi (ACs 4-5, itself blocked). g0f is a user decision and\nnow has teeth -- the per-client ClientSession makes divergence expressible.\n\nDesign, for whoever picks this up:\n* The producer decides what changed by DIFF, not by asking effects. It rebuilds\n each widget's desired state from the App and diffs against a per-client\n mirror. Making every effect report its touched cells would be a second\n description of the same mutation, free to drift from the first.\n* EffectKind is the GATE, not the answer: Dirty::of(kind) is an exhaustive\n match, so a new ModelChange variant fails the build. Over-reporting costs\n work; under-reporting is the bug.\n* Only the grid keeps a mirror -- every other cache replaces wholesale, so\n Dirty is that widget's whole decision. That also splits the two APIs:\n document-scoped projections come from reduce_full, the viewport-dependent\n grid from Sessions::project_all.\n* grid_updates emits RowLabels/ColLabels/ColWidths BEFORE Cells and diffs the\n cells against fold(mirror, label_updates) rather than against mirror, so\n emission is exact through reshapes and never needs Invalidated.\n* Viewport extent is host-reported (ClientSession::set_extent), not inferred:\n offsets already ride the command stream, but how many rows fit is a terminal\n fact no derived Command carries, and inventing a wire variant for it would\n break command_set_derived_not_invented.\n* Bootstrap shares the steady-state path: connect resets the mirror and\n projects Dirty::EVERYTHING, so a Snapshot seed is the diff-from-nothing.\nAC wording deviation: there is no ScrollTo in the derived Command set;\nSetRowOffset/SetColOffset do that job.\n15 tests, each proven to fire by injection; two injections that did NOT fire\nwere treated as test weaknesses and the tests rewritten rather than accepted.","status":"closed","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:23:12Z","created_by":"spot","updated_at":"2026-09-19T23:29:48Z","closed_at":"2026-09-19T23:29:48Z","close_reason":"Implementation landed in wave 2 (crates/improvise-command/src/ui/project.rs), reviewed, three HIGH bugs fixed, merged to main. All five ACs met. Design recorded in this ticket and in gotchas 33/35/36/39 plus doctrine(lockstep_binds_every_session_not_the_asker). Blockers now all resolved: g0f decided by the user, mae and 8uz closed, gxi re-scoped and closed. Closing unblocks improvise-1ey.","dependencies":[{"issue_id":"improvise-cqq","depends_on_id":"improvise-8uz","type":"blocks","created_at":"2026-08-30T10:08:34Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-cqq","depends_on_id":"improvise-cqi","type":"blocks","created_at":"2026-04-14T00:25:39Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-cqq","depends_on_id":"improvise-g0f","type":"blocks","created_at":"2026-09-01T02:02:48Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-cqq","depends_on_id":"improvise-gxi","type":"blocks","created_at":"2026-04-14T00:53:45Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-cqq","depends_on_id":"improvise-mae","type":"blocks","created_at":"2026-04-14T00:25:38Z","created_by":"spot","metadata":"{}"}],"dependency_count":5,"dependent_count":4,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-cqi","title":"Extract improvise-protocol crate (Command, ViewState, reduce_view)","description":"Create a new crate that holds the types shared between the server and the wasm client: Command enum (the wire format), ViewState struct, render cache types (CellDisplay, visible region), and the reduce_view function. Depends on improvise-core so it can reference CellKey, CellValue, AppMode. Serde on everything that crosses the wire. This is the load-bearing crate for the browser epic.","design":"pub enum Command { /* user-initiated */ MoveCursor(Delta), EnterMode(AppMode), AppendMinibufferChar(char), ScrollTo { row, col }, SetCell { key: CellKey, value: CellValue }, AddCategory(String), ... /* server→client projections */ CellsUpdated { changes: Vec\u003c(CellKey, CellDisplay)\u003e }, ColumnLabelsChanged { labels: Vec\u003cString\u003e }, ColumnWidthsChanged { widths: Vec\u003cu16\u003e }, RowLabelsChanged { labels: Vec\u003cString\u003e }, ViewportInvalidated, ... }. pub fn reduce_view(vs: \u0026mut ViewState, cache: \u0026mut RenderCache, cmd: \u0026Command) — pattern-matches the command and applies view-slice effects + render-cache updates. Client imports this directly. Server imports Command + ViewState so it can serialize wire messages and maintain a mirror of each client's view state for projection computation.","acceptance_criteria":"(1) New crate crates/improvise-protocol/ exists. (2) Depends only on improvise-core, improvise-formula (if needed for anything), and serde. No ratatui, no crossterm. (3) Command, ViewState, RenderCache, CellDisplay all implement Serialize+Deserialize. (4) reduce_view has unit tests for view effect application and cache updates. (5) Crate builds for both host target and wasm32-unknown-unknown.","notes":"Depends on improvise-vb4 (ViewState must exist as a distinct type first) and on crate-epic step 2 (improvise-36h — improvise-core must be extracted so protocol can depend on it without pulling ratatui).\nNote (2026-08-29): acceptance criterion 5 (builds for wasm32-unknown-unknown) is not verifiable in the current dev environment — no wasm32 std in the Nix toolchain and no rustup. See improvise-ywd notes. The rest of this ticket is host-verifiable.\nCORRECTION (2026-08-29): the DESIGN section's justification for ViewState serialization is wrong, and misleading in a way that would invert the architecture.\n\nIt says: 'Server imports Command + ViewState so it can serialize wire messages and maintain a mirror of each client's view state.' The server does NOT serialize ViewState to maintain the mirror. Per improvise-cqq, it updates each session's view state 'via the shared reduce_view' — i.e. by applying the same commands the client applies. ViewState is a PROJECTION of the command stream, not a wire message. improvise-ltq (commands both directions) and improvise-m91 ('a command log is a deterministic replay') say the same.\n\nViewState: Serialize is still needed, but for exactly one reason: improvise-q08's bootstrap — 'On connect: create App + ClientSession, send initial Snapshot (full ViewState + render cache filled from current viewport)'. A client that never saw the earlier stream needs a value to fold onto. Snapshot-then-stream, not state-on-every-change.\n\nConsequences for whoever picks this up:\n- AC 3 stands, but do not build a server that ships ViewState on change. Steady-state traffic is Commands only.\n- RenderCache is local, rebuilt by applying projection commands; it needs Serialize only to ride in that same bootstrap Snapshot.\n- Because the only serialization point is bootstrap, host-only fields must NOT be in ViewState: a fresh client should not inherit a half-open import wizard, a pending prefix key, or staged drill edits.\n\nBLOCKER FOUND (2026-08-29): ViewState as it stands cannot live in this crate at all. crates/improvise-command/src/ui/app.rs:381 holds transient_keymap: Option\u003cArc\u003cKeymap\u003e\u003e. Keymap is in improvise-command, which will depend on improvise-protocol (improvise-gxi puts reduce_full there, taking \u0026Command) — a hard dependency cycle, independent of serde. wizard: Option\u003cImportWizard\u003e is a softer version (improvise-io is acyclic but outside AC 2's allowlist); drill_state and AppMode simply lack derives.\n\nProposed fix for the hard one, per the maintainer: flip the Arc. The transient keymap is always the sub-keymap of a Binding::Prefix at a specific (mode, key, mods), set during dispatch and consumed on the next keypress. ViewState can hold that address — Option\u003c(KeyCode, KeyModifiers)\u003e — and let KeymapSet, which already owns every keymap, resolve it. Both consumers can: App::handle_key has keymap_set and the mode; draw.rs only needs binding_hints() for the which-key popup. Nothing between set and consume changes the mode, since a Prefix binding emits SetTransientKeymap and nothing else.","status":"closed","priority":2,"issue_type":"feature","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-14T07:23:10Z","created_by":"spot","updated_at":"2026-09-01T09:03:56Z","started_at":"2026-09-01T08:55:39Z","closed_at":"2026-09-01T09:03:56Z","close_reason":"crates/improvise-protocol created. AC(1) crate exists; AC(2) depends only on improvise-core + serde, verified by cargo tree (no ratatui/crossterm/tokio) and by the CI wasm job; AC(3) Command/ViewState/RenderCache/CellDisplay all Serialize+Deserialize with a per-variant JSON round-trip test; AC(4) reduce_view has 12 tests covering view application, cache updates, out-of-range projections, and determinism over a repeated stream; AC(5) builds for host and wasm32, added to the CI wasm job.\n\nCommand's client-\u003eserver half was derived, not invented: it is the set of ViewOnly effects targeting the portable slice, which improvise-h0b had already separated from host-local state.\n\nOne addition beyond a pure move: ViewState::active_view, because reduce_view runs on a client with no Workbook and cursors is keyed by view name. Two writers only (App::switch_to_view, App::install_workbook), guarded by the_two_active_view_names_never_diverge. Whether the two copies may diverge is improvise-g0f, which now blocks improvise-cqq.\n\nFollowed the 2026-08-29 correction on the ticket: ViewState is a projection of the command stream, serialized only for the bootstrap Snapshot. Nothing here ships ViewState on change.","dependencies":[{"issue_id":"improvise-cqi","depends_on_id":"improvise-36h","type":"blocks","created_at":"2026-04-14T00:25:38Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-cqi","depends_on_id":"improvise-a2x","type":"blocks","created_at":"2026-08-30T15:56:13Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-cqi","depends_on_id":"improvise-h0b","type":"blocks","created_at":"2026-08-29T20:48:44Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-cqi","depends_on_id":"improvise-jc8","type":"blocks","created_at":"2026-08-29T20:44:42Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-cqi","depends_on_id":"improvise-vb4","type":"blocks","created_at":"2026-04-14T00:25:37Z","created_by":"spot","metadata":"{}"}],"dependency_count":5,"dependent_count":6,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-mae","title":"Tag existing effects as model / view / projection-emitting","description":"Add a classification to every Effect so the server can decide which effects trigger projection-command emission to connected clients. Pure view effects (cursor move, mode change) don't project. Model effects (SetCell, AddCategory, AddFormula) do project — they emit CellsUpdated or similar commands to clients whose viewport includes the change. Prerequisite for the server-side projection emission layer.","design":"Add a method to Effect like fn kind(\u0026self) -\u003e EffectKind where EffectKind is { ModelTouching, ViewOnly }. If Step 4 of crate epic (Effect→enum) has landed, this is a const match on variant. If not, each struct impls a kind() method. ModelTouching effects are further classified by what they change, so projection computation knows what kind of command to emit: { CellData, CategoryShape, ColumnLabels, RowLabels, FormulaResult, LayoutGeometry }.","acceptance_criteria":"(1) Every existing Effect has a kind classification. (2) A const/fn on Effect returns the kind without running apply. (3) Unit tests verify classification for each effect. (4) Audit notes list every effect and its kind.","notes":"Does not depend on Effect-as-enum refactor (crate epic step 4) but is much cleaner if that's landed. Depends on the AppState split so effects that cross slices are clearly cross-cutting.\nAUDIT (AC 4) — all 58 effects and their kinds, as of the closing commit:\n\nCellData (12): AddCategory, AddFormula, AddItem, AddItemInGroup, ApplyAndClearDrill, CleanEmptyRecords, ClearCell, RemoveCategory, RemoveFormula, RemoveItem, SetCell, SortData\n\nLayoutGeometry (14): CreateView, CycleAxis, DeleteView, HideItem, SetAxis, SetNumberFormat, SetPageSelection, ShowItem, SwitchView, ToggleGroup, TogglePruneEmpty, TransposeAxes, ViewBack, ViewForward\n\nWholeWorkbook (3): AcceptWizardModel, ImportJsonHeadless, LoadModel\n\nViewOnly (29): AbortChain, ChangeMode, CloseWizard, EnterEditAtCursor, ExportCsv, HelpPageNext, HelpPagePrev, HelpPageSet, MarkDirty, RebuildLayout, Save, SaveAs, SetBuffer, SetColOffset, SetDrillPendingEdit, SetPanelCursor, SetPanelOpen, SetRowOffset, SetSearchMode, SetSearchQuery, SetSelected, SetStatus, SetTileCatIdx, SetTransientKeymap, SetYanked, StartDrill, StartImportWizard, ToggleCatExpand, WizardApply\n\nNotes on the non-obvious ones:\n- SetSelected/SetRowOffset/SetColOffset are ViewOnly despite writing the shared, persisted View: cursor and scroll are per-client. Residual inconsistency filed as improvise-8uz.\n- Save/SaveAs/MarkDirty touch model_state (dirty, file_path) but change nothing renderable -\u003e ViewOnly.\n- ExportCsv reads the model and writes a file; no state change -\u003e ViewOnly.\n- RebuildLayout rebuilds a derived cache -\u003e ViewOnly.\n- SortData reorders the data store, which changes records-mode row identity -\u003e CellData.\n- ApplyAndClearDrill writes cells through free-function helpers -\u003e CellData.\n- SetTransientKeymap lives in command/keymap.rs, not effect.rs.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-14T07:22:02Z","created_by":"spot","updated_at":"2026-08-29T20:03:28Z","started_at":"2026-08-29T19:57:52Z","closed_at":"2026-08-29T20:03:28Z","close_reason":"All 58 effects classified. EffectKind::{ViewOnly, Model(ModelChange)} with ModelChange::{CellData, LayoutGeometry, WholeWorkbook}. Full audit table in the notes (AC 4).\n\nkind() HAS NO DEFAULT IMPL. The design section offered a per-struct method; making it required instead means a new effect cannot forget to classify itself. That matters because the failure mode is silent — a mis-declared ViewOnly edit would simply never reach connected clients. It paid off immediately: the compiler found a 58th Effect impl, SetTransientKeymap, living in command/keymap.rs rather than effect.rs, which I would have missed classifying by eye.\n\nDERIVED THE CLASSIFICATION, THEN VERIFIED IT. I extracted what each apply() body touches and reasoned about it in Prolog rather than eyeballing 58 effects. Two things that came out of it:\n\n1. The naive signal is WRONG for 21 effects. The View is stored inside model_state.workbook, so 'touches model_state' does not mean 'is a model change'. SetAxis, SetPageSelection, ToggleGroup, HideItem, ShowItem, TransposeAxes, CycleAxis, SetNumberFormat, SwitchView, ViewBack, ViewForward, CreateView, DeleteView and TogglePruneEmpty are LayoutGeometry, not CellData; Save/SaveAs/MarkDirty touch model_state for bookkeeping only and are ViewOnly. A classification done by grepping for model_state would have had the projection layer broadcasting cell updates for cursor moves.\n\n2. My own extraction was wrong twice, and I caught it by disbelieving the output rather than by luck: multi-line method chains hid workbook.model.set_cell(), and free-function helpers hid ApplyAndClearDrill's writes, so SetCell, SortData, RemoveItem and others first came out ViewOnly — obviously false. Fixed the extraction (flatten chains) and re-derived, then read the remaining suspicious ones directly.\n\nTESTS VERIFY DECLARATIONS EMPIRICALLY, not by restating them (AC 3). Rather than a table asserting kind() == what kind() returns, the tests apply each effect to a probe App and fingerprint the shared state it claims not to touch: view_only_effects_leave_shared_state_alone (16 effects, model data and view config both unchanged), cell_data_effects_actually_change_model_data, layout_geometry_effects_actually_change_view_config. Verified they bite by mislabelling SetCell as ViewOnly — the exact silent-drop bug — which fails cell_data_effects_actually_change_model_data.\n\nJUDGEMENT CALL, recorded rather than buried: SetSelected/SetRowOffset/SetColOffset write the shared, persisted View but are per-client concerns — one client scrolling must not move another's viewport — so they are ViewOnly. They are the only ViewOnly effects that mutate shared workbook state, which the test has to special-case. Filed improvise-8uz to resolve that properly before improvise-cqq builds the projection layer on this.","dependencies":[{"issue_id":"improvise-mae","depends_on_id":"improvise-vb4","type":"blocks","created_at":"2026-04-14T00:25:36Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-vb4","title":"Split AppState into ModelState + ViewState (standalone refactor)","description":"Refactor the current App struct to make the model/view slice boundary explicit in types. Today App mixes Model with session state (mode, cursor, scroll, buffers, etc.); split them so that ModelState holds document state and ViewState holds per-session UI state. Server continues to own both, but as distinct fields. Valuable independently of the browser epic: cleaner persistence (only model slice serializes), cleaner undo (undo walks model effects only), better test boundaries, and it's the foundational slice separation every downstream issue depends on.","design":"pub struct ModelState { model: Model, file_path: Option\u003cPathBuf\u003e, dirty: bool }. pub struct ViewState { mode: AppMode, selected, row_offset, col_offset, search_query, search_mode, yanked, buffers, expanded_cats, help_page, drill_state_session_part, tile_cursor, panel_cursors }. App becomes { model_state: ModelState, view_state: ViewState, layout: GridLayout, ... }. Audit every App field and assign it. Gray zones: Model.views stays in Model (document state). drill_state staging map is session (View); commit flushes to Model. yanked is View. file_path/dirty are Model.","acceptance_criteria":"(1) App contains ModelState and ViewState as typed subfields. (2) No App field lives outside exactly one slice (or is explicitly called out as derived, like layout cache). (3) All existing tests pass. (4) Effect apply methods updated to take \u0026mut the correct slice where obvious, or \u0026mut App where cross-cutting. (5) Audit report in issue notes: every field classified.","notes":"Additional implementation note merged from superseded improvise-dwe: a small number of effects today read App-level/layout-derived state at apply time (e.g. EnterEditAtCursor reads display_value from layout). For these, pre-compute the derived value in the command's Cmd::execute (where CmdContext has layout access) and bake it into the effect struct's own fields. Apply bodies then become pure model/view state writes. Example: EnterEditAtCursor { target_mode, initial_value: String } with initial_value computed in execute and consumed by apply.\n\n---\n\n## Field-by-field audit (improvise-97o)\n\nRefactor landed across 4 commits: f11d79f (3vr scaffold), 917b928 (x2c\nModelState fields), 9ad8abd (ew0 ViewState fields), 7249fac (99k residue\ndocs). All 29 original App fields classified below.\n\n| Field | Slice | Why |\n|---|---|---|\n| workbook | ModelState | Document semantics (Workbook = Model + named views + active view). |\n| file_path | ModelState | Path on disk for save/load — IO bookkeeping. |\n| dirty | ModelState | Divergence from on-disk state — IO bookkeeping. |\n| mode | ViewState | Current AppMode (Normal / Editing / etc.). |\n| status_msg | ViewState | Transient status-line text. |\n| wizard | ViewState | Optional import wizard, present only in import flow. |\n| search_query | ViewState | Current search string. |\n| search_mode | ViewState | True while typing into the search prompt. |\n| formula_panel_open | ViewState | Formula side panel toggle. |\n| category_panel_open | ViewState | Category side panel toggle. |\n| view_panel_open | ViewState | View side panel toggle. |\n| cat_panel_cursor | ViewState | Selected row in category panel. |\n| view_panel_cursor | ViewState | Selected row in view panel. |\n| formula_cursor | ViewState | Selected formula in formula panel. |\n| yanked | ViewState | Last `y`-yanked CellValue for paste. |\n| tile_cat_idx | ViewState | Highlighted tile in tile-select mode. |\n| view_back_stack | ViewState | View navigation history (back). |\n| view_forward_stack | ViewState | View navigation history (forward). |\n| drill_state | ViewState | Frozen records snapshot + staging edits for drill view. |\n| help_page | ViewState | Current page in help overlay. |\n| expanded_cats | ViewState | Categories expanded in the panel tree. |\n| buffers | ViewState | Named text-entry buffers (edit, formula, command, ...). |\n| transient_keymap | ViewState | Emacs-style prefix key chord state. |\n| layout | App-derived | Pure function of model_state.workbook + view_state.drill_state + active view; rebuilt via rebuild_layout(). |\n| last_autosave | App-runtime | Wall-clock instant for autosave debounce. |\n| term_width | App-runtime | Terminal width, updated on resize. |\n| term_height | App-runtime | Terminal height, updated on resize. |\n| abort_effects | App-transient | Flag scoped to a single apply_effects call; reset on entry. |\n| keymap_set | App-config | Keymap configuration loaded once at startup. |\n\nCounts: ModelState 3, ViewState 20, App-derived 1, App-runtime 3, App-transient 1, App-config 1 = 29 total.\n\nimprovise-drg (step 5: narrow Effect::apply signatures) was deferred —\nthe issue's framing has a hidden trait-design decision (you can't change\none impl's signature without changing the trait). Three options floated\n(split into ModelEffect/ViewEffect/AppEffect, body-level rebind, or\ndefault-method dispatch); none picked yet. vb4 does not depend on drg.","status":"closed","priority":2,"issue_type":"feature","assignee":"jeeves","owner":"el-github@elangley.org","created_at":"2026-04-14T07:21:59Z","created_by":"spot","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-05-03T04:16:32Z","close_reason":"Audit report written into vb4 notes per criterion (5). All 29 original App fields classified into ModelState (3) / ViewState (20) / App-derived (1) / App-runtime (3) / App-transient (1) / App-config (1). vb4 acceptance criteria all met: (1) typed model_state + view_state subfields ✓ (2) no overlap ✓ (3) tests pass ✓ (4) Effect::apply still \u0026mut App per criterion (4)'s allowance for cross-cutting; impl-level narrowing deferred to improvise-drg (step 5) which is P3/optional. Refactor commits: f11d79f, 917b928, 9ad8abd, 7249fac.","dependencies":[{"issue_id":"improvise-vb4","depends_on_id":"improvise-97o","type":"blocks","created_at":"2026-04-26T11:17:18Z","created_by":"jeeves","metadata":"{}"}],"dependency_count":1,"dependent_count":3,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-6jk","title":"Epic: Browser frontend via synchronized Redux-style stores","description":"Build a browser frontend for improvise by treating the client as a thin Redux-style peer of the existing server. Commands are the wire format; each side runs a reducer over its own state slice. Server is structurally unchanged (still runs today's full App with ratatui session, effect pipeline, formula eval). Client is a new thin peer: ViewState + render cache + reduce_view + keymap + DOM renderer. Commands flow both directions — upstream (user intent resolved client-side) and downstream (projection commands emitted by the server after model-touching effects). See child issues for sequenced steps.","design":"Client holds ViewState (mode, cursor, scroll, minibuffer, search, yanked, expanded cats, drill buffer, panel cursors) plus a render cache (visible cells, labels, col widths). Server holds full App as today. Wire type: Command enum, serde over websocket. Upstream: user-initiated commands resolved by client-side keymap. Downstream: projection commands (CellsUpdated, ColumnLabelsChanged, etc.) emitted by server after effect application. Client reduce_view pattern-matches commands and applies view-slice effects; client never runs formula eval or touches Model. Server emits projections by walking the effect list after each command and computing per-viewport deltas per connected client. Effect split (model vs view) is a server-internal tag driving projection emission — never crosses the wire. Key→command resolution is client-side (keymap in wasm bundle) for local responsiveness.","acceptance_criteria":"Closes when improvise-1ey does. 1ey's criteria (1)-(4) and (6) are met and evidenced better than the ticket asked — browser_round_trip.rs drives real frames through a real Host, and harness/tests/shell.spec.js renders web/app.js under Chromium. (5), 'scrolling fetches cells for the new viewport', is covered in principle by set_viewport and GridExtent but NOT by an executed test, because web/demo.improv is too small to scroll — improvise-plcx is what would close that. Decide whether (5) needs the test before this epic closes.","notes":"Prereqs from crate-split epic (improvise-xgl): step 2 (improvise-core, improvise-36h) for shared types; step 5 (improvise-command split, improvise-3mm) so wasm client can import keymap + Command + reduce_view without ratatui. Step 4 (Effect enum, improvise-45v) useful but not strictly required. Target bundle: 200-500 KB compressed — formula layer stays server-side.\nCHECKED, wave 2 WU-P: STAYS OPEN, held by improvise-1ey, which is held by\nimprovise-plcx.\n\nThis epic's criteria asked to \"decide whether (5) needs the test before this\nepic closes\". The decision is YES, and the reason is that (5) is not the same\nquestion as the resize test that stands in for it. improvise-1ey's annotation\nnow says so: browser_round_trip.rs covers the EXTENT changing at a fixed\noffset, while scrolling is the OFFSET moving, which is exactly where gotcha\n47's \"the window is the client's own fact\" and the GridExtent arithmetic bite\n— the row frame's own length is the window clipped to the grid and so differs\nprecisely at the bottom edge. Standing on the resize test would be counting a\ncriterion as met by a test that cannot see it, which is the defect shape this\nrepo names most often.\n\nimprovise-plcx supplies the fixture that makes the test possible.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:21:21Z","created_by":"spot","updated_at":"2026-09-20T18:05:07Z","labels":["area:epic"],"dependencies":[{"issue_id":"improvise-6jk","depends_on_id":"improvise-1ey","type":"blocks","created_at":"2026-04-14T00:26:00Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-6jk","depends_on_id":"improvise-cqi","type":"blocks","created_at":"2026-04-14T00:25:57Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-6jk","depends_on_id":"improvise-cqq","type":"blocks","created_at":"2026-04-14T00:25:58Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-6jk","depends_on_id":"improvise-cr3","type":"blocks","created_at":"2026-04-14T00:26:00Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-6jk","depends_on_id":"improvise-gsw","type":"blocks","created_at":"2026-04-14T00:25:59Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-6jk","depends_on_id":"improvise-mae","type":"blocks","created_at":"2026-04-14T00:25:57Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-6jk","depends_on_id":"improvise-q08","type":"blocks","created_at":"2026-04-14T00:25:58Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-6jk","depends_on_id":"improvise-vb4","type":"blocks","created_at":"2026-04-14T00:25:56Z","created_by":"spot","metadata":"{}"}],"dependency_count":8,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-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}
|
||
{"_type":"issue","id":"improvise-36h","title":"Step 2: Break Model↔View cycle and extract improvise-core crate","description":"Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap\u003cString,View\u003e, and view/layout.rs reads \u0026Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.","design":"Introduce pub struct Workbook { pub model: Model, pub views: IndexMap\u003cString, View\u003e, pub active_view: String, pub measure_agg: HashMap\u003cString, AggFunc\u003e }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a \u0026View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.","acceptance_criteria":"(1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).","notes":"This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.","status":"closed","priority":2,"issue_type":"feature","assignee":"spot","owner":"el-github@elangley.org","created_at":"2026-04-14T06:35:05Z","created_by":"spot","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-16T05:50:39Z","close_reason":"Phase A + Phase B complete on worktree-improvise-ewi-formula-crate. Model↔View cycle broken via Workbook wrapper (pure-data Model with view state lifted to Workbook); improvise-core sub-crate extracted containing model/, view/, workbook.rs, format.rs; depends only on improvise-formula; builds standalone via 'cargo build -p improvise-core'. All 616 tests pass, clippy clean, persistence round-trips intact.","dependencies":[{"issue_id":"improvise-36h","depends_on_id":"improvise-ewi","type":"blocks","created_at":"2026-04-13T23:54:38Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":3,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-ewi","title":"Step 1: Set up Cargo workspace and extract improvise-formula crate","description":"Convert the repo to a Cargo workspace and pull formula/ (parser, AST, Expr/BinOp/AggFunc/Formula) out into a standalone improvise-formula crate. This is the warm-up step — formula/ has zero local deps so extraction is mechanical. Proves the workspace plumbing before tackling the structural work.","acceptance_criteria":"(1) Root Cargo.toml becomes a workspace. (2) crates/improvise-formula/ contains everything from src/formula/ and compiles standalone. (3) improvise (the main crate) depends on improvise-formula and re-exports or uses it at crate::formula paths. (4) All existing tests pass. (5) cargo build and cargo test work from the workspace root.","notes":"Touchpoints: src/formula/{ast,parser,mod}.rs move; src/model/types.rs (uses AggFunc, Formula) and src/persistence/mod.rs and src/import/wizard.rs update imports. No logic changes.","status":"closed","priority":2,"issue_type":"task","assignee":"spot","owner":"el-github@elangley.org","created_at":"2026-04-14T06:34:50Z","created_by":"spot","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-15T10:09:14Z","close_reason":"Workspace set up, improvise-formula extracted as standalone sub-crate under crates/. Root crate re-exports as crate::formula via 'pub use improvise_formula as formula;' so existing paths unchanged. 35 formula tests run standalone in the sub-crate, 537 tests run in the root crate (572 total, matching pre-refactor). cargo build, cargo test --workspace, and cargo clippy --workspace all clean.","dependency_count":0,"dependent_count":2,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-xgl","title":"Epic: Split improvise into enforced-boundary workspace crates","description":"Convert the single improvise crate into a Cargo workspace of 5 crates so that module boundaries become compile-enforced rather than convention. Today nothing stops model/types.rs from reaching into ui::app; the goal is to make that a compile error. Target shape: improvise-formula (leaf) → improvise-core (model+view+format) → improvise-io (persistence+import) → improvise-command → improvise-tui (bin). See child issues for sequenced steps. The expensive work is breaking the Model↔View cycle and decoupling Effect from \u0026mut App — those are features, not costs.","design":"Target crate graph: improvise-formula (no deps) ← improvise-core (model+view+format) ← improvise-io (persistence+import) ← improvise-command ← improvise-tui. Two structural obstacles: (1) Model owns views: IndexMap\u003cString,View\u003e, creating a model↔view cycle — fix by moving views out into a Workbook wrapper. (2) Effect::apply takes \u0026mut App, so command transitively depends on App — fix by converting Effect to an enum with apply(app, effect) in the tui crate. Prefer the enum over a trait-based EffectTarget: effects become loggable/replayable (already gestured at in design-principles §1) and it matches the existing enum-heavy style (Binding, Axis, CategoryKind, BinOp).","notes":"Order matters: do the formula extraction first as a warm-up, then core, then io, then the Effect enum conversion in-place (no crate change), then finally the command/tui split. Steps 1-3 are mostly mechanical; step 4 is the real semantic work; step 5 should mostly just work after 4 lands.","status":"closed","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T06:33:09Z","created_by":"spot","updated_at":"2026-07-31T05:57:04Z","closed_at":"2026-07-31T05:57:04Z","close_reason":"All 5 sequenced steps landed: formula/core/io/command crates extracted, Effect converted to enum, Model↔View cycle broken via Workbook. Boundaries now compile-enforced.","dependencies":[{"issue_id":"improvise-xgl","depends_on_id":"improvise-36h","type":"blocks","created_at":"2026-04-13T23:55:04Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-xgl","depends_on_id":"improvise-3mm","type":"blocks","created_at":"2026-04-13T23:55:06Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-xgl","depends_on_id":"improvise-45v","type":"blocks","created_at":"2026-04-13T23:55:05Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-xgl","depends_on_id":"improvise-8zh","type":"blocks","created_at":"2026-04-13T23:55:04Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-xgl","depends_on_id":"improvise-ewi","type":"blocks","created_at":"2026-04-13T23:55:03Z","created_by":"spot","metadata":"{}"}],"dependency_count":5,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-s0h","title":"'o' (add-record-row) broken in fresh data models","description":"Pressing 'o' in records mode to add a new record row doesn't work correctly with fresh data models. The keybinding exists (add-record-row + enter-edit-at-cursor sequence) but the behavior is broken. Needs investigation to reproduce and fix.","status":"closed","priority":2,"issue_type":"bug","assignee":"edwlan","owner":"el-github@elangley.org","created_at":"2026-04-09T22:18:58Z","created_by":"spot","updated_at":"2026-04-26T17:03:28Z","closed_at":"2026-04-26T17:03:28Z","close_reason":"Fixed in 1cea06e fix(records): allow adding rows in empty records view (2026-04-14), with followups 9710fb5 (synthetic-records direct commit) and 38f83b2 (_Measure column in records mode). Regression tests in src/ui/app.rs all pass: add_record_row_in_empty_records_view_creates_first_row, edit_record_row_in_blank_model_persists_value, leaving_records_mode_cleans_empty_key_cells. Previous assignee cursor-f4c497bb shipped the fix but left the issue open.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-32r","title":"Drill into formula cell shows empty view","description":"Drilling into a cell whose value comes from a formula (e.g. Profit = Revenue - Cost) enters _Drill view with 0 rows. Formula cells have no raw data backing them, so the drill finds nothing. Should either show the constituent data rows that feed the formula, or display a meaningful message like 'Formula cell — no raw data'.","status":"closed","priority":2,"issue_type":"bug","assignee":"spot","owner":"el-github@elangley.org","created_at":"2026-04-09T22:03:59Z","created_by":"spot","updated_at":"2026-04-14T06:35:38Z","closed_at":"2026-04-14T06:35:38Z","close_reason":"Strip formula target from drill key so matching_cells finds raw data. Test: drill_into_formula_cell_returns_data_records","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-gmx","title":"Bug: tile bar doesn't scroll when cursor moves offscreen","description":"When there are more category tiles than fit in the tile bar width, moving the cursor past the visible area doesn't scroll. The selected tile is simply not rendered. Found during demo recording with 8 categories.","status":"closed","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T08:37:04Z","created_by":"Edward Langley","updated_at":"2026-04-09T08:38:35Z","closed_at":"2026-04-09T08:38:35Z","close_reason":"Tile bar now auto-scrolls to keep selected tile visible, with ◀▶ overflow indicators.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-4ig","title":"Stress-test .improv parser for double quotes and commas","description":"Write targeted tests exposing bugs in the .improv parser around: embedded quotes in text values, commas in category/item names, commas in text values, escaped quotes, and round-trip fidelity for edge-case data.","notes":"Found 4 bugs:\n1. Newlines in text values break line-based parser (format_md writes newlines literally)\n2. Category names containing ', ' break coordinate split(\", \") parsing \n3. Item names containing '[...]' are misinterpreted as group syntax by parse_bracketed\n4. Extreme floats (subnormal numbers) don't round-trip through 4-decimal display format\n\nEmbedded double quotes actually work by coincidence (strip_prefix/strip_suffix only remove one char).\n\nTests added to src/persistence/mod.rs: 15 targeted unit tests + 5 proptest property tests (500 cases each).","status":"closed","priority":2,"issue_type":"bug","assignee":"spot","owner":"el-github@elangley.org","created_at":"2026-04-09T07:29:50Z","created_by":"spot","updated_at":"2026-04-09T09:01:59Z","closed_at":"2026-04-09T09:01:59Z","close_reason":"Fixed 6 parser bugs (newlines, commas in names, brackets in names, float precision, view name ambiguity, group brackets). Rewrote format: v2025-04-09 version line, Initial View, pipe quoting, Views→Formulas→Categories→Data order, comma-separated items. Added pest grammar, grammar-driven generator, 83 persistence tests including property tests.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-79u","title":"Bug: CommitFormula targets virtual category on empty model","description":"CommitFormula uses category_names().first() to pick the formula target category. category_names() includes virtual categories (_Index, _Dim). On a model with no regular categories, formulas would be assigned to _Index — which is meaningless since virtual categories exist only for drill-down rendering. Should filter to regular categories only, or show an error like 'Add at least one category first.' Found during test audit of command/cmd.rs.","status":"closed","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T05:26:09Z","created_by":"Edward Langley","updated_at":"2026-04-09T06:38:06Z","closed_at":"2026-04-09T06:38:06Z","close_reason":"Added Model::regular_category_names() that filters out virtual categories. Updated CommitFormula and ImportPipeline::build_model to use it. Regression test confirms empty model shows 'Add at least one category first.'","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-aa9","title":"Test audit: import/wizard.rs at 60% coverage","description":"import/wizard.rs has 60% line coverage with significant untested paths in the ImportPipeline and ImportWizard. Pipeline creation, schema inference, and complex nested JSON structures need more test coverage.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T05:04:43Z","created_by":"Edward Langley","updated_at":"2026-04-09T05:53:16Z","closed_at":"2026-04-09T05:53:16Z","close_reason":"Added 23 wizard tests covering step transitions, cursor movement, proposal toggle/cycle, formula lifecycle, date config, preview summary, sample formulas, and edge cases. Coverage from 60% to 94%.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-2kg","title":"Test audit: ui/effect.rs at 18% coverage","description":"ui/effect.rs has 50+ effect types but only 18% line coverage. Most effects are thin apply() methods tested indirectly through app.rs integration tests. Complex effects like drill reconciliation and import deserve targeted unit tests per the testing guidelines.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T05:04:30Z","created_by":"Edward Langley","updated_at":"2026-04-09T05:36:48Z","closed_at":"2026-04-09T05:36:48Z","close_reason":"Added 41 direct effect tests covering model mutations, view navigation stacks, drill state (value edit, coord rename, clear), panels, toggles, search buffer special case, and more. Coverage from 18% to 75%.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-c02","title":"Test audit: command/cmd.rs at 52% coverage","description":"command/cmd.rs has 40+ commands but only 52% line coverage. Commands are pure functions (receive \u0026CmdContext, return Vec\u003cEffect\u003e) so they're easy to test. Many commands like add-category, add-item, set-cell, transpose, drill, export, and various panel commands lack tests. This is the highest bang-for-buck coverage improvement.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T05:04:01Z","created_by":"Edward Langley","updated_at":"2026-04-09T05:28:07Z","closed_at":"2026-04-09T05:28:07Z","close_reason":"Added 53 tests. Coverage from 52% to 75%. Also found bug: CommitFormula targets virtual categories on empty models (filed as improvise-79u).","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-abz","title":"2.5 Verify all Phase 2 artifacts exist and are committed","description":"Confirm: README.md with all 10 sections, docs/demo.gif under 5MB referenced from README, docs/demo.tape regenerates GIF, all 4 .cast files exist, example files exist with synthetic data only, flake.nix includes asciinema/vhs/cargo-dist, nix develop succeeds.","status":"closed","priority":2,"issue_type":"task","assignee":"spot","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:48Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-09T22:24:08Z","close_reason":"All Phase 2 artifacts verified: README, demo.gif (791KB), demo.tape, 4 casts at 120x37, example files, flake tooling, nix develop works","dependencies":[{"issue_id":"improvise-abz","depends_on_id":"improvise-d4w","type":"blocks","created_at":"2026-04-08T21:09:25Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-abz","depends_on_id":"improvise-pby","type":"blocks","created_at":"2026-04-08T21:09:25Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":2,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-d4w","title":"2.4 Record asciinema casts","description":"Record four .cast files under docs/casts/: import.cast (CSV import wizard), pivot.cast (axis reassignment), drill.cast (drill into aggregated cell), formulas.cast (add Profit formula). Terminal: 100x30. Use -i 2 flag. Each under 60 seconds. Add recording helper script.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:47Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-09T08:20:50Z","close_reason":"All four casts recorded: pivot, drill, formulas, import.","dependencies":[{"issue_id":"improvise-d4w","depends_on_id":"improvise-ihv","type":"blocks","created_at":"2026-04-08T21:09:23Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-d4w","depends_on_id":"improvise-n1h","type":"blocks","created_at":"2026-04-08T21:09:23Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":3,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-odx","title":"2.3 Create docs/demo.tape and generate docs/demo.gif","description":"Write VHS .tape file scripting a ~20-second pivot reassignment demo. Generate GIF via nix develop --command vhs docs/demo.tape. Must be under 5MB, show start in pivot view -\u003e T -\u003e axis reassigns -\u003e T -\u003e axis reassigns again. Iterate until readable.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:44Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-09T08:03:49Z","close_reason":"docs/demo.tape and docs/demo.gif created, LFS-tracked, wired into README.","dependencies":[{"issue_id":"improvise-odx","depends_on_id":"improvise-ihv","type":"blocks","created_at":"2026-04-08T21:09:21Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-odx","depends_on_id":"improvise-n1h","type":"blocks","created_at":"2026-04-08T21:09:22Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0}
|
||
{"_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-r0qa","title":"The evidence checker validates only heading-to-test, never test-to-evidence","description":"Found by adversarial review of PR #59, 2026-09-20, as the first case to walk into the gap.\n\nscripts/check-comment-runs.py --check-evidence validates ONE direction: every '### name' heading in context/test-evidence/ must name a test that exists. design-principles §7 states that choice deliberately — 'a test may have no evidence (most do not), but evidence naming a test nobody can find is a dangling explanation.'\n\nThe blind spot is the reverse. PR #59 added a Rust test whose doc comment says 'see context/test-evidence/clients.md', and that file has NO entry for the_drill_key_and_an_aggregate_edit_refuse_for_different_reasons. The checker passed (503 headings, all resolving). So the new assertion's proof lived only in the PR body — which is not in the repo, and which design-principles §6 now says is exactly where an argument must not live, because nothing can audit it.\n\nWHY THE ORIGINAL ASYMMETRY IS STILL RIGHT, and why this is a narrower fix than 'check both ways': most tests have no evidence and should not. The check is not 'every test has evidence' — it is 'a test that CITES evidence cites evidence that exists'. That is a different predicate and it has no false positives: a doc comment naming context/test-evidence/\u003cfile\u003e.md (and optionally a heading) is a claim, and the claim is checkable.\n\nNote the failure mode it closes is the one this repo keeps finding: a pointer that reads as a promise the thing exists. Same argument as a dead ticket ID, in another key.\n\n## Steps to Reproduce\n\n1. Add a Rust test whose doc comment cites context/test-evidence/clients.md for a section that does not exist.\n2. python3 scripts/check-comment-runs.py --check-evidence\n3. It passes.","acceptance_criteria":"A test whose doc comment cites context/test-evidence/ for a section that does not exist fails the checker. Demonstrated by pointing a test's doc comment at an absent heading and watching --check-evidence fail.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T20:39:27Z","created_by":"orchestrator","updated_at":"2026-09-20T20:39:27Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-0x9u","title":"A click cannot move the grid selection while an editor is open","description":"Found by adversarial review of the unreviewed pointer work (959f249), demonstrated in Chromium.\n\nweb/app.js:333-339 (cell click) plus edit()'s 'if (editing) return;' (~:354) and its blur -\u003e close(false) -\u003e repaint().\n\nDemonstrated: click cell 0 (editor opens, input present) -\u003e click cell 4 -\u003e 0 inputs, selection STILL AT INDEX 0. The mousedown on cell 4 blurs the input, close(false) calls repaint(), el.grid.replaceChildren(..) destroys the \u003ctd\u003e that received the mousedown, so no click event ever fires and click_cell is never called. The pointer is dead for that interaction; the user must Escape or click outside the grid first.\n\nSo the property the new spec names — 'a click moves the selection, not only the editor' — DOES NOT HOLD from an editing state, and pointer.spec.js:112 cannot see it because it presses Escape before asserting. That is the repo's characteristic find: a test that passes while the thing it names is broken in a neighbouring state.\n\nPre-existing in the edit()/repaint interaction rather than introduced by 959f249 — but now LOAD-BEARING, because the click is advertised as a selection move.\n\nRepair: defer the teardown (queueMicrotask/setTimeout(0) around repaint() in close), or use mousedown rather than click for the cell handler so it fires before the blur tears the table down. The regression test belongs in harness/tests/pointer.spec.js per gotcha 48.\n\n## Steps to Reproduce\n\n1. Load /app/index.html, click an editable cell — an editor opens.\n2. Without pressing Escape, click a different editable cell.\n3. The editor closes and the selection does not move.","acceptance_criteria":"Clicking a second cell while an editor is open moves the selection to it. A harness assertion in pointer.spec.js covers the editing state specifically, not only the post-Escape state.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T20:18:11Z","created_by":"orchestrator","updated_at":"2026-09-20T20:18:11Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-h04z","title":"Decide whether UI glyphs move to the protocol, as short_label() did","description":"web/app.js's indicator() carries the comment 'only this spelling is the page's (gotcha 26)', so 'the fact is shared, the glyph is the page's' is a DOCUMENTED decision, not an oversight. This ticket asks whether that decision is still right. It is filed rather than answered because overturning something written down deliberately is a product call.\n\nEVIDENCE, which is observed rather than argued. The convention has already failed three times inside the code that states it:\n\n1. web/app.js categoryList inlined U+25B8/U+25BE (SMALL triangles) and did not call indicator(), which returns U+25B6/U+25BC. So the browser's category panel disagreed with src/ui/category_panel.rs:78 AND with the browser's own grid group headers. Fixed on PR #55 (323677b) by calling indicator().\n2. harness/tests/widgets.spec.js asserted the divergent pair, so the obvious cure would have failed a test. Fixed in the same commit.\n3. harness/tests/pointer.spec.js had INDEPENDENTLY grown the same wrong assertions on a different branch. Three files converging on a spelling nothing compares is the failure design-principles §5 predicts, and this is it observed with data rather than argued from doctrine.\n\nStill outstanding after those fixes: web/app.js viewRow spells the active-view marker \"▶ \" as a literal, duplicating src/ui/view_panel.rs:62. The two AGREE today (both U+25B6), so it is the hazard rather than the divergence — which is exactly the state categoryList was in before it drifted.\n\nTHE DECISION. Either (a) glyphs move to the shared layer the way Axis::short_label()/initial()/glyph() already did, making the TUI and the browser draw one spelling by construction; or (b) the per-page convention is kept and the comment is corrected to say 'one spelling PER PAGE, which may differ from the TUI', which is what it actually means today. What must not persist is the current state, where the comment says 'only this spelling is the page's' (singular) while nothing enforces it.\n\nNote (a) is not free: a glyph is presentation, and design-principles §5 explicitly lists 'the mapping from a semantic value to a framework type' as what legitimately stays per-framework (AxisColor -\u003e ratatui::Color). The counter-argument is that a terminal and a DOM can both render U+25B6, so unlike a Color there is nothing to translate — which is why this is a decision and not an obvious cleanup.\n\nFound by the review agent on PR #55.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T19:50:18Z","created_by":"wave3-webui","updated_at":"2026-09-20T19:50:18Z","comments":[{"id":"246755fe-5911-5924-96d1-9f3403529bf8","issue_id":"improvise-h04z","author":"wave3-webui","text":"COUNTER-ARGUMENT, recorded before anyone decides — because the evidence in the\ndescription supports two readings and the incident makes only one of them salient.\n\nTHE READING THE DESCRIPTION IMPLIES: the convention is wrong, so glyphs should\nmove to the shared layer as Axis::short_label()/initial()/glyph() did.\n\nTHE OTHER READING, which fits the same evidence: the convention is RIGHT and was\nsimply not followed, because nothing enforced it. Gotcha 26's pattern is that the\n*fact* is shared and the *rendering* is local — exactly what AxisColor -\u003e\nratatui::Color does — and a third front end would rightly pick its own glyph. On\nthis reading the fix is to enforce one-home-per-front-end and to correct\nindicator()'s comment, which claims a singular spelling that nothing checks.\n\nWHAT DECIDES BETWEEN THEM, and it is a fact rather than a preference:\n`web/app.js indicator()` and `src/ui/category_panel.rs:78` ALREADY AGREED —\nU+25B6/U+25BC in both. Fixing `categoryList` to call `indicator()` made the two\nfront ends agree with NO shared-layer change at all. That is direct evidence the\nconvention was sufficient and merely unenforced.\n\nI OVERSTATED THE EVIDENCE AND WANT THAT ON THE RECORD. I wrote that three files\nconverging \"answers 'is one duplicated literal worth a protocol change' with data\ninstead of doctrine.\" It does not. The three are not three independent\nderivations:\n\n 1. categoryList BYPASSED indicator(), the one home the page already had.\n 2. widgets.spec.js asserted the page's resulting (wrong) output.\n 3. pointer.spec.js asserted the same output, on a later branch.\n\nOnly (1) chose a glyph. (2) and (3) are downstream of it — tests pinning an\nobserved rendering, which is the \"a test cements the wrong state\" failure and is\nserious, but is NOT evidence about where glyphs should live. So the three answer\n\"was the page's own one-home rule enforced?\" (no), not \"is a protocol change\nwarranted?\" — a different question from the one I claimed.\n\nTHE ONE PIECE OF EVIDENCE THAT DOES SURVIVE FOR THE PROTOCOL READING:\n`web/app.js viewRow` spells the active-view marker \"▶ \" as a literal duplicating\nsrc/ui/view_panel.rs:62. That one is NOT a bypass — there is no browser-side\nmarker helper to bypass — so it is a genuine second spelling of a fact the TUI\nalso spells, and it is the only true cross-front-end duplication left after the\ncategoryList fix. A decision should turn on that case, not on the three.\n\nWHAT MUST NOT PERSIST EITHER WAY: indicator()'s comment says \"only this spelling\nis the page's\" (singular) while nothing enforces it — which is how the bypass\nwent unnoticed. Whichever reading wins, that comment is currently false.\n\nRecorded at the orchestrator's request so the decision is made against both\nreadings. Not resolved here.\n","created_at":"2026-09-20T20:10:52Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1}
|
||
{"_type":"issue","id":"improvise-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"],"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}
|
||
{"_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}
|
||
{"_type":"issue","id":"improvise-seik","title":"Client::viewport is kept for a reason web/app.js falsified in the same wave","description":"Found by the post-wave code consistency review.\n\ncrates/improvise-wasm-client/src/lib.rs:141-158. `Client::viewport` is a #[wasm_bindgen]-exported door with NO PRODUCTION CALLER. Its doc says it is \"kept because web/app.js already spells it and retiring it is a co-ordinated change\" — and web/app.js:137-144 now says the OPPOSITE in its own comment and calls `set_viewport`.\n\nSo the justification for keeping it was falsified IN THE SAME WAVE that wrote it.\n\nThis matters more than an ordinary unused function because its documented failure mode is SILENT: calling `viewport` instead of `set_viewport` does not error, it quietly turns a whole feature off — the window is not recorded, so cursor keys stop resolving locally and every one of them becomes an upstream black hole (doctrine(an_upstream_key_is_a_black_hole)). Gotcha 47 is explicit that the announced window is NOT derivable from row_labels.len(), which is the window clipped to the grid.\n\ndoctrine(a_documented_door_with_no_caller_is_the_dangerous_kind), with the justification removed.\n\nSIBLING, same file, lower severity: `Client::render_state` (lib.rs:120-124) is exported to JS with ZERO callers anywhere, while the crate's module doc (lib.rs:8) still names it as what the DOM renderer consumes. The page consumes `render_model`. `revision()` is likewise uncalled by the page, but `on_frame` returns the revision anyway, so that one is harmless.\n","acceptance_criteria":"Client::viewport is deleted and its tests move onto set_viewport, or its doc states a reason that is true at HEAD. Same for render_state: deleted, or the crate module doc stops naming it as what the DOM renderer consumes. Check web/app.js and the generated .d.ts before deleting — the page is not compiled against the crate, so a removed export is a runtime failure there, not a build error.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T07:49:55Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:49:55Z","labels":["area:browser"],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-748f","title":"A drill exited with no edits, and view-back, mark the document dirty","description":"Found by the independent adversarial review of PR #26, sharpening something that PR had recorded in an assertion but framed too kindly.\n\nTHE BEHAVIOUR: ApplyAndClearDrill::apply RETURNS IMMEDIATELY when drill_state is None -- it writes nothing at all -- but declares EffectKind::Model(CellData), so App::apply_effects raises model_state.dirty anyway. And ViewNavigate { forward: false } (view-back) emits that effect UNCONDITIONALLY, whether or not a drill was ever opened.\n\nEXECUTED: on a clean document with drill_state == None, applying [ApplyAndClearDrill] gives dirty == true.\n\nFAILURE SCENARIO: open a document, press view-back, press :q. The quit is refused for unsaved changes on a document nothing has touched. Autosave also arms 30 seconds later.\n\nWHY THE \"SAME AS LoadModel\" FRAMING IS WRONG, and this is the part worth keeping: a SUCCEEDING LoadModel leaving the document dirty is a CORRECT answer to a real divergence -- LoadModel does not move file_path, so the in-memory document really is a different one from the file the path names, and PR #26's own test pins that REASON rather than just the flag. This is a WRONG answer to no divergence at all, on a routine navigation keypress. Calling them the same over-approximation flattens a distinction another test in the same PR was making.\n\nPRE-EXISTING: true since improvise-gv9r's fix (0b298d7) made dirtiness come off the effect kind, and invisible until now only because ApplyAndClearDrill's tests drove apply directly, bypassing apply_effects -- they measured the effect in isolation rather than the path production takes.\n\nTHE OBVIOUS FIX IS UNAVAILABLE, stated so the next person does not try: Effect::kind(\u0026self) takes no App, so the classification CANNOT be state-dependent. An effect cannot declare \"Model if there is a drill to commit, ViewOnly otherwise\".\n\nTHE CHEAP FIX ALREADY EXISTS IN THE CODEBASE: apply_effects already computes a per-record XOR fingerprint diff whenever a batch declares a CellData effect (gotcha 40), so \"did any record actually change\" is ALREADY KNOWN at the end of the batch. Raising dirty off that observation for the CellData tier is strictly more accurate and reuses machinery that is already there for the basis tiers. It does NOT cover LayoutGeometry or WholeWorkbook, so it is a partial move and a design call -- which is why this is a bead and not a patch.\n\nDIRECTION OF FAILURE IS THE SAFE ONE (a spurious prompt, never a lost edit), which is why it is P3 rather than higher. But it is a wrong answer the user meets on an ordinary keypress, and the fix reuses machinery that exists.\n\n## Steps to Reproduce\n\n1. Open any document and make no edit of any kind.\n2. Press `view-back`, which emits `ApplyAndClearDrill` unconditionally.\n3. `ApplyAndClearDrill::apply` returns immediately because `drill_state` is `None` and writes nothing — but it declares `EffectKind::Model(CellData)`, so `App::apply_effects` raises `model_state.dirty`.\n4. Press `:q`. It is refused for unsaved changes on a document nothing has touched. Autosave also arms 30 seconds later.\n\nDirectly executable: on a clean document with `drill_state == None`, applying `[ApplyAndClearDrill]` gives `dirty == true`.\n","acceptance_criteria":"A drill exited with no edits, and view-back on a document with no drill open, leave the document clean. Effect::kind cannot be state-dependent (it has no App), so the fix is elsewhere: App::apply_effects already computes the per-record fingerprint diff for CellData batches, so 'did any record actually change' is already known at the end of the batch. Raising dirty off that observation covers the CellData tier; say explicitly what LayoutGeometry and WholeWorkbook still over-report and why that is acceptable.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T07:29:28Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:57:15Z","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-jq8l","title":"web/app.js never calls scrollIntoView, so a selection can sit outside the window","description":"Surfaced by PR #23's viewport-reservation discussion (improvise-anr9) and confirmed by reading: web/app.js CALLS scrollIntoView NOWHERE AT ALL.\n\nSo a selection that moves outside the rendered window is simply not brought into view. The grid repaints with the cursor somewhere the user cannot see, and the only way back is to scroll manually until it appears.\n\nPRE-EXISTING AND NOT GROUP-SPECIFIC. It came up while arguing about whether inline group rows should be reserved in extent(), and the conclusion there was that the residual gap is \"neither new nor group-specific\" -- this is that gap, filed separately so it does not read as a consequence of the frame work.\n\nNOTE WHAT IT INTERACTS WITH, because the fix is not simply calling scrollIntoView:\n * The browser's frame is WINDOWED and ViewState::selected is ABSOLUTE (gotcha 42). render.rs reconciles by ADDING the scroll offset -- `r + cursor.row_offset == sel_row` -- which is why there is no underflow path and why a selection above the window simply never matches. A scroll-into-view fix must reconcile the same way or it will fight the projection.\n * Client::set_viewport records the window the page announced (gotcha 47), and the announced window is NOT derivable from row_labels.len(), which is the window CLIPPED to the grid. The two differ precisely at the bottom edge, which is where this bug lives.\n * Moving the selection is the client's own business -- \"the model is shared, the UI is not\" -- so this is local work, not a projection change.\n\nUNTESTABLE UNTIL THE HARNESS GROWS: harness/tests/shell.spec.js renders web/app.js in three browsers, but web/demo.improv is small enough that nothing scrolls. See the sibling bead asking for a bigger, grouped fixture.\n\n## Steps to Reproduce\n\n1. Open a document in the browser with more rows than fit the window.\n2. Move the selection below the visible window (cursor keys resolve client-side since improvise-bpq2).\n3. The grid repaints with the cursor outside the rendered window and nothing scrolls — `web/app.js` calls `scrollIntoView` nowhere at all.\n4. The only way back is to scroll manually until the selection reappears.\n\nNot currently reachable by the Playwright harness: `web/demo.improv` is small enough that nothing scrolls. See improvise-plcx.\n","acceptance_criteria":"A selection moved outside the rendered window brings itself into view. The fix reconciles the absolute selection against the announced window by ADDING the scroll offset, the way render.rs does (there is no underflow path, and a selection above the window simply never matches). It uses the window Client::set_viewport recorded, not row_labels.len(), which is the window clipped to the grid and differs exactly at the bottom edge. Verified in the harness once improvise-plcx gives it a document big enough to scroll.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T07:16:28Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:57:14Z","labels":["area:browser"],"dependencies":[{"issue_id":"improvise-jq8l","depends_on_id":"improvise-plcx","type":"blocks","created_at":"2026-09-20T16:21:01Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-gd83","title":"AxisEntry carries no group-end boundary, so group_of gives an ungrouped item the preceding group","description":"Found by Copilot on PR #23 (improvise-anr9), DECLINED there with reasons on the thread, and filed so it is a decision rather than a rediscovery. COPILOT IS RIGHT AND THIS IS A REAL BUG.\n\nAxisFrame::group_of answers with the NEAREST PRECEDING GROUP HEADER, which is not the same as the item's group membership. GridLayout::expand_category emits a header on ENTERING a group and NO BOUNDARY ON LEAVING ONE, so an ungrouped item appearing after a grouped run inherits that group -- in the frame, and in the span the TUI paints.\n\nIT IS GridLayout's BUG, NOT THE FRAME'S. The TUI has painted that span forever; improvise-anr9 faithfully reproduced the existing rule when it moved the frame onto the wire, which is what a migration should do (doctrine(characterization_survives_the_move)). What changed is that the rule is now WRITTEN DOWN and therefore visible.\n\nWHY IT WAS RIGHT TO DECLINE IT THERE: fixing it means changing AxisEntry in improvise-core to carry a group-end boundary, and changing what the TUI DRAWS -- which would destroy `the_whole_frame_is_unchanged`, the exact-buffer characterization that was PR #23's only evidence the migration changed nothing. Same shape as improvise-q0vb's decline on PR #12: a finding that is correct and still wrong to fix in that PR, because of what it costs in evidence.\n\nTHE EVIDENCE IS ALREADY WRITTEN AND IT IS THE GOOD KIND. `the_two_front_ends_draw_the_same_column_header` asserts the WRONG answer as the current one -- Q1 spanning all six columns -- with THE CORRECTED EXPECTATION `[(Some(\"Q1\"), 4), (None, 2)]` WRITTEN BESIDE IT. So the day someone fixes AxisEntry, that test fails and the fix is one line of expectation away, with the intended answer already argued. The doc on group_of states the rule and names the defect.\n\nWHAT THE FIX COSTS, so nobody starts it casually: AxisEntry is in improvise-core; a group-end boundary changes the shape every consumer of row_items/col_items walks; and the characterization buffers in src/ui/grid.rs move, which means re-pinning them deliberately and saying so in the commit, exactly as improvise-q0vb's acceptance clause required.\n\n## Steps to Reproduce\n\n1. Build an axis with a grouped run followed by an UNGROUPED item — `expand_category` emits a header on entering the group and no boundary on leaving it.\n2. Ask `AxisFrame::group_of(idx)` for the ungrouped item's index.\n3. It answers with the nearest preceding header, so the ungrouped item is reported as a member of the group that ended before it.\n4. The TUI paints the same span, so this is `GridLayout`'s rule faithfully reproduced rather than a frame defect.\n\n`the_two_front_ends_draw_the_same_column_header` in `tests/grid_frame_agreement.rs` asserts the wrong answer as the current one, with `THE_MEMBERSHIP_CORRECT_SPANS` and an `assert_ne!` beside it that starts failing the day this is fixed.\n","acceptance_criteria":"AxisEntry carries a group-end boundary, so group_of answers membership rather than nearest-preceding. THE_MEMBERSHIP_CORRECT_SPANS in tests/grid_frame_agreement.rs becomes the asserted value and its assert_ne! is removed. The TUI's painted spans change, so the characterization buffers move deliberately and the commit says so.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T07:16:26Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:57:13Z","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-xps8","title":"The handshake watchdog overwrites a genuine mid-session disconnect eight seconds later","description":"Found by improvise-vvl3's harness (PR #24) -- the first time web/app.js had ever been executed -- and it is a THIRD bug in the same family, distinct from both of the ones already dealt with.\n\nTHE SEQUENCE, measured rather than argued:\n\n open-then-close: connecting... -\u003e connected -\u003e disconnected\n -\u003e \"accepted the connection but sent no document\" \u003c- EIGHT SECONDS LATER\n\nOn the open-then-close arm the handshake watchdog IS armed (armWatchdog runs after transport.ready, which resolves on `open`), and nothing disarms it when the socket later closes. So a genuine mid-session disconnect is correctly reported, and then silently replaced eight seconds later by a message that is FALSE -- the peer did not fail to answer a handshake, it answered and then went away.\n\nIT SURVIVES improvise-taxx'S FIX, confirmed rather than assumed: the harness simulated the `opened` flag and the assertion still fails. taxx distinguishes never-connected from was-connected-and-dropped in the CLOSE listener; this is the TIMER overwriting whatever that listener concluded. Different mechanism, same surface.\n\nIT IS THE SAME CLASS AS THE BUG setShellFailure ALREADY FIXED -- a timer firing later and replacing a more precise message (gotcha 48, \"a watchdog must be disarmed, not pre-empted\"). The fix for that one cleared the timer on a LOUD failure. This arm is not loud: a close after a successful open is an ordinary event, not an error, so it never reaches setShellFailure. The likely answer is that the close listener disarms the watchdog on the opened path, since once a socket has opened the handshake question is settled either way -- but check whether any path legitimately wants the watchdog to survive a close before assuming it.\n\nWHY IT IS NOT A CI GUARD, and this is the part worth reading before trying to test it: the harness shipped an assertion for this and HAD TO REMOVE IT. It reproduces on macOS/Chromium and NOT on the GitHub runner, because the trigger is the ORDER of error/close/timer on the host's network stack. A Playwright test.fail is green when the bug is present and red when it is absent, so an expected-failure whose trigger is platform-dependent does not merely flake -- IT INVERTS CI'S MEANING, and it failed the browser job twice reporting \"expected to fail, but passed\". See doctrine(an_expected_failure_must_not_be_platform_dependent). The measurement and the reproduction recipe are recorded in harness/tests/shell.spec.js.\n\nNOTE THE SAME CAVEAT NOW APPLIES TO improvise-taxx: its bug is also only locally reproducible, which is why the two assertions the fix owed could not be shipped as guards either. The fix is still right; it is the pinning that is unavailable.\n\n## Steps to Reproduce\n\n1. Serve the page and point it at a reachable `improvise-ws-server` so the socket actually OPENS (status reads `connected`).\n2. Stop the server, or otherwise drop the connection mid-session.\n3. The status line reads `disconnected` — correct.\n4. Wait eight seconds. The handshake watchdog, armed on open and never disarmed, replaces it with `accepted the connection but sent no document`.\n\nReproduces on macOS/Chromium. Does NOT reproduce on the GitHub runner, where the order of `error`/`close`/timer on the host's network stack differs — which is why this cannot be pinned as a CI guard. See `harness/tests/shell.spec.js` for the measurement.\n","acceptance_criteria":"The watchdog is disarmed when a socket that had OPENED subsequently closes, so a mid-session disconnect keeps its own message. The never-opened path is unchanged (the watchdog is the only thing that speaks there). Stated in the code why this is not a CI guard: the trigger is the host network stack's ordering of error/close/timer, so an expected-failure would invert CI's meaning rather than flake.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T06:44:26Z","created_by":"Edward Langley","updated_at":"2026-09-20T08:05:26Z","closed_at":"2026-09-20T08:05:26Z","close_reason":"CLOSED AS INVALID 2026-09-20. THE BUG DOES NOT EXIST, and I filed it without checking the merged code.\n\nTHE CLAIM WAS: on the open-then-close path the handshake watchdog stays armed, so eight seconds after a genuine mid-session disconnect the status line replaces \"disconnected\" with \"accepted the connection but sent no document\".\n\nTHE CODE SAYS OTHERWISE, verified by reading at main 22d43b2:\n * web/app.js:248-250 routes BOTH arms of the close listener through setShellFailure.\n * setShellFailure (app.js:165-169) calls clearTimeout(watchdog) synchronously and nulls it.\n * The comment at app.js:241-247 states the property explicitly and ends \"Neither arm leaves one armed.\"\n\nHOW A REAL MEASUREMENT PRODUCED A FALSE BEAD, which is the part worth keeping:\n\nThe harness agent (improvise-vvl3) MEASURED the 8s overwrite for real. But its worktree was based at 97ea489 -- BEFORE PR #21 landed improvise-taxx's fix. It then reported the bug \"survives improvise-taxx's fix, confirmed by simulating PR #21's opened flag\". THAT SIMULATION WAS INCOMPLETE. PR #21's fix is not only the `opened` flag; it is routing BOTH ARMS THROUGH setShellFailure. Simulating the flag alone leaves the timer armed and faithfully reproduces a bug the real fix does not have.\n\nSo the measurement was honest, the simulation was not the fix it named, and I filed the bead on the report without checking the merged tree. doctrine(an_injected_fault_must_be_the_fault_you_named) one level up: A SIMULATED FIX MUST BE THE FIX YOU NAMED. A partial simulation of a repair reproduces the original bug by construction and reads as evidence that the repair failed.\n\nFOUND BY: a reviewer (ChatGPT) on PR #30, which had propagated the false claim into context/repo-map.md's gotcha 48 and into harness/tests/shell.spec.js's comment block. Both are being corrected.\n\nNOT WASTED: PR #30 adds the test the claim should always have had -- the open-then-close arm now runs past HANDSHAKE_TIMEOUT_MS and asserts \"sent no document\" never appears, proved discriminating by making setShellFailure not clear the timer. So the property is executed rather than argued, and the next person cannot re-derive this conclusion from a partial simulation.\n\nimprovise-taxx (the close-listener fix) remains correctly closed and is unaffected.","dependencies":[{"issue_id":"improvise-xps8","depends_on_id":"improvise-taxx","type":"relates-to","created_at":"2026-09-20T06:44:28Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-l8rh","title":"The ambiguous-date heuristic reads American-first, so a European CSV imports with days and months swapped","description":"Named and pinned by WU-L on PR #22 (improvise-7yx item 2), and left UNCHANGED by the user's ruling 2026-09-19: leave it pinned, file it as a known limit.\n\nTHE BEHAVIOUR: import/analyzer.rs::detect_date_format is a FIRST-MATCH heuristic, so for a column of values that fit both %m/%d/%Y and %d/%m/%Y -- \"01/02/2025\", \"03/04/2025\" -- the AMERICAN reading wins. A European CSV therefore imports with days and months SWAPPED, silently, and every date in the column is wrong in a way that still looks like a date.\n\nWHY IT IS TRACKED RATHER THAN FIXED, and this is the argument that carried:\n * The import wizard SHOWS the detected format and lets the user override it, so the mis-read is visible at the point of import to a user who looks. That is the same argument detect_short_year_format already recorded for its own guess.\n * Changing the default to day-first only moves which half of the world imports silently wrong.\n * The honest fix is larger than the heuristic: an AMBIGUOUS column should surface its ambiguity rather than resolve it, which changes the analyzer's return shape (FieldProposal) and the wizard step that displays it. Worth doing deliberately, not as a patch.\n\nWHAT PR #22 DID CHANGE, and it is why this is now a stable known limit rather than a lottery: analyze_records used to iterate a HashSet, so the ten samples detect_date_format looks at were a DIFFERENT TEN EVERY RUN -- meaning a column whose eleventh value rules out %m/%d/%Y detected a different format run to run. Same CSV, same import, different dates. That is fixed (first-seen order, invariant(analyzer_first_seen_order)), so the heuristic is now WRONG DETERMINISTICALLY, which is the precondition for anyone being able to reason about it at all.\n\nPINNED BY: an_ambiguous_date_is_read_american_first, whose doc-comment states plainly that this silently mis-reads a European CSV, with the day-first reading asserted to parse so the test is a statement about AMBIGUITY rather than about parsing. a_day_first_only_value_settles_the_ambiguity is the discriminating other arm.\n\nSTILL TRUE AND UNCHANGED: the ten-sample window itself. A column whose first ten values are ambiguous and whose eleventh is not will still be read american-first. Now deterministic, still a limit.\n\n## Decision\n\nLeave the first-match heuristic as it is, and track it as a known limit. Ruled by the user 2026-09-20.\n\n## Rationale\n\nThe import wizard SHOWS the detected format and lets the user override it, so the mis-read is visible at the point of import to a user who looks — the same argument `detect_short_year_format` already recorded for its own guess.\n\nWhat made it worth tracking rather than ignoring is that PR #22 changed its character: `analyze_records` used to iterate a `HashSet`, so the ten samples `detect_date_format` inspects were a DIFFERENT ten every run, and a column whose eleventh value ruled out `%m/%d/%Y` detected a different format run to run. It is now wrong DETERMINISTICALLY, which is the precondition for anyone being able to reason about it at all.\n\n## Alternatives Considered\n\n**Change the default to day-first.** Rejected: it only moves which half of the world imports silently wrong.\n\n**Surface the ambiguity instead of resolving it.** The honest fix and larger than the heuristic — it changes `FieldProposal`'s shape and the wizard step that displays it. Worth doing deliberately rather than as a patch, which is why it is not folded in here.\n\n**Widen the ten-sample window.** Does not address ambiguity at all; a fully ambiguous column stays fully ambiguous.\n","status":"open","priority":3,"issue_type":"decision","owner":"el-github@elangley.org","created_at":"2026-09-20T06:28:43Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:45:41Z","labels":["area:core-io"],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-rw3a","title":"Decide whether ZZ routes into the save prompt automatically","description":"The half of improvise-lae that was deliberately left open. PR #27 built the prompt (AppMode::SavePrompt, TextEntry builder, committing to SaveAs, bound Ctrl-W and reachable as `:enter-mode save-prompt`) and stopped short of making ZZ route into it.\n\nTODAY, after PR #27: ZZ on a pathless document refuses, stays in the app, and the status line names the prompt. That is already better than improvise-8tk left it -- the user is told where to go. The open question is whether being told is enough, or whether the vim reflex should just land somewhere useful.\n\nTHE OBSTACLE THE ORIGINAL TICKET NAMED, still true: CmdContext does not expose file_path, so a Cmd cannot currently decide \"is there a path\" at execute time. Adding the field is small; note improvise-2ud is separately proposing that CmdContext borrow the ModelState/ViewState slices rather than copying ~20 individual fields, which would make this free. Coordinate rather than adding a 21st copied field ahead of it.\n\nTHE CONSTRAINT PR #27 ESTABLISHED, and it must be respected by whatever lands here: the prompt is SESSION-LOCAL and was deliberately kept in the COMMAND layer rather than routed through effect::Save. Save is becoming a wire command (improvise-368h), and a prompt is addressed to precisely one session -- routing it through the effect would open a prompt on a host nobody asked, which is gotcha 38's argument for keeping AcceptWizardModel off the wire. So: a Cmd that inspects the path and emits a mode change, never an effect that decides to prompt.\n\nDECIDE ALSO what ZZ means once the prompt is open. ZZ is \"save and quit\"; if the prompt commits to SaveAs and the batch was aborted, the quit is gone. Should committing the path then quit? That is a second composition and the answer is not obviously yes -- a user who hit ZZ, got a prompt, and typed a path plausibly wants to leave, but making the prompt's Enter mode-dependent is the kind of hidden state improvise-ct5 just declined to add for `i`.\n\n## Decision\n\nOpen. This is the half of improvise-lae that was deliberately left undecided when the save prompt landed.\n\n## Rationale\n\nAfter PR #27, `ZZ` on a pathless document refuses, stays in the app, and the status line names the prompt. That is already better than improvise-8tk left it — the user is told where to go. The question is whether being told is enough, or whether the vim reflex should land somewhere useful by itself.\n\n## Alternatives Considered\n\n**Route `ZZ` into the prompt automatically.** Needs `CmdContext` to expose `file_path`, which it does not. improvise-2ud separately proposes that `CmdContext` borrow the ModelState/ViewState slices rather than copying ~20 fields, which would make this free — coordinate rather than adding a 21st copied field ahead of it.\n\n**Leave `ZZ` as it is.** The status line names `Ctrl-W`; the cost is one keystroke of discovery.\n\n**Constraint on any answer:** the prompt is SESSION-LOCAL and must stay in the command layer, never routed through `effect::Save`. Save is now a wire command (improvise-368h), and an effect that decides to prompt would open a prompt on a host nobody asked — gotcha 38's argument for keeping AcceptWizardModel off the wire.\n\n**Unanswered second question:** what `ZZ` means once the prompt is open. ZZ is save-AND-QUIT; if committing the path does not also quit, the reflex is only half honoured — but making the prompt's Enter mode-dependent is the hidden state improvise-ct5 just declined to add for `i`.\n","status":"open","priority":3,"issue_type":"decision","owner":"el-github@elangley.org","created_at":"2026-09-20T06:04:32Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:45:42Z","labels":["area:tui"],"dependencies":[{"issue_id":"improvise-rw3a","depends_on_id":"improvise-2ud","type":"blocks","created_at":"2026-09-20T16:20:29Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-g9jb","title":"The text-entry mode list is hand-written in two crates and checked against AppMode in neither","description":"Found by WU-G on PR #27 while adding AppMode::SavePrompt.\n\nThe set of text-entry modes is written down BY HAND IN TWO PLACES, in two crates:\n * improvise-command::keymap::tests::text_entry_modes_with_exits\n * improvise-wasm-client::keys::tests::text_entry_modes\n\nand NOTHING CHECKS EITHER AGAINST AppMode. So adding a text-entry variant compiles, passes, and is silently absent from both lists -- which matters more since PR #27, because the browser now refuses to ENTER a prompt it cannot commit (keys.rs::strands_the_user), and that refusal is driven off one of these hand-written lists.\n\nTHE FAILURE MODE IS THE QUIET ONE: a new prompt mode omitted from the wasm list is one a browser will happily open and then swallow the user's typing in -- doctrine(a_doorway_to_a_black_hole_is_worse_than_the_key), which PR #27 established against SavePrompt and found ExportPrompt had already.\n\nTHE FIX: derive the list once, from AppMode itself. `MinibufferConfig` is already the thing that makes a variant text-entry -- AppMode::minibuffer() is an exhaustive match returning Option\u003cMinibufferConfig\u003e (gotcha 11), so \"the text-entry modes\" is derivable rather than enumerable. A const or a function in improvise-protocol beside AppMode, with both crates reading it, replaces both lists.\n\nCHECK ONE THING BEFORE ASSUMING THE DERIVATION IS FREE: the wasm list and the command list may not be the same set for a reason -- SearchMode is hand-written on purpose and has no commit key (gotcha 11), and the browser's question (\"can this client commit here\") is not identical to the keymap's (\"does this mode take text\"). If they genuinely differ, the answer is two derived predicates over one enum, not two hand-written lists.\n\ndoctrine(a_hand_enumerated_mirror_needs_an_exhaustive_match) already covers this; this is an instance with no match behind it at all.\n","acceptance_criteria":"The set of text-entry modes is DERIVED from AppMode rather than hand-written twice. AppMode::minibuffer() is already an exhaustive match returning Option\u003cMinibufferConfig\u003e, so the set is derivable. Before assuming one derivation serves both: check whether the keymap's question ('does this mode take text') and the browser's question ('can this client commit here') are actually the same set — SearchMode is hand-written on purpose and has no commit key. If they differ, the answer is two derived predicates over one enum, never two hand-written lists.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T06:04:22Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:45:25Z","labels":["area:shared-frontend"],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-z05r","title":"CommitExport does not bail on an empty path, and ExportCsv does not abort the batch","description":"Found by WU-G on PR #27 while fixing the save arms, and left deliberately: these are the same two defects one file over, at lower severity, and fixing them in that PR would have widened a save-focused change into an export-focused one.\n\nTWO ARMS, both mirroring what was just fixed for save:\n 1. CommitExport does not bail on an empty path, where CommitBuffer's three kinds all do (gotcha 20: buffer -\u003e trim -\u003e bail-on-empty -\u003e dirty -\u003e status is the shared spine).\n 2. ExportCsv does not set abort_effects on failure, where Save and (as of PR #27) SaveAs both do. So a `Binding::Sequence` or a `:` line composing an export with anything after it continues past a failed write.\n\nWHY IT IS LOWER SEVERITY THAN THE SAVE ARMS, and this is the honest reason it was deferred rather than a claim that it does not matter: an export failure discards nothing. improvise-8tk was a P1 because ZZ quit and the model was gone. Here the model is untouched and the user has simply not got the CSV they asked for -- so the cost is a missing file and a message, not lost work.\n\nNOTE THE SHAPE, because it is the third instance in this area: improvise-8tk fixed Save and not SaveAs; PR #27 found SaveAs; and this is the export arm of the same rule. doctrine(one_arm_of_a_symmetric_rule_is_evidence_about_that_arm_only) exists for exactly this, and the general fix may be better than the third patch -- consider whether \"an effect that writes a file and fails aborts the batch\" can be made structural rather than remembered three times.\n\nALSO CHECK, while in there: whether the export error is visible at all, given that ExportPrompt is a minibuffer mode and a status message written while a minibuffer is open is never drawn (see the sibling bead on draw_bottom_bar).\n\n## Steps to Reproduce\n\n1. `CommitExport` with an empty path: it does not bail, where `CommitBuffer`'s three kinds all do (gotcha 20's shared spine is buffer → trim → bail-on-empty → dirty → status).\n2. Compose an export with anything after it — a `Binding::Sequence`, or a `:` line on ` . ` — and make the write fail. `ExportCsv` does not set `abort_effects`, so the chain continues past the failure, where `Save` and (since PR #27) `SaveAs` both stop it.\n\nLower severity than the save arms because an export failure discards nothing: the model is untouched and the user simply has not got their CSV.\n","acceptance_criteria":"CommitExport bails on an empty path like the other three commit kinds, and ExportCsv aborts the batch on a failed write like Save and SaveAs. Prefer the structural fix over the third patch: this is the third instance of the same asymmetry, so consider whether 'an effect that writes a file and fails aborts the batch' can be made impossible to forget rather than remembered three times.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T06:04:21Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:57:19Z","labels":["area:tui"],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-44ad","title":"ColWidths is redundant under the label fold, and the test that would change is gotcha 33(c)'s evidence","description":"Found while closing improvise-hkt6 (PR #20, WU-K), tried, and DELIBERATELY REVERTED rather than landed -- filed so it is a decision rather than a rediscovery.\n\nhkt6(b) obliged grid_updates to diff `totals` against the LABEL-FOLDED cache rather than against the mirror (see gotcha 33). The symmetric move for `col_widths` looks obviously right and is not free: it BREAKS `a_client_on_a_socket_is_seeded_and_gets_its_edit_back`.\n\nWHY, and it is a real property rather than a brittle assertion: folding the labels RE-FITS the widths (gotcha 33(c) -- every arm that touches labels or widths re-fits, because a renderer zips widths with labels and a length mismatch indexes off the end). So under a fold-relative diff the ColWidths that a reframe currently carries becomes REDUNDANT and is suppressed. The client is correct either way; what changes is whether the reframe still SENDS the widths.\n\nTHE REASON IT WAS NOT SPENT IN PR #20: that assertion is gotcha 33(c)'s standing evidence -- it is the test that catches \"a late ColWidths put the mismatch straight back\". Rewriting it inside a PR whose subject was three totality gaps would have destroyed the evidence for a rule while fixing something else, which is exactly the shape improvise-q0vb was declined for on PR #12. Two right changes, different costs.\n\nTHE DECISION TO MAKE:\n * Leave widths diffed against the mirror. Costs one redundant ColWidths per reframe -- the same direction Dirty::of is deliberately sloppy in (gotcha 35: over-report pays one redundant Projection, under-report leaves a client wrong forever). Defensible and arguably correct on that principle alone.\n * Or move widths to the fold and REPLACE the socket assertion with one that still pins 33(c)'s property -- that a client never holds widths of a different length from its labels -- rather than one that pins the message count. That is the better test either way, and writing it is most of the work.\n\nPREFER THE SECOND ONLY IF the replacement test is written first and shown to fail against a deliberately late ColWidths. Otherwise prefer the first and say so in gotcha 33, so the asymmetry between totals and widths is STATED rather than discovered by the next person who notices it.\n\n## Rationale\n\nimprovise-hkt6 obliged `grid_updates` to diff `totals` against the LABEL-FOLDED cache rather than the mirror, or a rename that leaves the number equal sends no `Totals` and the client blanks its own. The symmetric move for `col_widths` looks obviously right and is not free: it breaks `a_client_on_a_socket_is_seeded_and_gets_its_edit_back`.\n\nThat is a real property, not a brittle assertion. Folding the labels RE-FITS the widths (gotcha 33(c)), so under a fold-relative diff the `ColWidths` a reframe currently carries becomes redundant and is suppressed. The client is correct either way; what changes is whether the reframe still SENDS them.\n\n## Alternatives Considered\n\n**Leave widths diffed against the mirror.** Costs one redundant `ColWidths` per reframe — the same direction `Dirty::of` is deliberately sloppy in (gotcha 35: over-report pays one redundant Projection, under-report leaves a client wrong forever). Defensible on that principle alone, and then the asymmetry between totals and widths should be STATED in gotcha 33 rather than rediscovered.\n\n**Move widths to the fold and replace the socket assertion** with one that still pins 33(c)'s property — that a client never holds widths of a different length from its labels — rather than one that pins the message count. That is the better test either way, and writing it is most of the work.\n\n**Not acceptable: move widths to the fold and simply update the failing assertion.** It is gotcha 33(c)'s standing evidence, and rewriting it inside a change it is meant to guard destroys the evidence for that change — the improvise-q0vb / PR #12 shape.\n","status":"open","priority":3,"issue_type":"decision","owner":"el-github@elangley.org","created_at":"2026-09-20T05:54:56Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:45:42Z","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-j7v6","title":"The typed-string classification lives in two crates, and its guard test asserts by hand","description":"Named by improvise-r4lo (PR #21) while deleting the OTHER duplication, and deliberately left rather than folded in -- r4lo was about two answers on the CLIENT side, and deleting Client::set_cell/set_cell_at settled that. This is the duplication one level out, and it crosses a crate boundary.\n\nwasm-client/src/socket.rs::typed classifies a user-typed string into a CellValue. So does improvise-command's commit_regular_cell_value, on the host's own key path. Two implementations of \"what does this string mean\", in two crates, with nothing holding them together.\n\nBOTH ARE LEGITIMATE, which is why this is not simply r4lo again. The browser has no App to ask and ModelCommand::SetCell takes a CellValue, so somebody on the client side must decide; a host typing locally has an App and classifies there. The defect is that the RULE is written twice, not that it is applied in two places.\n\nTHE TEST THAT LOOKS LIKE A GUARD AND IS NOT: a_typed_string_is_classified_as_the_host_classifies_it states the obligation IN ITS NAME and does not discharge it -- it asserts three outcomes BY HAND rather than comparing against the host's function. So the two can drift and the test stays green, which is this repository's characteristic defect (doctrine(check_the_checker)): a test that passes while the thing it names is broken.\n\nTHE FIX: one classification function in improvise-protocol, which both crates already depend on, and both call sites calling it. Then the test either disappears (type identity replaces it) or becomes a real comparison. Note doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished) -- if the shared function lands and the hand-written three-outcome test survives beside it, the consolidation is not done.\n\nWATCH ONE THING WHILE MOVING IT: the classification must stay consistent with what the .improv format considers a number versus pipe-quoted text (gotcha 34, and format.rs::pipe_quote exists precisely so a text cell cannot read back as a number). A shared classifier that disagrees with the persistence layer would be a third answer wearing the badge of the first.\n","acceptance_criteria":"One classification function in improvise-protocol, called by socket.rs::typed and by commit_regular_cell_value. a_typed_string_is_classified_as_the_host_classifies_it either disappears (type identity replaces it) or becomes a real comparison against the shared function — if the shared function lands and the hand-written three-outcome test survives beside it, the consolidation is not finished. Check the result stays consistent with what .improv considers a number versus pipe-quoted text, or the shared classifier is a third answer wearing the badge of the first.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T05:45:29Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:45:25Z","labels":["area:shared-frontend"],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-rucf.3","title":"Projection freshness for a source an external writer can change","description":"Child of improvise-rucf (ADR 003, live-backed models). Filed and DEFERRED 2026-09-19 alongside its parent.\n\nADR 001 governs concurrent EDITS. A live-backed model is read-only, so it has none and the basis check does not apply to it -- the guarantee is not voided, it is scoped to documents improvise owns, which is what it always meant.\n\nWHAT REMAINS IS A DIFFERENT AND SMALLER QUESTION, and this bead is it: an EXTERNAL WRITER changes what clients SEE. So a live-backed projection needs a freshness story -- polled, triggered, or explicitly stale-until-refreshed. That is projection freshness, not conflict resolution, and conflating the two is the trap.\n\nNOTE WHAT CHANGES ABOUT THE PROJECTION ARCHITECTURE: today's diff (ui/project.rs::grid_updates) compares a desired state derived from the App against each client's mirror. With a live source, \"the desired state\" involves latency -- a HashMap lookup becomes a query -- so the VIEWPORT-BOUNDED PROJECTION STOPS BEING AN OPTIMISATION AND BECOMES LOAD-BEARING. improvise-i5tk (narrow the TUI's GridCache to its viewport, benchmark first) is the related bead and its answer may be forced by this one.\n\nSHARED WITH THE SQLITE SOURCE CHILD: the RecordHash -\u003e record index becomes a cache needing invalidation rather than a structure maintained alongside writes. One invalidation story, not two.\n\nEXPLICITLY OUT OF SCOPE: anything resembling a lease, a lock, or a reservation. Those are ADR 001 layer 2 and they are about edits.\n","status":"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":"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}
|
||
{"_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":"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":"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":"{}"}],"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}
|
||
{"_type":"issue","id":"improvise-j31x","title":"Server hosts several documents: a registry above Sessions","description":"DEFERRED by the user (2026-09-19) -- recorded so nothing forecloses it, not to be built now.\n\nimprovise is a multi-document app and the server should be able to host several separate models. The shape:\n server -\u003e many documents\n document -\u003e one App + one Sessions + many ClientSessions\nThat is NOT one App per session (what improvise-q08 ticket text says, written before the session model landed) and NOT one App total.\n\nNOTHING THAT HAS LANDED NEEDS TO CHANGE. Sessions does not own the App -- connect(\u0026mut self, app: \u0026App, ..), apply(\u0026mut self, app: \u0026mut App, ..) and project_all(\u0026mut self, app: \u0026App, ..) all take it as a parameter. So Sessions is already document-agnostic and a Sessions instance is implicitly bound to one document generation sequence, which is right: document_generation lives on the App. Multi-document is a REGISTRY ABOVE Sessions mapping a document id to its (App, Sessions) pair.\n\nWHY IT MATTERS BEYOND TIDINESS: sessions editing independent models are trivially non-conflicting, so ADR 001 concurrency design scopes per document FOR FREE -- the basis version tiers live on the App and each document has its own. A server that shared one App across documents would throw that away.\n\nWHAT TO AVOID IN THE MEANTIME: improvise-q08 must not hard-code a single App into a static or a lazily-initialised singleton. The document should be a PARAMETER even while there is only one, and a connection should be attachable to a document even if the MVP answers that from a CLI argument naming one .improv file.\n\nScope when picked up: the registry, document lifecycle (open/close/last-session-leaves), and how a client names the document it wants. Auth and access control are explicitly NOT in scope here -- file them separately if wanted.","status":"closed","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-19T23:32:31Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:33:15Z","closed_at":"2026-09-20T00:33:15Z","close_reason":"Registry landed in crates/improvise-ws-server/src/registry.rs. 1051 tests pass.\n\nIT CAUGHT A REAL BUG BY VERIFYING THE SEAM INSTEAD OF TRUSTING IT. My brief and the q08 notes both said nothing below the registry needs to change. Nearly true -- but Sessions::next_id is a PER-Sessions counter (confirmed: project.rs holds next_id on Sessions, not globally), so EVERY documents first client is SessionId(0), and transport.rs keyed its outs: HashMap\u003cSessionId, _\u003e on it. A registry that kept that key would have delivered document Bs frames to document As client the moment two documents each had one client.\n\nThe server-wide address is the pair, ClientId { document, session }. Registry::route(document, Vec\u003cAddressed\u003e) is the ONLY place a SessionId acquires a document -- the one it came out of, since that is the only one in scope there -- so cross-document delivery of a projection, a SwitchView broadcast (gotcha 36) or an Applied::Rebased fan-out (gotcha 39) is UNREPRESENTABLE rather than checked. The test names the hazard: an_edit_is_projected_only_to_the_edited_documents_clients asserts on_alpha.session == on_beta.session FIRST.\n\nLIFECYCLE, and the non-obvious half. Open at STARTUP from the files named on the command line -- open-on-request was rejected because deciding which paths a client may open IS access control, which this ticket put out of scope; serving only what was named leaves the question unanswered rather than answered badly. NOT closed when the last session leaves: eviction loses unsaved edits, and worse, document_generation and record_versions start at ZERO in a fresh App (gotcha 40), so a client reconnecting with a STALE basis would have it accepted as current -- a hole in the exact mechanism ADR 001 is building, bought for a memory saving nobody asked for. Registry::is_idle is the hook a future policy asks from; nothing evicts.\n\nA client names its document in the URL PATH (ws://host:port/quarterly), which leaves wire.rs COMPLETELY unchanged so the wasm-client frames keep working; / resolves to the only document when there is exactly one, and with two is ConnectError::Ambiguous rather than a coin toss. A refusal is a websocket CLOSE FRAME carrying the reason -- no ServerMsg::Error variant invented, because that is a connection fact and wire.rs only grows variants the mutations demand.\n\nEvery required claim has a counter-test beside it so it cannot pass vacuously -- e.g. the broadcast-isolation fixture gives BOTH documents a view named Second, so betas silence is the routing and not a name it would have rejected anyway.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-2wi8","title":"ADR-001 e: a forced overwrite is visible to the client it overwrote","description":"ADR 001 layer 1, step e. Depends on step c.\n\nWithout this, force is silent data loss with a confirmation step in front of it. The overwritten client edit was already committed, so this is a NOTIFICATION rather than a conflict -- but it must exist, or \"someone else won\" is indistinguishable from \"my edit never happened\".\n\nDesign note: the notification is about the loser OWN prior edit, so it is per-client and therefore UI -- the model is shared, the UI is not. Do not broadcast it as document state.","acceptance_criteria":"A client whose committed edit was overwritten by a force is TOLD. Without this, force is silent data loss with a confirmation step in front of it — 'someone else won' must be distinguishable from 'my edit never happened'. It is a NOTIFICATION rather than a conflict, because the overwritten edit was already committed. Per-client and therefore UI: do not broadcast it as document state (model-shared-ui-not-shared).","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-19T23:27:34Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:56:22Z","labels":["area:protocol"],"dependencies":[{"issue_id":"improvise-2wi8","depends_on_id":"improvise-3al1","type":"blocks","created_at":"2026-09-19T23:27:47Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-vb6l","title":"ADR-001 d: when an intent cannot be re-composed, show what changed","description":"ADR 001 layer 1, step d -- the expensive one, and the honest cost of this design. Depends on step c.\n\nConflict class 2 (shape change vs cell edit) has NOTHING COHERENT TO FORCE: the key names a coordinate that no longer exists, and applying anyway writes a CellKey naming a deleted category -- the ghost-coordinate corruption the whole ADR exists to prevent.\n\nSo for class 2 the UI owes the user WHAT CHANGED plus the concrete alternatives -- re-target the edit, or re-create the structure first -- rather than a bare refusal or a force button that cannot be honoured.\n\nFORCE NEVER CREATES STRUCTURE: re-instating a category someone deleted so an edit can land is a different and LARGER intent than the user expressed. It must be an explicit action, never a side effect of forcing a cell edit.\n\nClass 3 (a formula now shadows the value) is forceable WITH A WARNING: the write lands, the display stays derived.\n\nNOTE FOR WHOEVER PICKS THIS UP: the ADR says plainly that if this dialogue is more than we want to build, the fallback is making shape changes rarer under contention -- e.g. a category deletion being explicit about the cells it destroys -- rather than making the conflict cheaper to resolve. That is a product call, not an implementation detail.","acceptance_criteria":"For conflict class 2 the user is shown WHAT CHANGED plus the concrete alternatives — re-target the edit, or re-create the structure first — rather than a bare refusal or a force button that cannot be honoured. FORCE NEVER CREATES STRUCTURE: re-instating a deleted category so an edit can land is a larger intent than the user expressed and must be an explicit action. Class 3 (a formula now shadows the value) is forceable WITH A WARNING. AND THE PRODUCT CALL COMES FIRST: the ADR says plainly that if this dialogue is more than we want to build, the fallback is making shape changes rarer under contention — e.g. a category deletion being explicit about the cells it destroys — rather than making the conflict cheaper to resolve. Answer that before building.","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-19T23:27:33Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:56:22Z","labels":["area:protocol"],"dependencies":[{"issue_id":"improvise-vb6l","depends_on_id":"improvise-3al1","type":"blocks","created_at":"2026-09-19T23:27:46Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-7b8v","title":"Concurrent edits: advisory reservations (ADR 001, layer 2 — UX only)","description":"Implements layer 2 of context/adr/001-concurrent-edits.md. BLOCKED ON layer 1 by design, not by dependency: correctness must not rest on this layer.\n\nGeneralise the drill staging mechanism so a client publishes the KEYS it has staged, letting other clients grey them out and warn before someone types into a cell that is about to be taken. The VALUES stay private -- the user ruled on that in improvise-tom1: staged edits are uncommitted writes belonging to whoever staged them, which is why gotcha 30 keeps drill_state off ViewState.\n\nTHE DESIGN CONSTRAINT: this layer is ADVISORY ONLY. If a reservation is lost, never arrives, or leaks from a crashed client, the system degrades to \"you find out at commit\" -- never to corruption. That is what makes it safe to be sloppy about timeouts and disconnects, and it is exactly why the basis check must land first and stand alone.\n\nNote the split it implies: the reservation KEY SET is shared (everyone must see it to be warned) while the VALUE is private. That maps onto the existing EffectKind distinction rather than fighting it.\n\nDo not add lease renewal or lock timeouts as correctness machinery. If a reservation outlives its client, the worst outcome is a stale warning, and the basis check still rejects the actual conflict.","acceptance_criteria":"A client publishes the KEYS it has staged; other clients grey them out and warn before typing into a cell about to be taken. THE VALUES STAY PRIVATE — the user ruled on that in improvise-tom1. ADVISORY ONLY: if a reservation is lost, never arrives, or leaks from a crashed client, the system degrades to 'you find out at commit' and NEVER to corruption — which is what makes it safe to be sloppy about timeouts and is why the basis check must land first and stand alone. Do NOT add lease renewal or lock timeouts as correctness machinery.","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-19T23:09:54Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:56:23Z","labels":["area:protocol"],"dependencies":[{"issue_id":"improvise-7b8v","depends_on_id":"improvise-3al1","type":"blocks","created_at":"2026-09-19T23:27:48Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-ry89","title":"cv_regress/cv_classify with k=1 now return plausible numbers instead of NaN","description":"Wave-4 review (2026-09-19), CONFIRMED, LOW. A signal loss, not a wrong answer.\n\nmean_over(total, 0) == 0.0 replaced a 0.0/0.0 NaN, which is right everywhere it is used -- the reviewer looked for a case where an empty set WINS a comparison it should lose and found none (inside fit every candidate scores the same rows; cv_* pool pairs rather than reduce to a per-fold metric).\n\nBut with k=1, folds(n,1) puts every index in the single fold, so the training complement is EMPTY and the model trains on nothing. Pre-change, standardize(\u0026[]) gave mean = NaN and every pooled prediction was NaN -- the degenerate call announced itself. Post-change:\n k=1 pooled[0] = (1.48e-6, 8.0), nan_predictions = 0\nSo a library caller passing k=1 gets 37 near-zero cross-validated predictions from a tree fit on nothing, and anyone computing R2 off them gets a number rather than a NaN.\n\nexamples/fit_csv.rs guards with k \u003e= 2, so no shipped path hits it. Fix by documenting on cv_regress/cv_classify that k\u003c2 is meaningless, or by returning empty for k\u003c2. Note the folds doc argues max(1) is \"the answer the shape gives\" -- true for folds, and no longer true for the cross-validators.\n\nNIT from the same review: standardize_is_bit_for_bit_what_it_was is insensitive -- both perturbations of standardize left it green (one was caught by cv_regress instead). Its four witnesses do not separate accumulation orders on the 37-row fixture. Worth strengthening while here.","notes":"\n--- PARTIALLY ADDRESSED on PR #11 (2026-09-19) ---\nCopilot independently re-found this. The PR agent DOCUMENTED it rather than\nguarding it, and the reasoning is right: returning empty for k \u003c 2 is a\nbehaviour change to the exact functions scoring_known_answers.rs pins\nbit-for-bit, inside a PR whose entire claim is bit-identity. It deserves its\nown commit where it is the headline rather than a rider on a reshape.\n\nSTILL OPEN: decide guard vs document-only. If guarding, the known-answer\nvectors must be re-established afterwards, not assumed -- that is the whole\npoint of them.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T22:18:04Z","created_by":"Edward Langley","updated_at":"2026-09-19T23:40:24Z","closed_at":"2026-09-19T23:40:24Z","close_reason":"GUARDED, not documented. The returned value is pooled predictions from trees that never saw the row; with one fold there are none, so EMPTY IS THE TRUE COUNT, not a sentinel. Documenting would leave a library function whose degenerate answer looks like a models output.\n\nThe asymmetry is resolved by splitting the QUESTION, not by clamping differently: folds keeps its max(1) (a partition into one piece is still a partition) and gains a note that the answer is its alone; a new private cv_folds(n,k,rng) yields no fold below k=2 and is what both cross-validators ask. Declining BEFORE the shuffle costs the caller generator nothing.\n\nTHE TICKET PREMISE WAS FALSE and was checked rather than assumed: it said returning empty for k\u003c2 would change numbers the known-answer vectors pin bit-for-bit. Those vectors only ever call k=4 and k=3, so the guard changes none of them -- all six pass unchanged, not one pinned number edited.\n\nThe insensitive-witness NIT is fixed too: standardize_is_bit_for_bit_what_it_was pinned four numbers and let r.target/std - mean/std (same algebra, different rounding) through AND past both CV vectors; it now pins all 37 normalized targets, which that injection moves 21 of.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-k2i4","title":"The two nullary model commands are unaddressed and act on one client's staged state","description":"Wave-4 review (2026-09-19), CONFIRMED, LOW. Worth a decision and a note rather than an immediate change, since multi-client is not wired.\n\nApplyAndClearDrill and AcceptWizardModel are safe no-ops when nothing is staged (both early-return), so a client cannot corrupt the document by sending them blind. Two residual issues:\n\n(a) SPURIOUS PROJECTIONS. Dirty comes from the EffectKind, not from whether anything happened, so a no-op still dirties everything at that level and reaches every client. Measured: ApplyAndClearDrill dirties grid+category_tree+formulas; AcceptWizardModel dirties those plus view_list+tile_bar, emitting a non-empty projection list for a no-op.\n\n(b) THE STAGED STATE IS GLOBAL AND THE COMMAND IS NOT ADDRESSED. Sessions::apply routes Command::Model with no SessionId, while drill_state and wizard live on the single authoritative App. So client B sending Model(ApplyAndClearDrill) commits client A uncommitted staged edits, and Model(AcceptWizardModel) installs the workbook A half-finished wizard built -- replacing the document under everyone.\n\nThat collides with the user ruling on improvise-tom1: staged drill edits belong to whoever staged them and must not be visible to others. Committing them on someone else behalf is the stronger version of the same violation. Gotcha 30 calls this state session-local residue; the command layer now lets an unaddressed message act on it.\n\nDECIDE: either these commands become addressed (a SessionId, and the effect acts only on that session staged state -- which means the staging map moves off the single App), or they are removed from the wire vocabulary and stay key-path only. The second is cheaper and consistent with tom1.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T22:18:03Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:58:04Z","closed_at":"2026-09-20T00:58:04Z","close_reason":"Stale -- already resolved by improvise-3x3u, verified during improvise-fkfg. 3x3u removed ApplyAndClearDrill and AcceptWizardModel from ModelCommand entirely; only prose mentions remain in reduce.rs, no variants. So the premise of this ticket (two nullary wire variants acting on one client's staged state) no longer holds: those commands have NO WIRE FORM to carry a basis or a SessionId.\n\nConsequences, both checked while landing ADR 001 layer 1: layer 1 does not make this worse, and layer 1 LANDS HONESTLY WITHOUT IT. What survives is the successor 3x3u itself names -- multi-client drill wants the ADDRESSED form of those two commands, which is ADR 001 layer 2 work (improvise-tom1 and the reservations ticket), not this.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-ls3y","title":"[bug] A wire-driven SwitchView does not push the view history; the effect does","description":"Found during improvise-kmgs (2026-09-19), pre-existing, out of that ticket scope.\n\napply_command SwitchView arm calls app.switch_to_view(name) DIRECTLY, while effect::SwitchView first pushes a ViewFrame onto view_back_stack and clears the forward stack. So a switch driven over the wire is NOT undoable by ViewBack, and a key-driven one is -- two spellings of one user action that differ in whether history records it.\n\nSurfaced while building a ViewBack session test: the test had to walk forward through the EFFECT to fill the stack, because the command path never fills it. That asymmetry is the tell.\n\nDecide which is right rather than blindly making them agree: history is arguably UI state (the model is shared, the UI is not), in which case the per-client stack should be fed by whichever door the client used -- and then the command path is the one that is wrong. Note improvise-jytm will make active_view per-client, which touches the same ground.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T22:11:30Z","created_by":"Edward Langley","updated_at":"2026-09-19T22:50:02Z","closed_at":"2026-09-19T22:50:02Z","close_reason":"Found again by Copilot on PR #11 and fixed in ee767f9, merged as eb0c271. apply_command performed Command::SwitchView itself, calling App::switch_to_view directly instead of running effect::SwitchView -- and the EFFECT is the only thing that pushes onto view_back_stack and clears view_forward_stack. It was also the last hand-rolled mutation left in apply_command, which sat badly against the PR own \"effects are the only mutation site\" thesis.\n\nWAVE 4 IS WHAT MADE IT BITE: ModelCommand::ViewBack pops exactly that stack, so a wire client could switch views and then be unable to go back, while the same two keypresses worked. Before ModelCommand there was no wire way to ask, so the missing push was invisible.\n\nDemonstrated first: a_wire_view_switch_records_history_the_way_a_key_switch_does failed with the document still on Second. Nothing else in the suite could see it -- switch_view_moves_the_document_and_the_client_together checks where a switch LANDED, not what it RECORDED.\n\nPart of Copilot suggestion was declined with reasons on the thread: the arm still returns a hand-written Dirty rather than Dirty::of(Model(LayoutGeometry)), because Dirty::of is a per-KIND over-approximation and a view switch is the one member of that kind which leaves the view list alone -- which view is ACTIVE is not in ViewListCache at all. switching_views_projects_the_axis_widgets_only pins that.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_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":"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}
|
||
{"_type":"issue","id":"improvise-8x9o","title":"Vendored SplitMix64 has no known-answer test","description":"From the wave-1 gp-tree review (2026-09-19). CONFIRMED by fault injection: changing the generator's gamma constant from 0x9E3779B97F4A7C15 to ...7C17 left 27/27 tests passing.\n\nimprovise-lzn's whole promise (gp-tree/src/lib.rs:58-68) is that 'a bug report must be reconstructible from its seed alone'. Nothing in the workspace pins the generator's output, so a future cleanup of the vendored RNG silently invalidates every seed already printed by a fit and every Model.seed already saved -- with no test failing. This is doctrine(check_the_checker) exactly: the guarantee nobody has seen fail.\n\nFIX: three lines -- a known-answer vector, e.g. assert SplitMix64::new(0).range(0, 1_000_000) equals its current value, and one more for a non-zero seed.\n\nRelated, LOW: the determinism pair (the_same_seed_fits_the_same_formula / a_different_seed_fits_a_different_formula) does not by itself prove the search MOVED -- a GA whose breed did nothing would still differ across seeds because the initial random_tree differs. It happens to be true (instrumented: best cost 3.55 -\u003e 1.80 and 6.46 -\u003e 0.76 over 20 generations), but assert it: compare the fit against the generation-0 best.","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":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T20:43:12Z","created_by":"Edward Langley","updated_at":"2026-09-19T21:50:37Z","started_at":"2026-09-19T21:15:04Z","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}
|
||
{"_type":"issue","id":"improvise-ki7c","title":"EnterMode writes a runtime-named buffer without going through write_buffer","description":"From the wave-1 review (2026-09-19). Latent, not live.\n\nimprovise-gdl made cmd::core::write_buffer(name, value) the single accessor for a RUNTIME-NAMED buffer, so that the 'search' name routes to SetSearchQuery instead of a dead map entry. But cmd/mode.rs's EnterMode still builds effect::SetBuffer { name: mb.buffer_key.to_string(), .. } directly. Every other remaining direct SetBuffer uses a LITERAL name ('command', 'export', 'edit') that is statically not 'search', so EnterMode is the one site that writes a name it does not know at compile time and bypasses the router.\n\nUnreachable today because no AppMode carries buffer_key: \"search\". Concrete failure when that changes: the natural follow-up to 'SearchMode is Normal + a flag' (gotcha 11/14) is a real AppMode::Search { minibuf: ... buffer_key: \"search\" }. EnterMode would then clear buffers[\"search\"] while append-char/pop-char read and write view_state.search_query -- so entering search mode would leave the PREVIOUS query on screen. Exactly the desync gdl closed, reopened from the other side.\n\nFIX: one line -- effects.push(write_buffer(\u0026mb.buffer_key, String::new())) in EnterMode.","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":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T20:42:38Z","created_by":"Edward Langley","updated_at":"2026-09-19T21:50:35Z","started_at":"2026-09-19T21:15:03Z","closed_at":"2026-09-19T21:50:35Z","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-odi4","title":"Nothing ties AppMode::hint_text to the actual keymap","description":"From the wave-1 review (2026-09-19). only_quit_has_no_hint asserts a hint is PRESENT; nothing asserts it is ACCURATE. Rebinding a mode's keys leaves its hint silently lying to the user -- the same class of silent drift that doctrine(binding_dump_diff) exists for.\n\nConcretely: improvise-1c9 added 'Enter:export Esc:cancel — type a file path' to ExportPrompt. The reviewer confirmed it matches the real keymap today (Enter =\u003e SEQ[commit-export, clear-buffer export], Esc =\u003e SEQ[clear-buffer export, enter-mode normal]). Nothing keeps that true.\n\nFIX: for each mode, split its hint on whitespace and for every 'Key:verb' token assert keymap.lookup(key, NONE).is_some() in that mode. Prove it fires by rebinding or removing a key named in a hint.\n\nCosmetic while there: every other hint uses two spaces around the em dash; the new ExportPrompt one uses one.","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":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T20:42:37Z","created_by":"Edward Langley","updated_at":"2026-09-19T21:50:35Z","started_at":"2026-09-19T21:15:03Z","closed_at":"2026-09-19T21:50:35Z","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-ksst","title":"A gp-tree fit records its seed but not its GA config","description":"Follow-up from improvise-lzn (2026-09-19). data::Model now records the seed, so a fit looks reproducible, but the GA configuration (population, generations, elite count, penalty) is not recorded with it. A saved model is therefore only reproducible if the caller independently remembers the config -- and nothing says so, which is the trap: the seed's presence implies a completeness that is not there.\n\nEither record the config alongside the seed in data::Model, or document at the seed field that it is necessary but not sufficient.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T20:38:55Z","created_by":"Edward Langley","updated_at":"2026-09-19T21:23:03Z","closed_at":"2026-09-19T21:23:03Z","close_reason":"Model::config: Option\u003cFitConfig\u003e recorded alongside the seed -- the preferred option, record rather than document. Both stay Option for the same reason: every FitConfig value is legal, so none can stand for unknown, exactly as 0 is a legal seed. The seed doc now says it is necessary but not sufficient and points at config. FINDING worth keeping: the first injection -- deleting serde(default) from config -- did NOT fail any test, because serde already treats an Option field as optional. That attribute is documentation, not the mechanism, and the PRE-EXISTING a_model_saved_before_seeds_still_loads added earlier this session is equally non-discriminating against the fault it appears to guard. The injection was re-aimed at faults that matter: an absent config loading as the DEFAULT rather than as unknown (caught), and one FitConfig field silently not persisted (caught). Per populate_before_roundtrip the fixture differs from Default in every field, and the round trip asserts the loaded config is not the default, so a silent substitution cannot pass.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-szrt","title":"Wire improvise-gp into improvise (nothing depends on it yet)","description":"Follow-up from improvise-izk (2026-09-19). crates/improvise-gp exists and is tested but has no consumers -- it is a bridge with nothing crossing it. The next step is a user-facing path: fit a tree from the current model/view and install the emitted formula, presumably as a command in the Cmd/Effect layer.\n\nDesign constraints when doing it: the Cmd must stay pure and return effects (the fit itself is expensive, so decide whether it belongs in an Effect or off the main loop), and the emitted formula must go through the same add_formula path as a hand-typed one so _Measure target handling stays identical.","status":"closed","priority":3,"issue_type":"task","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-19T20:38:55Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:54:52Z","started_at":"2026-09-20T05:32:40Z","closed_at":"2026-09-20T07:54:52Z","close_reason":"Closed by PR #25 (WU-M). improvise-gp has consumers: the bridge now has something crossing it.\n\ngp-tree gained three pure functions -- denormalize (the inverse of standardize, applied to a tree), regression_quality (R2/RMSE AND the predict-the-mean baseline), importance_ranking -- and examples/fit_csv.rs now PRINTS FROM those instead of computing them inline, so the number a user decides whether to believe the rule by has one home. The library still returns data and still never prints (gotcha 37a); the CLI's output stayed byte-identical throughout, which is the evidence that held.\n\nimprovise-gp::fit::fit_measure is the whole sequence: dataset -\u003e cv_regress -\u003e standardize -\u003e fit -\u003e denormalize -\u003e fitted_formula -\u003e permutation_importance, returning a FitOutcome carrying the Formula, the Quality, the importance ranking, the warnings and the whole FitRequest.\n\nTHE DESIGN CONSTRAINTS THE TICKET SET WERE MET: the fit runs in Cmd::execute rather than an Effect, because a fit is decision logic -- a pure function of (Model, View, target, seed, FitConfig) that mutates nothing -- and the effects carry its RESULT. That buys purity, not asynchrony, so the cost was measured rather than assumed: FitRequest::default is 6ms / 12ms / 105ms over 12 / 200 / 2000 records in release, with MAX_FEATURES=64 and MAX_RECORDS=20000. And the emitted formula goes through the same add_formula path as a hand-typed one.\n\nQuality is an ENUM, not a number, because cv_regress pools nothing below two folds and an empty pool scores R2 exactly 0.0 -- \"nobody checked\" must not read as \"worth nothing\".","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-d44s","title":"gp_tree::data::preprocess writes to stderr from library code","description":"Follow-up from improvise-0yr (2026-09-19). preprocess() eprintln!s '(column X is entirely missing — skipped)'. That was correct in examples/fit_csv.rs but it is now library code, and a library deciding to write to the user's stderr is a layering problem -- the caller cannot suppress, redirect or localize it.\n\nLeft in place deliberately during 0yr so the extraction stayed byte-identical (the baselines it was verified against include that output). Fix by returning the skipped columns as data -- a warnings vec on Dataset, mirroring how improvise-io's build_model_with_warnings already surfaces import warnings rather than printing them.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T20:38:54Z","created_by":"Edward Langley","updated_at":"2026-09-19T21:22:56Z","closed_at":"2026-09-19T21:22:56Z","close_reason":"Dataset gains pub warnings: Vec\u003cString\u003e; preprocess pushes the skipped-column notice instead of printing it, and fit_csv.rs prints the vec at the same point so the CLI output does not move. Mirrors improvise-io build_model_with_warnings, which the ticket named as the shape -- a Warning enum was rejected because design-principles enums-over-strings is about state, not human-readable notices. Bug-first: the test failed to COMPILE before the fix (no field warnings on Dataset, 5 sites). Fault injected: replaced the push with a discarded format!, so the column is still dropped but silently -- an_entirely_missing_column_is_skipped_and_reported_as_data failed (left 0, right 1) while 24 others stayed green. CLI output verified byte-identical across 9 scenarios.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-z5eg","title":"Gate improvise-gp in the CI wasm job","description":"Follow-up from improvise-izk (2026-09-19). The new crates/improvise-gp is not in the wasm job in .github/workflows/ci.yml. Its dependency set (gp-tree + improvise-formula) should be wasm-clean, but 'should be' is exactly what the job exists to stop us asserting -- see invariant(compile_check_is_not_runtime_proof) and the ywd precedent. Add it to the job and confirm, or record why it is deliberately excluded.","status":"closed","priority":3,"issue_type":"chore","owner":"el-github@elangley.org","created_at":"2026-09-19T20:38:54Z","created_by":"Edward Langley","updated_at":"2026-09-19T21:15:51Z","closed_at":"2026-09-19T21:15:51Z","close_reason":"Added improvise-gp to the wasm job in .github/workflows/ci.yml. Verified by compiling first rather than asserting: cargo check -p improvise-gp --target wasm32-unknown-unknown finishes clean -- the review that filed this could not verify it (no wasm target installed there) and marked it SUSPECTED. Gated with a comment saying it is NOT in the browser bundle (nothing depends on it yet) but is a candidate -- fitting a tree in the browser -- and that staying wasm-clean is free now and expensive to retrofit. That answers the ticket's 'or record why it is deliberately excluded' branch by choosing the other one.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-heqk","title":"Lift parse_md's inner helpers to module scope in persistence/parse.rs","description":"Follow-up from improvise-nr5 (2026-09-19), explicitly out of scope there because nr5 was a pure relocation and this is a real restructuring.\n\nAfter the split, crates/improvise-io/src/persistence/parse.rs is 1378 lines, of which ~950 are the four pre-existing test modules. The remaining parse_md is ~300 lines of nested tree-walk with its helpers (next, first_str, extract_name, extract_name_pair) declared INSIDE the function body, so the parser cannot be reviewed section by section.\n\nLift those helpers and the per-section walkers to module scope. The pest grammar stays the single source of truth and must not change; the characterization_survives_the_move doctrine applies -- the 97 persistence tests must carry across unchanged in meaning.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T20:36:45Z","created_by":"Edward Langley","updated_at":"2026-09-19T21:21:37Z","closed_at":"2026-09-19T21:21:37Z","close_reason":"Lifted to module scope: next, first_str, extract_name, extract_name_pair, and the PCategory/PView collectors; added one walker per top-level grammar section (walk_category_section, walk_formulas_section, walk_data_section, walk_view_section and friends) plus parse_tree and section_title. parse_md is now a three-line composition: build_workbook(walk_file(parse_tree(text)?)?). improv.pest untouched.\n\nBehaviour preservation proven in the strongest form available: only lines 1-332 were replaced, and the four test modules are BYTE-IDENTICAL afterwards (diff of the old tail against the new differs by one leading blank line). No test needed a new assertion because no test line changed at all. invariant(indexmap_order) holds -- PCategory::items is still a Vec replayed in file order, now documented on the field. No tests added deliberately: adding them would have muddied the byte-identical proof.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-3qz2","title":"Cloud-session tooling: session-start hook, code-review skill, nix input seeding","description":"Retroactive record of PR #7 (merged into main as f01a07a on 2026-09-19, branch claude/cloud-agent-tooling). Cloud sessions could not reach the tracker or build the project: nix develop needs flake inputs that are not fetchable there, and a session had no way to pick up bd state at start. Filed during the 2026-09-19 groom because the work landed with no issue of its own.","design":"Four artifacts: .claude/hooks/session-start.sh (bd prime at session start, with a tracker swap that has to be safe on every failure path), .claude/skills/code-review/SKILL.md (review skill grounded in context/design-principles.md + kb.pl; renamed from design-review so Copilot picks it up), scripts/nix-seed-flake-inputs.sh (seeds locked flake inputs over git so nix develop works in a cloud session without touching the flake), context/cloud-agents.md (what the cloud environment can and cannot reach). Five hardening commits followed review: pin the bd installer, stop hiding jq failures, guard mktemp, if/else for the cargo build check (SC2015), and a documented deliberate SC2016.","notes":"The durable lesson is in kb.pl as doctrine(force_the_failure) (84c3c70): four unchecked statuses on that branch each produced a usable-looking value on failure — bd init leaving an empty database, mv landing the new tracker inside the old directory, jq's status swallowed by a process substitution, and a failed mktemp turning $TMP/$NAME into an absolute path at the filesystem root. Reading the failure paths found none of them; stubbing mv and mktemp to fail did.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T19:49:34Z","created_by":"Edward Langley","updated_at":"2026-09-19T19:49:34Z","closed_at":"2026-09-19T19:49:34Z","close_reason":"Merged as f01a07a (PR #7, plus #8 review follow-up). Filed retroactively 2026-09-19 during the groom; the work predates the ticket.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-sef","title":"Audit and repair prolog-sessions/kb.pl, add a maintained consistency check","description":"Retroactive record of work already landed (a406b52, 3a58d3c, 3697171, 2026-08-30/31). kb.pl had been appended to every session without anyone checking that it loads or reasons correctly; it needed an audit, and then a mechanical check so the same drift cannot accumulate again.","design":"Two unsound rules: may_depend_on/2 compared a crate against a concern and left its second argument unconstrained (so may_depend_on(improvise_core, banana) succeeded), and before_fixing/2's A=C guard bound a fresh variable and constrained nothing. Replaced with concern/1 + may_use/2 + crate_depends_on/2 facts read from the Cargo.tomls, and illegal_dependency/2 for inverted layering. The MCP runtime's own predicates (sleep, js_eval, future, await_any) had been captured into the file by a saveSession and were removed. kb_report/0 and kb_consistent/0 enumerate kb_error/1 over four classes: domain integrity, layering, rule soundness (a rule must REJECT a sentinel from no declared domain), and functional keys. Wired into CI as a third job.","notes":"Proving the checker fires found two bugs in the checker itself: depends_transitively/2 looped forever on the very input illegal_dependency/2 exists to detect (now carries a visited set), and trealla rejects the unparenthesized :- discontiguous form that the MCP server had silently tolerated. Every check class was verified by injecting the fault it targets. 3697171 additionally recorded the backlog-grooming method as kb facts: cycle detection, priority inversion, stale file:line citations and dissolved premises are mechanically checkable; prose mentions of other issues are not (of 21 one-way mentions, 2 were real dependencies).","status":"closed","priority":3,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-19T19:34:39Z","created_by":"Edward Langley","updated_at":"2026-09-19T19:34:47Z","closed_at":"2026-09-19T19:34:43Z","close_reason":"Landed in a406b52 (rule repair + shim removal), 3a58d3c (kb_report/kb_consistent + CI job, with every check class fault-injected), 3697171 (backlog-grooming method recorded). Filed retroactively 2026-09-19 while reconciling commits against the tracker; the work itself predates the ticket.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-fuj","title":"Two effects read files directly instead of going through Storage","description":"Found during the wasm audit (improvise-ywd). improvise-6mq put persistence behind a Storage trait, but two effects still call std::fs directly and so bypass it:\n\n crates/improvise-command/src/ui/effect.rs:931 StartImportWizard -\u003e std::fs::read_to_string\n crates/improvise-command/src/ui/effect.rs:1052 LoadModel -\u003e std::fs::read_to_string\n\nBoth COMPILE for wasm32-unknown-unknown — std::fs exists on that target — but they fail at runtime in a browser, which is exactly the class of bug a cargo check gate cannot catch. That is why they are filed rather than fixed under the check-based acceptance criteria of improvise-ywd.\n\nLoadModel is the clearer case: it duplicates what persistence::load already does, and persistence::load_from(\u0026dyn Storage, path) now exists. StartImportWizard reads a JSON file to seed the wizard; it wants the same treatment.\n\nBlocked in practice on how a wasm build gets its Storage instance — App has no Storage today, the free functions persistence::save/load hardwire PhysicalFs. Deciding where the backend is injected (App field? a parameter on the effect? a thread-local?) is the real design question, and it also determines how improvise-i34 (OPFS backend) plugs in.","status":"closed","priority":3,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-08-30T16:50:43Z","created_by":"Edward Langley","updated_at":"2026-08-30T17:01:09Z","started_at":"2026-08-30T16:57:21Z","closed_at":"2026-08-30T17:01:09Z","close_reason":"Wider than filed. The two effects I named were the visible symptom; the actual gap was that App had no storage backend at all, so Save, SaveAs, ExportCsv, LoadModel and CSV import ALL hardwired the filesystem through persistence::save/load/export_csv and csv_parser::parse_csv.\n\nINJECTION POINT: App::storage: Box\u003cdyn Storage\u003e, chosen by the host at construction. App::new still supplies PhysicalFs so every existing caller and all 14 suites are untouched; App::with_storage takes any other backend. That is the plumbing improvise-i34 and improvise-9x6 need — a worker constructs App with an OPFS backend and nothing else changes.\n\nConsidered the alternatives named on the ticket: a parameter on each effect spreads the backend through every signature for something that is per-session config, and a thread-local hides a dependency that App is the natural owner of. App already owns the other host config (keymap_set, terminal size).\n\nimprovise-io keeps its path-taking wrappers so the native CLI is unchanged, each delegating to a backend- or bytes-taking variant:\n save -\u003e save_to, load -\u003e load_from (from improvise-6mq)\n export_csv -\u003e export_csv_to, via a new render_csv that produces bytes\n parse_csv_with_warnings -\u003e parse_csv_bytes_with_warnings\n\nRESULT: production code in improvise-command now contains no std::fs at all — only tests do.\n\nThe test is the deliverable, not the refactor: the_document_surface_runs_on_a_non_filesystem_backend drives SaveAs, ExportCsv, LoadModel and StartImportWizard over a MemoryFs with no disk touched, and inspects the backend afterwards to confirm the bytes landed there. Any of those effects reaching for std::fs fails it. That is the property a browser backend depends on, verified now rather than discovered later in wasm.\n\nAlso verified: both targets still check clean (native + wasm32 for improvise-io and improvise-command), and the real CLI still imports, runs a headless command, and exports CSV.","dependency_count":0,"dependent_count":1,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-lae","title":"ZZ / :wq with no file path should prompt for a path instead of only erroring","description":"Follow-up to improvise-8tk. That fix stops ZZ/:wq from quitting when the save fails: with no file path the user now stays in the app with 'No file path — use :w \u003cpath\u003e' in the status bar. That is correct but not helpful — the vim reflex is ZZ, and the user has to know to type ':w \u003cpath\u003e' next.\n\nimprovise-8tk's fix sketch suggested opening the export prompt, but AppMode::ExportPrompt commits to ExportCsv (command/cmd/commit.rs::CommitExport) — it writes CSV, not a .improv model. There is no save-as prompt mode; ':w \u003cpath\u003e' goes straight through SaveOrSaveAs (command/cmd/effect_cmds.rs:390).\n\nOptions: (a) add a SavePrompt minibuffer mode built with the TextEntry builder in keymap.rs, committing to SaveAs; (b) make SaveAndQuit emit a mode change to that prompt when ctx has no file path — but CmdContext does not currently expose file_path, so that needs a context field.\n\nPrefer (a) plus a keymap binding; decide separately whether ZZ should route into it automatically.","status":"closed","priority":3,"issue_type":"task","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-08-29T17:34:38Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:54:18Z","started_at":"2026-09-20T05:32:30Z","closed_at":"2026-09-20T07:54:18Z","close_reason":"Closed by PR #27 (WU-G). Option (a) as the ticket preferred: AppMode::SavePrompt, built by the TextEntry builder, committing to SaveAs.\n\nBound Ctrl-W in Normal beside Ctrl-E, and reachable as `:enter-mode save-prompt`. The \"No file path\" message now names it. ExportPrompt was confirmed genuinely unreusable -- CommitExport writes CSV, not a model.\n\nEVIDENCE: the binding dump diff is purely additive (one Normal binding, inherited by RecordsNormal, plus the new keymap; no existing line moved). The two meta-tests carry it and BOTH WERE PROVED TO REACH IT by injection -- every_hint_key_resolves_in_its_mode fails on an unbound Tab in the new hint, every_bound_command_name_is_registered fails on a typo'd commit name. Plus commit_save_writes_the_model_to_the_typed_path, commit_save_on_an_empty_path_writes_nothing, and an end-to-end ctrl_w_prompts_for_a_path_and_a_failed_write_reports_it_where_it_shows.\n\nONE INJECTION THAT WAS NOT DISCRIMINATING AND WAS REPLACED, worth recording: the first attempt put Ctrl-x:nope in the hint and the test PASSED -- because text-entry keymaps bind AnyChar, so any char key resolves. Re-aimed at Tab, which those keymaps genuinely do not bind, and it failed correctly. doctrine(an_injected_fault_must_be_the_fault_you_named) in practice.\n\nTWO THINGS THAT CAME OUT OF IT, both filed: the ordering rule that an abort must not eat the mode restoration (now in gotcha 21), and improvise-rw3a, the open half -- whether ZZ should route into the prompt automatically, and what ZZ even means once it is open.","dependency_count":0,"dependent_count":1,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-ai7","title":"Collapse the row/col duplication in view/layout.rs","description":"Supersedes improvise-1wu and improvise-669. Both are the same defect — a row function and a col function that are near-identical — inside the same ~120-line region of crates/improvise-core/src/view/layout.rs, so they are one editing pass, not two.\n\n(1) from improvise-1wu — row_label (layout.rs:327) and col_label (layout.rs:342) are character-identical except for which items list they read. Extract fn axis_label(items: \u0026[AxisEntry], idx: usize) -\u003e String and call it from both. ~14 LOC.\n\n(2) from improvise-669 — the row prune loop (~layout.rs:249) and the col prune loop (~layout.rs:271) both walk a keep_row/keep_col bool vector while tracking data_idx separately from iteration order. The deep review flagged the data_idx tracking as brittle. Extract fn prune_axis_items(items: \u0026mut Vec\u003c_\u003e, keep: \u0026[bool]) so the invariant (data_idx == keep.len() at loop exit) is asserted in one place instead of zero. ~20 LOC. This is the half with actual correctness content: today a bug in the row prune can hide while the col prune stays correct.\n\nWHY TOGETHER: same file, same region, same row/col-symmetry smell, and the same test surface — layout tests would be re-read once instead of twice. Neither is big enough to justify its own session.\n\nDo (2) first; it is the one that can hide a bug. (1) is cosmetic.\n\nNOT included: improvise-rml (consolidate Axis display into methods on Axis) stays separate — different file (view/axis.rs plus the UI widgets) and a different concern (display strings and colors, not layout arithmetic).","notes":"STALE REF (groomed 2026-08-30, line numbers shifted by this session's work): view/layout.rs:249/271/327/342 -\u003e ALL FOUR are stale after improvise-c5v rewrote records mode; 249 and 271 now land inside the new records_columns(). The row/col duplication this ticket is about now lives in prune_empty (crates/improvise-core/src/view/layout.rs:321), which still filters row_items and col_items through two near-identical blocks. Re-verify the scope before starting: c5v deleted the unreachable records branch that used to sit at the end of that function.\nGROOMED 2026-09-19: prune_empty is layout.rs:323 now (the 2026-08-30 note said 321). Confirmed the c5v cleanup: the unreachable records tail is gone (improvise-ede closed today), so the remaining duplication is the row_items/col_items pair inside prune_empty plus row_label/col_label.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-08-14T01:11:07Z","created_by":"Edward Langley","updated_at":"2026-09-19T20:33:46Z","closed_at":"2026-09-19T20:33:46Z","close_reason":"Collapsed the row/col duplication in view/layout.rs into four private free functions over \u0026[AxisEntry]: prune_axis_items (the half with correctness content -- the two hand-tracked data_idx loops in prune_empty now share one debug_assert on the parallel-length invariant, with bounds behaviour preserved via keep.get().unwrap_or(false)), axis_label, data_items (an iterator that replaced six copies of the DataItem filter), and data_index_to_visual. Net -46 lines. Deliberately left asymmetric: records mode entirely (rows are coordinate tuples, columns are measures plus the + column -- real asymmetry, not duplication), the has_value/keep_row/keep_col derivations, row_group_for/col_group_for, and the GridLayout row/col field pairing (unifying would be a speculative abstraction churning every consumer). No test changed meaning; 805 workspace tests pass, clippy clean.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-djn","title":"AddCategory effect silently swallows failures","description":"ui/effect.rs AddCategory does 'let _ = app.model_state.workbook.add_category(\u0026self.0)'. Duplicate names and the 12-regular-category limit are both dropped on the floor, so 'a' in the category panel with 12 categories already present reports 'Added category \"X\"' and adds nothing.\n\nAddItem and AddFormula already write their error to view_state.status_msg, and since improvise-6t3 the commit command emits its optimistic status BEFORE the add effect precisely so the add gets the last word. AddCategory just needs to do the same:\n\n if let Err(e) = app.model_state.workbook.add_category(\u0026self.0) {\n app.view_state.status_msg = e.to_string();\n }\n\nFound while unifying the commit commands (improvise-6t3). Needs a test that applies AddCategory over a full model and asserts the status bar reports the limit.","notes":"GROOMED 2026-09-19: still true; the site is crates/improvise-command/src/ui/effect.rs:71-74 (let _ = app.model_state.workbook.add_category(\u0026self.0)). Noticed while verifying: that effect declares EffectKind::Model(ModelChange::CellData), though adding a category changes the grid geometry — worth a look when fixing this, but not part of this ticket.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-08-01T23:22:54Z","created_by":"Edward Langley","updated_at":"2026-09-19T20:35:55Z","closed_at":"2026-09-19T20:35:55Z","close_reason":"Bug confirmed by a failing test written first: add_category_past_the_limit_reports_the_failure fills to MAX_CATEGORIES and asserts the 13th reports the limit. It failed on exactly the status-bar assertion -- the category was correctly rejected, only the message was lost. One-line fix as the ticket specified; test kept as a regression guard with the bug in its doc-comment.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-toc","title":"Effect::changes_mode() is a static flag but ViewBack/ViewForward/StartImportWizard change the mode conditionally","description":"Effect::changes_mode() is a per-type constant, and its only consumer is ExecuteCommand: 'return to Normal unless a command already changed mode' (command/cmd/text_buffer.rs).\n\nThree effects mutate view_state.mode only on some runs, so no static answer is right:\n- ViewBack / ViewForward (ui/effect.rs) restore frame.mode only when the back/forward stack is non-empty.\n- StartImportWizard (ui/effect.rs) sets AppMode::ImportWizard only when the file parses.\n\nReporting false (today) means the success path is wrong: ':import data.csv' opens the wizard and is then snapped back to Normal, leaving the wizard drawn with the Normal keymap bound. Same for ':view-back' when there IS history. Reporting true would make the failure path wrong: a failed ':import' would strand the user in CommandMode.\n\nEnterEditAtCursor had the same symptom but is unconditional, so it was fixed by overriding changes_mode() (improvise-6sq).\n\nOptions:\n(a) have these effects set an app-level 'mode was changed' flag during apply, and let ExecuteCommand decide after applying rather than before — but ExecuteCommand is a pure Cmd and cannot observe apply.\n(b) split the conditional effects into their decision (in the Cmd, where the stack/file state is visible) and an unconditional ChangeMode effect. ViewBack's stack is on App though, not CmdContext.\n(c) give ExecuteCommand a trailing 'return to Normal if still in CommandMode' effect instead of the compile-time check — an effect CAN read app state at apply time, which is where this decision actually belongs.\n\n(c) looks cleanest and would let changes_mode() be deleted entirely.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-08-01T20:29:19Z","created_by":"Edward Langley","updated_at":"2026-09-19T19:48:50Z","closed_at":"2026-09-19T19:48:50Z","close_reason":"Fixed on the cloud-agent branch merged as f01a07a: a341717 \"settle : command mode by observation, not prediction\" plus e8dcdb1 (Copilot review follow-up). Effect::changes_mode() is gone; ExecuteCommand now ends every batch with ReturnToNormalUnlessMoved(ctx.mode) (effect.rs:556), which drops to Normal only if the mode is still what the batch started in — so ViewBack/ViewForward/StartImportWizard need no up-front answer. repo-map gotcha 31 documents it. Closed during the 2026-09-19 groom; the commits landed without closing the ticket.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-1c9","title":"ExportPrompt mode shows no status-bar hint","description":"AppMode::hint_text() returns \"\" for AppMode::ExportPrompt, so when the user hits the export prompt the status bar goes blank instead of telling them what to type (a path) or how to cancel (Esc).\n\nFound while making hint_text exhaustive in improvise-2hi: the old match had a '_ =\u003e \"\"' arm that hid the gap. The exhaustive match now names ExportPrompt explicitly, and app.rs::only_export_prompt_and_quit_have_no_hint pins the current behavior — update that test when adding the hint.\n\nQuit is also blank, but correctly so: it is never rendered.\n\nFix: give ExportPrompt a hint along the lines of 'Enter:export Esc:cancel — type a file path'.","notes":"GROOMED 2026-09-19: still true, new home — AppMode and hint_text moved to improvise-protocol in improvise-cqi. The empty arm is crates/improvise-protocol/src/lib.rs:218 (ExportPrompt and Quit share it; Quit having no hint is fine).","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-08-01T19:17:44Z","created_by":"Edward Langley","updated_at":"2026-09-19T20:35:55Z","closed_at":"2026-09-19T20:35:55Z","close_reason":"Bug confirmed by a failing characterization test: only_export_prompt_and_quit_have_no_hint became only_quit_has_no_hint and failed with left: [EXPORT, QUIT]. Added the hint in improvise-protocol's AppMode::hint_text: 'Enter:export Esc:cancel — type a file path', with keys matching the actual ExportPrompt keymap (TextEntry + .commit(Enter, commit-export)). Quit keeps its blank arm and the doc-comment now says why.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-7na","title":"Decide whether fitted gp-tree models are persisted alongside the formula","description":"The emitted formula is enough to recompute existing cells, so the .improv file needs nothing extra for the common case. But scoring NEW rows — the --predict path in fit_csv — needs the encoding plan (which columns are categorical, their training-set distinct values, imputation means), which the formula text does not carry.\n\nDecide: (a) formula only, and refitting is the way to score new data; (b) store the gp_tree Model as a sidecar/JSON blob; (c) store the seed + config so the fit is reproducible without storing the tree. Option (c) is cheap and matches the format's plain-text character, but only works if the training data is unchanged. This is a format decision, so settle it before the fit command ships and starts writing files.","acceptance_criteria":"A decision is recorded with its rationale, and the .improv grammar either changes or is explicitly confirmed unchanged.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-08-01T17:25:21Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:34:50Z","closed_at":"2026-09-20T00:34:50Z","close_reason":"DECIDED: (a) formula only. improv.pest explicitly confirmed unchanged; no code, three tests, recorded on improvise-gp crate docs.\n\nAGAINST THE BLOB, gotcha 41 SECOND half transfers and decides it: a blob is a second copy of what the file already carries -- the tree IS the formula -- with nothing keeping the two in agreement after a hand-edited formula or an edited cell, and a round-trip test over the blob cannot see that drift because it never compares the two. Gotcha 41 FIRST half does NOT transfer (an opaque production implements no grammar), and saying which half applies is the useful part. Plus Tree/FitConfig are gp-tree types, so improvise-io would gain a gp-tree dependency and .improv would track a third crate serde layout forever.\n\nWHAT THE BLOB WAS FOR DOES NOT ARISE: the encoding plan exists so fit_csv --predict can score a row arriving as untyped CSV text; improvise new row is a record in the same document, scored through the stored formula by recompute_formulas. Demonstrated by a_record_added_after_the_fit_is_scored_by_the_formula_alone, which adds a REGION THE FIT NEVER SAW and gets a correct score.\n\nSeed + config not written either: gp-tree records them already (gotcha 37b) and nothing reads them until a fit command exists -- provenance is that ticket call, alongside how it words the fit to the user.\n\nTHE TICKET PREMISE WAS FALSE AND THAT IS THE LARGER FINDING. \"The emitted formula is enough to recompute existing cells\" holds ONLY for a fit over numeric measures. preprocess one-hots a categorical column into a feature named Region=North -- an encoding artefact, not an item of the document -- so Expr::Ref fails find_item_category and EVERY CELL of the fitted measure is Error(\"ref:Region=North\"). col.missing and the col.year date family are in the same position. Pinned as a_split_on_a_one_hot_feature_does_not_evaluate (characterization; flip it when fixed).","dependencies":[{"issue_id":"improvise-7na","depends_on_id":"improvise-izk","type":"blocks","created_at":"2026-08-01T10:26:23Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-vx3","title":"fit-measure command: fit a tree and add it as a formula","description":"The user-facing surface. A command (:fit-measure \u003ctarget\u003e or similar) that extracts features from the active view, runs the fit, emits the champion as formula text and adds it as a new measure — after which it is an ordinary improvise formula: recomputed by the fixed-point evaluator, editable in the formula panel, saved in the ## Formulas section, drillable.\n\nReport quality alongside it, not just the rule: cross-validated R²/RMSE against the predict-the-mean baseline, and the permutation feature-importance ranking. A decision tree that looks tidy but generalizes no better than the mean is worse than useless — it is a rule the user will believe. Per the command/effect split, the fit belongs in a Cmd (pure over CmdContext) with effects that add the formula and set status.","acceptance_criteria":"(1) A command fits and installs the formula. (2) The formula survives save/load and recomputes. (3) Fit quality vs baseline is reported. (4) Fitting is cancellable or bounded — a GA run must not freeze the TUI.","status":"closed","priority":3,"issue_type":"feature","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-08-01T17:25:21Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:54:52Z","started_at":"2026-09-20T05:32:41Z","closed_at":"2026-09-20T07:54:52Z","close_reason":"Closed by PR #25 (WU-M). `:fit-measure \u003ctarget\u003e [\u003cmeasure-name\u003e] [\u003cseed\u003e]` exists and emits SetStatus + MarkDirty + AddFormula. After it there is nothing GP-specific in the document, and no grammar change was needed.\n\nQUALITY IS REPORTED ALONGSIDE THE RULE, which was the ticket's most important line and the reason it insisted: \"a decision tree that looks tidy but generalizes no better than the mean is worse than useless -- it is a rule the user will believe\". Cross-validated R2/RMSE against the predict-the-mean baseline, plus the permutation importance ranking, all read off gp-tree's own functions rather than recomputed.\n\nTHE BUG THE TICKET DID NOT PREDICT, and it is the reason the fit is stored correctly at all: THE FIT IS STANDARDIZED, so a champion written down untouched is out by an affine map in every cell -- well-formed, parsing, and wrong. `denormalize` is what closes it, and the_fitted_formula_predicts_in_the_documents_own_units is the guard: dropping denormalize gives `record r0 predicted -0.91` against a 1000-4000 document, AND NOTHING ELSE FAILS.\n\nEvery new test asserts against a REAL DOCUMENT. None uses the improvise_eval shape that fabricates a _Measure item per feature name -- the helper that builds the world the code wants, which gotcha 37 records as the test that proved nothing.\n\nNOT DONE, and now the whole of improvise-4zl7: a formula evaluated at an AGGREGATED cell routes on the aggregate, and aggregate-then-route is not route-then-aggregate. So every fitted tree -- numeric splits included -- means one thing on a record row and another one row up. fit-measure on web/demo.improv is currently REFUSED, and on a document with several categories the same command refuses or succeeds DEPENDING ON THE SEED. The user has ruled: aggregate the rule over the members.\n\nNO KEYBINDING, deliberately: a bare key for a 100ms stochastic search that rewrites the document is the wrong default.","dependencies":[{"issue_id":"improvise-vx3","depends_on_id":"improvise-0ai","type":"blocks","created_at":"2026-08-01T10:26:21Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-vx3","depends_on_id":"improvise-4s8","type":"blocks","created_at":"2026-08-01T10:26:22Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-vx3","depends_on_id":"improvise-izk","type":"blocks","created_at":"2026-08-01T10:26:20Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-vx3","depends_on_id":"improvise-lzn","type":"blocks","created_at":"2026-08-01T10:26:21Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":4,"dependent_count":1,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-4s8","title":"Extract a gp-tree feature matrix from a View's records","description":"Turn the active view's data into gp-tree's fixed-width f32 feature vectors: pick a target measure, use the remaining measures as numeric features and the regular categories as categorical features (one-hot, via the preprocessing promoted out of fit_csv). One row per distinct combination of the non-measure coordinates — which is what records mode already enumerates, so GridLayout's records path is the natural source rather than a new traversal.\n\nOpen questions worth settling here: whether hidden items and page filters should scope the training rows (the user's mental model is probably 'fit what I can see'), whether formula-derived measures may be used as features (they can, but a feature that already depends on the target makes the fit trivially perfect and useless — needs a guard), and what to do with Label categories, which are high-cardinality per-row text and would one-hot into thousands of columns.","acceptance_criteria":"(1) A function maps Model + View + target measure to (feature matrix, targets, Spec with names and ranges). (2) Spec ranges are supersets of the actual values — gp-tree's simplify prunes reachable branches otherwise, silently changing outputs. (3) A feature that is the target, or a formula depending on it, is rejected or excluded. (4) Tests over a small fixture model.","status":"closed","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-08-01T17:25:20Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:34:49Z","closed_at":"2026-09-20T00:34:49Z","close_reason":"crates/improvise-gp/src/dataset.rs: feature_table / dataset turn a View visible records into the string table gp_tree::data::preprocess reads. THE SHAPE WAS NOT INVENTED -- records mode ALREADY IS a feature matrix (gotcha 1), a row is one coordinate tuple and each measure a column -- so it enumerates the same rows against the USER OWN view rather than through GridLayout, which would need _Index/_Dim on the axes and therefore a different view from the one being looked at.\n\nFOUR SELECTION RULES, each a place a wrong answer is silent:\n * rows scoped by View::filtering_cats() (Page UNION Filter), NOT categories_on(Axis::Page) -- gotcha 27, and the Page answer quietly fits against rows a drill excluded;\n * hidden items scope rows AND feature columns (\"fit what I can see\"), an explicitly named target exempt;\n * every regular category is a feature WHATEVER AXIS it sits on including Axis::None, because a coordinate is part of what makes a record distinct -- drop a None category and two rows get identical feature vectors with different targets; Label categories excluded by not being is_regular, as in records_columns, since per-row text one-hots into a perfect memoriser;\n * NO FORMULA-DERIVED VALUE ENTERS AT ALL -- a formula target is not a feature and is refused AS the target, one rule covering both halves with no dependency analysis. Membership read off model.formulas(), never off absence from the explicit _Measure item list, because a name can be BOTH.\n\nTWO INJECTIONS FOUND WEAK TESTS AND CHANGED THE WORK, which is the discipline paying out: the formula-feature injection first PASSED because the fixture Profit was only implied by its formula and so never reached the explicit item list, making the filter dead code from the test point of view -- the fixture now lists Profit as an explicit item AS WELL AS a formula target, the only configuration in which that rule is observable. And dropping the record sort left records_come_out_in_coordinate_order green (2 rows), so that test was DELETED rather than kept vacuous, with the ordering claim moved to the exact-table test doc comment plus an honest note that a single-process test cannot distinguish sorted from hashed-that-way-today.\n\nRecords are sorted by coordinate tuple because matching_cells walks a HashSet and f32 accumulation is order-dependent. Numbers reach the table via f64::to_string, never format_f64 (gotcha 8). A record with no target value is dropped with a warning -- keeping it flips preprocess whole target to CLASSIFICATION on one unparseable cell. 13 tests.","dependencies":[{"issue_id":"improvise-4s8","depends_on_id":"improvise-0yr","type":"blocks","created_at":"2026-08-01T10:26:10Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-lzn","title":"Seeded Rng implementation for gp-tree fits in improvise","description":"gp_tree::Rng is a trait the caller implements, deliberately, so evolution is reproducible and the crate pulls in no RNG. improvise has no PRNG dependency today. Decide between a small vendored deterministic generator (xorshift/PCG, ~20 lines, no new dep) and taking on rand. Reproducibility is not optional here: a fit writes a formula into the user's file, so re-running the same fit on the same data must produce the same rule, and a bug report must be reconstructible from the seed. Whatever is chosen, the seed should be visible to the user and recorded with the fit.","acceptance_criteria":"(1) An Rng impl exists for improvise fits. (2) Same seed + same data + same config yields an identical formula, asserted by a test. (3) The seed is surfaced, not hidden.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-08-01T17:24:34Z","created_by":"Edward Langley","updated_at":"2026-09-19T20:38:43Z","closed_at":"2026-09-19T20:38:43Z","close_reason":"Vendored SplitMix64 as gp_tree::SplitMix64 beside the Rng trait rather than adding a rand dependency -- the same generator was already written out three times, and the requirement is reproducibility, not statistical quality. Seed is surfaced, not hidden: SplitMix64::seed() keeps the starting value, fit_csv takes --seed (decimal or 0x hex) and prints it on every fit, and --save records it in data::Model::seed as #[serde(default)] so earlier models still load (verified by --predict against a pre-change baseline). Determinism tests pair the_same_seed_fits_the_same_formula with a_different_seed_fits_a_different_formula, so 'reproducible' cannot be satisfied by a search that never moved.","dependency_count":0,"dependent_count":2,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-0ai","title":"gp-tree: expose a fit/evolve entry point in the library","description":"The library exposes the GP operators — random_tree, mutate_at, crossover, subtree_at, graft_at, simplify, plus Population, GaConfig and breed — but no loop that drives them. The search itself lives in examples/fit_csv.rs, so every consumer reimplements generation-stepping, elitism, parsimony penalty and stopping. Add a fit entry point taking a dataset, a GaConfig and an Rng, returning the champion (simplified). Keep the k-fold cross-validation and permutation feature-importance reporting available too — those are what make the output trustworthy rather than just plausible.","acceptance_criteria":"(1) A public fit function evolves a champion from data + config + Rng. (2) fit_csv uses it. (3) Same seed and config produce the same tree.","notes":"GROOMED 2026-09-19 — STALE REF: examples/fit_csv.rs does not exist at that path; it is crates/gp-tree/examples/fit_csv.rs (gp-tree is a workspace member, not the root crate).","status":"closed","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-08-01T17:24:33Z","created_by":"Edward Langley","updated_at":"2026-09-19T21:23:04Z","closed_at":"2026-09-19T21:23:04Z","close_reason":"New crates/gp-tree/src/fit.rs (425 lines, 7 tests): FitConfig with Default = the CLI historical numbers, fit() returning the simplified champion across restarts, plus cv_regress/cv_classify/folds/standardize/permutation_importance/mse. Nothing in the module prints -- CV returns pooled prediction/actual pairs and importance a score per feature; tables stay in the CLI, the same boundary d44s drew. fit_csv.rs drops 787 to 598 lines. Premise verified before designing: the generation loop really was Trainer::fit in the example with pop/gens as function-local consts. Fault injected: the generation loop bounded to zero, i.e. a fit returning its seed tree unevolved. The two DETERMINISM tests stayed GREEN -- they would have been fooled, which is why they are not sufficient -- while a_fit_improves_on_the_tree_it_started_from and the_config_changes_the_fit_at_a_fixed_seed failed. Reproducibility verified end to end: same seeds produce the same fits after promoting the loop, predictions and saved model included, so RNG call order is provably unchanged.","dependencies":[{"issue_id":"improvise-0ai","depends_on_id":"improvise-0yr","type":"blocks","created_at":"2026-08-01T10:26:10Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-0yr","title":"gp-tree: promote fit_csv preprocessing into the library","description":"examples/fit_csv.rs carries 1261 lines of genuinely reusable preprocessing that no library consumer can reach: per-column type inference (numeric/date/categorical), one-hot encoding of categoricals, mean-imputation with a col.missing 0/1 indicator so absence is learnable, and the Model struct pairing fitted trees with the encoding plan needed to re-encode fresh rows.\n\nimprovise needs all of this to turn a view's records into gp-tree's fixed-width f32 feature vector — categorical dimensions are the norm in a pivot model, not the exception. Move it behind a public API; the example becomes a thin CLI over the library.","acceptance_criteria":"(1) Type inference, encoding, imputation and Model are public API. (2) examples/fit_csv.rs is a CLI wrapper with no preprocessing logic of its own. (3) Existing fit_csv behavior unchanged — same output for the same CSV.","notes":"GROOMED 2026-09-19 — STALE REF: examples/fit_csv.rs does not exist at that path; it is crates/gp-tree/examples/fit_csv.rs (gp-tree is a workspace member, not the root crate).","status":"closed","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-08-01T17:21:15Z","created_by":"Edward Langley","updated_at":"2026-09-19T20:38:43Z","closed_at":"2026-09-19T20:38:43Z","close_reason":"Preprocessing promoted into a public gp_tree::data module (534 lines): ColumnKind, ColumnSpec, Target, Model, Dataset, preprocess, encode_row, read_csv, parse_datetime and friends. examples/fit_csv.rs went 1261 -\u003e 787 lines and now holds only CLI concerns. Two deliberate shape changes: preprocess returns a Dataset struct instead of a positional 5-tuple, and build_date/encode_row share a date_parts helper. Behaviour preservation verified by captured baselines compared before/after -- demo fit, a mixed numeric/date/categorical CSV with a missing cell, the saved model JSON and a --predict run are byte-identical; the only difference anywhere is one panic message, now that it is library code. 12 new tests.","dependency_count":0,"dependent_count":3,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-izk","title":"gp-tree step 1: emit a fitted Tree as an improvise formula string","description":"Pure function turning a gp_tree::Tree plus its Spec into improvise formula text: Split{feat,thresh,lo,hi} -\u003e IF(\u003cname\u003e \u003c \u003cthresh\u003e, \u003clo\u003e, \u003chi\u003e), Leaf(s) -\u003e the number. Feature names come from Spec::names and must go through the same CL-style pipe quoting the formula tokenizer uses, so a feature called 'Net Revenue' or one colliding with a keyword still parses.\n\nLives in a new crate improvise-gp (depends on gp-tree + improvise-formula). It cannot live in gp-tree — that would invert the dependency and drag improvise into a generic GP crate — and it cannot live in improvise-formula, which must not know GP exists.\n\nThis is the proof-of-concept for the whole epic: it needs no GA, no UI, and no feature extraction, and it either works or kills the idea early.","design":"fn formula_text(tree: \u0026Tree, spec: \u0026Spec, target: \u0026str) -\u003e String. Float thresholds need enough precision to round-trip; check what the formula tokenizer accepts for decimals and whether f32-\u003etext-\u003ef64 shifts a boundary case (a threshold that reads back slightly larger flips rows across the split).","acceptance_criteria":"(1) formula_text output parses via parse_formula. (2) Property test over random trees: gp_tree::eval(tree, features) equals Model::evaluate of the emitted formula for the same feature values, within tolerance. (3) Feature names needing pipe quotes are handled. (4) A nested tree emits nested IFs.","status":"closed","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-08-01T17:21:14Z","created_by":"Edward Langley","updated_at":"2026-09-19T20:38:42Z","closed_at":"2026-09-19T20:38:42Z","close_reason":"New crate crates/improvise-gp: formula_text(tree,spec,target) emits IF(\u003cname\u003e \u003c \u003cthresh\u003e, \u003clo\u003e, \u003chi\u003e) / leaf numbers, and fitted_formula runs the text back through parse_formula so the emitter checks its own contract. Key finding: threshold precision is load-bearing, not cosmetic -- the grammar's number has no exponent form, so emission is the shortest fixed-point decimal that parses back to the IDENTICAL f64. The shortest-round-trip-to-f32 form is unsafe: 0.7f32 is 0.699999988..., '0.7' parses as a strictly greater f64, and a row whose feature equals the threshold then routes lo in the formula but hi in gp_tree::eval. Named regression test verified to fail against a naive format!. 7 tests incl. 12 awkward pipe-quoted names round-tripped on the parsed Expr::Ref, and 60 random trees x 10 feature vectors evaluated through a real improvise_core::Model vs gp_tree::eval with assert_eq! and no tolerance.","dependency_count":0,"dependent_count":3,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-21d","title":"Epic: Integrate gp-tree — fit interpretable decision trees as computed measures","description":"crates/gp-tree is a workspace member that builds and tests alongside improvise, but nothing in improvise depends on it. Goal: let a user fit a decision tree over the model's own data and land the result as a first-class computed measure.\n\nThe enabling fact: gp-tree's Tree maps 1:1 onto improvise's EXISTING formula AST. Tree::Split{feat,thresh,lo,hi} is IF(feat \u003c thresh, lo, hi) and Tree::Leaf(s) is a number. improvise-formula already parses exactly this shape, nesting included — see parser.rs:765, parse_formula(\"X = IF(A \u003e B, IF(C \u003e D, 1, 2), 3)\"). So a fitted tree can be emitted as formula TEXT and inherit everything the formula layer already does: fixed-point recomputation, persistence in the ## Formulas section, the formula panel, drill-down. No changes to the formula engine, evaluator, or file format.\n\nThree known obstacles, addressed by the child issues: (1) gp-tree's preprocessing (type inference, one-hot encoding, imputation) lives in examples/fit_csv.rs, 1261 lines that library consumers cannot call; (2) the GA search loop lives there too — the library exposes operators (Population, GaConfig, breed) but no fit entry point; (3) gp-tree's Rng is a trait the caller implements by design, and improvise has no PRNG dependency.","design":"New bridging crate improvise-gp depending on gp-tree + improvise-formula + improvise-core, keeping the enforced-boundary graph from improvise-xgl intact: the emitter cannot live in gp-tree (that would invert the dependency) and cannot live in improvise-formula (which must not know about GP). improvise-gp sits above both. The fitted tree's encoding plan is only needed to score NEW rows; the emitted formula alone is enough to recompute existing cells, so persisting the tree itself is a separate decision (see the child on model persistence).","acceptance_criteria":"A user can fit a measure from the model's other measures/dimensions and have the rule appear as an editable, persisted formula.","notes":"Sequenced so the cheapest, highest-signal piece comes first: the Tree-\u003eformula emitter is pure, needs no GA and no UI, and is testable by asserting gp_tree::eval agrees with improvise's eval_formula on the emitted string. That single child proves the whole idea before anyone builds a fitting UI.\nGROOMED 2026-09-19 — STALE REF: examples/fit_csv.rs does not exist at that path; it is crates/gp-tree/examples/fit_csv.rs (gp-tree is a workspace member, not the root crate).","status":"closed","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-08-01T17:20:36Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:55:11Z","closed_at":"2026-09-20T07:55:11Z","close_reason":"Closed: every child is closed. improvise-vx3 was the last (PR #25).\n\nTHE EPIC'S ENABLING FACT HELD AND WAS INSUFFICIENT, which is the thing to carry forward. \"gp-tree's Tree maps 1:1 onto improvise's existing formula AST, so no changes to the formula engine, evaluator or file format\" is correct about SHAPE and silent about SEMANTICS -- twice over:\n\n 1. THE FIT IS STANDARDIZED, so a champion written down untouched is out by an affine map in every cell: well-formed, parsing, and wrong. Closed by `denormalize` in PR #25.\n 2. A FORMULA EVALUATED AT AN AGGREGATED CELL ROUTES ON THE AGGREGATE. 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 the 1:1 mapping holds PER RECORD and not per pivot cell.\n\nBoth are the same failure: AN ISOMORPHISM OF SYNTAX MISTAKEN FOR AN ISOMORPHISM OF MEANING. The second is still open as improvise-4zl7, now re-scoped to the whole feature rather than to categorical splits, with the user's ruling (aggregate the rule over the members) recorded on it.\n\nWHAT LANDED: the preprocessing and the GA loop are in the library (0yr, 0ai); a seeded Rng is vendored (lzn); a feature matrix comes off a View's records (4s8); a fitted Tree emits as formula text validated back through parse_formula (izk); persistence of the fit metadata is decided (7na); and `:fit-measure` is the user-facing path (vx3), reporting cross-validated quality against the predict-the-mean baseline because a rule the user will believe is worse than useless if it generalizes no better than the mean.\n\nSTILL TRUE AND WORTH KNOWING: nothing in the shipped TUI binds it to a key, deliberately.","dependencies":[{"issue_id":"improvise-21d","depends_on_id":"improvise-0ai","type":"blocks","created_at":"2026-08-01T10:26:07Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-21d","depends_on_id":"improvise-0yr","type":"blocks","created_at":"2026-08-01T10:26:06Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-21d","depends_on_id":"improvise-4s8","type":"blocks","created_at":"2026-08-01T10:26:08Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-21d","depends_on_id":"improvise-7na","type":"blocks","created_at":"2026-08-01T10:26:09Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-21d","depends_on_id":"improvise-izk","type":"blocks","created_at":"2026-08-01T10:26:06Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-21d","depends_on_id":"improvise-lzn","type":"blocks","created_at":"2026-08-01T10:26:08Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-21d","depends_on_id":"improvise-vx3","type":"blocks","created_at":"2026-08-01T10:26:23Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":7,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-gfq","title":"Import wizard defers the 'no category accepted' failure to the very last keystroke","description":"build_model errors with 'At least one category must be accepted' only when Enter is pressed on NameModel — the final step. An import with no category-classified field (e.g. a single numeric column, or an empty array) walks the user through ReviewProposals, DefineFormulas and NameModel before refusing. Contrast improvise-mzv, which added a pre-check for the opposite failure (too many categories) at the step where the user can still fix it. Same treatment wanted here: block advancing out of ReviewProposals while zero categories are accepted, with a message naming the fix. Noticed during improvise-2wm.","acceptance_criteria":"Advancing out of ReviewProposals with no accepted Category/TimeCategory shows a message and stays on the step; a test covers it alongside the existing category_limit_message tests.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-07-31T07:21:55Z","created_by":"Edward Langley","updated_at":"2026-08-14T01:10:29Z","closed_at":"2026-08-14T01:10:29Z","dependencies":[{"issue_id":"improvise-gfq","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-o1c","title":"Import wizard has no way back; Esc discards all field decisions silently","description":"Every browsing step binds Esc to close-the-wizard, and no step binds a back key. A user who reaches ConfigureDates or NameModel and wants to change a field kind must Esc (losing every proposal toggle, formula, and name they entered) and restart the import. This is sharpest with the category-limit guard: advance() refuses to leave ReviewProposals/ConfigureDates telling the user to fix their proposals, but from ConfigureDates there is no key that returns to ReviewProposals. Suggest binding a back key (e.g. Backspace or Left) to a WizardStep::previous(), and/or requiring a confirm on Esc once proposals have been edited. Noticed during improvise-2wm.","acceptance_criteria":"A user can move backwards through wizard steps without losing entered state; Esc's destructiveness is either confirmed or reversible.","status":"closed","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-07-31T07:21:42Z","created_by":"Edward Langley","updated_at":"2026-08-14T01:10:30Z","closed_at":"2026-08-14T01:10:30Z","dependencies":[{"issue_id":"improvise-o1c","depends_on_id":"improvise-907","type":"supersedes","created_at":"2026-08-13T18:10:29Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-c6j","title":"WizardStep::Done is unreachable from the UI","description":"ImportWizard::advance() maps NameModel -\u003e Done, but the key layer never calls advance() on NameModel: Enter there commits the build (BuildWizardModel), which closes the wizard outright. So Done is only reachable from wizard.rs unit tests calling advance() directly. Either drop the variant and its close-on-any-key branch in command/cmd/wizard.rs, or route the successful build through Done so there is a real confirmation step. Noticed while writing characterization tests for improvise-2wm — the test for Done has to force the step by hand.","acceptance_criteria":"Either WizardStep::Done is removed (with its key branch and advance() arm) or a UI path reaches it; no state in the enum is unreachable.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-07-31T07:21:37Z","created_by":"Edward Langley","updated_at":"2026-08-14T01:10:30Z","closed_at":"2026-08-14T01:10:30Z","dependencies":[{"issue_id":"improvise-c6j","depends_on_id":"improvise-907","type":"supersedes","created_at":"2026-08-13T18:10:30Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-ych","title":"Null category values become the literal item \"null\" on import","description":"ImportPipeline::build_model_with_warnings resolves a category value as v.as_str() or else v.to_string(). A JSON null therefore yields the item name \"null\" rather than being treated as missing. The Label branch a few lines below explicitly maps null to \"\", so the two paths disagree on what a null means. Noticed while implementing improvise-5j9 (skipped-record warnings).","acceptance_criteria":"A record with a null category value is either skipped-and-counted (like an absent key) or normalized consistently with the Label path; test covers the chosen behavior.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-07-31T06:02:57Z","created_by":"Edward Langley","updated_at":"2026-09-19T20:33:47Z","closed_at":"2026-09-19T20:33:47Z","close_reason":"Fixed via a single scalar_name(\u0026Value) -\u003e Option\u003cString\u003e helper used by both the Category and Label branches. Value::Null is absence (csv_parser already maps a blank CSV field to Null) so the record is skipped and counted in the existing improvise-5j9 warning; the literal string 'null' stays a real item; numbers/booleans still stringify. Two failing tests written first demonstrated the bug, plus two pinning tests that must not break. NOTE beyond the ticket's letter: empty/whitespace-only strings are now also treated as missing, so JSON '' and a blank CSV field behave alike; revert by dropping the .trim().is_empty() clause in scalar_name and the distinct_values filter. wasm check clean.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-2ud","title":"CmdContext copies individual fields instead of borrowing ModelState/ViewState slices","description":"crates/improvise-command/src/command/cmd/core.rs:15-56 + crates/improvise-command/src/ui/app.rs cmd_context(): after the ModelState/ViewState split (improvise-x2c/ew0), CmdContext still copies ~20 individual fields (dirty, search_query, panel flags/cursors, nav stacks) out of the slices. Follow-up: have CmdContext borrow \u0026ModelState/\u0026ViewState (or narrowed sub-views) so new fields don't require threading through cmd_context() and there is one source of truth. Also: display_value is computed eagerly on every context build (app.rs ~388-406) even when the command never reads it — consider lazy/on-demand. Complements improvise-drg (narrow Effect::apply signatures). Found in systematic review 2026-06-09.","acceptance_criteria":"CmdContext borrows \u0026ModelState/\u0026ViewState (or narrowed sub-views) instead of copying ~20 individual fields, so a new field does not have to be threaded through cmd_context(). display_value stops being computed eagerly on every context build when no command reads it. Note improvise-rw3a is waiting on this: it needs file_path on the context and should not add a 21st copied field ahead of the refactor.","notes":"SIZED, NOT DONE (wave 2, 2026-09-20). Attempted and reverted rather than\nhalf-landed. The bead reads as a cheap cleanup and is not one; here are the\ncounts, so the next attempt is planned rather than discovered.\n\nMEASURED SCOPE\n- 104 `ctx.\u003cfield\u003e` reads across 14 files (panel.rs 21, commit.rs 16,\n registry.rs 15, text_buffer.rs 13, search.rs 8, grid.rs 7, mode.rs 6,\n keymap.rs 4, navigation.rs 4, tile.rs 3, cell.rs 2, core.rs 2, wizard.rs 1,\n and 2 in improvise-wasm-client/src/render.rs).\n- 46 sites that MUTATE a ctx field in place to set a scenario up\n (`ctx.buffers = \u0026bufs;`, `ctx.mode = \u0026item_add_mode;`, ...). These cannot be\n rewritten mechanically: once CmdContext borrows the slice, the ViewState has\n to be built and mutated BEFORE the context exists, so each is a per-test\n restructure.\n- 99 `make_ctx` call sites, whose helper has to change shape to own or take a\n \u0026ViewState. That reaches essentially every command-layer test.\n\nDESIGN THAT WAS REACHED (worth keeping, it is most of the work)\n CmdContext { model, workbook, view, layout, registry,\n view_state: \u0026ViewState, model_state: \u0026ModelState,\n drill: Option\u003c\u0026DrillState\u003e, wizard, visible_rows, visible_cols,\n key_code }\nEach removed field is read at its one home (`ctx.view_state.mode`,\n`ctx.model_state.dirty`) rather than through an accessor, because an accessor\nbeside the field is a second answer to the same question. `has_drill_state: bool`\nbecomes `drill: Option\u003c\u0026DrillState\u003e` -- strictly more information, and it is what\nmakes the laziness possible.\n\nDISPLAY_VALUE: the mechanism is to lift App::display_value_of into a free\n`display_value_of(layout, model, drill, key)` in ui/app.rs that both\n`App::display_value_of` and a new `CmdContext::display_value()` call, so there is\none derivation with two callers. Cheap and self-contained.\n\nTHE EAGER COST IS NOT display_value, AND THE BEAD NAMES THE WRONG FIELD.\n`display_value_at_cursor` is one `get_cell`. `visible_cols` is the expensive one:\n`cmd_context()` computes it by calling `ui::viewport::compute_col_widths`, which\nwalks the WHOLE GRID TWICE (see improvise-jmjz). Benchmarked this wave at 169ms\nfor 18,000 cells -- and `cmd_context` is built on every keypress. Gotcha 23\nalready warns \"don't build a CmdContext inside apply (it recomputes column\nwidths)\" without saying what that costs. Making `visible_cols` lazy is worth more\nthan making `display_value` lazy by about four orders of magnitude, and it is\ncomputable from the fields the context already holds plus the terminal width.\nGROOMED 2026-09-19 (restored; a --notes above replaced it): still true; CmdContext is cmd/core.rs:18-59 (cited 15-56) and still copies 25 fields, including the eagerly-computed display_value.","status":"open","priority":3,"issue_type":"task","assignee":"wave2-n-cleanups","owner":"el-github@elangley.org","created_at":"2026-06-10T03:59:05Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:10:30Z","started_at":"2026-09-20T18:08:15Z","labels":["area:command"],"dependency_count":0,"dependent_count":1,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-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}
|
||
{"_type":"issue","id":"improvise-gdl","title":"SetBuffer effect special-cases the \"search\" buffer name","description":"crates/improvise-command/src/ui/effect.rs:433-440: SetBuffer routes to view_state.search_query when self.name == \"search\", otherwise to the buffers map. Callers must know the magic name; the special case is undiscoverable and weakens the buffer abstraction. Fix: a dedicated SetSearchQuery effect (or fold search_query into the buffers map). Related to improvise-k8h (minibuffer buffer_key strings). Found in systematic review 2026-06-09.","notes":"STALE REF (groomed 2026-08-30, line numbers shifted by this session's work): effect.rs:433-440 -\u003e SetBuffer is now at crates/improvise-command/src/ui/effect.rs:614 (pub struct SetBuffer). The special case is unchanged: SetBuffer still routes name==\"search\" to view_state.search_query.\nGROOMED 2026-09-19: still true; SetBuffer is effect.rs:621-630 now, not 433-440. The comment on the special case still reads \"backward compat\".\n\n--- CORRECTION to this ticket's close reason (2026-09-19 review) ---\nThe close reason said 'no keymap binds it, so no user-visible change'. That is\ntrue only of KEYBINDINGS, and was verified: zero occurrences of\nclear-buffer [\"search\"] across all 9,760 (mode, key, modifier) lookups in all\n16 modes. But ClearBufferCmd is registered with a free-form arg, so\n':clear-buffer search' typed in CommandMode, a script file, and\n'improvise cmd clear-buffer search' all reach it.\n\nSo the behaviour delta is reachable by the user, just not by a keypress: it\nused to park a dead map entry and now blanks view_state.search_query\n(SetSearchQuery is a bare ViewOnly setter with no side effects). Harmless and\narguably more correct -- but do NOT record 'unreachable' as an invariant.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-06-10T03:58:41Z","created_by":"Edward Langley","updated_at":"2026-09-19T20:42:43Z","closed_at":"2026-09-19T20:35:54Z","close_reason":"Premise true and worse than stated: the 'search' special case was in three places, not one (the SetBuffer effect plus AppendChar and PopChar). Fixed with one cmd::core::write_buffer(name, value) placed under the read_buffer it mirrors; SetBuffer is now a plain map write. Side effect: 'clear-buffer search' now clears the query instead of parking an unread map entry -- it previously worked only by accident of the effect's special case, and no keymap binds it, so no user-visible change.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_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-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}
|
||
{"_type":"issue","id":"improvise-669","title":"Extract axis pruning helper in view/layout.rs","description":"crates/improvise-core/crates/improvise-core/src/view/layout.rs:245-262 (rows) and :265-284 (cols) contain near-identical pruning loops that track data_idx separately from iteration order against a keep_row/keep_col bool vector. The data_idx tracking was flagged as brittle in the deep review.\\n\\nAbstraction: fn prune_axis_items(items: \u0026mut Vec\u003cAxisEntry\u003e, keep: \u0026[bool]) -\u003e Vec\u003cAxisEntry\u003e\\n\\nCorrectness win: centralizes the invariant (one place to assert data_idx == keep.len() at loop exit). A bug in the row prune can't hide while the col prune stays correct. ~20 LOC collapsed.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:54:12Z","created_by":"Ed L","updated_at":"2026-08-14T01:11:08Z","closed_at":"2026-08-14T01:11:08Z","dependencies":[{"issue_id":"improvise-669","depends_on_id":"improvise-ai7","type":"supersedes","created_at":"2026-08-13T18:11:08Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-aaw","title":"Replace CreateAndSwitchView with Binding::Sequence","description":"command/cmd/panel.rs:546-560 bundles create-view + switch-view + change-mode. Both primitives already exist via effect_cmds.\\n\\nFix: bind Sequence [(create-view nix-shell-env), (switch-view nix-shell-env), (enter-mode normal)] in the keymap. If the auto-generated view name is load-bearing, either thread through a minibuffer (matches other add flows) or keep a single create-view primitive that both creates and switches by default.\\n\\n~12 LOC.","notes":"Groomed 2026-08-13 — considered merging improvise-git, improvise-aaw and improvise-s13 into one 'collapse composite commands into keymap composition' ticket and DECIDED AGAINST IT. They are the same principle (do not name a command AAndB; do not wrap a generic command in a shim) and they touch the same file cluster, so batching them in one session is worthwhile — hence the 'related' links. But merging the tickets would have taken improvise-git's new dependency on improvise-8tk (the ZZ/:wq silent-data-loss bug) and applied it to the whole group, blocking two pieces of work that are ready today behind a P1 bug they have nothing to do with. Keeping them separate keeps s13 and aaw actionable.\n\nIf batching them, do improvise-s13 first (biggest and cleanest, ~40 LOC across four shims), then improvise-aaw (has an unresolved design question about whether the auto-generated view name is load-bearing), and improvise-git last, only after improvise-8tk. Verify all of them in one pass with the (mode, key, modifier) binding-table dump described in improvise-4pg's notes.\nGROOMED 2026-09-19: still true; CreateAndSwitchView is cmd/panel.rs:658, not 546-560.\n\n--- INVESTIGATED 2026-09-19, NOT DONE: premise false ---\nCreateAndSwitchView is NOT a shim. It computes the new view's name from\ncontext -- format!(\"View {}\", ctx.workbook.views.len() + 1) -- and a static\nBinding::Sequence can only carry literal args, so [create-view X, switch-view X,\nenter-mode normal] would hard-code one name and produce duplicates on the\nsecond 'n'. The auto-generated name is load-bearing, so the command has a real\ndecision and is not replaceable by composition as written.\n\nThe ticket's own two fallbacks are both BEHAVIOUR changes needing a product\ndecision, not refactors:\n (a) thread the name through a new minibuffer mode -- that is a feature, and\n it changes what 'n' does in the view panel;\n (b) make the create-view primitive also switch -- that changes :create-view\n for scripts and duplicates what grid.rs already does by hand for the\n _Drill/_Records views.\nNeither was forced. RE-SCOPE this ticket to 'should creating a view from the\npanel prompt for a name?' or close it won't-fix.","status":"closed","priority":3,"issue_type":"task","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-04-16T18:53:54Z","created_by":"Ed L","updated_at":"2026-09-20T07:54:25Z","started_at":"2026-09-20T05:32:31Z","closed_at":"2026-09-20T07:54:25Z","close_reason":"Closed WON'T FIX by PR #27 (WU-G), confirming the earlier investigation.\n\nThe new view's name is `format!(\"View {}\", ctx.workbook.views.len() + 1)` -- DERIVED. A Binding::Sequence carries only literal args, so it cannot express it. The ticket's own fallback (\"if the auto-generated view name is load-bearing, thread a minibuffer or keep a create-view primitive that creates and switches by default\") is what the code already is.\n\nTHE MORE VALUABLE FIND, and the reason this close is worth reading: `create_and_switch_view_names_incrementally` asserted that CreateView/SwitchView/Normal appeared and NEVER LOOKED AT THE NAME -- the one thing that makes this a command rather than a shim. A hard-coded name kept it green, which is exactly the change under discussion. It now asserts the name at TWO different view counts, because one count cannot separate \"derived\" from \"constant\". Fault injected by replacing the derivation with the literal \"View 2\"; the second arm failed.\n\nSo the ticket was right that this looks like an A-and-B command, and wrong that it is one -- and the test that should have settled it was blind to the deciding fact.","dependencies":[{"issue_id":"improvise-aaw","depends_on_id":"improvise-s13","type":"related","created_at":"2026-08-13T18:34:23Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-git","title":"Replace SaveAndQuit with Binding::Sequence in keymap","description":"command/cmd/mode.rs:232-241 is an 8-line command chaining Save + ChangeMode(Quit). Both primitives already exist.\\n\\nFix: delete the struct; bind via Sequence [(w, []), (force-quit, [])] in the keymap.\\n\\nTrivial application of the 'if the keybinding is A then B, the binding should be a Sequence, not a command named AAndB' principle.\\n\\n~8 LOC.","notes":"Groomed 2026-08-13 — considered merging improvise-git, improvise-aaw and improvise-s13 into one 'collapse composite commands into keymap composition' ticket and DECIDED AGAINST IT. They are the same principle (do not name a command AAndB; do not wrap a generic command in a shim) and they touch the same file cluster, so batching them in one session is worthwhile — hence the 'related' links. But merging the tickets would have taken improvise-git's new dependency on improvise-8tk (the ZZ/:wq silent-data-loss bug) and applied it to the whole group, blocking two pieces of work that are ready today behind a P1 bug they have nothing to do with. Keeping them separate keeps s13 and aaw actionable.\n\nIf batching them, do improvise-s13 first (biggest and cleanest, ~40 LOC across four shims), then improvise-aaw (has an unresolved design question about whether the auto-generated view name is load-bearing), and improvise-git last, only after improvise-8tk. Verify all of them in one pass with the (mode, key, modifier) binding-table dump described in improvise-4pg's notes.\nUNBLOCKED (groomed 2026-08-30): improvise-8tk is closed, and its fix removes this ticket's stated objection rather than merely satisfying it.\n\nThe blocker was recorded as: 'a Sequence has no way to stop between steps, so converting first would cement the ZZ/:wq data-loss bug into the keymap'. That is no longer true. Keymap::dispatch flattens a Binding::Sequence into ONE effect vec applied by a single App::apply_effects call, and the Save effect now sets abort_effects when the save fails — so the abort carries across sequence steps exactly as it does within a command's own effect list. A [w, force-quit] Sequence would stop after a failed save.\n\nVerified by ui/effect.rs::tests::save_without_file_path_aborts_the_chain and failed_save_to_path_aborts_the_chain, plus ui/app.rs::tests::prefix_z_then_z_reaches_save_and_quit, which drives the real ZZ path.\n\nSo the conversion is now safe. Still check improvise-lae first: it may change what ZZ does when there is no file path, which would change what the Sequence should contain.\nGROOMED 2026-09-19: the \"still check improvise-lae first\" sentence in this ticket is a real ordering constraint, now recorded as a dependency edge rather than prose. Stale ref: SaveAndQuit is cmd/mode.rs:243 (cited 232-241).","status":"closed","priority":3,"issue_type":"task","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-04-16T18:53:48Z","created_by":"Ed L","updated_at":"2026-09-20T07:54:21Z","started_at":"2026-09-20T05:32:31Z","closed_at":"2026-09-20T07:54:21Z","close_reason":"Closed WON'T FIX by PR #27 (WU-G), on evidence, and the user accepted the decline.\n\n`wq` IS NOT ONLY A KEYBINDING. It is a REGISTERED COMMAND NAME, so `:wq` resolves through the registry -- and Quit's own unsaved-changes message advertises it: \"Use :q! to force quit or :wq to save+quit\". A keymap Sequence is unreachable from the `:` line, so removing the registration gives exactly `\":wq — Unknown command: wq\"`.\n\nKeeping the command AND adding the Sequence would be worse than doing nothing: two independently-written spellings of \"save then quit\". Today ONE DEFINITION SERVES BOTH SURFACES.\n\nAND THE TICKET'S LITERAL BINDING DOES NOT DISPATCH AT ALL: [(w, []), (force-quit, [])] is a dead key, because `w` is register_pure and its interactive constructor rejects an empty arg list. The nullary spelling is `save`. Found by trying to build the thing the ticket asked for -- which is the cheapest way to evaluate a ticket in this repo.\n\nEVIDENCE LANDED: colon_wq_saves_then_quits (`:wq` had no direct test at all) plus a doc comment on SaveAndQuit recording why it is not a shim. Fault injected by deleting the registration; watched to fail.\n\nTHE GENERAL RULE, now doctrine(one_definition_two_surfaces_is_not_an_aandb_command): before collapsing a composite command into a keymap Sequence, ENUMERATE ITS SURFACES. A keybinding and an ex-command name reaching one command is one definition; a Sequence plus a surviving command is two.","dependencies":[{"issue_id":"improvise-git","depends_on_id":"improvise-8tk","type":"blocks","created_at":"2026-08-13T18:08:05Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-git","depends_on_id":"improvise-lae","type":"blocks","created_at":"2026-09-19T12:47:58Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-git","depends_on_id":"improvise-s13","type":"related","created_at":"2026-08-13T18:34:24Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-s13","title":"Delete mode-entry shim commands; use enter-mode with arg","description":"command/cmd/mode.rs:311-319 has EnterExportPrompt and similar shims (EnterSearchMode, EnterTileSelect, EnterFormulaEdit) that are each ~9-line wrappers around change_mode(AppMode::foo()). The generic EnterMode already exists with parse_mode_name() in the registry.\\n\\nFix: delete the shims; bind 'enter-mode' with the mode name as an arg in the keymap.\\n\\nCorrectness win: one code path for mode transitions. A new invariant on mode enter (e.g., 'always rebuild layout') needs to be added in one place instead of N shims.\\n\\n~40 LOC across all shims.","notes":"Groomed 2026-08-13 — considered merging improvise-git, improvise-aaw and improvise-s13 into one 'collapse composite commands into keymap composition' ticket and DECIDED AGAINST IT. They are the same principle (do not name a command AAndB; do not wrap a generic command in a shim) and they touch the same file cluster, so batching them in one session is worthwhile — hence the 'related' links. But merging the tickets would have taken improvise-git's new dependency on improvise-8tk (the ZZ/:wq silent-data-loss bug) and applied it to the whole group, blocking two pieces of work that are ready today behind a P1 bug they have nothing to do with. Keeping them separate keeps s13 and aaw actionable.\n\nIf batching them, do improvise-s13 first (biggest and cleanest, ~40 LOC across four shims), then improvise-aaw (has an unresolved design question about whether the auto-generated view name is load-bearing), and improvise-git last, only after improvise-8tk. Verify all of them in one pass with the (mode, key, modifier) binding-table dump described in improvise-4pg's notes.\nGROOMED 2026-09-19: still true; the shims have moved apart — EnterExportPrompt cmd/mode.rs:326, EnterSearchMode :338, EnterTileSelect :353, and EnterFormulaEdit is in cmd/panel.rs:465, not mode.rs.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:53:44Z","created_by":"Ed L","updated_at":"2026-09-19T20:35:53Z","closed_at":"2026-09-19T20:35:53Z","close_reason":"Deleted the two genuine shims: EnterExportPrompt and EnterFormulaEdit, rebinding Ctrl-e to 'enter-mode export-prompt' and formula-panel a/n/o to 'enter-mode formula-edit'. Premise was half false -- the ticket named four. EnterSearchMode changes no mode at all (SearchMode is Normal + a flag, not an AppMode) and EnterTileSelect guards on category count and seeds SetTileCatIdx(0); both are real commands and were left alone. Side effect, a fix not a regression: EnterMode clears the target mode's minibuffer, which the shims did not, though EnterFormulaEdit's doc-comment already claimed it did.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-7yx","title":"Close test gaps flagged by deep review","description":"Deep review identified several test-coverage gaps worth closing:\\n\\n1. Keymap fallback chain: command/keymap.rs has 22 tests but no case for 'transient keymap exists but key does not match' (app.rs:396-406).\\n2. Ambiguous date detection: import/analyzer.rs:49-77 first-match heuristic not tested against ['01/02/2025', '02/03/2025'] style ambiguity.\\n3. CSV adversarial inputs: no tests for BOM, CRLF, empty file, single-column file.\\n4. Records missing a category field mid-import: wizard.rs:160-229 has the valid=false/break path but it is not covered by build_model_cells_match_source_data or similar.\\n5. Gzip corruption error path: save_and_load_roundtrip_gzip tests happy path only; add a test that load() on a truncated .gz file surfaces an error.\\n\\nLow-effort, high-value additions. Batch into a single test-coverage PR.","notes":"GROOMED 2026-09-19 — two of the five items are now covered; three remain. (1) Keymap fallback COVERED: ui/app.rs::tests::unmatched_key_after_prefix_is_swallowed, added by improvise-jc8 when the transient keymap became a prefix address. (4) Records missing a category mid-import COVERED: wizard.rs::build_model_record_with_missing_category_value_skipped and build_model_warns_about_records_skipped_for_missing_category. STILL OPEN: (2) ambiguous date detection — analyzer.rs has detect_iso_date_format / detect_us_date_format / detect_no_date_format and no case where two readings both parse; (3) CSV adversarial — rfc4180 quoting and short rows ARE tested; BOM, CRLF, empty file and single-column file are not; (5) gzip corruption — only gzip_round_trips_through_memory_storage and save_and_load_roundtrip_gzip, both happy-path. Also all paths in the description predate the crate split: command/keymap.rs -\u003e crates/improvise-command/src/command/keymap.rs, import/* and persistence/* -\u003e crates/improvise-io/src/.","status":"closed","priority":3,"issue_type":"task","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-04-16T18:19:07Z","created_by":"Ed L","updated_at":"2026-09-20T06:28:59Z","started_at":"2026-09-20T05:32:38Z","closed_at":"2026-09-20T06:28:59Z","close_reason":"Closed by PR #22 (WU-L). Items 2, 3 and 5 done; items 1 and 4 were already closed and both tests were verified to exist rather than assumed.\n\nITEM 2 FOUND A LIVE BUG ONE LINE AWAY, and it is the most consequential change on the branch though no ticket names it. analyze_records collected each string column's distinct values into a HashSet AND ITERATED IT -- invariant(indexmap_order) / gotcha 18 in a second place. build_model adds items in that order, IndexMap keeps it, persistence relies on it: THE SAME CSV IMPORTED TWICE PRODUCED TWO DIFFERENT .improv FILES AND TWO DIFFERENT ROW ORDERS. A probe saw five different orders in five runs of one process. It reached the date heuristic too: detect_date_format tests only the first ten samples, so WHICH ten varied, and a column whose eleventh value rules out %m/%d/%Y detected a different format run to run -- every date in it off by the American/European difference. Fixed with a filtered insert: the set still deduplicates and is never iterated. First-seen order. The two determinism tests were watched failing on the parent commit.\n\nThe heuristic ITSELF is pinned, not changed, per the user's ruling -- see improvise-l8rh. an_ambiguous_date_is_read_american_first states in its doc-comment that this silently mis-reads a European CSV, with the day-first reading asserted to parse so the test is about ambiguity rather than parsing; a_day_first_only_value_settles_the_ambiguity is the discriminating arm.\n\nITEM 3, five CSV tests (BOM, CRLF, empty, header-only, single column). All four inputs already behaved correctly, SO EACH TEST CARRIES A CONTROL proving it can see its own fault -- a BOM in the SECOND header is not leading, is not stripped, and does appear in the key; a LONE \\r is itself a record terminator to this reader and splits one row into two.\n\nITEM 5, three gzip tests kept apart by cause: truncation -\u003e UnexpectedEof; CRC corruption -\u003e InvalidInput AND a cause naming \"checksum\"; a valid gzip of invalid UTF-8 -\u003e the UTF-8 diagnosis. One non-test line changed: the gzip branch of load_from used a bare ? where the UTF-8 branch two lines below carried with_context, so a corrupt .gz reported \"incomplete deflate stream\" and no filename.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-61f","title":"Add invariant test for command registry name mapping","description":"crates/improvise-command/src/command/cmd/registry.rs is 586 lines with 0 tests. Each registration manually duplicates the command name as a string alongside the Cmd::name() implementation on the struct. Nothing verifies they match. Fix: add a test that iterates all registered entries and asserts registry_name == command.name() after construction. Low effort, locks down a class of typo bugs.","notes":"Groomed 2026-06-09: complementary to improvise-9cn (register_cmd! macro). The macro prevents future drift at compile/registration time; this test catches drift that predates or escapes the macro. Doing 9cn first likely shrinks this to a trivial registry-walk test — sequence them together.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:55Z","created_by":"Ed L","updated_at":"2026-08-29T19:10:27Z","closed_at":"2026-08-29T19:10:27Z","close_reason":"Delivered as part of improvise-9cn. See that issue for why the invariant as specified here ('assert registry_name == command.name() for all entries') is necessary but NOT sufficient: a wrong-variant prototype collides with its sibling rather than producing a mismatch, so the roundtrip test passes while a command name vanishes from the registry. Shipped alongside registered_names_are_unique, which is the check that actually reaches that class, plus a keymap-side test that every bound command name resolves. All four verified by fault injection.","dependencies":[{"issue_id":"improvise-61f","depends_on_id":"improvise-9cn","type":"blocks","created_at":"2026-04-16T11:54:34Z","created_by":"Ed L","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-nr5","title":"Split persistence/mod.rs into focused sub-modules","description":"crates/improvise-io/crates/improvise-io/src/persistence/mod.rs is 2402 lines mixing four cohesive units: pipe quoting (lines ~20-82), format_md (~155-282), parse_md (~285-580), export_csv (~581-630). Split into persistence/{quoting,format,parse,export}.rs behind the existing pub facade; no public-API change. Improves navigability and makes adding new format features easier to review.","notes":"GROOMED 2026-09-19 — STALE REFS. Path was written twice: crates/improvise-io/crates/improvise-io/src/persistence/mod.rs -\u003e crates/improvise-io/src/persistence/mod.rs. Size is 2783 lines now, not 2402, and every line range in the description is stale. Current boundaries by symbol (prefer these; they survive edits): quoting = is_bare_name/escape_pipe/unescape_pipe/quote_name/pipe_quote (24-86) plus format_number/parse_number_cell; entry points save/load/save_to/load_from/autosave_path (128-178); format_md (197); parse_md (330); coord_str (630); export_csv/export_csv_to/render_csv (639-700). Tests start at line 706 (#[cfg(test)]) and are roughly three quarters of the file — the split should move each function with its tests.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:52Z","created_by":"Ed L","updated_at":"2026-09-19T20:36:35Z","closed_at":"2026-09-19T20:36:35Z","close_reason":"Split persistence/mod.rs (2783 lines) into six files: mod.rs (259, document lifecycle + facade + test_helpers), quoting.rs (63, the five pipe-quoting helpers as pub(super)), format.rs (350), parse.rs (1378, incl. the four existing test modules moved whole), export.rs (156), roundtrip_tests.rs (635). Two deviations from the ticket sketch, both deliberate: format_number/parse_number_cell went to their own direction's file rather than into quoting.rs, leaving quoting.rs a real shared leaf; and the 34 tests that call BOTH format_md and parse_md got their own file rather than being arbitrarily assigned. Pure relocation verified body-by-body: every function body identical modulo indentation, only signature-level pub(super)/pub changes; persistence test count 97 before and after; 815 workspace tests pass; wasm, clippy and fmt clean. Grammar untouched.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-t8s","title":"View::axis_of panics on missing category","description":"crates/improvise-core/src/view/types.rs:118 uses expect('axis_of called for category not registered with this view'). The invariant is that Workbook::add_category registers every category with every view, but the panic surface is fragile for new callers. Fix: either audit all callers and document the invariant clearly, or change signature to fn axis_of(\u0026self, cat: \u0026str) -\u003e Option\u003cAxis\u003e and let callers handle None.","status":"closed","priority":3,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:48Z","created_by":"Ed L","updated_at":"2026-06-10T04:46:47Z","closed_at":"2026-06-10T04:46:47Z","close_reason":"Fixed (test-first where applicable): records_display returns None for OOB columns (f04fe51); try_axis_of added + axis_of invariant documented + cycle_axis no longer panics on unregistered categories (f04fe51); scrolled header suppression fixed via pure show_sublabel helper for both row and column headers (df9a02b); CyclePanelFocus cycles from current panel with wrap (9e02245); AGENTS.md merge target corrected (9634c95).","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-oaq","title":"Redundant file re-read in ImportJsonHeadless effect","description":"src/ui/effect.rs:846 chains 'serde_json::from_str(\u0026std::fs::read_to_string(\u0026self.path).unwrap_or_default()).unwrap_or(serde_json::Value::Array(records.clone()))'. This re-reads and re-parses the JSON file after records were already parsed earlier (lines 799-812). Dead code path from earlier refactor. Fix: remove the redundant read, use the already-parsed records array.","status":"closed","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:45Z","created_by":"Ed L","updated_at":"2026-06-10T04:46:39Z","closed_at":"2026-06-10T04:46:39Z","close_reason":"Fixed in f0b9227/77a5124 (test-first): short CSV rows padded with explicit nulls + per-column warnings threaded to status line (stderr on CLI path); wizard pre-checks 12-category limit at advance with actionable message rendered in UI; ImportJsonHeadless parses the file exactly once via extracted json_import_records helper.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-mzv","title":"Import wizard does not pre-check 12-category limit","description":"crates/improvise-io/src/import/wizard.rs:84-150 build_model() fails when \u003e12 categories are proposed, but the ImportWizard UI does not pre-check before allowing the user to confirm. A 20-column CSV imported as 20 categories fails late with 'Category limit exceeded' after the user commits. Fix: validate category count in the UI step and require the user to mark some as Measure/Skip before proceeding. Also add a test for the \u003e12 case.","status":"closed","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:44Z","created_by":"Ed L","updated_at":"2026-06-10T04:46:38Z","closed_at":"2026-06-10T04:46:38Z","close_reason":"Fixed in f0b9227/77a5124 (test-first): short CSV rows padded with explicit nulls + per-column warnings threaded to status line (stderr on CLI path); wizard pre-checks 12-category limit at advance with actionable message rendered in UI; ImportJsonHeadless parses the file exactly once via extracted json_import_records helper.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-0hb","title":"Make Sequence bindings transactional at command level","description":"Currently Keymap::dispatch (crates/improvise-command/src/command/keymap.rs:261-267) calls each Sequence step's execute(ctx) with the same pre-dispatch ctx. Effects accumulate in order and apply atomically as a batch via App::apply_effects, but later commands in a Sequence cannot observe earlier commands' effects through ctx — only effects-after-effects can see prior mutations.\n\nFor example, the records 'o' sequence [add-record-row, enter-edit-at-cursor records-editing] works only because the EnterEditAtCursor effect itself calls app.rebuild_layout() before reading display_value. Effects can self-heal mid-batch, but commands cannot.\n\nTrue command-level transactionality would require, between Sequence steps: apply effects-so-far, rebuild layout, build a fresh ctx, then dispatch the next command. This would change the semantics of every existing Sequence and is worth its own design pass.\n\nAcceptance: design doc + implementation; existing Sequences keep working; new tests prove a later step sees prior steps' model mutations.","notes":"Surfaced while parameterizing EnterEditAtCursor / CommitAndAdvance to remove is_records() runtime checks (improvise-4ju).\nGROOMED 2026-09-19: still true; Keymap::dispatch is keymap.rs:255-272 now (cited 261-267) and the Sequence arm still feeds every step the same pre-dispatch ctx.\nDEFERRED by PR #27 (WU-G) with the design note and its guard, rather than built. The user accepted the deferral.\n\nKeymap::dispatch calls each Sequence step's execute(ctx) with the SAME PRE-DISPATCH ctx, so a later command cannot observe an earlier one's effects. True command-level transactionality means, between steps: apply effects so far, rebuild layout, build a fresh CmdContext, dispatch the next.\n\nTHREE FINDINGS, ALL CHECKABLE, AND THE FIRST IS DISQUALIFYING:\n\n1. IT WOULD SILENTLY REOPEN improvise-8tk, A P1 DATA LOSS. apply_effects resets abort_effects at entry and when it breaks, because the flag is per batch. Applying between steps dispatches step 2 into a FRESH batch with the flag cleared -- so the Quit after a failed Save lands and the model is discarded. Gotcha 21's claim that the abort carries across Sequence steps is true BECAUSE OF the flattening 0hb removes, and it was UNTESTED until this PR.\n\n2. ALMOST NOTHING BENEFITS. Of 21 shipped Sequence bindings, 20 are TextEntry's [\u003ccommit\u003e, clear-buffer] or [clear-buffer, enter-mode], whose second step reads nothing from ctx. The one real composition, records `o`, already works because EnterEditAtCursor's effect self-heals with rebuild_layout. Every Esc and commit key would pay a layout rebuild plus a CmdContext build, which recomputes column widths.\n\n3. THE MOTIVATING REWRITE IS NOT DISPATCHABLE -- see improvise-git.\n\nLANDED AS THE GUARD: a_sequence_whose_first_step_aborts_does_not_run_the_second, the first test to drive a Sequence whose first step aborts. Fault injected by making the abort per-effect, which is what 0hb would do; it failed.\n\nTO UN-DEFER: the blocker is (1). Any design that applies between steps must first answer what abort_effects means across batch boundaries -- and note PR #26 has since made that harder rather than easier, because clause 4 (improvise-yqpd) wants a ROLLBACK at apply_effects, and an abort is not a rollback. Decide those two together or not at all.","status":"deferred","priority":3,"issue_type":"feature","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-04-16T05:45:49Z","created_by":"fido","updated_at":"2026-09-20T07:54:40Z","started_at":"2026-09-20T05:32:32Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-m91","title":"(Stretch) Command log + native undo/replay","description":"Once Commands are the universal unit of intent, log them per-session and support undo/redo via inverse commands or periodic snapshots. Enabled by the unified reducer; valuable for all three deployment modes but especially natural in the native TUI. Also enables debugging and reproducing bugs: a command log is a deterministic replay.","design":"Two approaches: (1) Inverse commands — each command definition includes how to undo it; undo-stack walks the log in reverse applying inverses. Clean but requires per-command inverse logic. (2) Snapshot-and-replay — periodic snapshots of ModelState + current log index; undo rolls back to nearest snapshot and replays up to target index. Simpler, works for any command, costs memory + replay time. Start with (2) for simplicity. The log is per-session (ClientSession.command_log) and survives only within a session. Persistence to disk as a .improv-journal companion file is a further enhancement. Replay use case: load a file, enable log recording, hit a bug, save the log, bug-report-as-replay.","acceptance_criteria":"(1) Every dispatched Command is logged in the ClientSession (or equivalent server-side state). (2) An Undo Command pops the log and restores state via snapshot+replay. (3) Redo re-applies. (4) Native TUI bindings for Ctrl-Z / Ctrl-Shift-Z. (5) Replay test: record a sequence, reset, replay, assert final state equals record-end state.","notes":"Stretch goal — valuable across all deployment modes but not part of the core architectural unification. Depends on issue improvise-gxi (reduce_full) and ideally on a persistence mechanism (improvise-6mq VFS abstraction) if logs should survive restart.\nDEFERRED 2026-09-19 by user ruling. Stretch item, and its natural home is the e0u restructuring (also deferred): a command log is most useful once Commands are genuinely the universal unit of intent through one reducer, which is what e0u finishes.","status":"deferred","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:53:02Z","created_by":"spot","updated_at":"2026-09-20T05:31:02Z","dependencies":[{"issue_id":"improvise-m91","depends_on_id":"improvise-gxi","type":"blocks","created_at":"2026-04-14T00:53:44Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-uq7","title":"(Stretch) Hybrid mode: native TUI with remote browser observer","description":"Once the native binary has the in-process client/server split (issue improvise-e0u), allow a second subscriber to attach via a local ws-server thread. Result: a developer running the native TUI can share their session with a teammate in a browser, or have their own browser tab observing while they work in the terminal. Both subscribers receive projection commands from the same in-process server; commands from either side flow through the same reduce_full.","design":"Native binary gains a --serve PORT flag (or similar) that, when set, spawns a ws-server thread alongside the native client. The ws-server is exactly improvise-q08's server logic, attached to the same in-process App as the native client. Two ClientSessions: one for the local ratatui client (zero-transport), one (or more) for remote websocket clients. Each has its own ViewState + render cache + subscription set. The projection layer fans out effect-driven updates to every session. The two subscribers see independent cursor/scroll/mode state (because view state is per-session) but the same model state (because model is shared). This is the deployment mode that fully justifies per-session view state tracking on the server.","acceptance_criteria":"(1) Native binary has an optional --serve flag that spawns a ws-server in the same process. (2) A remote browser can connect and see the native user's current session (shared model, independent view). (3) Edits from either side show up on the other after a round trip. (4) Graceful disconnect/reconnect of remote clients while native keeps running.","notes":"Stretch goal — not required for the core unification. Depends on issue e0u (binary split done) and improvise-q08 (ws-server exists). Orthogonal to the browser and standalone epics. The architectural investment in per-session view state and subscription-based projections is what makes this possible with minimal additional code.\nDEFERRED 2026-09-19: blocked by improvise-e0u, which was deferred by user ruling this session. Hybrid mode needs the in-process client/server split before a second subscriber can attach.","status":"deferred","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:52:57Z","created_by":"spot","updated_at":"2026-09-20T05:31:02Z","dependencies":[{"issue_id":"improvise-uq7","depends_on_id":"improvise-e0u","type":"blocks","created_at":"2026-04-14T00:53:42Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-uq7","depends_on_id":"improvise-q08","type":"blocks","created_at":"2026-04-14T00:53:43Z","created_by":"spot","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-9ix","title":"SQLite persistence format (alternative save/restore)","description":"Implement .sqlite as an alternative file format alongside .improv. Tables for categories, items, cells, formulas, views. Works over any VFS backend — native filesystem, OPFS, etc. — because the underlying storage layer is abstracted. Enables structured queries over model state and provides a more scalable container than plaintext for very large models. Orthogonal to the core standalone deployment; filed as a backlog follow-on since the MVP doesn't need it.","design":"Use rusqlite with the 'bundled' feature so sqlite itself compiles into the wasm bundle. Schema: one row per cell keyed by (coord_hash, category_coords...); one row per category with kind + ordering; one row per item with group membership; one row per formula with raw + target. Views stored as JSON or as structured rows. Persistence API mirrors .improv: save_sqlite(storage, path) / load_sqlite(storage, path). Rusqlite on wasm may need custom VFS hookup — investigate sqlite-wasm-rs or similar for browser-friendly builds. Round-trip tests: model→sqlite→model must be identity.","acceptance_criteria":"(1) save_sqlite and load_sqlite functions exist and round-trip cleanly. (2) rusqlite links against the VFS abstraction correctly on both native and wasm. (3) Unit tests for schema, round-trip, and edge cases. (4) File manager UI (if any) offers .sqlite as a save format option.","notes":"Depends on improvise-6mq (VFS abstraction). Independent of the standalone deployment critical path — can be tackled any time after the Storage trait lands. Filed as backlog (P3).","status":"closed","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:39:41Z","created_by":"spot","updated_at":"2026-09-20T00:08:35Z","closed_at":"2026-09-20T00:08:35Z","close_reason":"DECLINED with measurements, not intuition. No code written; the tree was left untouched.\n\nALL THREE OF THE TICKET CLAIMS WERE TESTED:\n\n(1) \"Works over any VFS backend because the storage layer is abstracted\" -- FALSE. improvise-6mq did not deliver a VFS. Storage is four whole-document operations (read/write bytes, list, delete) with no offsets, seeking, handles or locking; invariant(storage_is_two_ops) states it as doctrine. An sqlite3_vfs needs xRead(offset,len)/xWrite/xTruncate/xLock and only the first two could even be faked -- by rewriting the whole database on every page access.\nBUT the agent PROVED SQLite can still fit before declining: rusqlite with features=[bundled,serialize] round-trips a database through bytes with zero filesystem contact (probe test passed; a one-row DB serializes to 8192 bytes). So the decline rests on VALUE, not on imagined plumbing trouble -- \"declining on a capability that actually exists would have been the wrong decline\".\n\n(2) \"A more scalable container than plaintext for very large models\" -- FALSE, MEASURED on an 18,000-cell model using the ticket own schema, VACUUMed:\n big.improv 1,280,846 bytes\n big.sqlite 1,568,768 +22%\n big.improv.gz 136,264\n big.sqlite.gz 642,897 4.7x LARGER\nFloor case: a 5-table schema holding 3 rows is 24,576 bytes against roughly 150 bytes of .improv -- about 160x. And because writes go through Storage::write(whole bytes), every save still rewrites the entire document, so the one dimension where SQLite normally wins -- incremental page writes -- is unavailable.\n\n(3) \"Enables structured queries over model state\" -- NOT DELIVERED by this design. The artifact is a closed blob; querying means deserializing the whole database into RAM beside the Model that is already fully in RAM. Anything you could ask it you can already ask DataStore::matching_values/matching_cells over interned, indexed data.\n\nALSO: cargo check --target wasm32-unknown-unknown fails in libsqlite3-sys C build. The agent was precise that this is the NIX CC-WRAPPER injecting -fzero-call-used-regs, not proof bundled SQLite can never target wasm -- but it is real evidence the repo wasm CI gate would break the day this landed.\n\nTHE COST THE TICKET DOES NOT PRICE: .improv is grammar-defined and the pest grammar is the single source of truth. A SQLite writer/reader is a SECOND IMPLEMENTATION OF THE WHOLE FORMAT -- groups, formula target categories, hidden items, collapsed groups, number formats, page/filter selections, IndexMap insertion order, the _Measure explicit-vs-formula-target split, virtual-view suppression -- which must track every future grammar change forever. And the ticket own acceptance test (model-\u003esqlite-\u003emodel identity) CANNOT DETECT DRIFT from .improv, because it never compares the two. That missing cross-format test is what made the ticket look cheap.\n\nFormat dispatch and naming were both checked and are NOT the problem: save_to/load_from are the only chokepoints, SQLite 16-byte header is a cleaner discriminator than the existing leading-brace JSON sniff, and .improv.sqlite would follow invariant(gzip_is_a_name_property).\n\nSALVAGE FILED SEPARATELY: the one claim with standing is INTEROP -- opening a model in sqlite3 or DB Browser. That is EXPORT, not persistence.","dependencies":[{"issue_id":"improvise-9ix","depends_on_id":"improvise-6mq","type":"blocks","created_at":"2026-04-14T00:40:34Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-avy","title":"Formula tokenizer: support quoted identifiers for ambiguous names","description":"The formula tokenizer currently uses heuristics to handle multi-word identifiers (greedy space consumption with keyword/operator break rules). This is fragile — we just fixed a bug where WHERE inside aggregates was consumed as part of an identifier.\n\nA more robust approach: support SQL/CL-style quoted identifiers. Two candidate syntaxes:\n- SQL style: double-quotes for identifiers, e.g. \"Total Revenue\" = \"Base Revenue\" + Bonus\n- CL style: pipe-delimited, e.g. |Total Revenue| = |Base Revenue| + Bonus\n\nSQL double-quoting is natural since the formula syntax already uses quotes for WHERE filter string literals. Disambiguation: in expression position, a quoted string becomes Token::Ident; in WHERE value position, it becomes Token::Str (parser already distinguishes these contexts).\n\nThis would let us simplify the tokenizer's space-handling heuristics and cleanly support category/item names that collide with keywords (WHERE, SUM, IF, etc.).","status":"closed","priority":3,"issue_type":"feature","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T06:45:27Z","created_by":"Edward Langley","updated_at":"2026-04-09T06:48:41Z","closed_at":"2026-04-09T06:48:41Z","close_reason":"Implemented pipe-quoted identifiers |...| in the formula tokenizer. Pipes produce Token::Ident, work in expressions, aggregates, and WHERE clauses. split_where and parse_where also updated. 6 new tests.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-0zf","title":"Bug: WHERE inside aggregate parens broken by greedy identifier tokenizer","description":"The tokenizer treats multi-word identifiers greedily — 'Revenue WHERE' becomes a single token. This means SUM(Revenue WHERE Region=East) doesn't parse correctly. The WHERE keyword is consumed as part of the identifier. Top-level WHERE (outside parens) works because split_where handles it before tokenization. Fix: either stop allowing spaces in identifiers inside parens, or detect WHERE as a keyword break point in the tokenizer.","status":"closed","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T06:06:15Z","created_by":"Edward Langley","updated_at":"2026-04-09T06:38:08Z","closed_at":"2026-04-09T06:38:08Z","close_reason":"Fixed tokenizer to break multi-word identifiers at keywords (WHERE, SUM, AVG, MIN, MAX, COUNT, IF). Two-pronged: (1) break when current identifier IS a keyword, (2) break when next word IS a keyword. SUM(Revenue WHERE Region=East) now parses correctly.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-w82","title":"Test audit: persistence/mod.rs at 82% coverage","description":"persistence/mod.rs is at 82% - just above the floor. Missing coverage for edge cases: empty models, special characters in names, view state persistence details, and formula preservation corner cases.","status":"closed","priority":3,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T05:04:46Z","created_by":"Edward Langley","updated_at":"2026-04-09T06:15:10Z","closed_at":"2026-04-09T06:15:10Z","close_reason":"Added 16 tests covering save/load roundtrip (plain + gzip), autosave_path, export_csv, collapsed groups, page-without-selection, none axis, number format, text values, multiple views, and full feature roundtrip. Coverage from 82% to 97%.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-kq6","title":"Test audit: formula/parser.rs at 76% coverage","description":"formula/parser.rs is at 76% line coverage, just under the 80% target. Missing coverage for MIN, MAX, COUNT aggregate functions, complex nested expressions, and error/edge case paths.","status":"closed","priority":3,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T05:04:44Z","created_by":"Edward Langley","updated_at":"2026-04-09T06:10:12Z","closed_at":"2026-04-09T06:10:12Z","close_reason":"Added 19 tests covering MIN/MAX/COUNT aggregates, comparison operators, power/unary/mul/div, WHERE with quotes, error paths, multi-word identifiers, aggregate-name-as-ref edge case. Coverage from 76% to 89%. Found bug: WHERE inside aggregate parens broken by greedy tokenizer (improvise-0zf).","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-3tj","title":"3.3 Tag v0.1.0 release","description":"Create git tag v0.1.0, push it, verify cargo dist workflow produces release artifacts. Update README prebuilt binaries link to point at actual release.","acceptance_criteria":"Tag v0.1.0 exists and is pushed, the cargo-dist workflow has produced release artifacts for Linux x86_64 and macOS (Intel + Apple Silicon), and README's prebuilt-binaries link points at the actual release rather than a placeholder. NEEDS THE USER: tagging and pushing a release is theirs to authorise, and it is ordered after improvise-l36.","notes":"STATE CHECKED 2026-09-20 at 52507a6 (wave 2, WU-P), and one premise of this\nticket is already false.\n\nThe README's prebuilt-binaries link is NOT a placeholder. `v0.1.0-rc1` and\n`v0.1.0-rc2` both exist as prereleases and `v0.1.0-rc2` carries real cargo-dist\nartifacts: improvise-{aarch64,x86_64}-apple-darwin.tar.xz,\nimprovise-{aarch64,x86_64}-unknown-linux-gnu.tar.xz, each with a .sha256, plus\nimprovise-installer.sh, sha256.sum, dist-manifest.json and source.tar.gz. So\ncargo-dist demonstrably works on this repository, and the four targets in\ndist-workspace.toml are the four that were built.\n\nWhat remains is therefore narrower than the ticket reads: there is no `v0.1.0`\ntag, only the two rc tags, and the rcs are marked prerelease so the releases\npage has no \"Latest\". README was updated in wave 2 to say exactly that rather\nthan to imply a stable release exists.\n\nMECHANISM: `.github/workflows/release.yml` fires on a pushed version tag and\nruns dist itself. Nothing needs to be built by hand; the tag is the trigger.\n\nBEFORE TAGGING: ten manifests say `0.1.0-rc2` (and `crates/gp-tree` says\n`0.1.0`). A `v0.1.0` tag on manifests that say `0.1.0-rc2` makes dist announce\na version nothing in the tree claims, so the bump comes first, in the same\ncommit, and gp-tree's disagreement is resolved with it.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-09T04:08:02Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:01:39Z","labels":["area:release","needs:user"],"dependencies":[{"issue_id":"improvise-3tj","depends_on_id":"improvise-11a","type":"blocks","created_at":"2026-04-08T21:09:30Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-3tj","depends_on_id":"improvise-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.","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.\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}
|
||
{"_type":"issue","id":"improvise-taxx","title":"[bug] The socket close listener overwrites a precise error with disconnected","description":"Found during the PR #13 review, reported and not fixed -- same family as the watchdog bug Copilot found, different question.\n\nweb/app.js socket close listener writes \"disconnected\" UNCONDITIONALLY, so a connection that failed loudly (the error listener already wrote \"cannot reach ... -- is improvise-ws-server running?\") is immediately followed by a close event that replaces it with the vaguer message. The user sees the least informative of the two things that happened.\n\nTHE WATCHDOG BUG WAS THE SAME SHAPE AND WAS FIXED: a timer fired 8s later and overwrote whatever a loud failure had already written; setShellFailure now clears it. This one was left because the question is genuinely different -- what should the page say when a connection BOTH failed AND went away? -- and because suppressing close unconditionally could hide a GENUINE MID-SESSION DISCONNECT, which is the one thing that listener is really for.\n\nLIKELY ANSWER: distinguish \"never connected\" from \"was connected and dropped\", which the socket readyState at close time can tell you. Not \"skip close if error fired\".\n\nUntestable here (no browser, no headless DOM), which is the standing caveat on every web/*.js finding -- see the browser harness ticket.","status":"closed","priority":4,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T03:35:28Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:22:09Z","started_at":"2026-09-20T05:32:34Z","closed_at":"2026-09-20T06:22:09Z","close_reason":"Closed by PR #21 (WU-I). Fixed -- AND THE TICKET'S PROPOSED MECHANISM WAS WRONG, which is the part worth keeping.\n\nThe ticket said: \"distinguish 'never connected' from 'was connected and dropped', which the socket readyState at close time can tell you.\" IT CANNOT. readyState is already CLOSED by the time any close listener runs, whichever happened; and the CloseEvent cannot separate them either, because a refused connection and a mid-session drop are BOTH code 1006 with wasClean:false. Nothing on the event carries the distinction.\n\nThe only discriminator is whether `open` ever fired, which the page must REMEMBER ITSELF. Hence an `opened` flag set in the open handler.\n\nDeliberately NOT the watchdog's fix: the watchdog is disarmed because it exists for silence only, whereas suppressing `close` outright would hide a genuine mid-session disconnect, which is the one thing that listener is really for.\n\nA DETAIL FOUND WHILE FIXING IT, worth knowing before touching this again: armWatchdog runs only AFTER `transport.ready` resolves -- on `open` -- so on the never-opened path the watchdog never exists at all. That is not what its own comment leads you to expect, and it means the original watchdog bug could only ever have fired on a connection that SUCCEEDED.\n\nSTILL UNVERIFIED BY EXECUTION, and stated rather than glossed: web/app.js has never been run here. The change is argued from the WebSocket spec and checked with node --check (proved discriminating by appending an unbalanced paren and watching exit 1), not observed. The four assertions this fix owes are recorded on improvise-vvl3 so the browser harness can discharge them -- including the one that must FAIL against the previous unconditional handler, which is the guard and not the decoration.","dependencies":[{"issue_id":"improvise-taxx","depends_on_id":"improvise-xps8","type":"relates-to","created_at":"2026-09-20T06:44:28Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-7v62","title":"Checked does not bind the App it was checked against","description":"Residual hole in improvise-ondj, found and documented by the agent that closed it. Not reachable today; filed so it is a decision rather than a discovery.\n\nChecked\u003ca\u003e is a private-field newtype over a \u0026Command whose only constructor, Checked::against(\u0026App, \u0026Command), runs the basis check. But the resulting Checked carries no evidence of WHICH App it was checked against, so a multi-document host could in principle check against one App and apply to another -- and the versions would be meaningless, since document_generation, layout_version and record_versions all start at zero in a fresh App and are per document (ADR 001 scopes them that way deliberately).\n\nWHY IT IS NOT REACHABLE TODAY: a Sessions is bound to one document (connect/apply/project_all all take the App as a parameter), and Registry hands out one (App, Sessions) pair per DocumentId. It is documented on the type.\n\nTHE OPTIONS, in increasing cost: leave it documented (defensible -- this is a coordination check, not a security boundary, and the misuse requires a host to actively mix two documents); give Checked a lifetime tied to the \u0026App so the borrow checker forbids the mix; or carry a DocumentId. The middle one is probably free and worth trying before accepting the first.\n\nWON T-FIX IS A LEGITIMATE OUTCOME -- but say so deliberately, because improvise-j31x made this a multi-document server on purpose and ADR 001 notes that a document is never evicted precisely so a reconnecting client cannot have a stale basis accepted as current. The invariant is load-bearing.","status":"closed","priority":4,"issue_type":"task","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T01:54:31Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:29:49Z","started_at":"2026-09-20T05:32:30Z","closed_at":"2026-09-20T07:29:49Z","close_reason":"Closed WON'T FIX by PR #26 (WU-C), deliberately and with the compiler output on the type, which is what the ticket asked for (\"won't-fix is a legitimate outcome -- but say so deliberately\").\n\nThe ticket's middle option -- give Checked a lifetime tied to the \u0026App so the borrow checker forbids applying it to a different document -- WAS TRIED. It is not free, and the reason generalises: A PROOF DISCHARGED BEFORE A MUTATION CANNOT BE REPRESENTED AS A BORROW HELD ACROSS IT. A Checked is evidence produced by reading the App and whose whole job is to be carried INTO the mutation; a borrow must stay live DURING it. Every call site becomes E0502, starting with Sessions::apply. Now doctrine(a_proof_obligation_is_not_a_borrow).\n\nThe DocumentId option is worse placed than it looks: DocumentId lives in the SERVER'S REGISTRY and an App does not have one, because the unit of authority is one document per App. Threading it in would put a server concept into the command layer to guard against a misuse the server's own structure already prevents.\n\nWHAT KEEPS THE MISUSE UNREACHABLE, unchanged and load-bearing: a Sessions is bound to one document (connect/apply/project_all all take the App as a parameter) and Registry hands out one (App, Sessions) pair per DocumentId. ADR 001 notes a document is never evicted precisely so a reconnecting client cannot have a stale basis accepted as current.\n\nThe ticket was right that this is a coordination check and not a security boundary, and right that the invariant is load-bearing. It is documented on the type, with the reason the cheap fix is unavailable, so the next person does not re-derive it.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_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":"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":"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":"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}
|
||
{"_type":"issue","id":"improvise-ede","title":"prune_empty's records branch was unreachable dead code","description":"Noticed while doing improvise-c5v. GridLayout::prune_empty starts with 'if self.is_records_mode() { return; }', which guarantees self.records is None for the rest of the function — but the tail carried an 'if let Some(records) = \u0026self.records' branch that re-indexed row_items and rebuilt the records vec. It could never run.\n\nI removed it rather than leaving it, because the RecordRow type change made it stop compiling, so it became my mess to clean rather than pre-existing dead code to leave alone (AGENTS.md).\n\nFiling this only as a record of the removal, in case the intent was that records mode SHOULD prune empty rows and the early return is the actual bug. If so, that is a separate behavior question: today records mode deliberately shows every row ('the user drilled in to see all the raw data', per the doc comment on prune_empty). Close this if the early return is intended.","status":"closed","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-08-29T19:29:02Z","created_by":"Edward Langley","updated_at":"2026-09-19T19:48:04Z","closed_at":"2026-09-19T19:48:04Z","close_reason":"Answered by the code the ticket points at. prune_empty carries the doc comment \"In records mode every column is shown (the user drilled in to see all the raw data)\", and repo-map gotcha 1 says the same: a records row is one coordinate tuple and every raw record is meant to be visible. So the early return is intended and the removed branch was correctly dead. Groomed 2026-09-19 per the ticket own instruction (\"Close this if the early return is intended\"). Reopen if P in records mode doing nothing is itself worth a UX ticket.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-ct5","title":"i on an empty records grid reports 'cannot drill', leaking a pivot-mode concept","description":"In records mode every regular category sits on Axis::None (ToggleRecordsMode, command/cmd/grid.rs:432), so EditOrDrill's regular_none is always true and the edit branch depends entirely on is_synthetic. On an empty records grid there is no synthetic record at the cursor, so i takes the drill branch, finds no cell key, and sets 'cannot drill — no cell at cursor'.\n\nThe outcome (nothing to edit) is defensible, but the message talks about drilling — a pivot-mode concept the user is not in. In records mode the user's model is 'a table I type into', and o one key away does add a row and open an edit.\n\nOptions: (a) reword the message for records mode; (b) make i on an empty records grid behave like o. (b) is more helpful but makes i mode-dependent, which cuts against edit-or-drill being mode-agnostic — decide deliberately.\n\nPinned by ui/app.rs::tests::i_and_o_diverge_on_an_empty_records_grid, which exists to record that i and o diverge here (see improvise-6sq). Changing this behavior means updating that test; read its doc-comment first — the divergence itself is load-bearing.","notes":"STALE REF (groomed 2026-08-30, line numbers shifted by this session's work): command/cmd/grid.rs:432 -\u003e the axis loop moved to crates/improvise-command/src/command/cmd/grid.rs:443 (the '!CategoryKind::of_name(name).is_index_or_dim()' loop in ToggleRecordsMode).","status":"closed","priority":4,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-08-29T17:52:53Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:54:39Z","started_at":"2026-09-20T05:32:33Z","closed_at":"2026-09-20T07:54:39Z","close_reason":"Closed by PR #27 (WU-G). Option (a): reword, do not make `i` behave like `o`.\n\nTHE DECISION AND WHY: the divergence between `i` and `o` on an empty records grid is what improvise-6sq exists to record, and it is why edit-or-drill and enter-edit-at-cursor are two commands. Option (b) collapses it. The OUTCOME (nothing to edit) was never the bug -- the WORDING was, because \"cannot drill\" leaks a pivot-mode concept into a mode whose model is \"a table I type into\".\n\nWHICH SENTENCE IS SHOWN is read off `edit_mode` -- THE ARGUMENT THE KEYMAP SUPPLIES -- not off ctx.mode, so no runtime is_records() check returns (improvise-4ju).\n\nEVIDENCE: the pinning test ui/app.rs::tests::i_and_o_diverge_on_an_empty_records_grid keeps its load-bearing assertion (`i` does not enter editing) and was watched FAILING on the old message before the fix. New the_empty_grid_message_is_not_the_records_one_in_pivot_mode is the negative arm -- without it, \"say the records sentence everywhere\" passes, and would promise `o` in pivot mode where `o` adds no row. Fault injected by collapsing to one sentence; failed.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-ont","title":"Migrate existing callers of workbook.views.get() to Workbook::active_view","description":"Workbook already exposes active_view() and active_view_mut() (crates/improvise-core/crates/improvise-core/src/workbook.rs:87-97) but many callers still do workbook.views.get(\u0026workbook.active_view).unwrap().X or wb.views.get('view_name').unwrap().axis_of(...).\\n\\n11 lookup sites identified:\\n- crates/improvise-core/crates/improvise-core/src/workbook.rs:167-168, 176-177 (within workbook.rs itself — these may be the implementations and be fine)\\n- crates/improvise-core/crates/improvise-core/src/model/types.rs:1868, 1871, 1873, 1886, 1890 (tests)\\n- crates/improvise-io/crates/improvise-io/src/persistence/mod.rs:943, 946, 1165\\n\\nFix: audit each site; migrate to Workbook::active_view() / Workbook::view(name) / add Workbook::view_axis(view_name, cat_name) -\u003e Option\u003cAxis\u003e to absorb the common chain.\\n\\nMostly a cleanup task but reduces future refactor exposure. ~15 LOC.","notes":"Groomed 2026-08-13 — re-verified against the tree; the premise has largely evaporated.\n\nCurrent state: 14 `views.get(` call sites remain, and 13 of them are inside #[cfg(test)] modules (workbook.rs, model/types.rs, persistence/mod.rs). Test code asserting on a NAMED view is not the chain this ticket wants to remove — `active_view()` is the wrong tool there, because the whole point of those assertions is to look at a view that is not active.\n\nThe single production site is src/ui/view_panel.rs:31:\n let Some(view) = self.workbook.views.get(view_name) else { ... }\nThat takes a dynamic view name from the panel list, so active_view() does not apply to it either. At most it wants a `Workbook::view(name) -\u003e Option\u003c\u0026View\u003e` accessor, which is a 3-line change with no correctness content.\n\nThe ticket's original count of '11 lookup sites' predates the improvise-tvt item-facade work, which removed most of the interesting ones. What is left is not worth a P3 slot. Dropped to P4.\n\nRECOMMEND: either close as wont-do, or rescope to exactly 'add Workbook::view(name) and use it in view_panel.rs' — which is small enough to fold into whatever next touches that file.","status":"closed","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T19:02:37Z","created_by":"Ed L","updated_at":"2026-08-30T17:07:07Z","closed_at":"2026-08-30T17:07:07Z","close_reason":"OBSOLETE — the pattern this ticket targets no longer exists (verified 2026-08-30 while grooming).\n\nThe description says 'many callers still do workbook.views.get(\u0026workbook.active_view).unwrap().X'. A grep for 'views.get(\u0026' across crates/ and src/ now returns ZERO matches; the migration to Workbook::active_view() has already happened, presumably in the crate-split work.\n\nOf the 14 remaining '.views.get(' call sites, 13 are in test modules (workbook.rs, persistence/mod.rs, model/types.rs — all asserting on a specific named view, which is the correct API for that) and exactly one is production code:\n\n src/ui/view_panel.rs:31 self.workbook.views.get(view_name)\n\nThat one looks up a view BY NAME from the panel's own list, so active_view() does not apply to it. There is nothing left to migrate.\n\nNot carried forward: the ticket also floated adding Workbook::view_axis(view_name, cat_name) to absorb the .unwrap().axis_of(...) chain. That chain now appears only in tests, so the absorption would buy little; file it fresh if it ever earns its keep.\n\nAlso noted while here: the description's paths were malformed — 'crates/improvise-core/crates/improvise-core/src/workbook.rs' — with the crate prefix doubled throughout, which is why they resolved to nothing.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-ete","title":"Add Model/Category getter methods to absorb .unwrap().items/groups chains","description":"Several callers reach through Model and Category to get item names, groups, or counts — each chain includes an .unwrap() that panics if the category is missing:\\n\\n- persistence/mod.rs:1635-1639, 2006-2007: model.category(name).unwrap().items.values().map(|i| i.name.clone()).collect() — 4 sites\\n- persistence/mod.rs:831: \u0026m2.model.category('Month').unwrap().groups — 1 site\\n- crates/improvise-command/src/ui/cat_tree.rs:32: cat.map(|c| c.items.len()) — item count\\n\\nAdd:\\n- Model::item_names(cat: \u0026str) -\u003e Option\u003cVec\u003cString\u003e\u003e\\n- Model::group_names(cat: \u0026str) -\u003e Option\u003cVec\u003cString\u003e\u003e\\n- Category::item_count() -\u003e usize\\n\\nCorrectness win: (a) prevents panics if the category lookup fails; (b) hides the items/groups IndexMap/HashMap from callers — internal-rep changes don't cascade; (c) the Option return type forces explicit handling.\\n\\n~15 LOC plus cleaner call sites.","notes":"GROOMED 2026-09-19 — DISSOLVED PREMISE, mostly. The four persistence/mod.rs sites this ticket was built on (model.category(name).unwrap().items.values()...) no longer exist in production code; grep for unwrap().items / unwrap().groups now hits only test modules (persistence/mod.rs is #[cfg(test)] from line 706 on, workbook.rs likewise). The stated correctness win — \"prevents panics if the category lookup fails\" — is therefore void: there is no production panic left to prevent. What survives is encapsulation in test code plus cat_tree.rs:32 (cat.map(|c| c.items.len()).unwrap_or(0)), which already handles the missing category. P3 -\u003e P4 on that basis; close it if the encapsulation alone is not worth 15 LOC.","status":"closed","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T19:02:34Z","created_by":"Ed L","updated_at":"2026-09-19T20:52:14Z","closed_at":"2026-09-19T20:52:14Z","close_reason":"OBSOLETE -- premise dissolved, verified item by item rather than accepted from the groom's note. (1) Model::item_names would DUPLICATE the existing Model::effective_item_names, and in a worse form: the ticket's version reading category(name).items would silently omit formula targets for _Measure, which is exactly the trap gotcha 7 warns about; the existing one routes _Measure through measure_item_names and returns Vec rather than Option\u003cVec\u003e, which is also the right call under 'keep Option at the boundaries'. (2) Model::group_names has no caller inside improvise-core -- the site the ticket cites now lives in an improvise-io test. (3) Category::item_count has no in-scope caller either; the live one is improvise-command's cat_tree.rs, which already handles the missing category.\n\nThe ticket's stated correctness win -- 'prevents panics if the category lookup fails' -- is void: every unwrap().items / unwrap().groups chain in the repo is now inside a #[cfg(test)] module. The single production reader of cat.items.values() is persistence/format.rs, the serializer legitimately walking items to write them, which is not an unwrap chain and not something a getter should hide from a formatter.\n\nIf the encapsulation is still wanted for improvise-command's cat_tree.rs, that is one method for whoever owns that crate, not the three proposed here.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-a5q","title":"Parquet columnar export (read-only)","description":"One-way export to Apache Parquet for interop with data tools (pandas, Arrow, BI tools). Columnar layout maps naturally to improvise's category/measure/cell structure: each category becomes a column of the key space, each measure a value column. Read-back not required for MVP; this is primarily a data-out path. Orthogonal to the core standalone deployment; filed as a backlog follow-on.","design":"Use arrow-rs + parquet crates (or polars as a simpler wrapper). Model shape → Arrow RecordBatch: one row per cell, columns are (category_1, category_2, ..., measure_name, value). Write the batch to a parquet file via the VFS. Read-back (parquet → model) is a stretch goal — most users will export once and consume the file elsewhere. Browser build: parquet + arrow crates are heavy; may push the wasm bundle size significantly. Consider making parquet export a separate optional wasm chunk loaded on demand.","acceptance_criteria":"(1) Export function writes a valid parquet file from the current model. (2) File opens correctly in pandas / pyarrow and reproduces the improv data. (3) Works over VFS (native filesystem + OPFS).","notes":"Depends on improvise-6mq (VFS abstraction). Very low priority — a nice data-science handoff story but not required for core functionality. Filed as P4 backlog.\nDEFERRED 2026-09-19 by user ruling during the whole-backlog campaign. Orthogonal data-out path with no consumer asking for it; it sits behind the ADR 003 data-source direction (improvise-rucf), which is itself filed not-a-priority. Revisit if an interop request is concrete.","status":"deferred","priority":4,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:39:46Z","created_by":"spot","updated_at":"2026-09-20T05:31:03Z","dependencies":[{"issue_id":"improvise-a5q","depends_on_id":"improvise-6mq","type":"blocks","created_at":"2026-04-14T00:40:35Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-3gy","title":"4.2 Enable GitHub Pages","description":"Enable Pages in GitHub repo settings: source=main branch, folder=/docs. Verify site is live at the GitHub Pages URL.","notes":"CORRECTION (2026-09-20): this ticket's text says source=main branch, folder=/docs. That is the WRONG SETTING for the pipeline that landed in improvise-d31.\n\nThe correct setting is Settings -\u003e Pages -\u003e Source: GITHUB ACTIONS. The workflow uploads a Pages artifact from pages-build and deploys it with deploy-pages@v4; a branch/folder source would serve the repo instead and silently ignore the workflow.\n\nThe workflow attempts enablement itself (configure-pages@v5, enablement: true), so this may need no human at all -- verified as NOT currently enabled (the Pages API 404s). If the run fails at configure-pages, note that default_workflow_permissions on this repo is 'read'; the job declares permissions: {pages: write, id-token: write} explicitly, which should grant above the default, but it is the first thing to check on a 403.","status":"closed","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-09T04:08:10Z","created_by":"Edward Langley","updated_at":"2026-09-20T04:20:51Z","closed_at":"2026-09-20T04:20:51Z","close_reason":"Done. GitHub Pages is enabled and serving: https://fiddlerwoaroof.github.io/improvise/ renders the demo grid from the in-page worker, verified by the user in Safari 27 on 2026-09-20.\n\nNote the ticket's own text was WRONG about the setting and was corrected before this closed: it said source = main branch, folder = /docs. The pipeline improvise-d31 built uploads a Pages ARTIFACT and deploys it with deploy-pages@v4, so the correct source is GITHUB ACTIONS. configure-pages@v5 with enablement: true turned it on without a human touching the setting, which answers the open question about whether the workflow token may.","dependencies":[{"issue_id":"improvise-3gy","depends_on_id":"improvise-e61","type":"blocks","created_at":"2026-04-08T21:09:32Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-e61","title":"4.1 Create docs/index.html landing page","description":"Vanilla HTML, single file, under 200 lines. Dark background, monospace headings. Embed asciinema-player from jsdelivr CDN. Sections: title/tagline, pivot cast, drill cast, formulas cast, import cast, install commands + GitHub link. Player config: rows 30, cols 100, theme monokai, autoPlay false, loop true.","acceptance_criteria":"docs/index.html exists: vanilla HTML, single file, under 200 lines, dark background and monospace headings, asciinema-player embedded from jsdelivr, sections for title/tagline, the pivot/drill/formulas/import casts, install commands and a GitHub link. Player config rows 30, cols 100, theme monokai, autoPlay false, loop true. The four casts already exist (improvise-d4w is closed) — check they still reflect the current UI before embedding them, since the wave changed the records columns and the status line.","notes":"CRITERION REINTERPRETED, on review of PR #47, rather than a second bead filed.\n\n\"Under 200 lines\" is a PROXY for what this ticket actually cared about — single\nfile, vanilla, no build step, no framework — and those are the properties worth\nholding. The line count is not: it is met or missed by an arbitrary margin, it is\nchecked by nothing, and at 199 lines it was \"met\" only by accident, with the next\nperson to touch the file silently breaking it.\n\n`docs/index.html` is now 209 lines. The properties the number stood for are all\nintact: one file, no build step, no framework, no dependency but the pinned\nasciinema-player from a CDN.\n\nWhat took it over was a fix, not padding. At a 390px viewport the four players\ncomputed a 4.83px terminal font — measured in Chromium against the assembled\ntree — because a 120-column cast shrunk into a phone is unreadable. That was\ntolerable when the landing page sat at /landing/; since the user's ruling it is\nthe first thing a phone visitor sees. A media query under 900px now scrolls each\nplayer sideways at a legible size instead: 11.95px, measured the same way, with\nthe PAGE's own scrollWidth still equal to its clientWidth, so the overflow is\ncontained inside the player and there is no horizontal page scroll.\nTWO CORRECTIONS TO THE NOTE ABOVE, and one thing it should not be read as saying.\n\nFIRST, THE NUMBERS IN THAT NOTE ARE NOW WRONG. It says the file is 209 lines and\nthat a media query fixes the phone rendering. A user ruling has since reverted\nthat fix — the mobile defect is filed as improvise-o31u and the page is\nunchanged — so `docs/index.html` is **213 lines**, and what took it past 200 is\nthe drill-cast caption below, not the mobile fix. The criterion reinterpretation\nstands on its own reasoning and does not depend on which change crossed the\nline: \"under 200 lines\" was a proxy for single file, vanilla, no build step, no\nframework, and all four hold.\n\nSECOND, AND MORE IMPORTANT: THE CAPTION IS A LABEL ON AN UNMET CRITERION, NOT A\nMET ONE. This ticket asked that the casts be checked against the current UI\nbefore embedding. They were, and `drill.cast` fails: it shows the narrow records\nformat with a single `Value` column, which improvise-c5v and improvise-2zq\nreplaced with a column per measure plus the `+cat` and `+` columns. The user\nruled to ship it with a visible caption rather than re-record or drop the\nsection, and to keep this ticket closed. That ruling is about what to SHIP; it\ndoes not make the criterion satisfied.\n\nSo the honest reading of this ticket's state is: delivered, with one embedded\ncast known to demo a UI the software no longer has, labelled as such on the page\nat body size and above the player rather than in a footnote, and improvise-2oxt\nopen to fix it properly. Anyone auditing \"do the casts reflect the current UI\"\nshould read improvise-2oxt, not this close reason, for the answer.","status":"closed","priority":4,"issue_type":"task","assignee":"wave2-p-milestones","owner":"el-github@elangley.org","created_at":"2026-04-09T04:08:07Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:22:56Z","started_at":"2026-09-20T18:02:40Z","closed_at":"2026-09-20T18:04:48Z","close_reason":"docs/index.html on wave/p-milestones: 194 lines, vanilla, single file, dark\nbackground, monospace headings, asciinema-player 3.10.0 pinned from jsdelivr\n(both URLs checked live, 200), all six sections, and the four improvise-d4w\ncasts. Verified by driving it in Chromium against both docs/ and the assembled\nsite/landing/: four players construct, each renders 37 terminal lines after\nplay, zero failed requests, zero page errors. That check was proved able to\nfail first — with autoPlay false the cast is not fetched until the click, so\ncounting players passes on a page whose every cast path 404s; breaking\n./casts/ to ./cats/ passes the construction check and fails the play check.\nNothing in CI asserts it yet: improvise-bgtf.\n\nTWO DEVIATIONS, both stated rather than papered over.\n\nPlayer geometry is cols 120 / rows 37, not the 100x30 this ticket and\nimprovise-d4w both name. Every cast header says 120x37, so the recordings\nnever complied with d4w's own \"Terminal: 100x30\"; a 30-row player clips the\nbottom seven rows of a 37-row cast, which is where the tile bar and the status\nline are.\n\nThe \"check they still reflect the current UI\" criterion was performed and the\nanswer is partly no. The tile bar's short_label vocabulary and the mode line\nstill match; the casts predate improvise-2zq's +cat column by five months and\nevery one opens on the recorder's shell prompt and home directory. Filed as\nimprovise-2oxt rather than embedded as if current.","labels":["area:release"],"dependencies":[{"issue_id":"improvise-e61","depends_on_id":"improvise-d4w","type":"blocks","created_at":"2026-04-08T21:09:31Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0}
|
||
{"_type":"issue","id":"improvise-kh8","title":"Phase 4: Landing page (optional)","description":"Create docs/index.html with embedded asciinema casts and enable GitHub Pages. Optional but recommended for launch.","acceptance_criteria":"Closes when improvise-e61 closes; improvise-3gy (enable GitHub Pages) is already closed. NOTE A COLLISION worth resolving before starting: Pages is already serving the standalone web app from the CI assemble step (improvise-d31/bck). Decide whether the landing page replaces that, sits beside it at a path, or is dropped in favour of the live app being the landing page — which is arguably the better demo and would close this as won't-fix.","notes":"CORRECTION TO THE CLOSE REASON ABOVE — THE USER OVERRULED IT, same session.\n\nThe close reason records the landing page sitting BESIDE the app at /landing/,\nwith the app keeping the root. The user ruled the other way:\n\n \"The landing page should be served at the root of the repository and link\n to the in-browser demo.\"\n\nSo the arrangement is inverted. `docs/index.html` is the site root and links\nprominently to the app, which moves to `/app/`. The argument for the other\narrangement is left standing above rather than deleted, because it is still the\nreason the alternative was considered and the collision this ticket asked to\nresolve was a genuine one — but it is OVERRULED, not merely superseded, and the\nroot is now the landing page.\n\nWHAT THE INVERSION COST, which is the part worth carrying:\n\n* The app moves under `app/` WHOLE — `index.html`, `app.js`, `style.css`,\n `worker.js`, `demo.improv` and BOTH bundle directories. Leaving `pkg/` and\n `worker-pkg/` at the root would have meant editing the wasm-pack generated\n glue; every path in app.js and worker.js is relative, so moving the bundle\n together keeps all of them correct and the glue untouched. `worker.js`'s\n `fetch(\"demo.improv\")` resolves against the WORKER's url, so the seed follows\n the move for the same reason.\n* `web/assemble.sh`'s four `stamped` guards had to follow the files. They are\n the only check that the cache-bust substitution worked, and a guard pointed at\n a path nothing writes fails in the direction that looks like success.\n* `harness/tests/shell.spec.js` navigates to `/index.html` six times, including\n the `?ws=` and `?doc=` variants. Every one of those now lands on the landing\n page, so all six were repointed at `/app/index.html`. The browser harness is a\n CI job that can fail the build, so this was not optional and not someone\n else's to discover.\n\nVERIFIED against the ASSEMBLED tree, not against docs/ directly, because a path\nmove is exactly what re-arms the trap this page already caught once: the four\ncasts play 37 lines each at the root, the primary call-to-action resolves to\n/app/ and returns 200, and there are no failed requests and no page errors. The\ncall-to-action check was proved able to fail by pointing the link at ./aap/,\nwhich returns 404 while the page still looks entirely correct.","status":"closed","priority":4,"issue_type":"feature","assignee":"wave2-p-milestones","owner":"el-github@elangley.org","created_at":"2026-04-09T04:05:53Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:10:48Z","started_at":"2026-09-20T18:02:40Z","closed_at":"2026-09-20T18:04:51Z","close_reason":"Both children terminal: improvise-3gy was already closed, improvise-e61 closes\nwith this wave.\n\nTHE COLLISION THIS TICKET ASKED TO RESOLVE FIRST, resolved: Pages serves the\nstandalone web app at the root (improvise-d31/bck), and the landing page now\nsits BESIDE it at /landing/ rather than replacing it. The reasoning is this\nticket's own — the live app is the better front door — and the third option,\ndropping the landing page in favour of the app, was declined only because the\nrecorded terminal casts show the TUI, which the browser app does not.\n\nweb/assemble.sh copies docs/index.html and docs/casts/ into the deployed tree,\nso the page is reachable and the browser harness serves the same tree the\ndeploy publishes. A landing page the deploy never copies would have satisfied\nthis ticket's letter and been a file nobody can open.\n\nREADME links both.","labels":["area:release"],"dependencies":[{"issue_id":"improvise-kh8","depends_on_id":"improvise-3gy","type":"blocks","created_at":"2026-04-08T23:37:42Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-kh8","depends_on_id":"improvise-e61","type":"blocks","created_at":"2026-04-08T23:37:41Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0}
|