From 31252a174481ac25ea75f5118f6ea593aeeb84fd Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Sun, 20 Sep 2026 00:52:33 -0700 Subject: [PATCH] chore: update configs --- .beads/issues.jsonl | 10 +++++++++- .claude/settings.json | 3 +++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 0f35df11..02a356d8 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -36,6 +36,9 @@ {"_type":"issue","id":"improvise-km8","title":"1.2 Fix Cargo.toml metadata","description":"Add missing package fields: description, repository, homepage, documentation, readme, keywords, categories, license. Description: 'Terminal pivot-table modeling in the spirit of Lotus Improv'. Keywords: tui, pivot, spreadsheet, data, improv. Categories: command-line-utilities, visualization.","status":"closed","priority":1,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:06:48Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-09T07:20:17Z","close_reason":"Metadata already present: description, license, repository, homepage, readme, keywords, categories all populated.","dependency_count":0,"dependent_count":2,"comment_count":0} {"_type":"issue","id":"improvise-902","title":"1.1 Audit and remove or update context/SPEC.md","description":"Compare context/SPEC.md against actual code. If spec contradicts code significantly, delete it. Optionally move salvageable conceptual content to docs/design-notes.md with a staleness disclaimer. Do not try to bring spec into sync.","status":"closed","priority":1,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:06:46Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-09T07:10:05Z","close_reason":"Audited SPEC.md against code. Found minor inaccuracies (storage internals, wizard step count, search mode representation) but no major architectural contradictions. Deleted SPEC.md since it was largely redundant with repo-map.md and design-principles.md. Salvaged product vision and non-goals into docs/design-notes.md with staleness disclaimer.","dependency_count":0,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"improvise-tik","title":"Phase 1: Repository hygiene","description":"Raise the quality floor of what a stranger sees when they land on the repo. Subtasks cover spec audit, Cargo.toml metadata, publish-readiness, CSV audit, and example files.","status":"closed","priority":1,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-09T04:05:45Z","created_by":"Edward Langley","updated_at":"2026-04-09T07:22:34Z","closed_at":"2026-04-09T07:22:34Z","close_reason":"All 5 subtasks complete: SPEC.md audited, Cargo.toml metadata fixed, publish dry-run clean, CSV audit done, example files created.","dependencies":[{"issue_id":"improvise-tik","depends_on_id":"improvise-2fr","type":"blocks","created_at":"2026-04-08T23:37:31Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-tik","depends_on_id":"improvise-902","type":"blocks","created_at":"2026-04-08T23:37:30Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-tik","depends_on_id":"improvise-bv1","type":"blocks","created_at":"2026-04-08T23:37:32Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-tik","depends_on_id":"improvise-km8","type":"blocks","created_at":"2026-04-08T23:37:30Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-tik","depends_on_id":"improvise-n1h","type":"blocks","created_at":"2026-04-08T23:37:32Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":5,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-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","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","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","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","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#\n\n#\n\n\n\nS\n\nt\n\ne\n\np\n\ns\n\n\n\nt\n\no\n\n\n\nR\n\ne\n\np\n\nr\n\no\n\nd\n\nu\n\nc\n\ne\n\n\n\n\n\n1\n\n.\n\n\n\nB\n\nu\n\ni\n\nl\n\nd\n\n\n\na\n\n\n\nv\n\ni\n\ne\n\nw\n\n\n\nw\n\nh\n\no\n\ns\n\ne\n\n\n\nr\n\no\n\nw\n\n\n\na\n\nx\n\ni\n\ns\n\n\n\nh\n\na\n\ns\n\n\n\na\n\n\n\ng\n\nr\n\no\n\nu\n\np\n\n\n\na\n\ns\n\n\n\ni\n\nt\n\ns\n\n\n\nF\n\nI\n\nR\n\nS\n\nT\n\n\n\ne\n\nn\n\nt\n\nr\n\ny\n\n\n\n—\n\n\n\ne\n\n.\n\ng\n\n.\n\n\n\na\n\n\n\nc\n\na\n\nt\n\ne\n\ng\n\no\n\nr\n\ny\n\n\n\nw\n\nh\n\no\n\ns\n\ne\n\n\n\nf\n\ni\n\nr\n\ns\n\nt\n\n\n\ni\n\nt\n\ne\n\nm\n\n\n\nb\n\ne\n\nl\n\no\n\nn\n\ng\n\ns\n\n\n\nt\n\no\n\n\n\ng\n\nr\n\no\n\nu\n\np\n\n\n\n`\n\nC\n\no\n\na\n\ns\n\nt\n\na\n\nl\n\n`\n\n.\n\n\n\n2\n\n.\n\n\n\nO\n\np\n\ne\n\nn\n\n\n\ni\n\nt\n\n\n\ni\n\nn\n\n\n\nt\n\nh\n\ne\n\n\n\nT\n\nU\n\nI\n\n\n\na\n\nt\n\n\n\n`\n\nr\n\no\n\nw\n\n_\n\no\n\nf\n\nf\n\ns\n\ne\n\nt\n\n\n\n=\n\n=\n\n\n\n0\n\n`\n\n.\n\n\n\n3\n\n.\n\n\n\nT\n\nh\n\ne\n\n\n\n`\n\n▼\n\n\n\nC\n\no\n\na\n\ns\n\nt\n\na\n\nl\n\n`\n\n\n\nh\n\ne\n\na\n\nd\n\ne\n\nr\n\n\n\ni\n\ns\n\n\n\nn\n\no\n\nt\n\n\n\nd\n\nr\n\na\n\nw\n\nn\n\n\n\na\n\nt\n\n\n\na\n\nl\n\nl\n\n:\n\n\n\n`\n\nb\n\no\n\nd\n\ny\n\n_\n\nf\n\nr\n\no\n\nm\n\n(\n\n0\n\n)\n\n`\n\n\n\ns\n\nt\n\na\n\nr\n\nt\n\ns\n\n\n\na\n\nt\n\n\n\nt\n\nh\n\ne\n\n\n\nf\n\ni\n\nr\n\ns\n\nt\n\n\n\nI\n\nT\n\nE\n\nM\n\n\n\na\n\nn\n\nd\n\n\n\ns\n\nk\n\ni\n\np\n\ns\n\n\n\nt\n\nh\n\ne\n\n\n\nl\n\ne\n\na\n\nd\n\ni\n\nn\n\ng\n\n\n\n`\n\nF\n\nr\n\na\n\nm\n\ne\n\nE\n\nn\n\nt\n\nr\n\ny\n\n:\n\n:\n\nG\n\nr\n\no\n\nu\n\np\n\n`\n\n.\n\n\n\n`\n\nG\n\nR\n\nO\n\nU\n\nP\n\nE\n\nD\n\n_\n\nF\n\nR\n\nA\n\nM\n\nE\n\n`\n\n\n\ni\n\nn\n\n\n\n`\n\ns\n\nr\n\nc\n\n/\n\nu\n\ni\n\n/\n\ng\n\nr\n\ni\n\nd\n\n.\n\nr\n\ns\n\n`\n\n\n\np\n\ni\n\nn\n\ns\n\n\n\nt\n\nh\n\ni\n\ns\n\n,\n\n\n\nb\n\ny\n\nt\n\ne\n\n-\n\ni\n\nd\n\ne\n\nn\n\nt\n\ni\n\nc\n\na\n\nl\n\n\n\nt\n\no\n\n\n\np\n\nr\n\ne\n\n-\n\na\n\nn\n\nr\n\n9\n\n.\n\n\n\n4\n\n.\n\n\n\nN\n\no\n\nw\n\n\n\nc\n\no\n\nl\n\nl\n\na\n\np\n\ns\n\ne\n\n\n\nt\n\nh\n\na\n\nt\n\n\n\ng\n\nr\n\no\n\nu\n\np\n\n.\n\n\n\nI\n\nt\n\ns\n\n\n\nr\n\no\n\nw\n\ns\n\n\n\nd\n\ni\n\ns\n\na\n\np\n\np\n\ne\n\na\n\nr\n\n\n\na\n\nn\n\nd\n\n\n\ni\n\nt\n\ns\n\n\n\nh\n\ne\n\na\n\nd\n\ne\n\nr\n\n\n\ni\n\ns\n\n\n\ns\n\nt\n\ni\n\nl\n\nl\n\n\n\nn\n\no\n\nt\n\n\n\nd\n\nr\n\na\n\nw\n\nn\n\n\n\n—\n\n\n\ns\n\no\n\n\n\nt\n\nh\n\ne\n\nr\n\ne\n\n\n\ni\n\ns\n\n\n\nn\n\no\n\nt\n\nh\n\ni\n\nn\n\ng\n\n\n\no\n\nn\n\n\n\ns\n\nc\n\nr\n\ne\n\ne\n\nn\n\n\n\nt\n\no\n\n\n\na\n\nc\n\nt\n\n\n\no\n\nn\n\n,\n\n\n\ni\n\nn\n\n\n\ne\n\ni\n\nt\n\nh\n\ne\n\nr\n\n\n\nf\n\nr\n\no\n\nn\n\nt\n\n\n\ne\n\nn\n\nd\n\n.\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:45:10Z","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","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":"{}"},{"issue_id":"improvise-plcx","depends_on_id":"improvise-jq8l","type":"relates-to","created_at":"2026-09-20T07:16:29Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -157,6 +160,10 @@ {"_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-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","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","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:49:56Z","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","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","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#\n\n#\n\n\n\nS\n\nt\n\ne\n\np\n\ns\n\n\n\nt\n\no\n\n\n\nR\n\ne\n\np\n\nr\n\no\n\nd\n\nu\n\nc\n\ne\n\n\n\n\n\n1\n\n.\n\n\n\nO\n\np\n\ne\n\nn\n\n\n\na\n\nn\n\ny\n\n\n\nd\n\no\n\nc\n\nu\n\nm\n\ne\n\nn\n\nt\n\n\n\na\n\nn\n\nd\n\n\n\nm\n\na\n\nk\n\ne\n\n\n\nn\n\no\n\n\n\ne\n\nd\n\ni\n\nt\n\n\n\no\n\nf\n\n\n\na\n\nn\n\ny\n\n\n\nk\n\ni\n\nn\n\nd\n\n.\n\n\n\n2\n\n.\n\n\n\nP\n\nr\n\ne\n\ns\n\ns\n\n\n\n`\n\nv\n\ni\n\ne\n\nw\n\n-\n\nb\n\na\n\nc\n\nk\n\n`\n\n,\n\n\n\nw\n\nh\n\ni\n\nc\n\nh\n\n\n\ne\n\nm\n\ni\n\nt\n\ns\n\n\n\n`\n\nA\n\np\n\np\n\nl\n\ny\n\nA\n\nn\n\nd\n\nC\n\nl\n\ne\n\na\n\nr\n\nD\n\nr\n\ni\n\nl\n\nl\n\n`\n\n\n\nu\n\nn\n\nc\n\no\n\nn\n\nd\n\ni\n\nt\n\ni\n\no\n\nn\n\na\n\nl\n\nl\n\ny\n\n.\n\n\n\n3\n\n.\n\n\n\n`\n\nA\n\np\n\np\n\nl\n\ny\n\nA\n\nn\n\nd\n\nC\n\nl\n\ne\n\na\n\nr\n\nD\n\nr\n\ni\n\nl\n\nl\n\n:\n\n:\n\na\n\np\n\np\n\nl\n\ny\n\n`\n\n\n\nr\n\ne\n\nt\n\nu\n\nr\n\nn\n\ns\n\n\n\ni\n\nm\n\nm\n\ne\n\nd\n\ni\n\na\n\nt\n\ne\n\nl\n\ny\n\n\n\nb\n\ne\n\nc\n\na\n\nu\n\ns\n\ne\n\n\n\n`\n\nd\n\nr\n\ni\n\nl\n\nl\n\n_\n\ns\n\nt\n\na\n\nt\n\ne\n\n`\n\n\n\ni\n\ns\n\n\n\n`\n\nN\n\no\n\nn\n\ne\n\n`\n\n\n\na\n\nn\n\nd\n\n\n\nw\n\nr\n\ni\n\nt\n\ne\n\ns\n\n\n\nn\n\no\n\nt\n\nh\n\ni\n\nn\n\ng\n\n\n\n—\n\n\n\nb\n\nu\n\nt\n\n\n\ni\n\nt\n\n\n\nd\n\ne\n\nc\n\nl\n\na\n\nr\n\ne\n\ns\n\n\n\n`\n\nE\n\nf\n\nf\n\ne\n\nc\n\nt\n\nK\n\ni\n\nn\n\nd\n\n:\n\n:\n\nM\n\no\n\nd\n\ne\n\nl\n\n(\n\nC\n\ne\n\nl\n\nl\n\nD\n\na\n\nt\n\na\n\n)\n\n`\n\n,\n\n\n\ns\n\no\n\n\n\n`\n\nA\n\np\n\np\n\n:\n\n:\n\na\n\np\n\np\n\nl\n\ny\n\n_\n\ne\n\nf\n\nf\n\ne\n\nc\n\nt\n\ns\n\n`\n\n\n\nr\n\na\n\ni\n\ns\n\ne\n\ns\n\n\n\n`\n\nm\n\no\n\nd\n\ne\n\nl\n\n_\n\ns\n\nt\n\na\n\nt\n\ne\n\n.\n\nd\n\ni\n\nr\n\nt\n\ny\n\n`\n\n.\n\n\n\n4\n\n.\n\n\n\nP\n\nr\n\ne\n\ns\n\ns\n\n\n\n`\n\n:\n\nq\n\n`\n\n.\n\n\n\nI\n\nt\n\n\n\ni\n\ns\n\n\n\nr\n\ne\n\nf\n\nu\n\ns\n\ne\n\nd\n\n\n\nf\n\no\n\nr\n\n\n\nu\n\nn\n\ns\n\na\n\nv\n\ne\n\nd\n\n\n\nc\n\nh\n\na\n\nn\n\ng\n\ne\n\ns\n\n\n\no\n\nn\n\n\n\na\n\n\n\nd\n\no\n\nc\n\nu\n\nm\n\ne\n\nn\n\nt\n\n\n\nn\n\no\n\nt\n\nh\n\ni\n\nn\n\ng\n\n\n\nh\n\na\n\ns\n\n\n\nt\n\no\n\nu\n\nc\n\nh\n\ne\n\nd\n\n.\n\n\n\nA\n\nu\n\nt\n\no\n\ns\n\na\n\nv\n\ne\n\n\n\na\n\nl\n\ns\n\no\n\n\n\na\n\nr\n\nm\n\ns\n\n\n\n3\n\n0\n\n\n\ns\n\ne\n\nc\n\no\n\nn\n\nd\n\ns\n\n\n\nl\n\na\n\nt\n\ne\n\nr\n\n.\n\n\n\n\n\nD\n\ni\n\nr\n\ne\n\nc\n\nt\n\nl\n\ny\n\n\n\ne\n\nx\n\ne\n\nc\n\nu\n\nt\n\na\n\nb\n\nl\n\ne\n\n:\n\n\n\no\n\nn\n\n\n\na\n\n\n\nc\n\nl\n\ne\n\na\n\nn\n\n\n\nd\n\no\n\nc\n\nu\n\nm\n\ne\n\nn\n\nt\n\n\n\nw\n\ni\n\nt\n\nh\n\n\n\n`\n\nd\n\nr\n\ni\n\nl\n\nl\n\n_\n\ns\n\nt\n\na\n\nt\n\ne\n\n\n\n=\n\n=\n\n\n\nN\n\no\n\nn\n\ne\n\n`\n\n,\n\n\n\na\n\np\n\np\n\nl\n\ny\n\ni\n\nn\n\ng\n\n\n\n`\n\n[\n\nA\n\np\n\np\n\nl\n\ny\n\nA\n\nn\n\nd\n\nC\n\nl\n\ne\n\na\n\nr\n\nD\n\nr\n\ni\n\nl\n\nl\n\n]\n\n`\n\n\n\ng\n\ni\n\nv\n\ne\n\ns\n\n\n\n`\n\nd\n\ni\n\nr\n\nt\n\ny\n\n\n\n=\n\n=\n\n\n\nt\n\nr\n\nu\n\ne\n\n`\n\n.\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:45:12Z","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#\n\n#\n\n\n\nS\n\nt\n\ne\n\np\n\ns\n\n\n\nt\n\no\n\n\n\nR\n\ne\n\np\n\nr\n\no\n\nd\n\nu\n\nc\n\ne\n\n\n\n\n\n1\n\n.\n\n\n\nO\n\np\n\ne\n\nn\n\n\n\na\n\n\n\nd\n\no\n\nc\n\nu\n\nm\n\ne\n\nn\n\nt\n\n\n\ni\n\nn\n\n\n\nt\n\nh\n\ne\n\n\n\nb\n\nr\n\no\n\nw\n\ns\n\ne\n\nr\n\n\n\nw\n\ni\n\nt\n\nh\n\n\n\nm\n\no\n\nr\n\ne\n\n\n\nr\n\no\n\nw\n\ns\n\n\n\nt\n\nh\n\na\n\nn\n\n\n\nf\n\ni\n\nt\n\n\n\nt\n\nh\n\ne\n\n\n\nw\n\ni\n\nn\n\nd\n\no\n\nw\n\n.\n\n\n\n2\n\n.\n\n\n\nM\n\no\n\nv\n\ne\n\n\n\nt\n\nh\n\ne\n\n\n\ns\n\ne\n\nl\n\ne\n\nc\n\nt\n\ni\n\no\n\nn\n\n\n\nb\n\ne\n\nl\n\no\n\nw\n\n\n\nt\n\nh\n\ne\n\n\n\nv\n\ni\n\ns\n\ni\n\nb\n\nl\n\ne\n\n\n\nw\n\ni\n\nn\n\nd\n\no\n\nw\n\n\n\n(\n\nc\n\nu\n\nr\n\ns\n\no\n\nr\n\n\n\nk\n\ne\n\ny\n\ns\n\n\n\nr\n\ne\n\ns\n\no\n\nl\n\nv\n\ne\n\n\n\nc\n\nl\n\ni\n\ne\n\nn\n\nt\n\n-\n\ns\n\ni\n\nd\n\ne\n\n\n\ns\n\ni\n\nn\n\nc\n\ne\n\n\n\ni\n\nm\n\np\n\nr\n\no\n\nv\n\ni\n\ns\n\ne\n\n-\n\nb\n\np\n\nq\n\n2\n\n)\n\n.\n\n\n\n3\n\n.\n\n\n\nT\n\nh\n\ne\n\n\n\ng\n\nr\n\ni\n\nd\n\n\n\nr\n\ne\n\np\n\na\n\ni\n\nn\n\nt\n\ns\n\n\n\nw\n\ni\n\nt\n\nh\n\n\n\nt\n\nh\n\ne\n\n\n\nc\n\nu\n\nr\n\ns\n\no\n\nr\n\n\n\no\n\nu\n\nt\n\ns\n\ni\n\nd\n\ne\n\n\n\nt\n\nh\n\ne\n\n\n\nr\n\ne\n\nn\n\nd\n\ne\n\nr\n\ne\n\nd\n\n\n\nw\n\ni\n\nn\n\nd\n\no\n\nw\n\n\n\na\n\nn\n\nd\n\n\n\nn\n\no\n\nt\n\nh\n\ni\n\nn\n\ng\n\n\n\ns\n\nc\n\nr\n\no\n\nl\n\nl\n\ns\n\n\n\n—\n\n\n\n`\n\nw\n\ne\n\nb\n\n/\n\na\n\np\n\np\n\n.\n\nj\n\ns\n\n`\n\n\n\nc\n\na\n\nl\n\nl\n\ns\n\n\n\n`\n\ns\n\nc\n\nr\n\no\n\nl\n\nl\n\nI\n\nn\n\nt\n\no\n\nV\n\ni\n\ne\n\nw\n\n`\n\n\n\nn\n\no\n\nw\n\nh\n\ne\n\nr\n\ne\n\n\n\na\n\nt\n\n\n\na\n\nl\n\nl\n\n.\n\n\n\n4\n\n.\n\n\n\nT\n\nh\n\ne\n\n\n\no\n\nn\n\nl\n\ny\n\n\n\nw\n\na\n\ny\n\n\n\nb\n\na\n\nc\n\nk\n\n\n\ni\n\ns\n\n\n\nt\n\no\n\n\n\ns\n\nc\n\nr\n\no\n\nl\n\nl\n\n\n\nm\n\na\n\nn\n\nu\n\na\n\nl\n\nl\n\ny\n\n\n\nu\n\nn\n\nt\n\ni\n\nl\n\n\n\nt\n\nh\n\ne\n\n\n\ns\n\ne\n\nl\n\ne\n\nc\n\nt\n\ni\n\no\n\nn\n\n\n\nr\n\ne\n\na\n\np\n\np\n\ne\n\na\n\nr\n\ns\n\n.\n\n\n\n\n\nN\n\no\n\nt\n\n\n\nc\n\nu\n\nr\n\nr\n\ne\n\nn\n\nt\n\nl\n\ny\n\n\n\nr\n\ne\n\na\n\nc\n\nh\n\na\n\nb\n\nl\n\ne\n\n\n\nb\n\ny\n\n\n\nt\n\nh\n\ne\n\n\n\nP\n\nl\n\na\n\ny\n\nw\n\nr\n\ni\n\ng\n\nh\n\nt\n\n\n\nh\n\na\n\nr\n\nn\n\ne\n\ns\n\ns\n\n:\n\n\n\n`\n\nw\n\ne\n\nb\n\n/\n\nd\n\ne\n\nm\n\no\n\n.\n\ni\n\nm\n\np\n\nr\n\no\n\nv\n\n`\n\n\n\ni\n\ns\n\n\n\ns\n\nm\n\na\n\nl\n\nl\n\n\n\ne\n\nn\n\no\n\nu\n\ng\n\nh\n\n\n\nt\n\nh\n\na\n\nt\n\n\n\nn\n\no\n\nt\n\nh\n\ni\n\nn\n\ng\n\n\n\ns\n\nc\n\nr\n\no\n\nl\n\nl\n\ns\n\n.\n\n\n\nS\n\ne\n\ne\n\n\n\ni\n\nm\n\np\n\nr\n\no\n\nv\n\ni\n\ns\n\ne\n\n-\n\np\n\nl\n\nc\n\nx\n\n.\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:45:11Z","dependencies":[{"issue_id":"improvise-jq8l","depends_on_id":"improvise-plcx","type":"relates-to","created_at":"2026-09-20T07:16:29Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"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#\n\n#\n\n\n\nS\n\nt\n\ne\n\np\n\ns\n\n\n\nt\n\no\n\n\n\nR\n\ne\n\np\n\nr\n\no\n\nd\n\nu\n\nc\n\ne\n\n\n\n\n\n1\n\n.\n\n\n\nB\n\nu\n\ni\n\nl\n\nd\n\n\n\na\n\nn\n\n\n\na\n\nx\n\ni\n\ns\n\n\n\nw\n\ni\n\nt\n\nh\n\n\n\na\n\n\n\ng\n\nr\n\no\n\nu\n\np\n\ne\n\nd\n\n\n\nr\n\nu\n\nn\n\n\n\nf\n\no\n\nl\n\nl\n\no\n\nw\n\ne\n\nd\n\n\n\nb\n\ny\n\n\n\na\n\nn\n\n\n\nU\n\nN\n\nG\n\nR\n\nO\n\nU\n\nP\n\nE\n\nD\n\n\n\ni\n\nt\n\ne\n\nm\n\n\n\n—\n\n\n\n`\n\ne\n\nx\n\np\n\na\n\nn\n\nd\n\n_\n\nc\n\na\n\nt\n\ne\n\ng\n\no\n\nr\n\ny\n\n`\n\n\n\ne\n\nm\n\ni\n\nt\n\ns\n\n\n\na\n\n\n\nh\n\ne\n\na\n\nd\n\ne\n\nr\n\n\n\no\n\nn\n\n\n\ne\n\nn\n\nt\n\ne\n\nr\n\ni\n\nn\n\ng\n\n\n\nt\n\nh\n\ne\n\n\n\ng\n\nr\n\no\n\nu\n\np\n\n\n\na\n\nn\n\nd\n\n\n\nn\n\no\n\n\n\nb\n\no\n\nu\n\nn\n\nd\n\na\n\nr\n\ny\n\n\n\no\n\nn\n\n\n\nl\n\ne\n\na\n\nv\n\ni\n\nn\n\ng\n\n\n\ni\n\nt\n\n.\n\n\n\n2\n\n.\n\n\n\nA\n\ns\n\nk\n\n\n\n`\n\nA\n\nx\n\ni\n\ns\n\nF\n\nr\n\na\n\nm\n\ne\n\n:\n\n:\n\ng\n\nr\n\no\n\nu\n\np\n\n_\n\no\n\nf\n\n(\n\ni\n\nd\n\nx\n\n)\n\n`\n\n\n\nf\n\no\n\nr\n\n\n\nt\n\nh\n\ne\n\n\n\nu\n\nn\n\ng\n\nr\n\no\n\nu\n\np\n\ne\n\nd\n\n\n\ni\n\nt\n\ne\n\nm\n\n'\n\ns\n\n\n\ni\n\nn\n\nd\n\ne\n\nx\n\n.\n\n\n\n3\n\n.\n\n\n\nI\n\nt\n\n\n\na\n\nn\n\ns\n\nw\n\ne\n\nr\n\ns\n\n\n\nw\n\ni\n\nt\n\nh\n\n\n\nt\n\nh\n\ne\n\n\n\nn\n\ne\n\na\n\nr\n\ne\n\ns\n\nt\n\n\n\np\n\nr\n\ne\n\nc\n\ne\n\nd\n\ni\n\nn\n\ng\n\n\n\nh\n\ne\n\na\n\nd\n\ne\n\nr\n\n,\n\n\n\ns\n\no\n\n\n\nt\n\nh\n\ne\n\n\n\nu\n\nn\n\ng\n\nr\n\no\n\nu\n\np\n\ne\n\nd\n\n\n\ni\n\nt\n\ne\n\nm\n\n\n\ni\n\ns\n\n\n\nr\n\ne\n\np\n\no\n\nr\n\nt\n\ne\n\nd\n\n\n\na\n\ns\n\n\n\na\n\n\n\nm\n\ne\n\nm\n\nb\n\ne\n\nr\n\n\n\no\n\nf\n\n\n\nt\n\nh\n\ne\n\n\n\ng\n\nr\n\no\n\nu\n\np\n\n\n\nt\n\nh\n\na\n\nt\n\n\n\ne\n\nn\n\nd\n\ne\n\nd\n\n\n\nb\n\ne\n\nf\n\no\n\nr\n\ne\n\n\n\ni\n\nt\n\n.\n\n\n\n4\n\n.\n\n\n\nT\n\nh\n\ne\n\n\n\nT\n\nU\n\nI\n\n\n\np\n\na\n\ni\n\nn\n\nt\n\ns\n\n\n\nt\n\nh\n\ne\n\n\n\ns\n\na\n\nm\n\ne\n\n\n\ns\n\np\n\na\n\nn\n\n,\n\n\n\ns\n\no\n\n\n\nt\n\nh\n\ni\n\ns\n\n\n\ni\n\ns\n\n\n\n`\n\nG\n\nr\n\ni\n\nd\n\nL\n\na\n\ny\n\no\n\nu\n\nt\n\n`\n\n'\n\ns\n\n\n\nr\n\nu\n\nl\n\ne\n\n\n\nf\n\na\n\ni\n\nt\n\nh\n\nf\n\nu\n\nl\n\nl\n\ny\n\n\n\nr\n\ne\n\np\n\nr\n\no\n\nd\n\nu\n\nc\n\ne\n\nd\n\n\n\nr\n\na\n\nt\n\nh\n\ne\n\nr\n\n\n\nt\n\nh\n\na\n\nn\n\n\n\na\n\n\n\nf\n\nr\n\na\n\nm\n\ne\n\n\n\nd\n\ne\n\nf\n\ne\n\nc\n\nt\n\n.\n\n\n\n\n\n`\n\nt\n\nh\n\ne\n\n_\n\nt\n\nw\n\no\n\n_\n\nf\n\nr\n\no\n\nn\n\nt\n\n_\n\ne\n\nn\n\nd\n\ns\n\n_\n\nd\n\nr\n\na\n\nw\n\n_\n\nt\n\nh\n\ne\n\n_\n\ns\n\na\n\nm\n\ne\n\n_\n\nc\n\no\n\nl\n\nu\n\nm\n\nn\n\n_\n\nh\n\ne\n\na\n\nd\n\ne\n\nr\n\n`\n\n\n\ni\n\nn\n\n\n\n`\n\nt\n\ne\n\ns\n\nt\n\ns\n\n/\n\ng\n\nr\n\ni\n\nd\n\n_\n\nf\n\nr\n\na\n\nm\n\ne\n\n_\n\na\n\ng\n\nr\n\ne\n\ne\n\nm\n\ne\n\nn\n\nt\n\n.\n\nr\n\ns\n\n`\n\n\n\na\n\ns\n\ns\n\ne\n\nr\n\nt\n\ns\n\n\n\nt\n\nh\n\ne\n\n\n\nw\n\nr\n\no\n\nn\n\ng\n\n\n\na\n\nn\n\ns\n\nw\n\ne\n\nr\n\n\n\na\n\ns\n\n\n\nt\n\nh\n\ne\n\n\n\nc\n\nu\n\nr\n\nr\n\ne\n\nn\n\nt\n\n\n\no\n\nn\n\ne\n\n,\n\n\n\nw\n\ni\n\nt\n\nh\n\n\n\n`\n\nT\n\nH\n\nE\n\n_\n\nM\n\nE\n\nM\n\nB\n\nE\n\nR\n\nS\n\nH\n\nI\n\nP\n\n_\n\nC\n\nO\n\nR\n\nR\n\nE\n\nC\n\nT\n\n_\n\nS\n\nP\n\nA\n\nN\n\nS\n\n`\n\n\n\na\n\nn\n\nd\n\n\n\na\n\nn\n\n\n\n`\n\na\n\ns\n\ns\n\ne\n\nr\n\nt\n\n_\n\nn\n\ne\n\n!\n\n`\n\n\n\nb\n\ne\n\ns\n\ni\n\nd\n\ne\n\n\n\ni\n\nt\n\n\n\nt\n\nh\n\na\n\nt\n\n\n\ns\n\nt\n\na\n\nr\n\nt\n\ns\n\n\n\nf\n\na\n\ni\n\nl\n\ni\n\nn\n\ng\n\n\n\nt\n\nh\n\ne\n\n\n\nd\n\na\n\ny\n\n\n\nt\n\nh\n\ni\n\ns\n\n\n\ni\n\ns\n\n\n\nf\n\ni\n\nx\n\ne\n\nd\n\n.\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:45:11Z","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -195,7 +202,7 @@ {"_type":"issue","id":"improvise-dkqn","title":"gp-tree::fit should take one slice of rows, not parallel x/y slices","description":"Filed from Copilot PR #10 round 2 (2026-09-19). The PR agent DECLINED the literal request and was right to; this is the real remedy.\n\nCopilot asked five public scoring functions (mse, cost, fit, cv_regress/cv_classify, permutation_importance) to enforce their basic preconditions -- i.e. validate that the parallel x and y slices have matching lengths. That was declined on the grounds that validating a parallel length is exactly the ASSERTED form improvise-n3wt removed from prune_axis_items in the very same PR (make the invariant unrepresentable, do not check it), and that Result on five functions is what AGENTS.md means by keeping Option/Result at the boundaries. The reasoning was posted on the PR thread citing both.\n\nTHE FIX IS A TYPE CHANGE: give these functions one slice of rows rather than parallel x/y. That removes the zip truncation Copilot identified -- the thing that made mse wrong -- and answers the empty-input NaN in standardize/misclass_rate/mse in the same move, because an empty row set is then one shape rather than four independent edge cases.\n\nCONSTRAINT: wave 3 established byte-identical CLI output across nine scenarios including predictions and the saved model. That evidence must be re-established after this change, not assumed -- the whole point of that harness is that a refactor of the search cannot be trusted on a green suite alone.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T22:01:11Z","created_by":"Edward Langley","updated_at":"2026-09-19T22:11:20Z","closed_at":"2026-09-19T22:11:20Z","close_reason":"Row\u003cT\u003e { feat: Vec\u003cf32\u003e, target: T } in fit.rs -- generic in the target because the two supervised shapes differ only there (Row\u003cf32\u003e regression, Row\u003cusize\u003e class code). mse/cost/fit/misclass_rate/standardize/cv_regress/cv_classify/permutation_importance all take one slice, so the parallel lengths zip used to truncate silently cannot be built. Rejected: a Rows newtype with a checked constructor (validation wearing a type -- the thing the doctrine declines), a bare tuple slice, and Result on five signatures. The pairing is made at the boundary that BUILDS the data: preprocess walks one row index and the rows live inside Target, because a Dataset carrying rows and target separately just moves the zip up one level into every caller. Empty input answered once in mean_over -- no rows scored is no error found, not the 0.0/0.0 NaN that propagated through cost and sorted unpredictably against real costs.\n\nBIT-IDENTITY ESTABLISHED, NOT ASSERTED: known-answer vectors were captured from the UNMODIFIED code first -- raw f32::to_bits for every scoring path plus rng.next_u64() after each stochastic run, which is what pins the generator call order and count (SplitMix64::seed() returns the original seed and witnesses nothing). Those literals are now tests/scoring_known_answers.rs (6 tests) and pass unchanged. 37 rows is prime, so no fold count divides evenly and the short last fold is exercised. Nine-scenario CLI byte-identity re-established including model.json sha256. Note the CLI prints 3-4 decimals, which is exactly why the bit-level tests exist: the diff alone could not see a moved accumulation.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-t3ux","title":"cv_regress and cv_classify are O(n-squared)","description":"Filed from Copilot PR #10 round 2 (2026-09-19). Confirmed, not speculative: both do test.contains(i) inside a filter over 0..y.len(), in both loops, so the inner membership test is linear in the fold size.\n\nA bool mask over the row indices fixes it and changes no results. Wants a benchmark rather than a bare assertion of improvement -- and note that results must be bit-identical afterwards, since gp-tree fits are reproducible from a seed and any change in accumulation order would invalidate every recorded seed (see the SplitMix64 known-answer work, improvise-8x9o).\n\nPairs naturally with the row-slice reshape ticket; doing them together avoids touching these functions twice.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T22:01:11Z","created_by":"Edward Langley","updated_at":"2026-09-19T22:11:21Z","closed_at":"2026-09-19T22:11:21Z","close_reason":"complement(n, test) marks the fold in a Vec\u003cbool\u003e and filters; both cross-validators call it. Measured on the split alone (release, 5 folds): n=1000 84us -\u003e 15us (5.8x), n=4000 1.01ms -\u003e 45us (22.7x), n=16000 16.2ms -\u003e 134us (120.6x), n=64000 258ms -\u003e 482us (535.5x). Quadrupling n multiplies the scan by ~16 and the mask by ~3.6 -- the shape changed, not just the constant.\n\nBit-identity: the_training_split_is_what_the_contains_scan_gave asserts complement(20, test) equals the old (0..20).filter(|i| !test.contains(i)) index-for-index over every fold of k in {1,2,3,5,7} -- the equivalence directly, not inferred from a fit that happened to agree. The known-answer vectors from improvise-dkqn also pass unchanged.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-pczv","title":".improv section headers are a second, unquoted naming rule","description":"Found during improvise-i2m5 (2026-09-19), pre-existing and deliberately left alone.\n\nPipe quoting now has one home and a category or item named SUM is written |SUM| in every REFERENCE -- axis lines, item lists, page selections, hidden:, data coords. But section headers never went through quote_name on either side and still do not: improv.pest has category_section = { \"## Category: \" ~ rest_of_line ~ ... } and view_section likewise, and parse.rs::section_title reads them with as_str().trim(). So a name is quoted everywhere it is referenced and raw in its own header.\n\nThat is coherent for a whole-line token, and it is why the round trip still works. But it means '# Model Name', 'Initial View:', '## Category:' and '## View:' are a second naming rule with different escaping. It matters if a name with leading/trailing whitespace, an embedded newline, or a line that looks like another section marker is ever reachable -- today the writer cannot produce one, but nothing enforces that.\n\nDECIDE: either route section headers through quote_name/extract_name like every other name position (grammar change first -- improv.pest is the source of truth), or state in the grammar that the whole-line token is the intended contract and add a test pinning what happens to an awkward name. The current answer is pinned only in a test comment.","notes":"\n--- CONTEXT (2026-09-19) ---\nThe user has confirmed the pipe-quoting canon itself is fine (\"Pipe quoting is\nfine\"), so this ticket is NOT about whether to quote -- it is only about the\none position that still does not: section headers. Decide it on its own merits\n(whole-line token vs. quoted name), not as a re-litigation of the guard.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T21:21:39Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:34:01Z","closed_at":"2026-09-20T00:34:01Z","close_reason":"IT WAS A CORRUPTION BUG, not a tidiness ticket -- reachability was verified rather than assumed, and it is reachable end to end with no hand-construction.\n\nCLASS 1, the file becomes unloadable: RFC 4180 permits a quoted CSV header field containing a newline; csv::Trim::All trims a field EDGES but preserves an embedded break, and build_model calls add_category on the field verbatim. So a header \"Region\\nName\" imports cleanly, format_md writes ## Category: Region / Name across two lines, and parse_md fails. The user imports, works, saves, and cannot reopen. A header containing \\n## Data wrote a second SECTION MARKER.\n\nCLASS 2, worse because it is silent: a name with leading/trailing whitespace round-tripped SUCCESSFULLY AND WRONGLY. The header was written raw and the reader trims, so the category came back as \"Region\" while every data coordinate still said |Region | -- references were already quoted. The cells survive in the file, attach to nothing, and render blank with no error anywhere. Reached via JSON import keys.\n\nCLASS 3, found while closing the others: a name literally spelled |Region| was written bare, consistent only while the reader never looked at pipes -- which stops being true the moment headers accept a quoted form.\n\nDECISION: keep the whole-line contract, STATE IT IN THE GRAMMAR, and quote only what the line cannot carry. improv.pest gains header_name = { pipe_quoted ~ \u0026NEWLINE | line_name }, used by model_name/initial_view/category_section/view_section; format.rs::quote_header is the writing half and names exactly four cases -- empty, edge whitespace, embedded \\n or \\r, and already spelled |...|. EACH CLAUSE IS ONE THING THE READER DOES, which is what makes the list a derivation rather than a grab-bag.\n\nREJECTED routing headers through quote_name, and not as over-caution: it quotes every name that is not a bare identifier, so \"# Q3 Budget\" becomes \"# |Q3 Budget|\" and every existing document header is rewritten on next save for zero correctness gain -- the DEFAULT model name \"Imported Model\" included. The whole-line contract is a genuine property of the position (a header is a token, a reference is not); the fix was to state it and make it TOTAL, not delete it.\n\n\u0026NEWLINE is load-bearing twice: it decides the one ambiguous line (## Category: |a| and |b| is a whole-line name) and it makes the change backward compatible, since a pre-pczv header has no closing pipe at end of line and falls to line_name. 7 tests including a proptest over awkward names on a POPULATED model; 5 faults injected, all caught by a check naming the specific thing broken -- and the pre-existing grammar-walking generated_file_roundtrips fired on two of them, having picked up header_name for free, which is the grammar-as-source-of-truth principle paying out.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-n89z","title":"Three Projection mutations have no producer","description":"Wave-2 review (2026-09-19), LOW/design note. document_projections only ever sends CategoryTreeUpdate::Categories(..). CategoryTreeUpdate::Items, CategoryTreeUpdate::Axes and GridUpdate::Invalidated have no emitter anywhere in the tree.\n\ndesign-principles 'nothing speculative': they are consumer-side machinery justified by prose rather than by a producer. Either emit them (Items and Axes are the fine-grained updates the producer would want once category-tree traffic matters) or record in the protocol why they exist ahead of a producer -- Invalidated has a stated meaning ('contents stale, frame unchanged') that the diff-based producer never needs, which is a legitimate reason to keep it, but it should be written down.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T21:04:42Z","created_by":"Edward Langley","updated_at":"2026-09-19T21:04:42Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-n89z","title":"Three Projection mutations have no producer","description":"Wave-2 review (2026-09-19), LOW/design note. document_projections only ever sends CategoryTreeUpdate::Categories(..). CategoryTreeUpdate::Items, CategoryTreeUpdate::Axes and GridUpdate::Invalidated have no emitter anywhere in the tree.\n\ndesign-principles 'nothing speculative': they are consumer-side machinery justified by prose rather than by a producer. Either emit them (Items and Axes are the fine-grained updates the producer would want once category-tree traffic matters) or record in the protocol why they exist ahead of a producer -- Invalidated has a stated meaning ('contents stale, frame unchanged') that the diff-based producer never needs, which is a legitimate reason to keep it, but it should be written down.","acceptance_criteria":"Each of CategoryTreeUpdate::Items, CategoryTreeUpdate::Axes and GridUpdate::Invalidated either has a production emitter, or carries a doc comment saying why it exists ahead of one, or is deleted. Whichever is chosen, gotcha 33 and the totals doc at render.rs:557-560 stop implying Invalidated is live when it is not — that is the half a reader is actually misled by.","notes":"CONFIRMED AND NARROWED by the post-wave code consistency review (2026-09-20), against main at 22d43b2.\n\nGridUpdate::Invalidated has NO PRODUCER in production: grid_updates never emits it and GridCache::seed never emits it. The only occurrences are render.rs:1211/1269/1939 and reduce.rs:518, ALL IN TESTS. So it is a wire variant that only tests construct.\n\nWHAT MAKES IT WORTH DECIDING RATHER THAN LEAVING: its semantics are documented as LOAD-BEARING in several places — gotcha 33 leans on 'Invalidated means contents stale, frame unchanged', and the totals doc at render.rs:557-560 cites it — so the docs read as if it were live. A reader reasoning about the fold's guarantees will assume something is producing it.\n\nThe two CategoryTreeUpdate arms (Items, Axes) are unchanged from the original report.\n\nSo the decision is unchanged in shape but sharper in evidence: emit them, or write down why a variant exists ahead of its producer. The third option the original text did not name — DELETE Invalidated and fold its meaning into the arms that do exist — is now on the table, because PR #23 gave invalidate_relabelled a real job (a cats change re-identifies every position) which is what Invalidated was describing in the abstract.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T21:04:42Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:50:11Z","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} @@ -261,6 +268,7 @@ {"_type":"issue","id":"improvise-l36","title":"3.2 Publish to crates.io","description":"After cargo publish --dry-run is clean and user confirms, run cargo publish. Verify crate appears at crates.io/crates/improvise and cargo install improvise works.","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-19T20:28:48Z","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).","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-19T20:28:48Z","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-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","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.","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-20T07:50:12Z","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","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} diff --git a/.claude/settings.json b/.claude/settings.json index 080202b6..4505a416 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,5 +1,8 @@ { "permissions": { + "deny": [ + "Write(./.beads/issues.jsonl)" + ], "allow": [ "Bash(cargo build:*)", "Bash(cargo test:*)",