From 2eea626ab4eb9f9fc0af23f541fd4d7eebf52683 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Sun, 20 Sep 2026 00:46:13 -0700 Subject: [PATCH] chore: update issues.jsonl --- .beads/issues.jsonl | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 442a843c..0f35df11 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -36,13 +36,13 @@ {"_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-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","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:29:16Z","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","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:26:33Z","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","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:16:28Z","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} -{"_type":"issue","id":"improvise-sihr","title":"Every minibuffer mode entered from records mode exits to plain Normal while the layout is still records","description":"Found by Copilot on PR #27 against the new Ctrl-W save prompt, and DECLINED there with evidence because fixing the one arm the finding named would have been one arm of a three-arm rule.\n\nTHE BEHAVIOUR: every minibuffer mode entered from RecordsNormal exits to plain Normal while the LAYOUT IS STILL RECORDS. It affects `:` (CommandMode), Ctrl-E (ExportPrompt) and Ctrl-W (SavePrompt, new in PR #27). Only the last is new; the class predates it.\n\nTHE MECHANISM: the exit mode is a CONSTANT, baked into TextEntry::new(.., \"normal\") in the keymap builder and into each commit command's ChangeMode. Nothing carries where the user came FROM, so nothing can put them back.\n\nWHAT IS NOT THE MECHANISM, checked rather than reasoned (doctrine(binding_dump_diff)): the binding TABLE is fine. RecordsNormal goes 155 -\u003e 156 dumped lines across PR #27, the single addition being Char('w')+CONTROL -\u003e enter-mode [\"save-prompt\"], with nothing removed. with_parent inheritance (gotcha 13) is intact. What is lost is the RUNTIME MODE, not a binding.\n\nCHARACTERIZED, NOT FIXED: every_minibuffer_mode_entered_from_records_returns_to_plain_normal drives all three and asserts both halves -- the mode is Normal, and the layout is still records. So the current behaviour is pinned and a fix will have to update it deliberately.\n\nTHE FIX, and it is why this is its own bead: text-entry modes must carry their entry mode. That is a change to TextEntry, to AppMode (the exit is part of a mode's configuration, so it likely belongs beside MinibufferConfig -- note gotcha 11, MinibufferConfig::buffer_key is a String and not \u0026'static str precisely because ViewState must stay Deserialize-able, and the same constraint applies to anything added there), and to three commit commands. Wider than the PR that found it.\n\nWATCH TWO THINGS:\n * The exit is currently spelled in TWO places per mode -- the TextEntry spine's Esc binding and the commit command's ChangeMode. If they are made to carry an entry mode, make them read ONE source or the two will disagree on cancel versus commit.\n * SearchMode is hand-written on purpose and has no commit key (gotcha 11). It is not part of this class; do not sweep it in.\n\nRELATED: improvise-mai9 (a status message written while a minibuffer is open is never drawn) is the other half of why these modes are awkward -- both are consequences of the minibuffer owning the bottom row and nothing tracking what it displaced.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T06:12:20Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:12:20Z","dependencies":[{"issue_id":"improvise-sihr","depends_on_id":"improvise-mai9","type":"relates-to","created_at":"2026-09-20T06:12:22Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-kc7n","title":"improvise-ws-server never checks Origin, so any page a user visits can drive their session","description":"Found by Copilot on PR #21 round 5, against a reverse-proxy recipe whose IP allow-list had itself been added two rounds earlier to close a DIFFERENT hole. Verified independently by the orchestrator: `grep -rni origin crates/improvise-ws-server/src/` returns NOTHING. The server never inspects the Origin header and accepts every handshake.\n\nTHE HOLE: a WebSocket is EXEMPT FROM THE SAME-ORIGIN POLICY -- there is no CORS preflight on a handshake. So any hostile page that an allowed user visits can open a socket to the endpoint FROM THAT USER'S OWN ALLOWED ADDRESS, and read and edit every document the server holds. That is cross-site WebSocket hijacking.\n\nWHY NETWORK CONTROLS CANNOT SEE IT: an IP allow-list, a private network, loopback binding -- all answer \"may this CLIENT reach the endpoint\", and in this attack the answer is genuinely yes. The request really does come from an allowed client; it is simply being DRIVEN by a page the user did not intend to trust. Only Origin separates the two.\n\nLOOPBACK IS NOT EXEMPT, and this is the half a documentation-only fix cannot reach. In the local pairing case there is no proxy at all, so there is nothing in front to check Origin -- and loopback is not a boundary a browser enforces. While the server runs, ANY page open in that browser can reach ws://127.0.0.1:8787. So the local development path, which is the one the project actually recommends, is the least protected.\n\nDISTINCT FROM improvise-kpnr, and both are needed. kpnr is AUTHENTICATION: who is allowed to talk to this server at all. This is CSWSH: given that someone is allowed, what may put words in their mouth. An answer to either leaves the other open.\n\nTHE FIX IS CHEAP AND SERVER-SIDE: an allow-list of permitted origins checked at the handshake, defaulting to something safe for the local case (the page's own origin, and whatever --bind implies), with a flag to widen it. Refuse the upgrade rather than accepting and ignoring.\n\nTWO THINGS TO GET RIGHT:\n * A MISSING Origin header is not the same as a disallowed one. Non-browser clients (the repo's own tests, curl, a native client) send none, and the browser always does. Decide explicitly what absent means rather than letting the match arm decide by accident.\n * PR #21 has already documented Origin filtering in the PROXY as a required companion to the network control, phrased as \"in addition to, never instead of\". When this lands, the README should say the server does it too, and the proxy line becomes defence in depth rather than the only defence.\n\nTHE GENERAL LESSON, now doctrine(who_may_connect_is_two_questions): ask of any access control whether it constrains the CLIENT or what may DRIVE the client. Three consecutive rounds of that PR reasoned about the page's capabilities and none about the attacker's.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T06:09:42Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:09:42Z","dependencies":[{"issue_id":"improvise-kc7n","depends_on_id":"improvise-kpnr","type":"relates-to","created_at":"2026-09-20T06:09:49Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-mai9","title":"A status message written while a minibuffer is open is never drawn","description":"Found by WU-G while building improvise-lae (PR #27), and CONFIRMED THE HARD WAY in the same PR: Copilot then found a regression that this mechanism causes, which the agent had itself written down an hour earlier and not checked its own change against.\n\nTHE MECHANISM: src/draw.rs::draw_bottom_bar renders the minibuffer INSTEAD OF the status line. So a status_msg written while a minibuffer is open is never drawn at all -- not truncated, not overwritten, simply never rendered.\n\nWHO IS AFFECTED TODAY:\n * CategoryAdd / ItemAdd: a failed `commit-buffer category` is INVISIBLE. Worse, improvise-6t3 deliberately ordered that command's effects so the add effect is emitted LAST, specifically so its failure message would WIN over the command's optimistic status. It wins a race to a surface nobody can see.\n * A failed `:w \u003cpath\u003e` from CommandMode, which is the regression Copilot found on PR #27: the abort stops the batch before ReturnToNormalUnlessMoved and clear-buffer run, so the app stays in CommandMode with the stale command in the minibuffer and the new error hidden behind it.\n\nTHE WORKAROUND ALREADY SHIPPED, and it is a workaround: CommitSave emits [ChangeMode(Normal), SaveAs] so the error lands when the bottom row is the status bar again. That works for one command and does not generalise -- every other text-entry commit has the same hole.\n\nTWO CANDIDATE FIXES, and the choice is the ticket:\n * Draw both: give the minibuffer and the status line separate rows, or render the status into the minibuffer row when a message is pending. Cheap to describe, and it changes frame geometry, which is pinned by exact-frame characterization tests (see improvise-q0vb for what that costs).\n * Make it unrepresentable: a status message set while a minibuffer is open is a bug in the CALLER, so make the caller unable to do it -- e.g. commit paths always leave the mode before reporting. That is what CommitSave now does by hand, promoted to a rule.\n\nPREFER WHICHEVER MAKES THE FAILURE LOUD. The current state is the quiet kind: the code sets a message, the test asserts the message was set, and the user never sees it. A test asserting `app.status_msg == \"...\"` passes in every one of these cases, which is why this survived improvise-6t3's careful effect ordering.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T06:04:20Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:04:20Z","dependencies":[{"issue_id":"improvise-mai9","depends_on_id":"improvise-sihr","type":"relates-to","created_at":"2026-09-20T06:12:22Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-kpnr","title":"improvise-ws-server has no authentication, and the documented sharing path now exposes it","description":"Found by Copilot on PR #21 (improvise-o8fa, the reverse-proxy documentation), and it is a finding ABOUT the documentation that PR introduced rather than about code.\n\nimprovise-ws-server has NO AUTHENTICATION BY DESIGN. crates/improvise-ws-server/src/transport.rs says it outright: anyone who can open a socket to the port can rewrite every document it serves. The only control today is that it binds loopback.\n\nTHE README NOW TELLS USERS TO PUT A PUBLIC TLS PROXY IN FRONT OF IT (improvise-o8fa, per the user's ruling that the reverse proxy is the supported path to reaching a TUI-hosted document from an https origin). That instruction REMOVES THE ONLY CONTROL. TLS is confidentiality, not access control, and in a copy-pasteable recipe the two read alike.\n\nSo proxy-side auth is now LOAD-BEARING DOCUMENTATION rather than a server feature. PR #21 mitigated it -- the warning leads the section, the Caddyfile carries basic_auth marked not-optional, the --bind note now separates what loopback buys from what it does not, and the tunnel line says cloudflared/ngrok print public URLs and authenticate nothing -- but mitigation in prose is not the same as a property of the system.\n\nTHE DECISION TO MAKE, and it is a real fork rather than an oversight to correct:\n * The server grows a shared secret or token, checked at the websocket handshake. Small, and it makes the documented path safe by default rather than safe if followed.\n * Or \"auth is the proxy's job\" is the PERMANENT answer, stated in the server's own docs as a design position rather than left as an absence. Defensible -- it is what most single-binary dev servers do -- but then the README recipe is the security boundary and should say so in those words.\n\nDO NOT SPLIT THE DIFFERENCE. A token that is optional and off by default is the worst of both: it reads as a feature and defends nothing.\n\nNOTE THE GENERALISABLE PART, worth keeping whichever way this goes: DOCUMENTATION THAT INSTRUCTS AN ACTION CARRIES THAT ACTION'S CONSEQUENCES. The PR #21 section was scrupulous about one axis (build complexity, the cost of a second binary to a one-binary tool) and blind on another (exposure), and the --bind paragraph actively REASSURED the reader -- \"not reachable from anywhere else\", true of the port and false of the deployment. Neither the ticket, nor gotcha 48, nor the agent's own /code-review pass caught it; a reviewer reading the server's source did.\n","status":"open","priority":2,"issue_type":"decision","owner":"el-github@elangley.org","created_at":"2026-09-20T05:45:28Z","created_by":"Edward Langley","updated_at":"2026-09-20T05:45:28Z","dependencies":[{"issue_id":"improvise-kpnr","depends_on_id":"improvise-kc7n","type":"relates-to","created_at":"2026-09-20T06:09:49Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-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} +{"_type":"issue","id":"improvise-sihr","title":"Every minibuffer mode entered from records mode exits to plain Normal while the layout is still records","description":"Found by Copilot on PR #27 against the new Ctrl-W save prompt, and DECLINED there with evidence because fixing the one arm the finding named would have been one arm of a three-arm rule.\n\nTHE BEHAVIOUR: every minibuffer mode entered from RecordsNormal exits to plain Normal while the LAYOUT IS STILL RECORDS. It affects `:` (CommandMode), Ctrl-E (ExportPrompt) and Ctrl-W (SavePrompt, new in PR #27). Only the last is new; the class predates it.\n\nTHE MECHANISM: the exit mode is a CONSTANT, baked into TextEntry::new(.., \"normal\") in the keymap builder and into each commit command's ChangeMode. Nothing carries where the user came FROM, so nothing can put them back.\n\nWHAT IS NOT THE MECHANISM, checked rather than reasoned (doctrine(binding_dump_diff)): the binding TABLE is fine. RecordsNormal goes 155 -\u003e 156 dumped lines across PR #27, the single addition being Char('w')+CONTROL -\u003e enter-mode [\"save-prompt\"], with nothing removed. with_parent inheritance (gotcha 13) is intact. What is lost is the RUNTIME MODE, not a binding.\n\nCHARACTERIZED, NOT FIXED: every_minibuffer_mode_entered_from_records_returns_to_plain_normal drives all three and asserts both halves -- the mode is Normal, and the layout is still records. So the current behaviour is pinned and a fix will have to update it deliberately.\n\nTHE FIX, and it is why this is its own bead: text-entry modes must carry their entry mode. That is a change to TextEntry, to AppMode (the exit is part of a mode's configuration, so it likely belongs beside MinibufferConfig -- note gotcha 11, MinibufferConfig::buffer_key is a String and not \u0026'static str precisely because ViewState must stay Deserialize-able, and the same constraint applies to anything added there), and to three commit commands. Wider than the PR that found it.\n\nWATCH TWO THINGS:\n * The exit is currently spelled in TWO places per mode -- the TextEntry spine's Esc binding and the commit command's ChangeMode. If they are made to carry an entry mode, make them read ONE source or the two will disagree on cancel versus commit.\n * SearchMode is hand-written on purpose and has no commit key (gotcha 11). It is not part of this class; do not sweep it in.\n\nRELATED: improvise-mai9 (a status message written while a minibuffer is open is never drawn) is the other half of why these modes are awkward -- both are consequences of the minibuffer owning the bottom row and nothing tracking what it displaced.\n\n#\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\nE\n\nn\n\nt\n\ne\n\nr\n\n\n\nr\n\ne\n\nc\n\no\n\nr\n\nd\n\ns\n\n\n\nm\n\no\n\nd\n\ne\n\n\n\n(\n\n`\n\nR\n\n`\n\n)\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\n:\n\n`\n\n,\n\n\n\no\n\nr\n\n\n\n`\n\nC\n\nt\n\nr\n\nl\n\n-\n\nE\n\n`\n\n,\n\n\n\no\n\nr\n\n\n\n`\n\nC\n\nt\n\nr\n\nl\n\n-\n\nW\n\n`\n\n\n\n—\n\n\n\na\n\nn\n\ny\n\n\n\nm\n\ni\n\nn\n\ni\n\nb\n\nu\n\nf\n\nf\n\ne\n\nr\n\n\n\nm\n\no\n\nd\n\ne\n\n.\n\n\n\n3\n\n.\n\n\n\nC\n\no\n\nm\n\nm\n\ni\n\nt\n\n\n\no\n\nr\n\n\n\nc\n\na\n\nn\n\nc\n\ne\n\nl\n\n.\n\n\n\n4\n\n.\n\n\n\nT\n\nh\n\ne\n\n\n\na\n\np\n\np\n\n\n\ni\n\ns\n\n\n\nn\n\no\n\nw\n\n\n\ni\n\nn\n\n\n\np\n\nl\n\na\n\ni\n\nn\n\n\n\n`\n\nN\n\no\n\nr\n\nm\n\na\n\nl\n\n`\n\n\n\nw\n\nh\n\ni\n\nl\n\ne\n\n\n\nt\n\nh\n\ne\n\n\n\nL\n\nA\n\nY\n\nO\n\nU\n\nT\n\n\n\ni\n\ns\n\n\n\ns\n\nt\n\ni\n\nl\n\nl\n\n\n\nr\n\ne\n\nc\n\no\n\nr\n\nd\n\ns\n\n:\n\n\n\nt\n\nh\n\ne\n\n\n\ne\n\nx\n\ni\n\nt\n\n\n\nm\n\no\n\nd\n\ne\n\n\n\ni\n\ns\n\n\n\na\n\n\n\nc\n\no\n\nn\n\ns\n\nt\n\na\n\nn\n\nt\n\n\n\nb\n\na\n\nk\n\ne\n\nd\n\n\n\ni\n\nn\n\nt\n\no\n\n\n\n`\n\nT\n\ne\n\nx\n\nt\n\nE\n\nn\n\nt\n\nr\n\ny\n\n:\n\n:\n\nn\n\ne\n\nw\n\n(\n\n.\n\n.\n\n,\n\n\n\n\"\n\nn\n\no\n\nr\n\nm\n\na\n\nl\n\n\"\n\n)\n\n`\n\n\n\na\n\nn\n\nd\n\n\n\ni\n\nn\n\nt\n\no\n\n\n\ne\n\na\n\nc\n\nh\n\n\n\nc\n\no\n\nm\n\nm\n\ni\n\nt\n\n\n\nc\n\no\n\nm\n\nm\n\na\n\nn\n\nd\n\n'\n\ns\n\n\n\n`\n\nC\n\nh\n\na\n\nn\n\ng\n\ne\n\nM\n\no\n\nd\n\ne\n\n`\n\n,\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\nc\n\na\n\nr\n\nr\n\ni\n\ne\n\ns\n\n\n\nw\n\nh\n\ne\n\nr\n\ne\n\n\n\nt\n\nh\n\ne\n\n\n\nu\n\ns\n\ne\n\nr\n\n\n\nc\n\na\n\nm\n\ne\n\n\n\nf\n\nr\n\no\n\nm\n\n.\n\n\n\n\n\n`\n\ne\n\nv\n\ne\n\nr\n\ny\n\n_\n\nm\n\ni\n\nn\n\ni\n\nb\n\nu\n\nf\n\nf\n\ne\n\nr\n\n_\n\nm\n\no\n\nd\n\ne\n\n_\n\ne\n\nn\n\nt\n\ne\n\nr\n\ne\n\nd\n\n_\n\nf\n\nr\n\no\n\nm\n\n_\n\nr\n\ne\n\nc\n\no\n\nr\n\nd\n\ns\n\n_\n\nr\n\ne\n\nt\n\nu\n\nr\n\nn\n\ns\n\n_\n\nt\n\no\n\n_\n\np\n\nl\n\na\n\ni\n\nn\n\n_\n\nn\n\no\n\nr\n\nm\n\na\n\nl\n\n`\n\n\n\nd\n\nr\n\ni\n\nv\n\ne\n\ns\n\n\n\na\n\nl\n\nl\n\n\n\nt\n\nh\n\nr\n\ne\n\ne\n\n\n\na\n\nn\n\nd\n\n\n\na\n\ns\n\ns\n\ne\n\nr\n\nt\n\ns\n\n\n\nb\n\no\n\nt\n\nh\n\n\n\nh\n\na\n\nl\n\nv\n\ne\n\ns\n\n.\n","acceptance_criteria":"A minibuffer mode entered from RecordsNormal returns to RecordsNormal. Text-entry modes carry their entry mode rather than a constant 'normal'. The exit is currently spelled TWICE per mode — the TextEntry spine's Esc binding and the commit command's ChangeMode — so both read ONE source or they will disagree on cancel versus commit. SearchMode is hand-written on purpose and is not part of this class. every_minibuffer_mode_entered_from_records_returns_to_plain_normal is updated deliberately, not deleted.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T06:12:20Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:45:13Z","dependencies":[{"issue_id":"improvise-sihr","depends_on_id":"improvise-mai9","type":"relates-to","created_at":"2026-09-20T06:12:22Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-kc7n","title":"improvise-ws-server never checks Origin, so any page a user visits can drive their session","description":"Found by Copilot on PR #21 round 5, against a reverse-proxy recipe whose IP allow-list had itself been added two rounds earlier to close a DIFFERENT hole. Verified independently by the orchestrator: `grep -rni origin crates/improvise-ws-server/src/` returns NOTHING. The server never inspects the Origin header and accepts every handshake.\n\nTHE HOLE: a WebSocket is EXEMPT FROM THE SAME-ORIGIN POLICY -- there is no CORS preflight on a handshake. So any hostile page that an allowed user visits can open a socket to the endpoint FROM THAT USER'S OWN ALLOWED ADDRESS, and read and edit every document the server holds. That is cross-site WebSocket hijacking.\n\nWHY NETWORK CONTROLS CANNOT SEE IT: an IP allow-list, a private network, loopback binding -- all answer \"may this CLIENT reach the endpoint\", and in this attack the answer is genuinely yes. The request really does come from an allowed client; it is simply being DRIVEN by a page the user did not intend to trust. Only Origin separates the two.\n\nLOOPBACK IS NOT EXEMPT, and this is the half a documentation-only fix cannot reach. In the local pairing case there is no proxy at all, so there is nothing in front to check Origin -- and loopback is not a boundary a browser enforces. While the server runs, ANY page open in that browser can reach ws://127.0.0.1:8787. So the local development path, which is the one the project actually recommends, is the least protected.\n\nDISTINCT FROM improvise-kpnr, and both are needed. kpnr is AUTHENTICATION: who is allowed to talk to this server at all. This is CSWSH: given that someone is allowed, what may put words in their mouth. An answer to either leaves the other open.\n\nTHE FIX IS CHEAP AND SERVER-SIDE: an allow-list of permitted origins checked at the handshake, defaulting to something safe for the local case (the page's own origin, and whatever --bind implies), with a flag to widen it. Refuse the upgrade rather than accepting and ignoring.\n\nTWO THINGS TO GET RIGHT:\n * A MISSING Origin header is not the same as a disallowed one. Non-browser clients (the repo's own tests, curl, a native client) send none, and the browser always does. Decide explicitly what absent means rather than letting the match arm decide by accident.\n * PR #21 has already documented Origin filtering in the PROXY as a required companion to the network control, phrased as \"in addition to, never instead of\". When this lands, the README should say the server does it too, and the proxy line becomes defence in depth rather than the only defence.\n\nTHE GENERAL LESSON, now doctrine(who_may_connect_is_two_questions): ask of any access control whether it constrains the CLIENT or what may DRIVE the client. Three consecutive rounds of that PR reasoned about the page's capabilities and none about the attacker's.\n\n#\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\nR\n\nu\n\nn\n\n\n\n`\n\ni\n\nm\n\np\n\nr\n\no\n\nv\n\ni\n\ns\n\ne\n\n-\n\nw\n\ns\n\n-\n\ns\n\ne\n\nr\n\nv\n\ne\n\nr\n\n`\n\n\n\no\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\n\n\n2\n\n.\n\n\n\nF\n\nr\n\no\n\nm\n\n\n\na\n\n\n\nb\n\nr\n\no\n\nw\n\ns\n\ne\n\nr\n\n\n\nt\n\nh\n\na\n\nt\n\n\n\ni\n\ns\n\n\n\na\n\nl\n\nl\n\no\n\nw\n\ne\n\nd\n\n\n\nt\n\no\n\n\n\nr\n\ne\n\na\n\nc\n\nh\n\n\n\ni\n\nt\n\n\n\n—\n\n\n\nt\n\nh\n\ne\n\n\n\nu\n\ns\n\ne\n\nr\n\n'\n\ns\n\n\n\no\n\nw\n\nn\n\n\n\nm\n\na\n\nc\n\nh\n\ni\n\nn\n\ne\n\n,\n\n\n\no\n\nr\n\n\n\na\n\nn\n\ny\n\n\n\na\n\nd\n\nd\n\nr\n\ne\n\ns\n\ns\n\n\n\na\n\nn\n\n\n\nI\n\nP\n\n\n\na\n\nl\n\nl\n\no\n\nw\n\n-\n\nl\n\ni\n\ns\n\nt\n\n\n\np\n\ne\n\nr\n\nm\n\ni\n\nt\n\ns\n\n\n\n—\n\n\n\nv\n\ni\n\ns\n\ni\n\nt\n\n\n\na\n\nn\n\n\n\nu\n\nn\n\nr\n\ne\n\nl\n\na\n\nt\n\ne\n\nd\n\n\n\np\n\na\n\ng\n\ne\n\n\n\nt\n\nh\n\na\n\nt\n\n\n\no\n\np\n\ne\n\nn\n\ns\n\n\n\n`\n\nn\n\ne\n\nw\n\n\n\nW\n\ne\n\nb\n\nS\n\no\n\nc\n\nk\n\ne\n\nt\n\n(\n\n\"\n\nw\n\ns\n\n:\n\n/\n\n/\n\n1\n\n2\n\n7\n\n.\n\n0\n\n.\n\n0\n\n.\n\n1\n\n:\n\n8\n\n7\n\n8\n\n7\n\n\"\n\n)\n\n`\n\n.\n\n\n\n3\n\n.\n\n\n\nT\n\nh\n\ne\n\n\n\nh\n\na\n\nn\n\nd\n\ns\n\nh\n\na\n\nk\n\ne\n\n\n\ns\n\nu\n\nc\n\nc\n\ne\n\ne\n\nd\n\ns\n\n.\n\n\n\nA\n\n\n\nW\n\ne\n\nb\n\nS\n\no\n\nc\n\nk\n\ne\n\nt\n\n\n\ni\n\ns\n\n\n\ne\n\nx\n\ne\n\nm\n\np\n\nt\n\n\n\nf\n\nr\n\no\n\nm\n\n\n\nt\n\nh\n\ne\n\n\n\ns\n\na\n\nm\n\ne\n\n-\n\no\n\nr\n\ni\n\ng\n\ni\n\nn\n\n\n\np\n\no\n\nl\n\ni\n\nc\n\ny\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\n\n\nC\n\nO\n\nR\n\nS\n\n\n\np\n\nr\n\ne\n\nf\n\nl\n\ni\n\ng\n\nh\n\nt\n\n\n\na\n\nn\n\nd\n\n\n\n`\n\ng\n\nr\n\ne\n\np\n\n\n\n-\n\nr\n\nn\n\ni\n\n\n\no\n\nr\n\ni\n\ng\n\ni\n\nn\n\n\n\nc\n\nr\n\na\n\nt\n\ne\n\ns\n\n/\n\ni\n\nm\n\np\n\nr\n\no\n\nv\n\ni\n\ns\n\ne\n\n-\n\nw\n\ns\n\n-\n\ns\n\ne\n\nr\n\nv\n\ne\n\nr\n\n/\n\ns\n\nr\n\nc\n\n/\n\n`\n\n\n\nr\n\ne\n\nt\n\nu\n\nr\n\nn\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\nt\n\nh\n\ne\n\n\n\ns\n\ne\n\nr\n\nv\n\ne\n\nr\n\n\n\nn\n\ne\n\nv\n\ne\n\nr\n\n\n\nl\n\no\n\no\n\nk\n\ns\n\n.\n\n\n\n4\n\n.\n\n\n\nT\n\nh\n\na\n\nt\n\n\n\np\n\na\n\ng\n\ne\n\n\n\nc\n\na\n\nn\n\n\n\nn\n\no\n\nw\n\n\n\nr\n\ne\n\na\n\nd\n\n\n\na\n\nn\n\nd\n\n\n\ne\n\nd\n\ni\n\nt\n\n\n\ne\n\nv\n\ne\n\nr\n\ny\n\n\n\nd\n\no\n\nc\n\nu\n\nm\n\ne\n\nn\n\nt\n\n\n\nt\n\nh\n\ne\n\n\n\ns\n\ne\n\nr\n\nv\n\ne\n\nr\n\n\n\nh\n\no\n\nl\n\nd\n\ns\n\n,\n\n\n\nf\n\nr\n\no\n\nm\n\n\n\nt\n\nh\n\ne\n\n\n\nu\n\ns\n\ne\n\nr\n\n'\n\ns\n\n\n\no\n\nw\n\nn\n\n\n\na\n\nl\n\nl\n\no\n\nw\n\ne\n\nd\n\n\n\na\n\nd\n\nd\n\nr\n\ne\n\ns\n\ns\n\n.\n\n\n\n\n\nL\n\no\n\no\n\np\n\nb\n\na\n\nc\n\nk\n\n\n\ni\n\ns\n\n\n\nn\n\no\n\nt\n\n\n\ne\n\nx\n\ne\n\nm\n\np\n\nt\n\n\n\na\n\nn\n\nd\n\n\n\nh\n\na\n\ns\n\n\n\nn\n\no\n\n\n\np\n\nr\n\no\n\nx\n\ny\n\n\n\ni\n\nn\n\n\n\nf\n\nr\n\no\n\nn\n\nt\n\n\n\no\n\nf\n\n\n\ni\n\nt\n\n,\n\n\n\ns\n\no\n\n\n\nt\n\nh\n\ne\n\n\n\nl\n\no\n\nc\n\na\n\nl\n\n\n\nd\n\ne\n\nv\n\ne\n\nl\n\no\n\np\n\nm\n\ne\n\nn\n\nt\n\n\n\np\n\na\n\nt\n\nh\n\n\n\ni\n\ns\n\n\n\nt\n\nh\n\ne\n\n\n\nl\n\ne\n\na\n\ns\n\nt\n\n\n\np\n\nr\n\no\n\nt\n\ne\n\nc\n\nt\n\ne\n\nd\n\n.\n","acceptance_criteria":"improvise-ws-server refuses a handshake whose Origin is not allow-listed, defaulting to something safe for the local case with a flag to widen it. A MISSING Origin is decided explicitly rather than by accident — non-browser clients (the repo's own tests, curl, a native client) send none and browsers always do. The README's proxy-side Origin filtering becomes defence in depth rather than the only defence.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T06:09:42Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:45:13Z","dependencies":[{"issue_id":"improvise-kc7n","depends_on_id":"improvise-kpnr","type":"relates-to","created_at":"2026-09-20T06:09:49Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-mai9","title":"A status message written while a minibuffer is open is never drawn","description":"Found by WU-G while building improvise-lae (PR #27), and CONFIRMED THE HARD WAY in the same PR: Copilot then found a regression that this mechanism causes, which the agent had itself written down an hour earlier and not checked its own change against.\n\nTHE MECHANISM: src/draw.rs::draw_bottom_bar renders the minibuffer INSTEAD OF the status line. So a status_msg written while a minibuffer is open is never drawn at all -- not truncated, not overwritten, simply never rendered.\n\nWHO IS AFFECTED TODAY:\n * CategoryAdd / ItemAdd: a failed `commit-buffer category` is INVISIBLE. Worse, improvise-6t3 deliberately ordered that command's effects so the add effect is emitted LAST, specifically so its failure message would WIN over the command's optimistic status. It wins a race to a surface nobody can see.\n * A failed `:w \u003cpath\u003e` from CommandMode, which is the regression Copilot found on PR #27: the abort stops the batch before ReturnToNormalUnlessMoved and clear-buffer run, so the app stays in CommandMode with the stale command in the minibuffer and the new error hidden behind it.\n\nTHE WORKAROUND ALREADY SHIPPED, and it is a workaround: CommitSave emits [ChangeMode(Normal), SaveAs] so the error lands when the bottom row is the status bar again. That works for one command and does not generalise -- every other text-entry commit has the same hole.\n\nTWO CANDIDATE FIXES, and the choice is the ticket:\n * Draw both: give the minibuffer and the status line separate rows, or render the status into the minibuffer row when a message is pending. Cheap to describe, and it changes frame geometry, which is pinned by exact-frame characterization tests (see improvise-q0vb for what that costs).\n * Make it unrepresentable: a status message set while a minibuffer is open is a bug in the CALLER, so make the caller unable to do it -- e.g. commit paths always leave the mode before reporting. That is what CommitSave now does by hand, promoted to a rule.\n\nPREFER WHICHEVER MAKES THE FAILURE LOUD. The current state is the quiet kind: the code sets a message, the test asserts the message was set, and the user never sees it. A test asserting `app.status_msg == \"...\"` passes in every one of these cases, which is why this survived improvise-6t3's careful effect ordering.\n\n#\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\nE\n\nn\n\nt\n\ne\n\nr\n\n\n\na\n\n\n\nt\n\ne\n\nx\n\nt\n\n-\n\ne\n\nn\n\nt\n\nr\n\ny\n\n\n\nm\n\no\n\nd\n\ne\n\n\n\nt\n\nh\n\na\n\nt\n\n\n\no\n\np\n\ne\n\nn\n\ns\n\n\n\na\n\n\n\nm\n\ni\n\nn\n\ni\n\nb\n\nu\n\nf\n\nf\n\ne\n\nr\n\n\n\n—\n\n\n\n`\n\nC\n\na\n\nt\n\ne\n\ng\n\no\n\nr\n\ny\n\nA\n\nd\n\nd\n\n`\n\n\n\n(\n\n`\n\nN\n\n`\n\n)\n\n\n\ni\n\ns\n\n\n\nt\n\nh\n\ne\n\n\n\nc\n\nl\n\ne\n\na\n\nr\n\ne\n\ns\n\nt\n\n.\n\n\n\n2\n\n.\n\n\n\nC\n\no\n\nm\n\nm\n\ni\n\nt\n\n\n\ns\n\no\n\nm\n\ne\n\nt\n\nh\n\ni\n\nn\n\ng\n\n\n\nt\n\nh\n\na\n\nt\n\n\n\nf\n\na\n\ni\n\nl\n\ns\n\n,\n\n\n\ns\n\no\n\n\n\nt\n\nh\n\ne\n\n\n\nc\n\no\n\nm\n\nm\n\na\n\nn\n\nd\n\n\n\ns\n\ne\n\nt\n\ns\n\n\n\na\n\n\n\ns\n\nt\n\na\n\nt\n\nu\n\ns\n\n\n\nm\n\ne\n\ns\n\ns\n\na\n\ng\n\ne\n\n.\n\n\n\n3\n\n.\n\n\n\nN\n\no\n\nt\n\nh\n\ni\n\nn\n\ng\n\n\n\na\n\np\n\np\n\ne\n\na\n\nr\n\ns\n\n:\n\n\n\n`\n\ns\n\nr\n\nc\n\n/\n\nd\n\nr\n\na\n\nw\n\n.\n\nr\n\ns\n\n:\n\n:\n\nd\n\nr\n\na\n\nw\n\n_\n\nb\n\no\n\nt\n\nt\n\no\n\nm\n\n_\n\nb\n\na\n\nr\n\n`\n\n\n\nr\n\ne\n\nn\n\nd\n\ne\n\nr\n\ns\n\n\n\nt\n\nh\n\ne\n\n\n\nm\n\ni\n\nn\n\ni\n\nb\n\nu\n\nf\n\nf\n\ne\n\nr\n\n\n\nI\n\nN\n\nS\n\nT\n\nE\n\nA\n\nD\n\n\n\nO\n\nF\n\n\n\nt\n\nh\n\ne\n\n\n\ns\n\nt\n\na\n\nt\n\nu\n\ns\n\n\n\nl\n\ni\n\nn\n\ne\n\n,\n\n\n\ns\n\no\n\n\n\nt\n\nh\n\ne\n\n\n\nm\n\ne\n\ns\n\ns\n\na\n\ng\n\ne\n\n\n\ni\n\ns\n\n\n\nn\n\ne\n\nv\n\ne\n\nr\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\nn\n\no\n\nt\n\n\n\nt\n\nr\n\nu\n\nn\n\nc\n\na\n\nt\n\ne\n\nd\n\n,\n\n\n\nn\n\no\n\nt\n\n\n\no\n\nv\n\ne\n\nr\n\nw\n\nr\n\ni\n\nt\n\nt\n\ne\n\nn\n\n.\n\n\n\n4\n\n.\n\n\n\nA\n\n\n\nt\n\ne\n\ns\n\nt\n\n\n\na\n\ns\n\ns\n\ne\n\nr\n\nt\n\ni\n\nn\n\ng\n\n\n\n`\n\na\n\np\n\np\n\n.\n\ns\n\nt\n\na\n\nt\n\nu\n\ns\n\n_\n\nm\n\ns\n\ng\n\n\n\n=\n\n=\n\n\n\n\"\n\n.\n\n.\n\n.\n\n\"\n\n`\n\n\n\np\n\na\n\ns\n\ns\n\ne\n\ns\n\n,\n\n\n\nw\n\nh\n\ni\n\nc\n\nh\n\n\n\ni\n\ns\n\n\n\nw\n\nh\n\ny\n\n\n\nt\n\nh\n\ni\n\ns\n\n\n\ns\n\nu\n\nr\n\nv\n\ni\n\nv\n\ne\n\nd\n\n\n\ni\n\nm\n\np\n\nr\n\no\n\nv\n\ni\n\ns\n\ne\n\n-\n\n6\n\nt\n\n3\n\n'\n\ns\n\n\n\nc\n\na\n\nr\n\ne\n\nf\n\nu\n\nl\n\n\n\ne\n\nf\n\nf\n\ne\n\nc\n\nt\n\n\n\no\n\nr\n\nd\n\ne\n\nr\n\ni\n\nn\n\ng\n\n.\n\n\n\n\n\nS\n\ne\n\nc\n\no\n\nn\n\nd\n\n\n\np\n\na\n\nt\n\nh\n\n,\n\n\n\nf\n\no\n\nu\n\nn\n\nd\n\n\n\no\n\nn\n\n\n\nP\n\nR\n\n\n\n#\n\n2\n\n7\n\n:\n\n\n\na\n\n\n\nf\n\na\n\ni\n\nl\n\ne\n\nd\n\n\n\n`\n\n:\n\nw\n\n\n\n/\n\nb\n\na\n\nd\n\n/\n\np\n\na\n\nt\n\nh\n\n`\n\n\n\na\n\nb\n\no\n\nr\n\nt\n\ns\n\n\n\nb\n\ne\n\nf\n\no\n\nr\n\ne\n\n\n\n`\n\nR\n\ne\n\nt\n\nu\n\nr\n\nn\n\nT\n\no\n\nN\n\no\n\nr\n\nm\n\na\n\nl\n\nU\n\nn\n\nl\n\ne\n\ns\n\ns\n\nM\n\no\n\nv\n\ne\n\nd\n\n`\n\n,\n\n\n\nl\n\ne\n\na\n\nv\n\ni\n\nn\n\ng\n\n\n\nt\n\nh\n\ne\n\n\n\na\n\np\n\np\n\n\n\ni\n\nn\n\n\n\n`\n\nC\n\no\n\nm\n\nm\n\na\n\nn\n\nd\n\nM\n\no\n\nd\n\ne\n\n`\n\n\n\nw\n\ni\n\nt\n\nh\n\n\n\nt\n\nh\n\ne\n\n\n\ns\n\nt\n\na\n\nl\n\ne\n\n\n\nc\n\no\n\nm\n\nm\n\na\n\nn\n\nd\n\n\n\nd\n\nr\n\na\n\nw\n\nn\n\n\n\na\n\nn\n\nd\n\n\n\nt\n\nh\n\ne\n\n\n\ne\n\nr\n\nr\n\no\n\nr\n\n\n\nh\n\ni\n\nd\n\nd\n\ne\n\nn\n\n\n\nb\n\ne\n\nh\n\ni\n\nn\n\nd\n\n\n\ni\n\nt\n\n.\n","acceptance_criteria":"A status message set while a minibuffer is open reaches the user. Either the two share the bottom row, or a commit path is structurally unable to report while its minibuffer is open (which is what CommitSave now does by hand, promoted to a rule). Whichever is chosen, the failure becomes LOUD: a test asserting app.status_msg was set must no longer pass while the user sees nothing.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T06:04:20Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:45:12Z","dependencies":[{"issue_id":"improvise-mai9","depends_on_id":"improvise-sihr","type":"relates-to","created_at":"2026-09-20T06:12:22Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-kpnr","title":"improvise-ws-server has no authentication, and the documented sharing path now exposes it","description":"Found by Copilot on PR #21 (improvise-o8fa, the reverse-proxy documentation), and it is a finding ABOUT the documentation that PR introduced rather than about code.\n\nimprovise-ws-server has NO AUTHENTICATION BY DESIGN. crates/improvise-ws-server/src/transport.rs says it outright: anyone who can open a socket to the port can rewrite every document it serves. The only control today is that it binds loopback.\n\nTHE README NOW TELLS USERS TO PUT A PUBLIC TLS PROXY IN FRONT OF IT (improvise-o8fa, per the user's ruling that the reverse proxy is the supported path to reaching a TUI-hosted document from an https origin). That instruction REMOVES THE ONLY CONTROL. TLS is confidentiality, not access control, and in a copy-pasteable recipe the two read alike.\n\nSo proxy-side auth is now LOAD-BEARING DOCUMENTATION rather than a server feature. PR #21 mitigated it -- the warning leads the section, the Caddyfile carries basic_auth marked not-optional, the --bind note now separates what loopback buys from what it does not, and the tunnel line says cloudflared/ngrok print public URLs and authenticate nothing -- but mitigation in prose is not the same as a property of the system.\n\nTHE DECISION TO MAKE, and it is a real fork rather than an oversight to correct:\n * The server grows a shared secret or token, checked at the websocket handshake. Small, and it makes the documented path safe by default rather than safe if followed.\n * Or \"auth is the proxy's job\" is the PERMANENT answer, stated in the server's own docs as a design position rather than left as an absence. Defensible -- it is what most single-binary dev servers do -- but then the README recipe is the security boundary and should say so in those words.\n\nDO NOT SPLIT THE DIFFERENCE. A token that is optional and off by default is the worst of both: it reads as a feature and defends nothing.\n\nNOTE THE GENERALISABLE PART, worth keeping whichever way this goes: DOCUMENTATION THAT INSTRUCTS AN ACTION CARRIES THAT ACTION'S CONSEQUENCES. The PR #21 section was scrupulous about one axis (build complexity, the cost of a second binary to a one-binary tool) and blind on another (exposure), and the --bind paragraph actively REASSURED the reader -- \"not reachable from anywhere else\", true of the port and false of the deployment. Neither the ticket, nor gotcha 48, nor the agent's own /code-review pass caught it; a reviewer reading the server's source did.\n\n## Rationale\n\nimprovise-ws-server has no authentication by design and `transport.rs` says so outright: anyone who can open a socket to the port can rewrite every document it serves. The only control was loopback binding — and improvise-o8fa's README now instructs users to put a public TLS proxy in front of it, which REMOVES exactly that control. TLS is confidentiality, not access control, and in a copy-pasteable recipe the two read alike.\n\nSo proxy-side auth is now load-bearing documentation rather than a property of the system. PR #21 mitigated it in prose; mitigation in prose is not the same as a mechanism.\n\n## Alternatives Considered\n\n**A shared secret or token checked at the handshake.** Small, and it makes the documented path safe by default rather than safe-if-followed. Note a URL query parameter is the one credential `new WebSocket(url)` can carry — the API sets no headers.\n\n**\"Auth is the proxy's job\" as a stated permanent position**, written into the server's own docs as a design decision rather than left as an absence. Defensible — it is what most single-binary dev servers do — but then the README recipe IS the security boundary and should say so in those words.\n\n**Rejected: a token that is optional and off by default.** The worst of both: it reads as a feature and defends nothing.\n","status":"open","priority":2,"issue_type":"decision","owner":"el-github@elangley.org","created_at":"2026-09-20T05:45:28Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:45:40Z","dependencies":[{"issue_id":"improvise-kpnr","depends_on_id":"improvise-kc7n","type":"relates-to","created_at":"2026-09-20T06:09:49Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-p1ym","title":"Project the page-axis item lists so [ and ] work in a browser","description":"The one key pair an independent smoke test found inert that the shared keymap did NOT fix, and it is a genuine projection gap rather than a resolution gap.\n\nGridCache::page_coords carries (category, SELECTED ITEM). PageNext/PagePrev need THE ITEM LIST TO CYCLE THROUGH, which nothing projects. So [ and ] resolve to a command name the client holds but cannot compose an argument for, and they reach Host::handle empty vec -- doctrine(an_upstream_key_is_a_black_hole), so they do nothing at all.\n\nCONFIRMED BY SMOKE TEST 2026-09-20 against the live page: \"[ / ] did not cycle Quarter (never saw Q2 ~17,100)\". The demo model puts Quarter on the page axis specifically so paging is a thing a visitor would try.\n\nTHE AGENT WAS TOLD NOT TO FORCE IT and did not -- a truthful \"these four keys work, [ / ] needs X\" is worth more than five half-working keys.\n\nSCOPE: extend page_coords (or add a sibling) to carry the item list per page-axis category. Note gotcha 27 -- \"what data is visible\" is View::filtering_cats() (Page UNION Filter) while \"what the user can page\" is categories_on(Axis::Page). This needs the SECOND one; projecting the first would let a client try to cycle a Filter axis, which by design it must not.\n\nWhile you are there: check whether the item list should be the EFFECTIVE items (hidden items excluded) rather than all of them, since [ and ] in the TUI cycle what the user can actually select.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T04:38:45Z","created_by":"Edward Langley","updated_at":"2026-09-20T04:38:45Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-o8fa","title":"improvise-ws-server speaks ws:// only, so the deployed page cannot reach it","description":"Found by opening the deployed page (2026-09-20). The guidance the page and the README carried was WRONG in a way that sends someone down a dead end.\n\nFROM AN https ORIGIN A ws:// ADDRESS IS BLOCKED AS MIXED CONTENT, so the deployed GitHub Pages site needs wss://. improvise-ws-server has NO TLS AT ALL -- no rustls, no native-tls, nothing in the crate or its dependencies -- so wss:// is not obtainable from it. The text said \"note that from an https origin a ws:// address is blocked\", which correctly named the symptom and then offered an address that can never work.\n\nTEXT CORRECTED ALREADY: both web/index.html and README.md now say the address must be wss:// from the deployed page, that the server speaks plain ws:// only, and that the ws:// form works when the page is served over http://localhost. This ticket is the missing CAPABILITY.\n\nTHREE WAYS TO PROVIDE IT, and they are not equivalent:\n * TERMINATE TLS IN FRONT (nginx, caddy, cloudflared, ngrok). Zero code, works today, and is what the corrected text points at. But it means the documented path to sharing a TUI-hosted document involves a second piece of software, which is a poor answer for a tool whose selling point is that it is one binary.\n * NATIVE wss:// IN improvise-ws-server -- rustls plus a cert. Real work, and it raises the certificate question: a self-signed cert makes the browser refuse the socket with no usable error, so this is only pleasant with a real cert or an explicitly trusted local CA (mkcert).\n * DECIDE THE DEPLOYED PAGE DOES NOT PAIR WITH A LOCAL SERVER at all, and say so. Defensible: the standalone worker deployment is the point of the Pages site, and ws:// pairing is a LOCAL development convenience that works fine from the local page. Cheapest, and it makes the docs shorter rather than longer.\n\nThe third is probably right for now and the first is the escape hatch; the second only earns its place if sharing a TUI-hosted document to a remote browser becomes a real use case. Do not implement the second by default.","status":"closed","priority":2,"issue_type":"feature","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T03:53:54Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:22:10Z","started_at":"2026-09-20T05:32:35Z","closed_at":"2026-09-20T06:22:10Z","close_reason":"Closed by PR #21 (WU-I). DOCUMENTATION ONLY, per the user's ruling; native wss:// DECLINED.\n\nTHE DECLINE'S SUBSTANCE IS THE CERTIFICATE, NOT rustls. A self-signed cert makes the browser refuse the socket with no usable error, because a WebSocket handshake has no interstitial -- there is no place to click through. A real cert or mkcert is a second piece of software too, so the choice is two-either-way, with one option additionally shipping a TLS stack and a cert-loading surface in a pivot-table binary.\n\nWHAT LANDED: a worked Caddy example, the nginx difference that bites (proxy_http_version 1.1 plus the Upgrade/Connection headers), the honest cost (a second binary, a poor answer for a one-binary tool), and the framing that the standalone worker deployment is the point of the Pages site while ws:// pairing works fine from a local http://localhost page.\n\nTHIS BEAD COST SEVEN COPILOT ROUNDS AND NINE FINDINGS, EVERY ONE IN PROSE. The sequence is the value and is recorded in kb.pl:\n * the first recipe exposed an unauthenticated world-writable server -- documenting a proxy documents an exposure, because TLS termination REMOVES the loopback binding that was the only control;\n * the \"fix\" put Caddy basic_auth in front of a socket the page opens as new WebSocket(url), an API that can set no headers over a handshake that shows no prompt -- BROKEN reads as protection, which is worse than absent;\n * an ngrok --cidr-allow recipe was written without being run (deprecated-but-accepted, so the reviewer's own \"fails at startup\" was also wrong);\n * \"the browser cannot send a credential on a WebSocket handshake\" over-generalised the governing fact and contradicted the Cloudflare Access bullet three paragraphs up, which works BECAUSE the handshake carries a cookie;\n * an IP allow-list cannot see a cross-site socket at all, which is improvise-kc7n;\n * an absolute loopback claim denied a browser mitigation that exists but is mid-rollout;\n * and \"the auth above\" called an allow-list authentication in a section whose opening sentence is that the server has none.\n\nThe governing fact was ALREADY IN THE SAME FILE at every step, as the basis of this very decline. The lesson the repo did not have: a reviewed paragraph is not evidence.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-955n","title":"[bug] Host::commands applies a batch partially before rejecting","description":"Found by Copilot on PR #13 (the retro review of the unreviewed batch), DECLINED there with reasons on the thread, and filed rather than patched.\n\nADR 001 is emphatic that a stale batch is never applied, NOT EVEN PARTIALLY -- and improvise-ondj went to the trouble of a TYPE (Checked) to make the unchecked path unconstructible. But Checked is PER-COMMAND: Checked::against answers for one Command against the App AS IT STANDS. A ClientFrame::Commands is a SEQUENCE of them, and Host::commands applies in order and abandons at the first rejection. So a frame carrying SwitchView followed by a stale Command::Model has already applied the SwitchView.\n\nSO ADR 001 GUARANTEE IS CURRENTLY TRUE OF A SINGLE COMMAND AND FALSE OF A BATCH.\n\nA SECOND-ORDER EFFECT NOBODY HAD WRITTEN DOWN, found while declining it: Worker::on_frame skips persist() when ANY frame in the answer is Rejected. So a batch that DID apply a SwitchView before being refused leaves Workbook::active_view moved in memory and NOT WRITTEN BACK -- the document on disk and the document in the worker disagree, silently.\n\nWHY IT WAS DECLINED RATHER THAN PATCHED: closing it means the basis check moving from the COMMAND to the BATCH -- every command answered before any is applied. That is an ADR 001 design change with a real question inside it: WHAT DOES A BATCH BASIS MEAN WHEN ITS LATER COMMANDS WERE COMPOSED AGAINST ITS EARLIER ONES EFFECTS? A client resolving one keypress produces SetSelected plus offsets computed from the selection it just set. Answer that before writing code.\n\nREACHABILITY IS BOUNDED TODAY: no client in this workspace composes a batch containing more than one basis-carrying Command::Model -- the batches improvise-bpq2 produces are view commands, which carry no basis. Do this before the second tranche of client-side resolution creates a reason to batch two model commands.\n\nSupersedes the earlier ticket filed from bpq2 if there is one -- dedupe.","status":"closed","priority":2,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T03:35:27Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:29:48Z","started_at":"2026-09-20T05:32:29Z","closed_at":"2026-09-20T07:29:48Z","close_reason":"Closed by PR #26 (WU-C). improvise-08wb is closed as a duplicate of this one.\n\nADR 001 said a stale batch is never applied, NOT EVEN PARTIALLY, and improvise-ondj went to the trouble of a type (Checked) to make the unchecked path unconstructible -- but Checked is PER-COMMAND and a ClientFrame::Commands is a sequence of them, so Host::commands applied in order and abandoned at the first rejection. The guarantee was true of a single command and false of a batch.\n\nCheckedBatch::against now answers for EVERY command before any is applied.\n\nTHE DESIGN QUESTION THE TICKET DEMANDED WAS ANSWERED BY THE USER, not by the code, and it is larger than the ticket imagined. The ticket asked \"what does a batch's basis mean when its later commands were composed against its earlier ones' effects\". The user's ruling: \"a sequence of commands sent to the server should assume that it's played in order from start to end and that each command sees the state after the previous command runs\", as \"a single transaction from a given base\". Four clauses -- one basis per frame; checked once before anything; applied in order with data dependence EXPECTED; all or nothing. Clauses 1-3 landed here; clause 4 (rollback) is improvise-yqpd.\n\nAN EARLIER \"at most one document mutation per frame\" RULE WAS SHIPPED AND THEN REJECTED BY THE USER as forbidding the capability. It was wrong twice over: it forbids data-dependent chains, and arity is not a property of a variant anyway -- a future Command::RunScript is one variant performing unboundedly many mutations.\n\nTHE FINDING THAT MATTERED MOST came from the independent adversarial review that substituted for Copilot (quota exhausted), and it was a REGRESSION THIS WORK INTRODUCED: hoisting the basis check to the frame hoisted ALL THREE TIERS. The document and layout tiers are legitimately frame-level facts; THE RECORD TIER IS NOT AND CANNOT BE, because which records are at stake is a property of the command. So every mutation after the first had its records checked against nothing -- a silent lost update, confirmed by executing a real Host: client A's stale [SetCell(North), SetCell(South)] clobbered client B's write to South with no rejection, where the base commit refused it. Fixed: every mutation answers for its own records against the FRAME'S basis, with an exemption stated over a property -- a record that neither resolves nor is claimed is one the frame is creating. The reviewer attacked that exemption at all four corners and could not break it: the predicate is a conjunction and the client controls only one conjunct.\n\nTHE SECOND-ORDER WORKER EFFECT the ticket named is closed twice over; its test fails only when BOTH old behaviours are restored.\n\nA CAPABILITY NOBODY HAD NAMED, now guarded: a frame may write THE SAME RECORD TWICE -- two measures on one record IS one record -- and that works only because every check happens before any apply. Moving the checks into the loop breaks it, which is what stops someone closing clause 4 by taking that trade unnoticed.","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -51,8 +51,8 @@ {"_type":"issue","id":"improvise-vhcz","title":"[bug] OpfsFs::list disagrees with the other backends on path shape","description":"Found by improvise-9x6 (2026-09-20) and currently PAPERED OVER by always passing the flat root.\n\nOpfsFs::list returns BARE NAMES; MemoryFs and PhysicalFs return dir-JOINED ones. So Path::new(\"\") is the ONE dir at which all three agree, and the only one at which storage_contract actually describes OpfsFs.\n\nTHE TRAP: a worker that organised documents under docs/ would get an EMPTY PICKER in a browser and a FULL ONE in every test. The harness cannot see the disagreement because it never runs OpfsFs -- improvise-xf1 is deferred -- so this is precisely the class of defect the missing browser harness exists to catch, discovered by reading instead.\n\nTWO FIXES:\n * Make OpfsFs::list join dir, matching the other two. Straightforward, and makes storage_contract describe all three at every dir.\n * Or document the flat root as PART OF THE CONTRACT and assert it in storage_contract, so the constraint is stated rather than discovered.\n\nThe first is better unless there is a reason OPFS cannot -- note gotcha 28 says backends that are not trees treat dir as a NAME PREFIX, which is already the trait doc contemplating this, so check whether the doc or the impl is the thing that is wrong.","status":"closed","priority":2,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T02:10:14Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:44:42Z","started_at":"2026-09-20T05:32:36Z","closed_at":"2026-09-20T06:44:42Z","close_reason":"Closed by PR #24 (WU-J). THE TICKET ASKED WHICH OF THE DOC OR THE IMPL WAS WRONG: IT WAS THE IMPL.\n\nThe Storage trait doc already said that backends which are not trees treat `dir` as a NAME PREFIX. OpfsFs::list ignored `dir` outbound as well as inbound, returning bare names where MemoryFs and PhysicalFs return dir-joined ones -- so Path::new(\"\") was the one dir at which all three agreed, and the only one at which storage_contract actually described OpfsFs.\n\nNow it joins. The three backends agree at EVERY dir, and storage_contract runs against OpfsFs at both \"\" and \"docs\" in a real browser. `dir` stays DECORATION rather than a filter, because entry_name reduces every path to its file name and an OPFS entry name may not contain a separator.\n\nFAULT INJECTED: reverting to bare names fails storage_contract_at_\"docs\" on `lists the one document`.\n\nTHE TRAP THE TICKET NAMED IS CLOSED: a worker that organised documents under docs/ would have got an empty picker in a browser and a full one in every test. That divergence was invisible to the harness because nothing ran OpfsFs -- which is exactly why improvise-vvl3 had to land alongside this, and why this bead could only ever have been found by reading until it did.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-hmws","title":"Storage cannot expose an async seam through Box\u003cdyn Storage\u003e","description":"Found by improvise-9x6 (2026-09-20). Every worker host will hit this, so it is worth solving once rather than in each.\n\nOpfsFs::open / attach / refresh / commit_deletes are on the STRUCT, not on the Storage trait -- deliberately, per gotcha 28: Storage stays synchronous because createSyncAccessHandle yields a blocking handle, and only four operations are genuinely async. But App::storage is a Box\u003cdyn Storage\u003e, so HANDING THE BACKEND TO THE App PUTS ALL FOUR PERMANENTLY OUT OF REACH. The App owns the only copy and the trait object cannot name them.\n\nAND THE OBVIOUS FIX IS REFUSED BY THE COMPILER: impl Storage for Rc\u003cOpfsFs\u003e is two foreign types, so the orphan rule forbids it. improvise-9x6 worked around it with a local newtype (bindings::Shared) holding an Rc\u003cOpfsFs\u003e and forwarding the four sync methods. That works but every worker must write it again.\n\nTWO CANDIDATE FIXES:\n * Ship the shared wrapper FROM improvise-io, so the newtype exists once where OpfsFs does and the orphan rule is satisfied.\n * Or have OpfsFs hand out an attachable handle -- separate the thing the App holds from the thing the message loop awaits, which is arguably the more honest modelling since they have different lifetimes and different call sites.\n\nPrefer whichever keeps Storage synchronous; that part is not negotiable (gotcha 28 is the argument).","status":"closed","priority":2,"issue_type":"task","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T02:10:13Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:44:43Z","started_at":"2026-09-20T05:32:37Z","closed_at":"2026-09-20T06:44:43Z","close_reason":"Closed by PR #24 (WU-J). NEITHER OF THE TICKET'S TWO OPTIONS WAS NEEDED, and the reason is worth more than the fix.\n\nThe ticket said `impl Storage for Rc\u003cOpfsFs\u003e` is refused by the orphan rule. That is true IN improvise-worker-server, where both the trait and Rc are foreign -- which is where improvise-9x6 hit it and wrote bindings::Shared. It is NOT true in improvise-io, where the trait is LOCAL and the impl is simply allowed.\n\nSo: improvise-io ships a blanket `impl\u003cT: Storage + ?Sized\u003e Storage for Rc\u003cT\u003e`. bindings::Shared and its _shim_is_a_storage canary are DELETED, not moved, and no future worker writes one. Storage stays synchronous, which gotcha 28 says is not negotiable.\n\nTHE GUARD IS THE SIGNATURE: a_shared_backend_is_a_backend_and_a_clone_is_the_same_one does not compile without the impl. The body asserts what a hand-written newtype could still get wrong -- that a clone addresses the same backend -- and a fault injected into the forwarded `write` (made a no-op) fails it.\n\nTHE GENERALISABLE PART: an orphan-rule refusal is a fact about WHERE YOU ARE STANDING, not about the pair of types. Before working around one, check whether the impl belongs in the crate that owns the trait.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-9mo9","title":"[bug] Geometry effects that renumber the grid but never reset the cursor","description":"Found by the code review of improvise-gvnb (2026-09-19). A PRE-EXISTING gap that gvnb inherited rather than introduced -- but gvnb is what makes it matter, because the epoch is now what tells every client their cursor is invalid.\n\nApp::reset_cursor bumps cursor_epoch; Sessions::adopt_cursor_epoch watches the COUNTER and broadcasts Command::ResetCursor to all. Nothing declares which effects renumber the grid -- deliberately, per gotcha 31 -- which means a geometry effect that forgets reset_cursor is INVISIBLE IN BOTH DIRECTIONS: the host own cursor stays stale AND no client is told.\n\nToday only TransposeAxes and CycleAxis call it. These also renumber rows or columns and do not:\n SetAxis, HideItem, ShowItem, ToggleGroup, TogglePruneEmpty, AddCategory, RemoveCategory\n (plus AddItem/RemoveItem, which are CellData rather than LayoutGeometry)\n\nSetAxis IS THE SHARPEST CASE: CycleAxis is literally SetAxis to Axis::next() and resets; SetAxis does not.\n\nWHY THE REVIEW DID NOT JUST PATCH IT: SetAxis is used by DrillIntoCell (cmd/grid.rs:365-471) to build the _Drill view, and resetting there could discard the user position in the view they drilled FROM, depending on effect order. That is a decision about effect ordering and intended UX, not a patch.\n\nKEEP THE NEGATIVE ARM. a_model_command_that_does_not_reshape_the_grid_leaves_cursors_alone uses SetNumberFormat -- a LayoutGeometry effect that must NOT reset. Without it, \"reset on every model command\" would pass the positive test. Whatever you do here, do not lose it.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T01:05:15Z","created_by":"Edward Langley","updated_at":"2026-09-20T01:05:15Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-s1xw","title":"Multi-level grid labels on the wire -- the browser shows Food/Alice where the TUI shows two columns","description":"THE LAST THING KEEPING GridLayout INSIDE GridWidget, and a LIVE DIVERGENCE today, not a hypothetical. Found closing improvise-n10.\n\nGridCache::row_labels / col_labels are Vec\u003cString\u003e produced by GridLayout::row_label, which JOINS A MULTI-LEVEL TUPLE WITH A SLASH. That is exactly the rendering src/ui/grid.rs::two_row_categories_produce_cross_product_labels asserts must NOT appear (\"slash-joined labels should be gone\"). So the protocol carries a form of the labels THE TUI ITSELF CONSIDERS WRONG, and crates/improvise-wasm-client/src/render.rs renders precisely that form.\n\nConsequence: the browser grid has no multi-level headers, no repeat suppression, no groups and no collapse indicators, and shows Food/Alice where the TUI shows two header columns.\n\nSIX FACTS THE CACHE DOES NOT CARRY (exhaustive as of improvise-n10, by grep over the non-test body of src/ui/grid.rs):\n 1. number of header levels -- layout.row_cats / col_cats\n 2. multi-level label TUPLES -- layout.row_items (AxisEntry::DataItem(Vec\u003cString\u003e))\n 3. inline GROUP HEADER rows -- layout.row_items (AxisEntry::GroupHeader)\n 4. column GROUP SPANS -- layout.col_group_for(ci)\n 5. collapse indicator (down/right triangle) -- view.is_group_collapsed(cat, group)\n 6. records-mode column identity -- layout.record_col(ci). THIS ONE IS CORRECT TO KEEP OFF: it keys pending drill edits and is session-local by design.\n\nSo 1-5 are the gap.\n\nTWO CONSTRAINTS ON THE FIX:\n * IT IS A CO-ORDINATED CHANGE, NOT AN ADDITIVE ONE. row_labels/col_labels change TYPE, and improvise-wasm-client/src/render.rs consumes both fields, so the client updates in the same change.\n * DO NOT ADD row_levels BESIDE row_labels. Two answers to \"what are the labels\" is the disease this whole projection architecture exists to cure -- see improvise-edp, where the category panel was walked twice, two different ways.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T00:58:33Z","created_by":"Edward Langley","updated_at":"2026-09-20T05:32:27Z","started_at":"2026-09-20T05:32:27Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-4zl7","title":"A fitted tree must route per record and aggregate the results, not route on the aggregate","description":"Successor to improvise-8xhy, which shipped remedy 1 (refuse at emit) as an explicit interim. This is the remedy that produces a formula A HUMAN WOULD HAVE WRITTEN, and it is the right end state -- but it was costed during 8xhy and it is much larger than it looks.\n\nANSWER THIS FIRST, BEFORE TOUCHING THE GRAMMAR. A fitted measure cell is evaluated at a CellKey context. In any view that aggregates over the split category -- Region on Page or None -- context.get(\"Region\") is None, and today Formula::filter falls straight through to aggregate_raw. So a categorical fit would SILENTLY DEGRADE to something else in exactly the views a pivot-table user spends most of their time in. That is a product decision about what a fit MEANS for an aggregated cell, not an emitter detail.\n\nSCOPE, once that is answered:\n * Expr::Is(Filter) evaluating to 1.0/0.0. This keeps the f64-only evaluator and avoids introducing string comparison into eval_expr. The primitive already exists in the right shape -- types.rs reads context.get(\u0026filter.category), the current cell own coordinate.\n * A grammar production plus tokenizer handling in the 1109-line parser.rs, with quoting rules for the category and item inside it (gotcha 34 -- one home, improvise_formula::quote).\n * Arms in BOTH evaluators: eval_expr and eval_expr_cached.\n * Coverage in the grammar-walking generator.\n * Thread Dataset::columns (the ColumnSpec/ColumnKind plan) into the emitter, so feature index -\u003e role is read off preprocess own plan. Do NOT reconstruct it: Spec::names is \u0026[\u0026str], and reconstructing index-\u003erole is a second implementation of preprocess expansion order, exactly the drift risk gotcha 41 names in another key.\n * .improv is free -- the writer emits Formula::raw.\n\nIT CLOSES ONLY A THIRD OF 8xhy EITHER WAY. A WHERE predicate covers the one-hots. col.missing means \"this record has no value for measure X\", a different vocabulary. The date sub-features (when.year, when.month) need date arithmetic improvise does not have at all. Those stay refused, or get their own tickets.\n\nPAIRS WITH improvise-vx3 (the fit-measure command), which is where a user would actually learn about the restriction.","notes":"USER RULING 2026-09-20: AGGREGATE THE RULE OVER THE MEMBERS. Route-then-aggregate, per record. The other three options -- Error(\"aggregated\"), refuse at fit time, and fall through to aggregate_raw -- are all rejected.\n\nTHE BEAD'S SCOPE HAS CHANGED AND THE TITLE NOW SAYS SO. It asked what a CATEGORICAL split means in a cell that aggregates over the split category. PR #25's review established the question has nothing to do with the split being categorical:\n\nA tree is fitted on individual RECORDS. A formula is evaluated at a CellKey, and in any aggregating cell evaluate_aggregated aggregates each Expr::Ref FIRST and then evaluates the IF. A linear formula survives that -- SUM(Revenue) - SUM(Cost) is SUM(Revenue - Cost) -- and a tree does not, because AGGREGATE-THEN-ROUTE IS NOT ROUTE-THEN-AGGREGATE. So EVERY fitted tree, numeric splits included, means one thing on a record row and something else one row up.\n\nIT IS NOT THEORETICAL: fit-measure Revenue on the repo's own shipped web/demo.improv is refused, naming Product=Espresso/Product=Latte. And because which features a champion picks is a property of the SEARCH, on a document with several categories the same command refuses or succeeds DEPENDING ON THE SEED. That is not a contract.\n\nWHAT THE RULING COSTS, stated plainly because it is the expensive answer and was chosen anyway: evaluate_aggregated deliberately does NOT iterate the underlying records for a formula cell -- it aggregates the refs and evaluates once. Routing per record means a formula cell must be able to ask for the records behind it and fold over them. That touches the evaluator, and it interacts with gotcha 9 (fixed-point recomputation) and with Model::formula_cache, which is where derived values live precisely so raw data and derived data stay separate.\n\nNOTE WHAT IT BUYS BESIDES CORRECTNESS: it is what a user means. A fitted rule applied to a group should say what the rule says about the members, summed -- not what the rule says about a fictional average member. The second is the thing that looks right and is wrong, which is the failure mode vx3's own ticket warned about ('a rule the user will believe').\n\nALSO NOTE: this ruling supersedes improvise-8xhy's remedy 1 (refuse at emit) as the END STATE, but not as the interim -- the refusal stays until this lands, or fit-measure silently produces the wrong number in aggregating views.\n\nTHE TWO THIRDS THIS STILL DOES NOT COVER, unchanged from the original text: col.missing means 'this record has no value for measure X', a different vocabulary; and the date sub-features (when.year, when.month) need date arithmetic improvise does not have at all. Those stay refused or get their own beads.","status":"open","priority":2,"issue_type":"feature","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T00:45:22Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:54:23Z","started_at":"2026-09-20T05:32:41Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-s1xw","title":"Multi-level grid labels on the wire -- the browser shows Food/Alice where the TUI shows two columns","description":"THE LAST THING KEEPING GridLayout INSIDE GridWidget, and a LIVE DIVERGENCE today, not a hypothetical. Found closing improvise-n10.\n\nGridCache::row_labels / col_labels are Vec\u003cString\u003e produced by GridLayout::row_label, which JOINS A MULTI-LEVEL TUPLE WITH A SLASH. That is exactly the rendering src/ui/grid.rs::two_row_categories_produce_cross_product_labels asserts must NOT appear (\"slash-joined labels should be gone\"). So the protocol carries a form of the labels THE TUI ITSELF CONSIDERS WRONG, and crates/improvise-wasm-client/src/render.rs renders precisely that form.\n\nConsequence: the browser grid has no multi-level headers, no repeat suppression, no groups and no collapse indicators, and shows Food/Alice where the TUI shows two header columns.\n\nSIX FACTS THE CACHE DOES NOT CARRY (exhaustive as of improvise-n10, by grep over the non-test body of src/ui/grid.rs):\n 1. number of header levels -- layout.row_cats / col_cats\n 2. multi-level label TUPLES -- layout.row_items (AxisEntry::DataItem(Vec\u003cString\u003e))\n 3. inline GROUP HEADER rows -- layout.row_items (AxisEntry::GroupHeader)\n 4. column GROUP SPANS -- layout.col_group_for(ci)\n 5. collapse indicator (down/right triangle) -- view.is_group_collapsed(cat, group)\n 6. records-mode column identity -- layout.record_col(ci). THIS ONE IS CORRECT TO KEEP OFF: it keys pending drill edits and is session-local by design.\n\nSo 1-5 are the gap.\n\nTWO CONSTRAINTS ON THE FIX:\n * IT IS A CO-ORDINATED CHANGE, NOT AN ADDITIVE ONE. row_labels/col_labels change TYPE, and improvise-wasm-client/src/render.rs consumes both fields, so the client updates in the same change.\n * DO NOT ADD row_levels BESIDE row_labels. Two answers to \"what are the labels\" is the disease this whole projection architecture exists to cure -- see improvise-edp, where the category panel was walked twice, two different ways.","status":"closed","priority":2,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T00:58:33Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:41:34Z","started_at":"2026-09-20T05:32:27Z","closed_at":"2026-09-20T07:41:34Z","close_reason":"Closed as ABSORBED BY improvise-anr9, verified explicitly rather than assumed. s1xw enumerated six facts the cache did not carry; PR #23 landed 1-5 (header levels, multi-level label tuples, inline group headers, column group spans, the collapse indicator) and 6 is record_col, which s1xw itself says is CORRECT to keep off -- session-local, keys pending drill edits. Both of its stated constraints were honoured: it was a co-ordinated type change (row_labels/col_labels changed TYPE and improvise-wasm-client/src/render.rs updated in the same change) and no row_levels was added beside row_labels. The live divergence it reported -- the browser drawing Food/Alice where the TUI draws two header columns -- is closed, and tests/grid_frame_agreement.rs now drives both front ends over one cache and compares what each draws.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-4zl7","title":"A fitted tree must route per record and aggregate the results, not route on the aggregate","description":"Successor to improvise-8xhy, which shipped remedy 1 (refuse at emit) as an explicit interim. This is the remedy that produces a formula A HUMAN WOULD HAVE WRITTEN, and it is the right end state -- but it was costed during 8xhy and it is much larger than it looks.\n\nANSWER THIS FIRST, BEFORE TOUCHING THE GRAMMAR. A fitted measure cell is evaluated at a CellKey context. In any view that aggregates over the split category -- Region on Page or None -- context.get(\"Region\") is None, and today Formula::filter falls straight through to aggregate_raw. So a categorical fit would SILENTLY DEGRADE to something else in exactly the views a pivot-table user spends most of their time in. That is a product decision about what a fit MEANS for an aggregated cell, not an emitter detail.\n\nSCOPE, once that is answered:\n * Expr::Is(Filter) evaluating to 1.0/0.0. This keeps the f64-only evaluator and avoids introducing string comparison into eval_expr. The primitive already exists in the right shape -- types.rs reads context.get(\u0026filter.category), the current cell own coordinate.\n * A grammar production plus tokenizer handling in the 1109-line parser.rs, with quoting rules for the category and item inside it (gotcha 34 -- one home, improvise_formula::quote).\n * Arms in BOTH evaluators: eval_expr and eval_expr_cached.\n * Coverage in the grammar-walking generator.\n * Thread Dataset::columns (the ColumnSpec/ColumnKind plan) into the emitter, so feature index -\u003e role is read off preprocess own plan. Do NOT reconstruct it: Spec::names is \u0026[\u0026str], and reconstructing index-\u003erole is a second implementation of preprocess expansion order, exactly the drift risk gotcha 41 names in another key.\n * .improv is free -- the writer emits Formula::raw.\n\nIT CLOSES ONLY A THIRD OF 8xhy EITHER WAY. A WHERE predicate covers the one-hots. col.missing means \"this record has no value for measure X\", a different vocabulary. The date sub-features (when.year, when.month) need date arithmetic improvise does not have at all. Those stay refused, or get their own tickets.\n\nPAIRS WITH improvise-vx3 (the fit-measure command), which is where a user would actually learn about the restriction.","acceptance_criteria":"A fitted measure evaluated at an aggregating cell routes PER RECORD and aggregates the results, rather than routing on the aggregate. fit-measure on web/demo.improv succeeds and the resulting formula means the same thing on a record row and one row up. The seed-dependence is gone: whether the command succeeds no longer depends on which features the search happened to pick. Note what this costs — evaluate_aggregated deliberately does not iterate the records behind a formula cell — and state how it interacts with gotcha 9's fixed point and Model::formula_cache. col.missing and the date sub-features remain out of scope.","notes":"USER RULING 2026-09-20: AGGREGATE THE RULE OVER THE MEMBERS. Route-then-aggregate, per record. The other three options -- Error(\"aggregated\"), refuse at fit time, and fall through to aggregate_raw -- are all rejected.\n\nTHE BEAD'S SCOPE HAS CHANGED AND THE TITLE NOW SAYS SO. It asked what a CATEGORICAL split means in a cell that aggregates over the split category. PR #25's review established the question has nothing to do with the split being categorical:\n\nA tree is fitted on individual RECORDS. A formula is evaluated at a CellKey, and in any aggregating cell evaluate_aggregated aggregates each Expr::Ref FIRST and then evaluates the IF. A linear formula survives that -- SUM(Revenue) - SUM(Cost) is SUM(Revenue - Cost) -- and a tree does not, because AGGREGATE-THEN-ROUTE IS NOT ROUTE-THEN-AGGREGATE. So EVERY fitted tree, numeric splits included, means one thing on a record row and something else one row up.\n\nIT IS NOT THEORETICAL: fit-measure Revenue on the repo's own shipped web/demo.improv is refused, naming Product=Espresso/Product=Latte. And because which features a champion picks is a property of the SEARCH, on a document with several categories the same command refuses or succeeds DEPENDING ON THE SEED. That is not a contract.\n\nWHAT THE RULING COSTS, stated plainly because it is the expensive answer and was chosen anyway: evaluate_aggregated deliberately does NOT iterate the underlying records for a formula cell -- it aggregates the refs and evaluates once. Routing per record means a formula cell must be able to ask for the records behind it and fold over them. That touches the evaluator, and it interacts with gotcha 9 (fixed-point recomputation) and with Model::formula_cache, which is where derived values live precisely so raw data and derived data stay separate.\n\nNOTE WHAT IT BUYS BESIDES CORRECTNESS: it is what a user means. A fitted rule applied to a group should say what the rule says about the members, summed -- not what the rule says about a fictional average member. The second is the thing that looks right and is wrong, which is the failure mode vx3's own ticket warned about ('a rule the user will believe').\n\nALSO NOTE: this ruling supersedes improvise-8xhy's remedy 1 (refuse at emit) as the END STATE, but not as the interim -- the refusal stays until this lands, or fit-measure silently produces the wrong number in aggregating views.\n\nTHE TWO THIRDS THIS STILL DOES NOT COVER, unchanged from the original text: col.missing means 'this record has no value for measure X', a different vocabulary; and the date sub-features (when.year, when.month) need date arithmetic improvise does not have at all. Those stay refused or get their own beads.","status":"open","priority":2,"issue_type":"feature","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T00:45:22Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:45:26Z","started_at":"2026-09-20T05:32:41Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-hwpi","title":"The browser shell fails quietly, and never reads a key","description":"Found by the improvise-cr3 code review (2026-09-19) by READING web/app.js. There is no browser and no headless DOM in this environment, so none of this is observed -- it is what the code says will happen, and it is filed rather than fixed because each item is a judgement call about the shell rather than a defect in the Rust.\n\nFOUR THINGS, in descending order of how much they would cost a person at a demo:\n\n1. NO KEYBOARD AT ALL. #grid carries tabindex=\"0\" but the page registers no keydown handler, so Client::on_key_event -- and the whole keys.rs classifier and the lockstep machinery behind it -- is UNREACHED from the browser. Consistent with improvise-bpq2 (ClientMsg has no Key variant server-side) but larger than it: even a LOCAL key, one that needs no round trip, does nothing. improvise-1ey acceptance (3) is \"arrow keys move the cursor locally with no round-trip\", and nothing in the page can do it.\n\n2. A MALFORMED FRAME IS QUIET. client.on_frame(event.data) returns Result\u003c_, JsError\u003e, which wasm-bindgen turns into a THROWN EXCEPTION. It is thrown inside the message listener with no try, so it surfaces as an uncaught error in the devtools console and the following repaint() is skipped. Nothing in the UI says so: the grid simply stops updating. A try/catch writing to el.status would make it visible.\n\n3. NO error LISTENER ON THE SOCKET. A connection that never opens leaves #status blank -- only close writes \"disconnected\". So \"server not running\" and \"still connecting\" look the same.\n\n4. close() RUNS TWICE ON COMMIT, benignly. Enter -\u003e close(true) -\u003e td.replaceChildren(original) removes the focused \u003cinput\u003e -\u003e blur fires -\u003e close(false). editing is already null so it is harmless (one extra repaint), but there is no re-entry guard and only a real browser would show it.\n\nNOTE WHAT MAKES THIS TICKET DIFFERENT: items 2-4 are the kind of thing a test would catch instantly and we have no way to run one. Before fixing them, consider whether the right first move is a headless DOM in CI (jsdom or playwright) -- otherwise the fixes are as unverified as the bugs. improvise-xf1 (verify OpfsFs against a real browser) wants the same harness.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T00:40:36Z","created_by":"Edward Langley","updated_at":"2026-09-20T02:29:50Z","closed_at":"2026-09-20T02:29:50Z","close_reason":"All four items done, plus a fifth the ticket could not have predicted.\n\n1. KEYDOWN WIRED -- a window listener feeding Client::on_key_event. preventDefault fires only when a frame was produced and no Ctrl/Alt is held, so browser shortcuts stay the browser's; metaKey is skipped entirely, matching BrowserKey::to_key_event dropping Meta so Cmd-R cannot arrive as a bare r. The inline editor stopPropagation keeps it out of the way, with an if (editing) return as belt and braces.\n2. MALFORMED FRAME -- try/catch around on_frame writing to #status, plus console.error with the offending frame. render_model() is guarded the same way; it also returns a Result.\n3. SOCKET error LISTENER -- 'cannot reach ... -- is improvise-ws-server running?', so that no longer reads like 'still connecting'.\n4. close() RE-ENTRY GUARD -- a closed flag in edit().\n\nTHE FIFTH, AND IT IS THE INTERESTING ONE: a worker whose start() REJECTS is caught INSIDE worker.js, so it fires NO error event on the main thread. The page would have sat on 'in-page worker - demo.improv' over an empty grid forever -- THIS TICKET'S EXACT DISEASE, REBUILT IN THE NEW DEPLOYMENT ON DAY ONE. The guard is a first-frame watchdog (8s, cleared by the first frame), and it deliberately covers BOTH transports rather than each peer's own failure modes, because 'the peer never answered the handshake' is the one condition that means the same thing over a socket and over a port -- a check aimed per-peer would have missed exactly this one.\n\nKEYS ARE INERT AND THAT IS STATED, NOT HIDDEN. Host::handle answers ClientFrame::Key with an empty vec; improvise-bpq2 is the other half.\n\nSTILL UNRUN -- and this ticket predicted that would be the problem: 'consider whether the right first move is a headless DOM, otherwise the fixes are as unverified as the bugs.' That sentence is now true OF THESE FIXES. Three of the four are one-assertion Playwright tests. See the harness ticket.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-zv4m","title":"improvise-protocol should own the websocket framing, so both ends stop mirroring it","description":"Raised by improvise-cr3 (2026-09-19) as the thing to push back on, and filed rather than fixed there.\n\nTODAY: improvise-wasm-client/src/socket.rs declares ClientFrame/ServerFrame, which MIRROR improvise-ws-server/src/wire.rs ClientMsg/ServerMsg field for field. The client cannot depend on the server crate -- tokio, tungstenite, Box\u003cdyn Storage\u003e, all native -- so the duplication was the only way to speak the protocol from wasm.\n\nWHAT HOLDS THE LINE MEANWHILE: improvise-ws-server is a DEV-dependency of the client (the same argument the crate already makes for improvise-command), and the_socket_framing_matches_the_server round-trips every variant THROUGH THE SERVERS OWN TYPES in both directions; a_frame_named_the_other_envelopes_way_does_not_parse is its check-the-checker. So a drift is caught -- but only by a test whose job is to notice, rather than by a shared definition that cannot drift.\n\nTHE FIX: move the framing into improvise-protocol, which both ends already depend on and which is the crate whose charter is exactly this -- \"the wire vocabulary and the portable session slice\". Then the mirror, the dev-dependency and both compatibility tests all delete.\n\nNOTE THE THIRD ENVELOPE while you are in here: improvise-wasm-client/src/wire.rs has ClientMessage/ServerMessage framing the WORKER deployment (improvise-tm6). Three envelopes for two deployments is worth resolving in one pass -- decide whether the worker and the socket want the same vocabulary before moving either.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T00:37:29Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:37:50Z","closed_at":"2026-09-20T00:37:50Z","close_reason":"Duplicate of improvise-xks6 ('One home for the client/server frame envelopes'), filed minutes apart from the improvise-cr3 hand-back without spotting the existing ticket. Closing this one; xks6 is the survivor and its description should absorb two facts this one carried:\n\n * WHAT HOLDS THE LINE MEANWHILE: improvise-ws-server is a DEV-dependency of improvise-wasm-client, and the_socket_framing_matches_the_server round-trips every variant THROUGH THE SERVERS OWN TYPES in both directions, with a_frame_named_the_other_envelopes_way_does_not_parse as its check-the-checker. The drift is caught by a test whose job is to notice, rather than prevented by a shared definition.\n * THERE ARE THREE ENVELOPES, NOT TWO: improvise-wasm-client/src/wire.rs frames the WORKER deployment (improvise-tm6) separately again. Decide whether the worker and the socket want the same vocabulary before moving either, or the move produces two homes instead of one.\n\n(See also memory agent-issue-drift-pattern -- search before filing.)","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-8xhy","title":"[bug] A fit over a categorical feature emits an unevaluable formula","description":"Found during improvise-7na (2026-09-19), verified by probe, and pinned as a characterization test rather than fixed.\n\ngp_tree::data::preprocess one-hots a categorical column into a feature named Region=North. That is an ENCODING ARTEFACT, not an item of the document, so the emitted formula Expr::Ref fails Model::find_item_category and EVERY CELL of the fitted measure evaluates to Error(\"ref:Region=North\"). col.missing and the col.year / col.month date sub-features are in exactly the same position.\n\nSo \"fit a tree and get a working formula\" holds ONLY for a fit over numeric measures.\n\nTHE TEST THAT SHOULD HAVE CAUGHT IT WAS LYING: improvise_evaluation_matches_gp_tree_eval cannot see this, because its improvise_eval helper FABRICATES a _Measure item for every feature name, including outlook=rainy. It asserts the emitter and gp_tree::eval agree, which they do -- against a model that could not exist. Worth remembering as a shape: a helper that builds the world the code wants is not a test of whether that world exists.\n\nPinned as a_split_on_a_one_hot_feature_does_not_evaluate -- characterization, flip it when fixed.\n\nTHREE REMEDIES, pick deliberately: restrict fits to numeric features (smallest, and honest about what works); materialise one-hot columns as real _Measure items (makes the formula evaluable but pollutes the document with encoding artefacts); or teach the emitter improvise WHERE vocabulary so a categorical split becomes a real predicate over the category (largest, and the only one that produces a formula a human would have written). The third is probably right and should be costed before the first is shipped as permanent.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T00:35:05Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:45:21Z","closed_at":"2026-09-20T00:45:21Z","close_reason":"Fixed as REMEDY 1, explicitly labelled interim. fitted_formula now takes the \u0026Model the formula is to be stored in and refuses a tree whose splits it cannot resolve, listing them. formula_text is untouched -- it is the tree's own text and there is nothing else it could write. Nothing outside the crate called fitted_formula.\n\nWHY THE EMITTER AND NOT THE DATASET LAYER: dropping categorical columns before the fit is smaller, but it throws away real signal (permutation importance, and any future remedy-3 emitter) and makes the crate's halves disagree -- one-hot encoding is gp-tree's whole job. The defect is that the EMITTER spells a formula the document cannot evaluate.\n\nWHY THE CHECK ASKS THE DOCUMENT, NOT THE NAME'S SHAPE: refusing names containing '=' or matching .missing would be a syntactic proxy and a wrong one, because a measure LITERALLY NAMED Region=North is a legal document and must be accepted. The two are indistinguishable as strings; only the document can tell them apart. So it calls Model::find_item_category -- the exact precondition Expr::Ref has -- which covers .missing, the date parts, and any future sub-feature naming scheme with no second copy of preprocess's expansion rules to drift from. Both directions pinned.\n\nTHE LYING TEST IS KEPT, NOT DELETED. Quoting and threshold routing are real things improvise_evaluation_matches_gp_tree_eval checks, and outlook=rainy earns its place there as an awkward NAME TO SPELL. What changed is that it and its helper now state their scope -- a test of arithmetic and quoting against a STIPULATED model, unable to see whether such a model could exist. The property it could not state is asserted separately against a real document: a_formula_the_emitter_accepted_evaluates_everywhere -- if fitted_formula says Ok, no cell of the target is a ref: error. That test and the refusal test are each other's check; without the accepting case the refusal could pass by refusing everything. The characterization is flipped to a_split_on_a_one_hot_feature_is_refused.\n\nREMEDY 3 IS STILL RIGHT AND IS NOT THIS TICKET -- successor filed. It is much larger than it looked: improvise has NO SCALAR COORDINATE PREDICATE (Filter gates a formula or an aggregate, it is not an Expr), it would still leave col.missing and the date parts uncovered, and it must first answer what such a predicate means where context.get(cat) is None.","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -157,16 +157,16 @@ {"_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-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","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:29:28Z","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","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:16:28Z","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","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:16:26Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-xps8","title":"The handshake watchdog overwrites a genuine mid-session disconnect eight seconds later","description":"Found by improvise-vvl3's harness (PR #24) -- the first time web/app.js had ever been executed -- and it is a THIRD bug in the same family, distinct from both of the ones already dealt with.\n\nTHE SEQUENCE, measured rather than argued:\n\n open-then-close: connecting... -\u003e connected -\u003e disconnected\n -\u003e \"accepted the connection but sent no document\" \u003c- EIGHT SECONDS LATER\n\nOn the open-then-close arm the handshake watchdog IS armed (armWatchdog runs after transport.ready, which resolves on `open`), and nothing disarms it when the socket later closes. So a genuine mid-session disconnect is correctly reported, and then silently replaced eight seconds later by a message that is FALSE -- the peer did not fail to answer a handshake, it answered and then went away.\n\nIT SURVIVES improvise-taxx'S FIX, confirmed rather than assumed: the harness simulated the `opened` flag and the assertion still fails. taxx distinguishes never-connected from was-connected-and-dropped in the CLOSE listener; this is the TIMER overwriting whatever that listener concluded. Different mechanism, same surface.\n\nIT IS THE SAME CLASS AS THE BUG setShellFailure ALREADY FIXED -- a timer firing later and replacing a more precise message (gotcha 48, \"a watchdog must be disarmed, not pre-empted\"). The fix for that one cleared the timer on a LOUD failure. This arm is not loud: a close after a successful open is an ordinary event, not an error, so it never reaches setShellFailure. The likely answer is that the close listener disarms the watchdog on the opened path, since once a socket has opened the handshake question is settled either way -- but check whether any path legitimately wants the watchdog to survive a close before assuming it.\n\nWHY IT IS NOT A CI GUARD, and this is the part worth reading before trying to test it: the harness shipped an assertion for this and HAD TO REMOVE IT. It reproduces on macOS/Chromium and NOT on the GitHub runner, because the trigger is the ORDER of error/close/timer on the host's network stack. A Playwright test.fail is green when the bug is present and red when it is absent, so an expected-failure whose trigger is platform-dependent does not merely flake -- IT INVERTS CI'S MEANING, and it failed the browser job twice reporting \"expected to fail, but passed\". See doctrine(an_expected_failure_must_not_be_platform_dependent). The measurement and the reproduction recipe are recorded in harness/tests/shell.spec.js.\n\nNOTE THE SAME CAVEAT NOW APPLIES TO improvise-taxx: its bug is also only locally reproducible, which is why the two assertions the fix owed could not be shipped as guards either. The fix is still right; it is the pinning that is unavailable.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T06:44:26Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:44:26Z","dependencies":[{"issue_id":"improvise-xps8","depends_on_id":"improvise-taxx","type":"relates-to","created_at":"2026-09-20T06:44:28Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-l8rh","title":"The ambiguous-date heuristic reads American-first, so a European CSV imports with days and months swapped","description":"Named and pinned by WU-L on PR #22 (improvise-7yx item 2), and left UNCHANGED by the user's ruling 2026-09-19: leave it pinned, file it as a known limit.\n\nTHE BEHAVIOUR: import/analyzer.rs::detect_date_format is a FIRST-MATCH heuristic, so for a column of values that fit both %m/%d/%Y and %d/%m/%Y -- \"01/02/2025\", \"03/04/2025\" -- the AMERICAN reading wins. A European CSV therefore imports with days and months SWAPPED, silently, and every date in the column is wrong in a way that still looks like a date.\n\nWHY IT IS TRACKED RATHER THAN FIXED, and this is the argument that carried:\n * The import wizard SHOWS the detected format and lets the user override it, so the mis-read is visible at the point of import to a user who looks. That is the same argument detect_short_year_format already recorded for its own guess.\n * Changing the default to day-first only moves which half of the world imports silently wrong.\n * The honest fix is larger than the heuristic: an AMBIGUOUS column should surface its ambiguity rather than resolve it, which changes the analyzer's return shape (FieldProposal) and the wizard step that displays it. Worth doing deliberately, not as a patch.\n\nWHAT PR #22 DID CHANGE, and it is why this is now a stable known limit rather than a lottery: analyze_records used to iterate a HashSet, so the ten samples detect_date_format looks at were a DIFFERENT TEN EVERY RUN -- meaning a column whose eleventh value rules out %m/%d/%Y detected a different format run to run. Same CSV, same import, different dates. That is fixed (first-seen order, invariant(analyzer_first_seen_order)), so the heuristic is now WRONG DETERMINISTICALLY, which is the precondition for anyone being able to reason about it at all.\n\nPINNED BY: an_ambiguous_date_is_read_american_first, whose doc-comment states plainly that this silently mis-reads a European CSV, with the day-first reading asserted to parse so the test is a statement about AMBIGUITY rather than about parsing. a_day_first_only_value_settles_the_ambiguity is the discriminating other arm.\n\nSTILL TRUE AND UNCHANGED: the ten-sample window itself. A column whose first ten values are ambiguous and whose eleventh is not will still be read american-first. Now deterministic, still a limit.\n","status":"open","priority":3,"issue_type":"decision","owner":"el-github@elangley.org","created_at":"2026-09-20T06:28:43Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:28:43Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-rw3a","title":"Decide whether ZZ routes into the save prompt automatically","description":"The half of improvise-lae that was deliberately left open. PR #27 built the prompt (AppMode::SavePrompt, TextEntry builder, committing to SaveAs, bound Ctrl-W and reachable as `:enter-mode save-prompt`) and stopped short of making ZZ route into it.\n\nTODAY, after PR #27: ZZ on a pathless document refuses, stays in the app, and the status line names the prompt. That is already better than improvise-8tk left it -- the user is told where to go. The open question is whether being told is enough, or whether the vim reflex should just land somewhere useful.\n\nTHE OBSTACLE THE ORIGINAL TICKET NAMED, still true: CmdContext does not expose file_path, so a Cmd cannot currently decide \"is there a path\" at execute time. Adding the field is small; note improvise-2ud is separately proposing that CmdContext borrow the ModelState/ViewState slices rather than copying ~20 individual fields, which would make this free. Coordinate rather than adding a 21st copied field ahead of it.\n\nTHE CONSTRAINT PR #27 ESTABLISHED, and it must be respected by whatever lands here: the prompt is SESSION-LOCAL and was deliberately kept in the COMMAND layer rather than routed through effect::Save. Save is becoming a wire command (improvise-368h), and a prompt is addressed to precisely one session -- routing it through the effect would open a prompt on a host nobody asked, which is gotcha 38's argument for keeping AcceptWizardModel off the wire. So: a Cmd that inspects the path and emits a mode change, never an effect that decides to prompt.\n\nDECIDE ALSO what ZZ means once the prompt is open. ZZ is \"save and quit\"; if the prompt commits to SaveAs and the batch was aborted, the quit is gone. Should committing the path then quit? That is a second composition and the answer is not obviously yes -- a user who hit ZZ, got a prompt, and typed a path plausibly wants to leave, but making the prompt's Enter mode-dependent is the kind of hidden state improvise-ct5 just declined to add for `i`.\n","status":"open","priority":3,"issue_type":"decision","owner":"el-github@elangley.org","created_at":"2026-09-20T06:04:32Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:04:32Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-g9jb","title":"The text-entry mode list is hand-written in two crates and checked against AppMode in neither","description":"Found by WU-G on PR #27 while adding AppMode::SavePrompt.\n\nThe set of text-entry modes is written down BY HAND IN TWO PLACES, in two crates:\n * improvise-command::keymap::tests::text_entry_modes_with_exits\n * improvise-wasm-client::keys::tests::text_entry_modes\n\nand NOTHING CHECKS EITHER AGAINST AppMode. So adding a text-entry variant compiles, passes, and is silently absent from both lists -- which matters more since PR #27, because the browser now refuses to ENTER a prompt it cannot commit (keys.rs::strands_the_user), and that refusal is driven off one of these hand-written lists.\n\nTHE FAILURE MODE IS THE QUIET ONE: a new prompt mode omitted from the wasm list is one a browser will happily open and then swallow the user's typing in -- doctrine(a_doorway_to_a_black_hole_is_worse_than_the_key), which PR #27 established against SavePrompt and found ExportPrompt had already.\n\nTHE FIX: derive the list once, from AppMode itself. `MinibufferConfig` is already the thing that makes a variant text-entry -- AppMode::minibuffer() is an exhaustive match returning Option\u003cMinibufferConfig\u003e (gotcha 11), so \"the text-entry modes\" is derivable rather than enumerable. A const or a function in improvise-protocol beside AppMode, with both crates reading it, replaces both lists.\n\nCHECK ONE THING BEFORE ASSUMING THE DERIVATION IS FREE: the wasm list and the command list may not be the same set for a reason -- SearchMode is hand-written on purpose and has no commit key (gotcha 11), and the browser's question (\"can this client commit here\") is not identical to the keymap's (\"does this mode take text\"). If they genuinely differ, the answer is two derived predicates over one enum, not two hand-written lists.\n\ndoctrine(a_hand_enumerated_mirror_needs_an_exhaustive_match) already covers this; this is an instance with no match behind it at all.\n","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T06:04:22Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:04:22Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-z05r","title":"CommitExport does not bail on an empty path, and ExportCsv does not abort the batch","description":"Found by WU-G on PR #27 while fixing the save arms, and left deliberately: these are the same two defects one file over, at lower severity, and fixing them in that PR would have widened a save-focused change into an export-focused one.\n\nTWO ARMS, both mirroring what was just fixed for save:\n 1. CommitExport does not bail on an empty path, where CommitBuffer's three kinds all do (gotcha 20: buffer -\u003e trim -\u003e bail-on-empty -\u003e dirty -\u003e status is the shared spine).\n 2. ExportCsv does not set abort_effects on failure, where Save and (as of PR #27) SaveAs both do. So a `Binding::Sequence` or a `:` line composing an export with anything after it continues past a failed write.\n\nWHY IT IS LOWER SEVERITY THAN THE SAVE ARMS, and this is the honest reason it was deferred rather than a claim that it does not matter: an export failure discards nothing. improvise-8tk was a P1 because ZZ quit and the model was gone. Here the model is untouched and the user has simply not got the CSV they asked for -- so the cost is a missing file and a message, not lost work.\n\nNOTE THE SHAPE, because it is the third instance in this area: improvise-8tk fixed Save and not SaveAs; PR #27 found SaveAs; and this is the export arm of the same rule. doctrine(one_arm_of_a_symmetric_rule_is_evidence_about_that_arm_only) exists for exactly this, and the general fix may be better than the third patch -- consider whether \"an effect that writes a file and fails aborts the batch\" can be made structural rather than remembered three times.\n\nALSO CHECK, while in there: whether the export error is visible at all, given that ExportPrompt is a minibuffer mode and a status message written while a minibuffer is open is never drawn (see the sibling bead on draw_bottom_bar).\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T06:04:21Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:04:21Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-44ad","title":"ColWidths is redundant under the label fold, and the test that would change is gotcha 33(c)'s evidence","description":"Found while closing improvise-hkt6 (PR #20, WU-K), tried, and DELIBERATELY REVERTED rather than landed -- filed so it is a decision rather than a rediscovery.\n\nhkt6(b) obliged grid_updates to diff `totals` against the LABEL-FOLDED cache rather than against the mirror (see gotcha 33). The symmetric move for `col_widths` looks obviously right and is not free: it BREAKS `a_client_on_a_socket_is_seeded_and_gets_its_edit_back`.\n\nWHY, and it is a real property rather than a brittle assertion: folding the labels RE-FITS the widths (gotcha 33(c) -- every arm that touches labels or widths re-fits, because a renderer zips widths with labels and a length mismatch indexes off the end). So under a fold-relative diff the ColWidths that a reframe currently carries becomes REDUNDANT and is suppressed. The client is correct either way; what changes is whether the reframe still SENDS the widths.\n\nTHE REASON IT WAS NOT SPENT IN PR #20: that assertion is gotcha 33(c)'s standing evidence -- it is the test that catches \"a late ColWidths put the mismatch straight back\". Rewriting it inside a PR whose subject was three totality gaps would have destroyed the evidence for a rule while fixing something else, which is exactly the shape improvise-q0vb was declined for on PR #12. Two right changes, different costs.\n\nTHE DECISION TO MAKE:\n * Leave widths diffed against the mirror. Costs one redundant ColWidths per reframe -- the same direction Dirty::of is deliberately sloppy in (gotcha 35: over-report pays one redundant Projection, under-report leaves a client wrong forever). Defensible and arguably correct on that principle alone.\n * Or move widths to the fold and REPLACE the socket assertion with one that still pins 33(c)'s property -- that a client never holds widths of a different length from its labels -- rather than one that pins the message count. That is the better test either way, and writing it is most of the work.\n\nPREFER THE SECOND ONLY IF the replacement test is written first and shown to fail against a deliberately late ColWidths. Otherwise prefer the first and say so in gotcha 33, so the asymmetry between totals and widths is STATED rather than discovered by the next person who notices it.\n","status":"open","priority":3,"issue_type":"decision","owner":"el-github@elangley.org","created_at":"2026-09-20T05:54:56Z","created_by":"Edward Langley","updated_at":"2026-09-20T05:54:56Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-j7v6","title":"The typed-string classification lives in two crates, and its guard test asserts by hand","description":"Named by improvise-r4lo (PR #21) while deleting the OTHER duplication, and deliberately left rather than folded in -- r4lo was about two answers on the CLIENT side, and deleting Client::set_cell/set_cell_at settled that. This is the duplication one level out, and it crosses a crate boundary.\n\nwasm-client/src/socket.rs::typed classifies a user-typed string into a CellValue. So does improvise-command's commit_regular_cell_value, on the host's own key path. Two implementations of \"what does this string mean\", in two crates, with nothing holding them together.\n\nBOTH ARE LEGITIMATE, which is why this is not simply r4lo again. The browser has no App to ask and ModelCommand::SetCell takes a CellValue, so somebody on the client side must decide; a host typing locally has an App and classifies there. The defect is that the RULE is written twice, not that it is applied in two places.\n\nTHE TEST THAT LOOKS LIKE A GUARD AND IS NOT: a_typed_string_is_classified_as_the_host_classifies_it states the obligation IN ITS NAME and does not discharge it -- it asserts three outcomes BY HAND rather than comparing against the host's function. So the two can drift and the test stays green, which is this repository's characteristic defect (doctrine(check_the_checker)): a test that passes while the thing it names is broken.\n\nTHE FIX: one classification function in improvise-protocol, which both crates already depend on, and both call sites calling it. Then the test either disappears (type identity replaces it) or becomes a real comparison. Note doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished) -- if the shared function lands and the hand-written three-outcome test survives beside it, the consolidation is not done.\n\nWATCH ONE THING WHILE MOVING IT: the classification must stay consistent with what the .improv format considers a number versus pipe-quoted text (gotcha 34, and format.rs::pipe_quote exists precisely so a text cell cannot read back as a number). A shared classifier that disagrees with the persistence layer would be a third answer wearing the badge of the first.\n","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T05:45:29Z","created_by":"Edward Langley","updated_at":"2026-09-20T05:45:29Z","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} +{"_type":"issue","id":"improvise-xps8","title":"The handshake watchdog overwrites a genuine mid-session disconnect eight seconds later","description":"Found by improvise-vvl3's harness (PR #24) -- the first time web/app.js had ever been executed -- and it is a THIRD bug in the same family, distinct from both of the ones already dealt with.\n\nTHE SEQUENCE, measured rather than argued:\n\n open-then-close: connecting... -\u003e connected -\u003e disconnected\n -\u003e \"accepted the connection but sent no document\" \u003c- EIGHT SECONDS LATER\n\nOn the open-then-close arm the handshake watchdog IS armed (armWatchdog runs after transport.ready, which resolves on `open`), and nothing disarms it when the socket later closes. So a genuine mid-session disconnect is correctly reported, and then silently replaced eight seconds later by a message that is FALSE -- the peer did not fail to answer a handshake, it answered and then went away.\n\nIT SURVIVES improvise-taxx'S FIX, confirmed rather than assumed: the harness simulated the `opened` flag and the assertion still fails. taxx distinguishes never-connected from was-connected-and-dropped in the CLOSE listener; this is the TIMER overwriting whatever that listener concluded. Different mechanism, same surface.\n\nIT IS THE SAME CLASS AS THE BUG setShellFailure ALREADY FIXED -- a timer firing later and replacing a more precise message (gotcha 48, \"a watchdog must be disarmed, not pre-empted\"). The fix for that one cleared the timer on a LOUD failure. This arm is not loud: a close after a successful open is an ordinary event, not an error, so it never reaches setShellFailure. The likely answer is that the close listener disarms the watchdog on the opened path, since once a socket has opened the handshake question is settled either way -- but check whether any path legitimately wants the watchdog to survive a close before assuming it.\n\nWHY IT IS NOT A CI GUARD, and this is the part worth reading before trying to test it: the harness shipped an assertion for this and HAD TO REMOVE IT. It reproduces on macOS/Chromium and NOT on the GitHub runner, because the trigger is the ORDER of error/close/timer on the host's network stack. A Playwright test.fail is green when the bug is present and red when it is absent, so an expected-failure whose trigger is platform-dependent does not merely flake -- IT INVERTS CI'S MEANING, and it failed the browser job twice reporting \"expected to fail, but passed\". See doctrine(an_expected_failure_must_not_be_platform_dependent). The measurement and the reproduction recipe are recorded in harness/tests/shell.spec.js.\n\nNOTE THE SAME CAVEAT NOW APPLIES TO improvise-taxx: its bug is also only locally reproducible, which is why the two assertions the fix owed could not be shipped as guards either. The fix is still right; it is the pinning that is unavailable.\n\n#\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\nS\n\ne\n\nr\n\nv\n\ne\n\n\n\nt\n\nh\n\ne\n\n\n\np\n\na\n\ng\n\ne\n\n\n\na\n\nn\n\nd\n\n\n\np\n\no\n\ni\n\nn\n\nt\n\n\n\ni\n\nt\n\n\n\na\n\nt\n\n\n\na\n\n\n\nr\n\ne\n\na\n\nc\n\nh\n\na\n\nb\n\nl\n\ne\n\n\n\n`\n\ni\n\nm\n\np\n\nr\n\no\n\nv\n\ni\n\ns\n\ne\n\n-\n\nw\n\ns\n\n-\n\ns\n\ne\n\nr\n\nv\n\ne\n\nr\n\n`\n\n\n\ns\n\no\n\n\n\nt\n\nh\n\ne\n\n\n\ns\n\no\n\nc\n\nk\n\ne\n\nt\n\n\n\na\n\nc\n\nt\n\nu\n\na\n\nl\n\nl\n\ny\n\n\n\nO\n\nP\n\nE\n\nN\n\nS\n\n\n\n(\n\ns\n\nt\n\na\n\nt\n\nu\n\ns\n\n\n\nr\n\ne\n\na\n\nd\n\ns\n\n\n\n`\n\nc\n\no\n\nn\n\nn\n\ne\n\nc\n\nt\n\ne\n\nd\n\n`\n\n)\n\n.\n\n\n\n2\n\n.\n\n\n\nS\n\nt\n\no\n\np\n\n\n\nt\n\nh\n\ne\n\n\n\ns\n\ne\n\nr\n\nv\n\ne\n\nr\n\n,\n\n\n\no\n\nr\n\n\n\no\n\nt\n\nh\n\ne\n\nr\n\nw\n\ni\n\ns\n\ne\n\n\n\nd\n\nr\n\no\n\np\n\n\n\nt\n\nh\n\ne\n\n\n\nc\n\no\n\nn\n\nn\n\ne\n\nc\n\nt\n\ni\n\no\n\nn\n\n\n\nm\n\ni\n\nd\n\n-\n\ns\n\ne\n\ns\n\ns\n\ni\n\no\n\nn\n\n.\n\n\n\n3\n\n.\n\n\n\nT\n\nh\n\ne\n\n\n\ns\n\nt\n\na\n\nt\n\nu\n\ns\n\n\n\nl\n\ni\n\nn\n\ne\n\n\n\nr\n\ne\n\na\n\nd\n\ns\n\n\n\n`\n\nd\n\ni\n\ns\n\nc\n\no\n\nn\n\nn\n\ne\n\nc\n\nt\n\ne\n\nd\n\n`\n\n\n\n—\n\n\n\nc\n\no\n\nr\n\nr\n\ne\n\nc\n\nt\n\n.\n\n\n\n4\n\n.\n\n\n\nW\n\na\n\ni\n\nt\n\n\n\ne\n\ni\n\ng\n\nh\n\nt\n\n\n\ns\n\ne\n\nc\n\no\n\nn\n\nd\n\ns\n\n.\n\n\n\nT\n\nh\n\ne\n\n\n\nh\n\na\n\nn\n\nd\n\ns\n\nh\n\na\n\nk\n\ne\n\n\n\nw\n\na\n\nt\n\nc\n\nh\n\nd\n\no\n\ng\n\n,\n\n\n\na\n\nr\n\nm\n\ne\n\nd\n\n\n\no\n\nn\n\n\n\no\n\np\n\ne\n\nn\n\n\n\na\n\nn\n\nd\n\n\n\nn\n\ne\n\nv\n\ne\n\nr\n\n\n\nd\n\ni\n\ns\n\na\n\nr\n\nm\n\ne\n\nd\n\n,\n\n\n\nr\n\ne\n\np\n\nl\n\na\n\nc\n\ne\n\ns\n\n\n\ni\n\nt\n\n\n\nw\n\ni\n\nt\n\nh\n\n\n\n`\n\na\n\nc\n\nc\n\ne\n\np\n\nt\n\ne\n\nd\n\n\n\nt\n\nh\n\ne\n\n\n\nc\n\no\n\nn\n\nn\n\ne\n\nc\n\nt\n\ni\n\no\n\nn\n\n\n\nb\n\nu\n\nt\n\n\n\ns\n\ne\n\nn\n\nt\n\n\n\nn\n\no\n\n\n\nd\n\no\n\nc\n\nu\n\nm\n\ne\n\nn\n\nt\n\n`\n\n.\n\n\n\n\n\nR\n\ne\n\np\n\nr\n\no\n\nd\n\nu\n\nc\n\ne\n\ns\n\n\n\no\n\nn\n\n\n\nm\n\na\n\nc\n\nO\n\nS\n\n/\n\nC\n\nh\n\nr\n\no\n\nm\n\ni\n\nu\n\nm\n\n.\n\n\n\nD\n\no\n\ne\n\ns\n\n\n\nN\n\nO\n\nT\n\n\n\nr\n\ne\n\np\n\nr\n\no\n\nd\n\nu\n\nc\n\ne\n\n\n\no\n\nn\n\n\n\nt\n\nh\n\ne\n\n\n\nG\n\ni\n\nt\n\nH\n\nu\n\nb\n\n\n\nr\n\nu\n\nn\n\nn\n\ne\n\nr\n\n,\n\n\n\nw\n\nh\n\ne\n\nr\n\ne\n\n\n\nt\n\nh\n\ne\n\n\n\no\n\nr\n\nd\n\ne\n\nr\n\n\n\no\n\nf\n\n\n\n`\n\ne\n\nr\n\nr\n\no\n\nr\n\n`\n\n/\n\n`\n\nc\n\nl\n\no\n\ns\n\ne\n\n`\n\n/\n\nt\n\ni\n\nm\n\ne\n\nr\n\n\n\no\n\nn\n\n\n\nt\n\nh\n\ne\n\n\n\nh\n\no\n\ns\n\nt\n\n'\n\ns\n\n\n\nn\n\ne\n\nt\n\nw\n\no\n\nr\n\nk\n\n\n\ns\n\nt\n\na\n\nc\n\nk\n\n\n\nd\n\ni\n\nf\n\nf\n\ne\n\nr\n\ns\n\n\n\n—\n\n\n\nw\n\nh\n\ni\n\nc\n\nh\n\n\n\ni\n\ns\n\n\n\nw\n\nh\n\ny\n\n\n\nt\n\nh\n\ni\n\ns\n\n\n\nc\n\na\n\nn\n\nn\n\no\n\nt\n\n\n\nb\n\ne\n\n\n\np\n\ni\n\nn\n\nn\n\ne\n\nd\n\n\n\na\n\ns\n\n\n\na\n\n\n\nC\n\nI\n\n\n\ng\n\nu\n\na\n\nr\n\nd\n\n.\n\n\n\nS\n\ne\n\ne\n\n\n\n`\n\nh\n\na\n\nr\n\nn\n\ne\n\ns\n\ns\n\n/\n\nt\n\ne\n\ns\n\nt\n\ns\n\n/\n\ns\n\nh\n\ne\n\nl\n\nl\n\n.\n\ns\n\np\n\ne\n\nc\n\n.\n\nj\n\ns\n\n`\n\n\n\nf\n\no\n\nr\n\n\n\nt\n\nh\n\ne\n\n\n\nm\n\ne\n\na\n\ns\n\nu\n\nr\n\ne\n\nm\n\ne\n\nn\n\nt\n\n.\n","acceptance_criteria":"The watchdog is disarmed when a socket that had OPENED subsequently closes, so a mid-session disconnect keeps its own message. The never-opened path is unchanged (the watchdog is the only thing that speaks there). Stated in the code why this is not a CI guard: the trigger is the host network stack's ordering of error/close/timer, so an expected-failure would invert CI's meaning rather than flake.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T06:44:26Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:45:10Z","dependencies":[{"issue_id":"improvise-xps8","depends_on_id":"improvise-taxx","type":"relates-to","created_at":"2026-09-20T06:44:28Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-l8rh","title":"The ambiguous-date heuristic reads American-first, so a European CSV imports with days and months swapped","description":"Named and pinned by WU-L on PR #22 (improvise-7yx item 2), and left UNCHANGED by the user's ruling 2026-09-19: leave it pinned, file it as a known limit.\n\nTHE BEHAVIOUR: import/analyzer.rs::detect_date_format is a FIRST-MATCH heuristic, so for a column of values that fit both %m/%d/%Y and %d/%m/%Y -- \"01/02/2025\", \"03/04/2025\" -- the AMERICAN reading wins. A European CSV therefore imports with days and months SWAPPED, silently, and every date in the column is wrong in a way that still looks like a date.\n\nWHY IT IS TRACKED RATHER THAN FIXED, and this is the argument that carried:\n * The import wizard SHOWS the detected format and lets the user override it, so the mis-read is visible at the point of import to a user who looks. That is the same argument detect_short_year_format already recorded for its own guess.\n * Changing the default to day-first only moves which half of the world imports silently wrong.\n * The honest fix is larger than the heuristic: an AMBIGUOUS column should surface its ambiguity rather than resolve it, which changes the analyzer's return shape (FieldProposal) and the wizard step that displays it. Worth doing deliberately, not as a patch.\n\nWHAT PR #22 DID CHANGE, and it is why this is now a stable known limit rather than a lottery: analyze_records used to iterate a HashSet, so the ten samples detect_date_format looks at were a DIFFERENT TEN EVERY RUN -- meaning a column whose eleventh value rules out %m/%d/%Y detected a different format run to run. Same CSV, same import, different dates. That is fixed (first-seen order, invariant(analyzer_first_seen_order)), so the heuristic is now WRONG DETERMINISTICALLY, which is the precondition for anyone being able to reason about it at all.\n\nPINNED BY: an_ambiguous_date_is_read_american_first, whose doc-comment states plainly that this silently mis-reads a European CSV, with the day-first reading asserted to parse so the test is a statement about AMBIGUITY rather than about parsing. a_day_first_only_value_settles_the_ambiguity is the discriminating other arm.\n\nSTILL TRUE AND UNCHANGED: the ten-sample window itself. A column whose first ten values are ambiguous and whose eleventh is not will still be read american-first. Now deterministic, still a limit.\n\n## Decision\n\nLeave the first-match heuristic as it is, and track it as a known limit. Ruled by the user 2026-09-20.\n\n## Rationale\n\nThe import wizard SHOWS the detected format and lets the user override it, so the mis-read is visible at the point of import to a user who looks — the same argument `detect_short_year_format` already recorded for its own guess.\n\nWhat made it worth tracking rather than ignoring is that PR #22 changed its character: `analyze_records` used to iterate a `HashSet`, so the ten samples `detect_date_format` inspects were a DIFFERENT ten every run, and a column whose eleventh value ruled out `%m/%d/%Y` detected a different format run to run. It is now wrong DETERMINISTICALLY, which is the precondition for anyone being able to reason about it at all.\n\n## Alternatives Considered\n\n**Change the default to day-first.** Rejected: it only moves which half of the world imports silently wrong.\n\n**Surface the ambiguity instead of resolving it.** The honest fix and larger than the heuristic — it changes `FieldProposal`'s shape and the wizard step that displays it. Worth doing deliberately rather than as a patch, which is why it is not folded in here.\n\n**Widen the ten-sample window.** Does not address ambiguity at all; a fully ambiguous column stays fully ambiguous.\n","status":"open","priority":3,"issue_type":"decision","owner":"el-github@elangley.org","created_at":"2026-09-20T06:28:43Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:45:41Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-rw3a","title":"Decide whether ZZ routes into the save prompt automatically","description":"The half of improvise-lae that was deliberately left open. PR #27 built the prompt (AppMode::SavePrompt, TextEntry builder, committing to SaveAs, bound Ctrl-W and reachable as `:enter-mode save-prompt`) and stopped short of making ZZ route into it.\n\nTODAY, after PR #27: ZZ on a pathless document refuses, stays in the app, and the status line names the prompt. That is already better than improvise-8tk left it -- the user is told where to go. The open question is whether being told is enough, or whether the vim reflex should just land somewhere useful.\n\nTHE OBSTACLE THE ORIGINAL TICKET NAMED, still true: CmdContext does not expose file_path, so a Cmd cannot currently decide \"is there a path\" at execute time. Adding the field is small; note improvise-2ud is separately proposing that CmdContext borrow the ModelState/ViewState slices rather than copying ~20 individual fields, which would make this free. Coordinate rather than adding a 21st copied field ahead of it.\n\nTHE CONSTRAINT PR #27 ESTABLISHED, and it must be respected by whatever lands here: the prompt is SESSION-LOCAL and was deliberately kept in the COMMAND layer rather than routed through effect::Save. Save is becoming a wire command (improvise-368h), and a prompt is addressed to precisely one session -- routing it through the effect would open a prompt on a host nobody asked, which is gotcha 38's argument for keeping AcceptWizardModel off the wire. So: a Cmd that inspects the path and emits a mode change, never an effect that decides to prompt.\n\nDECIDE ALSO what ZZ means once the prompt is open. ZZ is \"save and quit\"; if the prompt commits to SaveAs and the batch was aborted, the quit is gone. Should committing the path then quit? That is a second composition and the answer is not obviously yes -- a user who hit ZZ, got a prompt, and typed a path plausibly wants to leave, but making the prompt's Enter mode-dependent is the kind of hidden state improvise-ct5 just declined to add for `i`.\n\n## Decision\n\nOpen. This is the half of improvise-lae that was deliberately left undecided when the save prompt landed.\n\n## Rationale\n\nAfter PR #27, `ZZ` on a pathless document refuses, stays in the app, and the status line names the prompt. That is already better than improvise-8tk left it — the user is told where to go. The question is whether being told is enough, or whether the vim reflex should land somewhere useful by itself.\n\n## Alternatives Considered\n\n**Route `ZZ` into the prompt automatically.** Needs `CmdContext` to expose `file_path`, which it does not. improvise-2ud separately proposes that `CmdContext` borrow the ModelState/ViewState slices rather than copying ~20 fields, which would make this free — coordinate rather than adding a 21st copied field ahead of it.\n\n**Leave `ZZ` as it is.** The status line names `Ctrl-W`; the cost is one keystroke of discovery.\n\n**Constraint on any answer:** the prompt is SESSION-LOCAL and must stay in the command layer, never routed through `effect::Save`. Save is now a wire command (improvise-368h), and an effect that decides to prompt would open a prompt on a host nobody asked — gotcha 38's argument for keeping AcceptWizardModel off the wire.\n\n**Unanswered second question:** what `ZZ` means once the prompt is open. ZZ is save-AND-QUIT; if committing the path does not also quit, the reflex is only half honoured — but making the prompt's Enter mode-dependent is the hidden state improvise-ct5 just declined to add for `i`.\n","status":"open","priority":3,"issue_type":"decision","owner":"el-github@elangley.org","created_at":"2026-09-20T06:04:32Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:45:42Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-g9jb","title":"The text-entry mode list is hand-written in two crates and checked against AppMode in neither","description":"Found by WU-G on PR #27 while adding AppMode::SavePrompt.\n\nThe set of text-entry modes is written down BY HAND IN TWO PLACES, in two crates:\n * improvise-command::keymap::tests::text_entry_modes_with_exits\n * improvise-wasm-client::keys::tests::text_entry_modes\n\nand NOTHING CHECKS EITHER AGAINST AppMode. So adding a text-entry variant compiles, passes, and is silently absent from both lists -- which matters more since PR #27, because the browser now refuses to ENTER a prompt it cannot commit (keys.rs::strands_the_user), and that refusal is driven off one of these hand-written lists.\n\nTHE FAILURE MODE IS THE QUIET ONE: a new prompt mode omitted from the wasm list is one a browser will happily open and then swallow the user's typing in -- doctrine(a_doorway_to_a_black_hole_is_worse_than_the_key), which PR #27 established against SavePrompt and found ExportPrompt had already.\n\nTHE FIX: derive the list once, from AppMode itself. `MinibufferConfig` is already the thing that makes a variant text-entry -- AppMode::minibuffer() is an exhaustive match returning Option\u003cMinibufferConfig\u003e (gotcha 11), so \"the text-entry modes\" is derivable rather than enumerable. A const or a function in improvise-protocol beside AppMode, with both crates reading it, replaces both lists.\n\nCHECK ONE THING BEFORE ASSUMING THE DERIVATION IS FREE: the wasm list and the command list may not be the same set for a reason -- SearchMode is hand-written on purpose and has no commit key (gotcha 11), and the browser's question (\"can this client commit here\") is not identical to the keymap's (\"does this mode take text\"). If they genuinely differ, the answer is two derived predicates over one enum, not two hand-written lists.\n\ndoctrine(a_hand_enumerated_mirror_needs_an_exhaustive_match) already covers this; this is an instance with no match behind it at all.\n","acceptance_criteria":"The set of text-entry modes is DERIVED from AppMode rather than hand-written twice. AppMode::minibuffer() is already an exhaustive match returning Option\u003cMinibufferConfig\u003e, so the set is derivable. Before assuming one derivation serves both: check whether the keymap's question ('does this mode take text') and the browser's question ('can this client commit here') are actually the same set — SearchMode is hand-written on purpose and has no commit key. If they differ, the answer is two derived predicates over one enum, never two hand-written lists.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T06:04:22Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:45:25Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-z05r","title":"CommitExport does not bail on an empty path, and ExportCsv does not abort the batch","description":"Found by WU-G on PR #27 while fixing the save arms, and left deliberately: these are the same two defects one file over, at lower severity, and fixing them in that PR would have widened a save-focused change into an export-focused one.\n\nTWO ARMS, both mirroring what was just fixed for save:\n 1. CommitExport does not bail on an empty path, where CommitBuffer's three kinds all do (gotcha 20: buffer -\u003e trim -\u003e bail-on-empty -\u003e dirty -\u003e status is the shared spine).\n 2. ExportCsv does not set abort_effects on failure, where Save and (as of PR #27) SaveAs both do. So a `Binding::Sequence` or a `:` line composing an export with anything after it continues past a failed write.\n\nWHY IT IS LOWER SEVERITY THAN THE SAVE ARMS, and this is the honest reason it was deferred rather than a claim that it does not matter: an export failure discards nothing. improvise-8tk was a P1 because ZZ quit and the model was gone. Here the model is untouched and the user has simply not got the CSV they asked for -- so the cost is a missing file and a message, not lost work.\n\nNOTE THE SHAPE, because it is the third instance in this area: improvise-8tk fixed Save and not SaveAs; PR #27 found SaveAs; and this is the export arm of the same rule. doctrine(one_arm_of_a_symmetric_rule_is_evidence_about_that_arm_only) exists for exactly this, and the general fix may be better than the third patch -- consider whether \"an effect that writes a file and fails aborts the batch\" can be made structural rather than remembered three times.\n\nALSO CHECK, while in there: whether the export error is visible at all, given that ExportPrompt is a minibuffer mode and a status message written while a minibuffer is open is never drawn (see the sibling bead on draw_bottom_bar).\n\n#\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\n`\n\nC\n\no\n\nm\n\nm\n\ni\n\nt\n\nE\n\nx\n\np\n\no\n\nr\n\nt\n\n`\n\n\n\nw\n\ni\n\nt\n\nh\n\n\n\na\n\nn\n\n\n\ne\n\nm\n\np\n\nt\n\ny\n\n\n\np\n\na\n\nt\n\nh\n\n:\n\n\n\ni\n\nt\n\n\n\nd\n\no\n\ne\n\ns\n\n\n\nn\n\no\n\nt\n\n\n\nb\n\na\n\ni\n\nl\n\n,\n\n\n\nw\n\nh\n\ne\n\nr\n\ne\n\n\n\n`\n\nC\n\no\n\nm\n\nm\n\ni\n\nt\n\nB\n\nu\n\nf\n\nf\n\ne\n\nr\n\n`\n\n'\n\ns\n\n\n\nt\n\nh\n\nr\n\ne\n\ne\n\n\n\nk\n\ni\n\nn\n\nd\n\ns\n\n\n\na\n\nl\n\nl\n\n\n\nd\n\no\n\n\n\n(\n\ng\n\no\n\nt\n\nc\n\nh\n\na\n\n\n\n2\n\n0\n\n'\n\ns\n\n\n\ns\n\nh\n\na\n\nr\n\ne\n\nd\n\n\n\ns\n\np\n\ni\n\nn\n\ne\n\n\n\ni\n\ns\n\n\n\nb\n\nu\n\nf\n\nf\n\ne\n\nr\n\n\n\n→\n\n\n\nt\n\nr\n\ni\n\nm\n\n\n\n→\n\n\n\nb\n\na\n\ni\n\nl\n\n-\n\no\n\nn\n\n-\n\ne\n\nm\n\np\n\nt\n\ny\n\n\n\n→\n\n\n\nd\n\ni\n\nr\n\nt\n\ny\n\n\n\n→\n\n\n\ns\n\nt\n\na\n\nt\n\nu\n\ns\n\n)\n\n.\n\n\n\n2\n\n.\n\n\n\nC\n\no\n\nm\n\np\n\no\n\ns\n\ne\n\n\n\na\n\nn\n\n\n\ne\n\nx\n\np\n\no\n\nr\n\nt\n\n\n\nw\n\ni\n\nt\n\nh\n\n\n\na\n\nn\n\ny\n\nt\n\nh\n\ni\n\nn\n\ng\n\n\n\na\n\nf\n\nt\n\ne\n\nr\n\n\n\ni\n\nt\n\n\n\n—\n\n\n\na\n\n\n\n`\n\nB\n\ni\n\nn\n\nd\n\ni\n\nn\n\ng\n\n:\n\n:\n\nS\n\ne\n\nq\n\nu\n\ne\n\nn\n\nc\n\ne\n\n`\n\n,\n\n\n\no\n\nr\n\n\n\na\n\n\n\n`\n\n:\n\n`\n\n\n\nl\n\ni\n\nn\n\ne\n\n\n\no\n\nn\n\n\n\n`\n\n\n\n.\n\n\n\n`\n\n\n\n—\n\n\n\na\n\nn\n\nd\n\n\n\nm\n\na\n\nk\n\ne\n\n\n\nt\n\nh\n\ne\n\n\n\nw\n\nr\n\ni\n\nt\n\ne\n\n\n\nf\n\na\n\ni\n\nl\n\n.\n\n\n\n`\n\nE\n\nx\n\np\n\no\n\nr\n\nt\n\nC\n\ns\n\nv\n\n`\n\n\n\nd\n\no\n\ne\n\ns\n\n\n\nn\n\no\n\nt\n\n\n\ns\n\ne\n\nt\n\n\n\n`\n\na\n\nb\n\no\n\nr\n\nt\n\n_\n\ne\n\nf\n\nf\n\ne\n\nc\n\nt\n\ns\n\n`\n\n,\n\n\n\ns\n\no\n\n\n\nt\n\nh\n\ne\n\n\n\nc\n\nh\n\na\n\ni\n\nn\n\n\n\nc\n\no\n\nn\n\nt\n\ni\n\nn\n\nu\n\ne\n\ns\n\n\n\np\n\na\n\ns\n\nt\n\n\n\nt\n\nh\n\ne\n\n\n\nf\n\na\n\ni\n\nl\n\nu\n\nr\n\ne\n\n,\n\n\n\nw\n\nh\n\ne\n\nr\n\ne\n\n\n\n`\n\nS\n\na\n\nv\n\ne\n\n`\n\n\n\na\n\nn\n\nd\n\n\n\n(\n\ns\n\ni\n\nn\n\nc\n\ne\n\n\n\nP\n\nR\n\n\n\n#\n\n2\n\n7\n\n)\n\n\n\n`\n\nS\n\na\n\nv\n\ne\n\nA\n\ns\n\n`\n\n\n\nb\n\no\n\nt\n\nh\n\n\n\ns\n\nt\n\no\n\np\n\n\n\ni\n\nt\n\n.\n\n\n\n\n\nL\n\no\n\nw\n\ne\n\nr\n\n\n\ns\n\ne\n\nv\n\ne\n\nr\n\ni\n\nt\n\ny\n\n\n\nt\n\nh\n\na\n\nn\n\n\n\nt\n\nh\n\ne\n\n\n\ns\n\na\n\nv\n\ne\n\n\n\na\n\nr\n\nm\n\ns\n\n\n\nb\n\ne\n\nc\n\na\n\nu\n\ns\n\ne\n\n\n\na\n\nn\n\n\n\ne\n\nx\n\np\n\no\n\nr\n\nt\n\n\n\nf\n\na\n\ni\n\nl\n\nu\n\nr\n\ne\n\n\n\nd\n\ni\n\ns\n\nc\n\na\n\nr\n\nd\n\ns\n\n\n\nn\n\no\n\nt\n\nh\n\ni\n\nn\n\ng\n\n:\n\n\n\nt\n\nh\n\ne\n\n\n\nm\n\no\n\nd\n\ne\n\nl\n\n\n\ni\n\ns\n\n\n\nu\n\nn\n\nt\n\no\n\nu\n\nc\n\nh\n\ne\n\nd\n\n\n\na\n\nn\n\nd\n\n\n\nt\n\nh\n\ne\n\n\n\nu\n\ns\n\ne\n\nr\n\n\n\ns\n\ni\n\nm\n\np\n\nl\n\ny\n\n\n\nh\n\na\n\ns\n\n\n\nn\n\no\n\nt\n\n\n\ng\n\no\n\nt\n\n\n\nt\n\nh\n\ne\n\ni\n\nr\n\n\n\nC\n\nS\n\nV\n\n.\n","acceptance_criteria":"CommitExport bails on an empty path like the other three commit kinds, and ExportCsv aborts the batch on a failed write like Save and SaveAs. Prefer the structural fix over the third patch: this is the third instance of the same asymmetry, so consider whether 'an effect that writes a file and fails aborts the batch' can be made impossible to forget rather than remembered three times.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T06:04:21Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:45:14Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-44ad","title":"ColWidths is redundant under the label fold, and the test that would change is gotcha 33(c)'s evidence","description":"Found while closing improvise-hkt6 (PR #20, WU-K), tried, and DELIBERATELY REVERTED rather than landed -- filed so it is a decision rather than a rediscovery.\n\nhkt6(b) obliged grid_updates to diff `totals` against the LABEL-FOLDED cache rather than against the mirror (see gotcha 33). The symmetric move for `col_widths` looks obviously right and is not free: it BREAKS `a_client_on_a_socket_is_seeded_and_gets_its_edit_back`.\n\nWHY, and it is a real property rather than a brittle assertion: folding the labels RE-FITS the widths (gotcha 33(c) -- every arm that touches labels or widths re-fits, because a renderer zips widths with labels and a length mismatch indexes off the end). So under a fold-relative diff the ColWidths that a reframe currently carries becomes REDUNDANT and is suppressed. The client is correct either way; what changes is whether the reframe still SENDS the widths.\n\nTHE REASON IT WAS NOT SPENT IN PR #20: that assertion is gotcha 33(c)'s standing evidence -- it is the test that catches \"a late ColWidths put the mismatch straight back\". Rewriting it inside a PR whose subject was three totality gaps would have destroyed the evidence for a rule while fixing something else, which is exactly the shape improvise-q0vb was declined for on PR #12. Two right changes, different costs.\n\nTHE DECISION TO MAKE:\n * Leave widths diffed against the mirror. Costs one redundant ColWidths per reframe -- the same direction Dirty::of is deliberately sloppy in (gotcha 35: over-report pays one redundant Projection, under-report leaves a client wrong forever). Defensible and arguably correct on that principle alone.\n * Or move widths to the fold and REPLACE the socket assertion with one that still pins 33(c)'s property -- that a client never holds widths of a different length from its labels -- rather than one that pins the message count. That is the better test either way, and writing it is most of the work.\n\nPREFER THE SECOND ONLY IF the replacement test is written first and shown to fail against a deliberately late ColWidths. Otherwise prefer the first and say so in gotcha 33, so the asymmetry between totals and widths is STATED rather than discovered by the next person who notices it.\n\n## Rationale\n\nimprovise-hkt6 obliged `grid_updates` to diff `totals` against the LABEL-FOLDED cache rather than the mirror, or a rename that leaves the number equal sends no `Totals` and the client blanks its own. The symmetric move for `col_widths` looks obviously right and is not free: it breaks `a_client_on_a_socket_is_seeded_and_gets_its_edit_back`.\n\nThat is a real property, not a brittle assertion. Folding the labels RE-FITS the widths (gotcha 33(c)), so under a fold-relative diff the `ColWidths` a reframe currently carries becomes redundant and is suppressed. The client is correct either way; what changes is whether the reframe still SENDS them.\n\n## Alternatives Considered\n\n**Leave widths diffed against the mirror.** Costs one redundant `ColWidths` per reframe — the same direction `Dirty::of` is deliberately sloppy in (gotcha 35: over-report pays one redundant Projection, under-report leaves a client wrong forever). Defensible on that principle alone, and then the asymmetry between totals and widths should be STATED in gotcha 33 rather than rediscovered.\n\n**Move widths to the fold and replace the socket assertion** with one that still pins 33(c)'s property — that a client never holds widths of a different length from its labels — rather than one that pins the message count. That is the better test either way, and writing it is most of the work.\n\n**Not acceptable: move widths to the fold and simply update the failing assertion.** It is gotcha 33(c)'s standing evidence, and rewriting it inside a change it is meant to guard destroys the evidence for that change — the improvise-q0vb / PR #12 shape.\n","status":"open","priority":3,"issue_type":"decision","owner":"el-github@elangley.org","created_at":"2026-09-20T05:54:56Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:45:42Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-j7v6","title":"The typed-string classification lives in two crates, and its guard test asserts by hand","description":"Named by improvise-r4lo (PR #21) while deleting the OTHER duplication, and deliberately left rather than folded in -- r4lo was about two answers on the CLIENT side, and deleting Client::set_cell/set_cell_at settled that. This is the duplication one level out, and it crosses a crate boundary.\n\nwasm-client/src/socket.rs::typed classifies a user-typed string into a CellValue. So does improvise-command's commit_regular_cell_value, on the host's own key path. Two implementations of \"what does this string mean\", in two crates, with nothing holding them together.\n\nBOTH ARE LEGITIMATE, which is why this is not simply r4lo again. The browser has no App to ask and ModelCommand::SetCell takes a CellValue, so somebody on the client side must decide; a host typing locally has an App and classifies there. The defect is that the RULE is written twice, not that it is applied in two places.\n\nTHE TEST THAT LOOKS LIKE A GUARD AND IS NOT: a_typed_string_is_classified_as_the_host_classifies_it states the obligation IN ITS NAME and does not discharge it -- it asserts three outcomes BY HAND rather than comparing against the host's function. So the two can drift and the test stays green, which is this repository's characteristic defect (doctrine(check_the_checker)): a test that passes while the thing it names is broken.\n\nTHE FIX: one classification function in improvise-protocol, which both crates already depend on, and both call sites calling it. Then the test either disappears (type identity replaces it) or becomes a real comparison. Note doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished) -- if the shared function lands and the hand-written three-outcome test survives beside it, the consolidation is not done.\n\nWATCH ONE THING WHILE MOVING IT: the classification must stay consistent with what the .improv format considers a number versus pipe-quoted text (gotcha 34, and format.rs::pipe_quote exists precisely so a text cell cannot read back as a number). A shared classifier that disagrees with the persistence layer would be a third answer wearing the badge of the first.\n","acceptance_criteria":"One classification function in improvise-protocol, called by socket.rs::typed and by commit_regular_cell_value. a_typed_string_is_classified_as_the_host_classifies_it either disappears (type identity replaces it) or becomes a real comparison against the shared function — if the shared function lands and the hand-written three-outcome test survives beside it, the consolidation is not finished. Check the result stays consistent with what .improv considers a number versus pipe-quoted text, or the shared classifier is a third answer wearing the badge of the first.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T05:45:29Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:45:25Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-rucf.3","title":"Projection freshness for a source an external writer can change","description":"Child of improvise-rucf (ADR 003, live-backed models). Filed and DEFERRED 2026-09-19 alongside its parent.\n\nADR 001 governs concurrent EDITS. A live-backed model is read-only, so it has none and the basis check does not apply to it -- the guarantee is not voided, it is scoped to documents improvise owns, which is what it always meant.\n\nWHAT REMAINS IS A DIFFERENT AND SMALLER QUESTION, and this bead is it: an EXTERNAL WRITER changes what clients SEE. So a live-backed projection needs a freshness story -- polled, triggered, or explicitly stale-until-refreshed. That is projection freshness, not conflict resolution, and conflating the two is the trap.\n\nNOTE WHAT CHANGES ABOUT THE PROJECTION ARCHITECTURE: today's diff (ui/project.rs::grid_updates) compares a desired state derived from the App against each client's mirror. With a live source, \"the desired state\" involves latency -- a HashMap lookup becomes a query -- so the VIEWPORT-BOUNDED PROJECTION STOPS BEING AN OPTIMISATION AND BECOMES LOAD-BEARING. improvise-i5tk (narrow the TUI's GridCache to its viewport, benchmark first) is the related bead and its answer may be forced by this one.\n\nSHARED WITH THE SQLITE SOURCE CHILD: the RecordHash -\u003e record index becomes a cache needing invalidation rather than a structure maintained alongside writes. One invalidation story, not two.\n\nEXPLICITLY OUT OF SCOPE: anything resembling a lease, a lock, or a reservation. Those are ADR 001 layer 2 and they are about edits.\n","status":"deferred","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T05:31:33Z","created_by":"Edward Langley","updated_at":"2026-09-20T05:31:35Z","dependencies":[{"issue_id":"improvise-rucf.3","depends_on_id":"improvise-rucf","type":"parent-child","created_at":"2026-09-20T05:31:33Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-rucf.2","title":"SQLite source implementation: aggregate pushdown over a user-supplied query, native-only and feature-gated","description":"Child of improvise-rucf (ADR 003, live-backed models). Filed and DEFERRED 2026-09-19 alongside its parent.\n\nThe SQLite implementation of the source trait: aggregate pushdown against a user-supplied query.\n\nWHICH ROWS: a user-supplied SELECT, with a bare table name as sugar over it. A query is strictly more useful (joins, filters, computed columns) and no harder to consume -- a result set is a result set.\n\nTYPE MAPPING, and it is BETTER INPUT THAN CSV rather than merely different: a SQLite column carries a declared type, where import/analyzer.rs::detect_date_format currently guesses a date by pattern-matching text. INTEGER/REAL are measure candidates, TEXT a category candidate, BLOB skip. NULL is ABSENCE, not the string \"null\" (improvise-ych). Dates may be TEXT, INTEGER (epoch) or REAL (julian) -- DETECT, DO NOT ASSUME. SQLite is dynamically typed per value, so both the declared-type and the sampled path are real and the classifier should take typed input where it has it.\n\nWHERE THE BOUNDARY GOES: forbidden(improvise_core, io). The source TRAIT belongs in improvise-core; this implementation belongs in improvise-io, FEATURE-GATED AND NATIVE-ONLY. libsqlite3-sys does not build for wasm32-unknown-unknown under this repo's nix cc-wrapper, and improvise-io is in the wasm bundle, so the wasm CI job must keep passing untouched. VERIFY THE GATE, do not assume it -- the improvise-9ix agent established the wasm failure is the nix wrapper injecting -fzero-call-used-regs rather than proof that bundled SQLite cannot target wasm, so the gating protects the browser bundle rather than working around an impossibility.\n\nNO WRITES, and not as a runtime refusal: per ADR 003 writes are not in the trait a query source implements, and a source projecting no CellId::Record makes a client write unconstructible.\n\nINTERNING AND IDENTITY: DataStore interns names and ADR 002 hashes the coordinate tuple. Both still work -- coordinates come from the query -- but the RecordHash -\u003e record index that resolves a CellId becomes A CACHE WITH AN INVALIDATION STORY rather than a structure maintained alongside writes. That invalidation is shared with the freshness child; do not solve it twice.\n","status":"deferred","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T05:31:32Z","created_by":"Edward Langley","updated_at":"2026-09-20T05:31:34Z","dependencies":[{"issue_id":"improvise-rucf.2","depends_on_id":"improvise-rucf","type":"parent-child","created_at":"2026-09-20T05:31:32Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-rucf.1","title":"The .improv source specification: a live-backed document is a saved query plus a pivot configuration","description":"Child of improvise-rucf (ADR 003, live-backed models). Filed and DEFERRED 2026-09-19 alongside its parent; the direction is accepted but not a priority.\n\nA live-backed document splits into OUR configuration and THEIR data, and .improv does not currently distinguish them. Today a file holds categories, views, formulas AND a ## Data section. A live-backed model's categories and cell values belong to the source; the views, formulas and axis assignments belong to improvise.\n\nSo the format grows a SOURCE SPECIFICATION where a live model would otherwise have data: a connection, a query, and the column-to-field mapping. Categories become DERIVED (SELECT DISTINCT) rather than declared.\n\nWHY THIS IS THE FIRST CHILD: persistence/improv.pest is the single source of truth (design-principles 3) and the parser is a tree-walker over it, so the grammar changes before the walker. The grammar-walking test generator reads the grammar AST via pest_meta, so a new production needs generator coverage or the round-trip proptests silently stop describing the format.\n\nTHE FRAMING THAT MAKES THIS FEEL RIGHT RATHER THAN LIKE A COMPLICATION, from the ADR: a live-backed .improv is A SAVED QUERY PLUS A PIVOT CONFIGURATION -- which is exactly what a saved report is in any BI tool. The document stops being \"my data\" and becomes \"my question\", portable between databases in a way a data file never is.\n\nOPEN: whether a document may be PARTLY live (some categories declared, some derived) or whether live and owned are exclusive per document. Exclusive is almost certainly right and much cheaper; say so deliberately rather than leaving it implied.\n","status":"deferred","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T05:31:31Z","created_by":"Edward Langley","updated_at":"2026-09-20T05:31:33Z","dependencies":[{"issue_id":"improvise-rucf.1","depends_on_id":"improvise-rucf","type":"parent-child","created_at":"2026-09-20T05:31:31Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -177,7 +177,7 @@ {"_type":"issue","id":"improvise-i5tk","title":"Narrow the TUI's GridCache to its viewport -- benchmark first","description":"Disclosed as a caveat of PR #12 rather than discovered by it: it is in the PR body, in the aa7a6d3 commit message, and in repo-map gotcha 44. Copilot raised it independently on round 2.\n\nproject::grid_of evaluates EVERY CELL PER FRAME, where GridWidget used to evaluate only the visible ones.\n\nBE PRECISE ABOUT WHAT CHANGED: this is a WIDENING, not an introduction. compute_col_widths was already whole-grid and the totals loop was already O(rows x visible cols). And it is UNBENCHMARKED IN BOTH DIRECTIONS -- \"this can dominate TUI rendering\" is exactly as unevidenced as \"it is fine\" would be. Do not fix it on the strength of the reasoning.\n\nSO ACCEPTANCE CRITERION #1 IS A BENCHMARK. The fix and the evidence that it was needed are the same work.\n\nWHAT THE FIX COSTS, if the benchmark says do it: teaching GridWidget that a cache position is RELATIVE, which is the browser client arrangement (gotcha 44 -- the TUI builds at offsets zero deliberately, and that is what let the scrolling arithmetic stay untouched through improvise-n10). So this is not a narrowing of a loop, it is a change to what a cache index MEANS in the TUI, and every scroll site is in scope.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T01:26:47Z","created_by":"Edward Langley","updated_at":"2026-09-20T01:26:47Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-6c6q","title":"[bug] apply_switch relays a refused switch to everyone but the asker","description":"Found by the code review of ADR 001 layer 1 (2026-09-19). Pre-existing -- the pre-wave broadcast: Option\u003cCommand\u003e was explicitly \"every OTHER client\" -- and the doc overclaims.\n\napply_switch doc says: \"a client that optimistically folded a name the document turned down is corrected by the same message that tells the others to follow.\" But it relays Broadcast::ToOthers, so THE ASKER IS NOT SENT THE CORRECTION. If the workbook refuses the switch (the view does not exist), the asker slice keeps the asked-for name while its mirror holds the accepted one -- gotcha 24 mismatch, and gotcha 29 says the two copies must move together.\n\nUNLIKE the apply_model case the review fixed, a blanket ToAll is WRONG here: the asker really did fold optimistically on the accepted path, so ToAll would double-apply on the common path.\n\nTHE HONEST FIX IS CONDITIONAL: ToAll when accepted != asked, ToOthers otherwise. Which is to say the audience is a function of whether the asker \"folded the right thing\", not of the command variant -- a slightly sharper version of doctrine(the_audience_follows_the_optimistic_fold).\n\nCheap. The reason it is P3 and not higher is that switching to a view that does not exist is not a thing a well-behaved client does; it is reachable through the : command line.","notes":"ALSO FIX THE DOC COMMENT, and treat it as the more dangerous half. apply_switch's doc (project.rs:~714, verbatim on main) actively claims 'a client that optimistically folded a name the document turned down is corrected by the same message that tells the others to follow.' It is not -- the relay is ToOthers. A comment that says the hole is closed is worse than the hole, because the next reader stops looking.\n\nCopilot surfaced this independently on PR #12 and its suggested shape is right and cheap: apply_switch ALREADY RECEIVES the asked-for name -- it takes the whole cmd, which IS Command::SwitchView(asked) -- it simply never destructures it. So the conditional audience (ToAll when accepted != asked, ToOthers otherwise) needs no new parameter.\n\nTHE REAL COST OF THIS TICKET IS THE REGRESSION TEST, NOT THE CHANGE. It must drive THE ASKER'S OWN SLICE, not the to_others helper -- that helper looks away from the asker by construction, and that is exactly how the sibling bug in apply_model (fixed in 9cb16ce) shipped green.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T01:05:15Z","created_by":"Edward Langley","updated_at":"2026-09-20T01:26:45Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-z754","title":"Document name, dirty flag and file path have no home a client can read","description":"draw_title reads workbook.model.name, model_state.dirty and model_state.file_path. NONE IS PROJECTED. improvise-wasm-client RenderModel carries mode, status and view_name but not these, so A BROWSER TITLE BAR CANNOT SHOW THE DOCUMENT NAME OR THE DIRTY MARKER.\n\nFound closing improvise-n10, alongside the grid-frame gaps.\n\nWORTH DECIDING RATHER THAN JUST ADDING: dirty is a fact about the document that every client shares, and name/path likewise -- so these want a projection or a ViewState field, not a per-widget cache. But note file_path is also the thing Save uses and a browser has no filesystem path in the same sense (gotcha 2, gotcha 28), so \"what a remote client should be told about where the document lives\" is a real question and not obviously \"the path\".","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T00:58:35Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:58:35Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-1125","title":"Row-header width is computed per frame in the widget","description":"src/ui/grid.rs::render_grid computes sub_widths / row_header_width by walking layout.row_items per level. GridCache carries col_widths but NO row-header width.\n\nIts sibling compute_col_widths already moved into improvise-command::ui::viewport; this one did not. So a browser client sizing its row header will invent a second rule -- the exact shape of improvise-edp (the category panel walked twice) and of the multi-level-labels divergence.\n\nSmall, and it pairs naturally with the multi-level labels ticket, because both are facts about the grid FRAME rather than its contents, and the frame is what improvise-n10 deliberately left behind.","status":"in_progress","priority":3,"issue_type":"task","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T00:58:34Z","created_by":"Edward Langley","updated_at":"2026-09-20T05:32:27Z","started_at":"2026-09-20T05:32:27Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-1125","title":"Row-header width is computed per frame in the widget","description":"src/ui/grid.rs::render_grid computes sub_widths / row_header_width by walking layout.row_items per level. GridCache carries col_widths but NO row-header width.\n\nIts sibling compute_col_widths already moved into improvise-command::ui::viewport; this one did not. So a browser client sizing its row header will invent a second rule -- the exact shape of improvise-edp (the category panel walked twice) and of the multi-level-labels divergence.\n\nSmall, and it pairs naturally with the multi-level labels ticket, because both are facts about the grid FRAME rather than its contents, and the frame is what improvise-n10 deliberately left behind.","status":"closed","priority":3,"issue_type":"task","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T00:58:34Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:41:34Z","started_at":"2026-09-20T05:32:27Z","closed_at":"2026-09-20T07:41:34Z","close_reason":"Closed by PR #23 (WU-A), and the ticket's premise was understated: the row-header width rule existed TWICE ALREADY -- inline in src/ui/grid.rs::render_grid and in viewport::compute_row_header_width -- rather than once-and-unshared.\n\nIt is now improvise_protocol::header_level_widths, with both callers reading it.\n\nTHE TICKET SAID TO PUT IT BESIDE compute_col_widths IN improvise-command::ui::viewport. THAT HOME IS UNREACHABLE and taking it would have guaranteed the second derivation this ticket exists to prevent: improvise-wasm-client cannot depend on improvise-command, so a browser sizing its own row header would have had to invent a third copy. improvise-protocol is the only shared home.\n\nIT TAKES AN ITERATOR OF BORROWED TUPLES rather than an AxisFrame, and that is load-bearing: its other caller is compute_row_header_width, reached from App::cmd_context ON EVERY KEYPRESS. Building a frame there would clone every row tuple per key. An independent reviewer read that path line by line and confirmed no allocation on the keypress path.\n\nimprovise-protocol gained unicode-width to do it, judged acceptable: forbidden(improvise_protocol, ui) is about UI FRAMEWORK types (the worked examples are AxisColor -\u003e ratatui::Color and MinibufColor), text measurement is not one, and GridCache::col_widths was already a Vec\u003cu16\u003e of display cells on the wire -- the unit was already there. Noted honestly: header_widths() has exactly one production consumer today, the TUI widget, so the \"a browser would have been a third copy\" justification is anticipatory rather than actual.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-x7sw","title":"Which-key hints have no protocol home (the open half of improvise-jb3)","description":"improvise-jb3 closed with its WIDGET half already done -- WhichKeyWidget::new(\u0026[(String, \u0026static str)]) was never \u0026App-coupled. This is the half that was never that ticket.\n\nThe derivation happens in draw.rs: App::armed_prefix_keymap() -\u003e Keymap::binding_hints(). ViewState::transient_prefix IS portable (gotcha 13 stores a prefix as an ADDRESS, not a keymap, precisely so ViewState stays free of improvise-command types) -- but THE KEYMAP SET IS NOT ON THE CLIENT, so a browser which-key cannot be derived from ViewState alone.\n\nTWO ANSWERS, and the choice is the ticket:\n * THE RESOLVED HINT LIST CROSSES -- a Projection, or a ViewState field. Cheap, and it keeps the keymap a client-only concept, which is the stated direction (\"keys should be converted into commands in the browser: the keymap should not be known server side at all\"). But it puts a UI string list on the wire, which is what improvise-k099 is already unhappy about for AppMode::hint_text/label.\n * THE CLIENT GETS THE KEYMAPS. Consistent with keys being a client concept, and the wasm client already has keys.rs -- but the hint list is currently derived from the SERVER-side KeymapSet, so this means the client owns the keymaps outright and the server stops having them. Larger, and probably where this is heading anyway.\n\nNote the second reading is the one that fits improvise-bpq2. Decide them together.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T00:58:33Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:58:33Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-2yc2","title":"DataStore::live_records walks a HashMap -- decide its order before writing the caller","description":"The next instance of improvise-shyt, found while fixing it, and deliberately LEFT ALONE rather than guessed at.\n\nDataStore::live_records walks the records: HashMap, so it yields a different order in every process -- the same defect that made records-mode rows reorder between sessions, misdirected drill staged edits, and drifted SUM/AVG in the last f64 bits.\n\nIt differs from shyt in one way that matters: IT HAS NO CALLERS YET. So this is not a bug report, it is \"decide the order deliberately before something depends on it\". The ADR 002 surrogate path will be the caller -- the RecordHash -\u003e RecordEntry index is what live_records walks -- and whoever writes it should pick an order on purpose rather than inherit a hash walk and then discover it, which is how shyt happened.\n\nTHE ANSWER SHYT LANDED ON, for reference: the index narrows, the primary IndexMap orders (matching_positions resolves each candidate with get_index_of, sorts, reads back with get_index). Measured at 18,000 cells that cost at worst +20% on one path and was 7% FASTER on the other. Whether that is the right answer here depends on what the surrogate path wants; a hash-keyed index may legitimately want no order at all, in which case say so and return something whose type admits it.","status":"closed","priority":3,"issue_type":"task","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T00:45:22Z","created_by":"Edward Langley","updated_at":"2026-09-20T05:54:43Z","started_at":"2026-09-20T05:32:37Z","closed_at":"2026-09-20T05:54:43Z","close_reason":"Closed by PR #20 (WU-K). DECIDED: live_records answers in the STORE'S OWN ORDER -- it walks the primary IndexMap's cells and dedupes by RecordHash, so a record sits where its FIRST cell sits. That is the order matching_positions already gives (gotcha 18), projected onto records, rather than a second answer to \"what order is this store in\".\n\nDerived on each call rather than memoised on RecordEntry, because a stored position is a copy and the tombstone a deleted record leaves would keep it while its cells moved to the end. That case is now tested (a_recreated_record_takes_its_new_place_not_its_old_one) -- and it was found because Copilot noticed the doc comment ASSERTED the derive-don't-memoise choice mattered for delete-and-recreate while nothing tested it; a memoising implementation passed all three other tests.\n\nTHE TICKET'S ALTERNATIVE WAS REJECTED WITH A REASON WORTH KEEPING: \"a hash-keyed index may want no order at all -- return something whose type admits it\" cannot be built. Every iterator has an order, and a type that merely DISCLAIMS one still hands the caller hash order. Disclaiming is not making unrepresentable.\n\nCost moves O(records) -\u003e O(cells). With no caller to benchmark, correctness is the defensible default and the doc says what a hot-path caller should bring.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-odbc","title":"[bug] bank-info.improv in the repo root does not parse","description":"Incidental finding from improvise-cr3 (2026-09-19), outside that ticket scope, unreproduced beyond the one load.\n\n Parse error at line 293, column 3: - JORDAN MARTINEZ (expected pipe_quoted)\n\nTWO READINGS, and they want different actions:\n * The file is STALE -- written before a grammar change -- in which case delete it or regenerate it, and the finding is that a sample file in the repo root is not exercised by anything.\n * The GRAMMAR HAS A GAP -- some name shape a writer emits that the reader rejects. That is the serious reading, because save/load round-trip is a property test (persistence proptests, parse(format(parse(generate())))) and a gap that survives it is a gap the GENERATOR does not reach.\n\nDecide which by checking whether improvise can WRITE that line today. If it can, the round-trip property is incomplete and the generator is the thing to fix, not the file.","status":"closed","priority":3,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T00:37:30Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:28:58Z","started_at":"2026-09-20T05:32:40Z","closed_at":"2026-09-20T06:28:58Z","close_reason":"Closed by PR #22 (WU-L). THE TICKET'S PREMISE WAS FALSE, AND TESTING IT RATHER THAN ASSUMING EITHER READING IS WHAT MADE THIS CHEAP.\n\nThe ticket offered two readings and hoped for the serious one: that the grammar had a gap some writer could emit, which would mean the round-trip property is incomplete and the GENERATOR is the thing to fix. It said to decide by checking whether improvise can WRITE the offending line. IT CANNOT. item_list's name is `pipe_quoted | bare_name`, bare_name is [A-Za-z_][A-Za-z0-9_-]* with no space, and quote_name mirrors it character for character -- so improvise writes |JORDAN MARTINEZ|. Reader and writer agree, the round-trip property is COMPLETE, and the grammar-walking generator needs no change.\n\nThe file was stale BY HAND: c48a5cd migrated it to v2025-04-09 by editing in place, pipe-quoting the Date items and not the Entity ones, and every line of its ## Data section names a category `Measure` where the file declares `_Measure`. Unloadable since April. Nothing loaded it.\n\nDELETED RATHER THAN REGENERATED, on the user's ruling 2026-09-19, together with foo.improv (the same class, one file over, with no version line at all). There is no generator for either -- they are hand-written fake data -- so regenerating meant inventing a canonical form and checking in 1200 lines whose only property is that they parse, which the grammar proptests already establish over a far wider input space.\n\nTHE EVIDENCE KEPT IS WORTH MORE THAN THE FILE:\n * an_item_name_the_item_line_cannot_carry_round_trips -- the deleted file's own names through format_md/parse_md.\n * a_bare_item_name_with_a_space_is_rejected -- the reader really refuses the unquoted line, with the quoted form parsing as a precondition. Without this the first test is consistent with BOTH spellings being accepted.\n * tests/shipped_documents_load.rs -- a DIRECTORY WALK over the two directories .gitignore un-ignores, not a list of names, because a list is a second place to remember and the forgotten file is the one nobody added to it. Fault injected and failed. Then HARDENED: it matched Path::extension() == \"improv\", which of x.improv.gz is \"gz\", so a gzipped shipped document would have been skipped -- the very hole the test exists to close, put back inside the fix for it.\n\nTHE FINDING, which is the opposite of what the ticket expected: a checked-in document in a format the repo parses is exercised by NOTHING unless something loads it, and grammar proptests cannot see it because they GENERATE their inputs.","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -261,7 +261,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-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","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-20T06:04:22Z","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} {"_type":"issue","id":"improvise-q0vb","title":"Pad the import wizard's hint line to the panel width","description":"Pre-existing, found by Copilot on PR #12 and deliberately NOT fixed there -- see the pushback at https://github.com/fiddlerwoaroof/improvise/pull/12#discussion_r4055516143\n\nFour buf.set_string(x, hint_y, ...) sites in src/ui/import_wizard_ui.rs write the hint without padding to the panel width, so a SHORTER message leaves the tail of the longer previous string visible.\n\nTHE REPRODUCTION IS ALREADY IN THE SUITE, as an EXPECTATION: the DefineFormulas characterization test asserts \"|something went wrongcel\" -- the \"cel\" being the tail of \"Esc: cancel\". The artifact is currently pinned as correct output.\n\nWHY IT WAS NOT FIXED IN THE MIGRATION PR: those exact-frame characterization tests were that PR's ONLY evidence that the widget migration changed nothing (doctrine(characterization_survives_the_move)). Rewriting them in the same change would have destroyed the evidence for the change. The sibling finding in the same file (the non-ASCII truncate panic) WAS applied, precisely because it is byte-identical on ASCII and therefore costs no evidence. The two findings differ in what they cost, not in whether they are right.\n\nACCEPTANCE: the tail is gone AND the affected characterization expectations are updated IN THE SAME COMMIT, so the frame change is the point of the commit rather than a side effect of one.","status":"closed","priority":4,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T01:26:46Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:29:00Z","started_at":"2026-09-20T05:32:39Z","closed_at":"2026-09-20T06:29:00Z","close_reason":"Closed by PR #22 (WU-L). The acceptance clause was met exactly: the tail is gone AND the affected characterization expectation moved in the same commit, with the commit message naming it.\n\nTHE DIAGNOSIS WAS BIGGER THAN THE TICKET'S. The bottom row had FIVE WRITERS -- four step hints plus the message that overrides them -- and none owned clearing it. So the defect was not a forgotten pad at four call sites; it was a row with five writers and no owner. One helper, set_bottom_line, now truncates to the panel width and pads to it, and all five go through it.\n\nEXACTLY ONE EXPECTATION MOVED, of eight:\n FORMULA_STEP bottom row: \"|something went wrongcel ...\" -\u003e \"|something went wrong ...\"\nThe \"cel\" was three characters of a hint the message had replaced; it was never part of the message. The other seven frames are BYTE-IDENTICAL, because padding with spaces is invisible to buf_text (which trims each row) -- so the change can only show where it ERASES something. That is the evidence this is a fix and not a rewrite of the characterization PR #12 was protecting.\n\nTWO OF COPILOT'S FOUR FINDINGS LANDED IN THIS FILE and both were about measurement rather than logic: padding by chars().count() over-pads a wide character past the panel border and under-pads a combining mark (now UnicodeWidthStr::width, as grid.rs does); and the test written to catch the second of those PASSED FOR THE WRONG REASON, because drawing through the widget cannot see under-padding at all -- the hint ran first and had already padded the row, so the cells the message failed to reach held spaces either way. Replaced with a sentinel-filled row asserted directly against set_bottom_line.\n\nTHE GENERAL LESSON, now doctrine(an_ascii_fixture_cannot_see_a_width_bug): the eight exact-frame fixtures in that file are ASCII, so bytes, chars and cells all coincide and they are blind to every question about display width. Eight green frames sat either side of both bugs.","dependency_count":0,"dependent_count":0,"comment_count":0}