diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 25a045d9..04ca2dcd 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,29 +1,33 @@ +{"_type":"issue","id":"improvise-6uva","title":"improvise script cannot move the cursor: every Move resolves through the pure-parse arm","description":"`improvise script` and `improvise cmd` resolve every command through CmdRegistry::parse -- the PURE arm, which builds CursorState::default() (row 0, col 0, row_count 0, col_count 0). The interactive arm, which builds CursorState::from_ctx(ctx), is never reached from a script. See command/cmd/core.rs:188 for the two-arm register.\n\nConsequence: every Move command (move-selection, scroll-rows, page-scroll, all four jump-*) and enter-advance snapshots a ZEROED cursor at PARSE time, clamps to (0,0), and emits SetSelected(0,0). It is worse than a no-op -- it RESETS the cursor. There is no way to position the cursor from a script, so every cursor-reading command is permanently pinned to the origin: clear-cell, yank, paste, cycle-axis-at-cursor, hide-selected-row-item, edit-or-drill, drill-into-cell, toggle-group-under-cursor, delete-*-at-cursor, commit-and-advance.\n\nDEMONSTRATED AT HEAD (a9cad15), web/demo.improv, Product on row:\n script A: \"move-selection 2 0\" then \"hide-selected-row-item\"\n script B: \"hide-selected-row-item\"\nBoth documents came out IDENTICAL, both carrying `hidden: Product/Espresso` -- row 0. diff reports IDENTICAL.\n\nBOTH DIRECTIONS DEMONSTRATED. Built the parent of the breaking commit (649d80c) in a worktree and probed:\n \"move-selection 2 0 . clear-selected-cell\" -\u003e cleared Product=Cold (row 2)\n \"clear-selected-cell\" -\u003e cleared Product=Espresso (row 0)\nThe cursor moved there. At HEAD it does not. So neither result is vacuous.\n\nBROKEN BY 35946af (2026-04-04), \"refactor(command): pre-resolve cell key and grid dimensions in CmdContext\", which introduced CursorState, InteractiveFn and the register/register_pure/register_nullary split. Verified ancestry: 35946af is an ancestor of HEAD and of 30c4bb6 (2026-09-19, the first wasm-client commit), so THE REGRESSION PREDATES THE BROWSER WORK BY FIVE MONTHS. The commit message says nothing about scripts.\n\nTHE SHAPE TO REMEMBER: a registry with two construction arms silently demotes every caller of the weaker arm, and nothing type-checks which arm a caller deserves.\n\nREPAIR (not written): run_headless_commands already builds a CmdContext for execute -- it just does not use one for CONSTRUCTION. There is no context-aware script parser today (parse_line_with also calls registry.parse), so closing this needs a parse_line_interactive(registry, line, ctx) calling registry.interactive, plus a PER-COMMAND re-parse inside the loop, since command n must see command n-1's cursor.\n\nA regression test must assert the cursor MOVED, not that the script ran: three identical replays of a fixture whose cursor moves are silent no-ops prove nothing, which is how this survived.\n\nFound by the improvise-ilvl replay audit.","status":"open","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T01:24:42Z","created_by":"Edward Langley","updated_at":"2026-09-23T01:24:42Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-xgra","title":"pr-reviews is blind to inline review comments","description":"scripts/pr-reviews printed review BODIES only. On PR #122 the user reviewed with the body 'Generally good, added comments inline.' and five substantive rulings as inline comments; the script printed the one-line body and nothing else, and an agent reported the PR as awaiting review. Extend it to all three surfaces (reviews, pulls/{n}/comments, issues/{n}/comments) interleaved in time order, and give it a fixture-driven check_the_checker.","notes":"FIXED in PR #133 (merge 22ab58f). pr-reviews now prints all three surfaces --\nreview bodies, INLINE comments, issue comments -- interleaved in time order, with\na fixture-driven check_the_checker (22 cases) in CI's kb job.\n\nTHE DAMAGE IT REPAIRS, for the record: on PR #122 the user reviewed with the body\n\"Generally good, added comments inline.\" and five substantive rulings underneath.\npr-reviews printed the one-liner, and the orchestrator reported the PR as awaiting\nreview while the answers had been sitting there. The user had to say so.\n\nTHE FINDING THAT IS SHARPER THAN THE BUG -- VERIFIED BY THE ORCHESTRATOR AGAINST\nTHE LIVE API: commit_id is PER-COMMENT, not per-review, and they differ INSIDE ONE\nSUBMISSION. On #122, one review submitted 19:48-19:53 carries five inline\ncomments: three naming 246e8f4a and two naming 6a4c7dcd, while its body names\n246e8f4a. GitHub pins each comment to the file version it was written against, so\na comment on an outdated diff position keeps the older commit while its siblings\nmove on.\n\nSo the old head check was answering about the WRONG OBJECT, not merely reading too\nfew of them. A head comparison against the review BODY is not even a proxy for its\nown comments.\n\n--wait: AN INLINE COMMENT AT THE HEAD SATISFIES IT, argued rather than assumed --\na pulls/{n}/comments entry carries a commit_id, so it is a claim about which tree\nwas read. The clincher is that GitHub's \"Add single comment\" CREATES A REVIEW\nWHOSE BODY IS EMPTY: under the old rule that reviewer's exit code was right while\ntheir words were invisible, so the surfaces and the wait had to move together or\nthe tool would be honest and useless. AN ISSUE-LEVEL COMMENT DOES NOT satisfy it\n-- no commit, so no claim about a tree; \"I am rebasing shortly\" satisfying a wait\nfor a review is the widening that looks like the fix, and there is a fixture and\nan injected fault for exactly it.\n\nSIX FAULTS INJECTED, each caught by the case that names it. The two found by Codex\non the PR are the interesting ones and are bash-5-only:\n - `local n=\"$1\" name=\"$2\" f=\"...$n...\"` is NOT left-to-right on bash 5: every\n name is created before the assignments run, so $n is unbound under set -u.\n macOS ships bash 3.2, where it works. THE SUITE PASSED 19/19 LOCALLY AND 2/19\n IN CI ON THE SAME COMMIT -- which is the argument for wiring a shell checker\n into CI in the SAME change that adds it.\n - gh api --paginate emits one array PER PAGE, so a long thread is several\n concatenated documents.\n\nAND THE DIAGNOSTIC WORTH CARRYING: both failed as EXIT 2 WITH NO OUTPUT AT ALL,\nbecause a dead command substitution hands jq --argjson an empty string and jq\nanswers an invalid --argjson with a USAGE error, which is 2. A shell fault at the\ntop of a script arrives wearing jq's exit code at the bottom.\n\nNOT VERIFIED: the LIVE --paginate path. No PR in this repo has enough inline\ncomments to reach a second page (#122 has five), so pagination is exercised by\nfixture only -- and the fixture runs through the SAME pages_to_array, so it can\nhold the concatenated shape the live fetch actually produces.\n\nTHE AGENT ALSO WALKED INTO THIS PR'S OWN TRAP WHILE BUILDING IT: it ran\n`./scripts/pr-watch 133 \u003e f 2\u003e\u00261; echo \"EXIT=$?\"` and its tooling reported exit 0\nover a FAILING kb job, because the echo took the status. pr-watch had correctly\nexited 1. That is the strongest argument for the widened header wording being in\nthe file rather than in anyone's memory.","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T20:44:08Z","created_by":"Edward Langley","updated_at":"2026-09-22T21:04:54Z","closed_at":"2026-09-22T21:04:54Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-e0ex","title":"A ratio renders as an integer: number_format is per view, which is too coarse for one grid","description":"USER 2026-09-22: \"Right now, a Formula like `Margin = Profit / Revenue` displays\nuseless numbers. Probably, in most cases 2 decimal places displayed is sufficient\"\n\nTHE IMMEDIATE CAUSE IS THE FORMAT, NOT THE ARITHMETIC, and that is worth stating\nplainly because the user's own diagnosis pointed at decimal math (filed separately\nas the numeric-tower ticket, which is legitimate on its own merits but is NOT what\nproduces this symptom).\n\nView::default() sets number_format to \",.0\" (improvise-core/src/view/types.rs:56),\nand web/demo.improv sets \",.0\" explicitly on both views. `.0` is ZERO DECIMAL\nPLACES. So Margin = Profit / Revenue, a ratio around 0.66, is rounded\nhalf-away-from-zero (gotcha 8) and drawn as \"1\". Exact decimal arithmetic would\nnot change that by one character: 0.66 formatted with \",.0\" is still \"1\".\n\nTHE REAL DEFECT IS THAT number_format IS PER VIEW, WHICH IS TOO COARSE. One\nformat cannot serve Revenue (18,400 -- wants \",.0\") and Margin (0.66 -- wants\n\".2\" or a percent) in the same grid. Blanket-changing the default to 2 decimals\njust moves the damage: every currency measure becomes \"18,400.00\". So the fix is\nNOT a new default; it is a format that can vary per measure.\n\nWHAT TO DECIDE, in the PR body, rather than assume:\n - PER WHAT? Per _Measure item is the obvious unit and matches how a user\n thinks (\"Margin is a percentage\"). Per formula is another candidate and is\n narrower -- note a formula TARGET is a _Measure item (gotcha 7), so the two\n are not independent and picking both would be two answers to one question.\n - WHERE DOES IT LIVE? A per-measure format is document data, so it persists --\n which means the .improv grammar gains something and persistence/improv.pest\n is the single source of truth (design-principles section 3). Grammar first,\n then the tree-walker.\n - WHAT IS THE FALLBACK? The view's number_format must remain the answer for a\n measure with no format of its own, or every existing document changes\n appearance on load.\n - IS A SENSIBLE DEFAULT INFERRABLE? A formula whose expression is a division\n is almost always a ratio. Inferring is tempting and is the kind of rule that\n is wrong 10% of the time and unexplainable when it is -- if you propose it,\n propose it as a DEFAULT THE USER CAN OVERRIDE and say how they override it.\n\nROUND-TRIP IS THE TEST THAT MATTERS: persistence/roundtrip_tests.rs and the\ngrammar-walking generator. A new format field that does not survive\nformat_md -\u003e parse_md is a silent data loss, and the generator is what will find\nthe spellings you did not think of.\n\nNOTE FOR SCOPE: this is display only. format_f64 is called in rendering and\nnowhere else; formula eval uses full f64 and must keep doing so (gotcha 8 -- never\nfeed a rounded value back into eval). Nothing here touches the evaluator.","notes":"USER RULING 2026-09-22, which NARROWS this ticket from what it was filed as:\n\"We need to default to showing decimals: the expectation is that 2/3 shows\nsomething like .66... rather than 1 for this sort of application, unless the\nformat is explicit in the formula\"\n\nTWO CLAUSES, and the second changes the design:\n\n1. THE DEFAULT SHOWS DECIMALS. View::default()'s \",.0\" becomes decimal-showing.\n Combined with the user's earlier \"in most cases 2 decimal places displayed is\n sufficient\", that reads as \",.2\": Revenue draws 18,400.00 and Margin draws\n 0.66. Accounting applications do exactly this, so the noise on whole-number\n measures is the expected cost rather than a regression.\n\n2. THE OVERRIDE IS SPELLED IN THE FORMULA. This SUPERSEDES the \"per-_Measure\n item\" framing this ticket was filed with. Per-formula is narrower and is what\n the user asked for -- and the two nearly coincide, because a formula TARGET is\n a _Measure item (gotcha 7). So: a formula may carry its own format, which wins\n for its target; a RAW measure keeps the view's format. Clean split, and the\n grammar change lands in the `## Formulas` line rather than in a new section.\n\nTHE MIGRATION IS CLEANER THAN EXPECTED -- verified, not assumed. format_md writes\n`format: \u003cfmt\u003e` whenever number_format is non-empty (persistence/format.rs:121),\nand View::default() is non-empty, so EVERY DOCUMENT IMPROVISE HAS EVER WRITTEN\ncarries an explicit format line. format_line is optional in the grammar\n(improv.pest:118/126), so changing the default affects only (a) newly created\nviews and (b) hand-written documents that omit the line. No migration is needed\nfor round-tripped files.\n\nBUT web/demo.improv SETS \",.0\" EXPLICITLY ON BOTH VIEWS (lines 11 and 19), so the\ndefault change alone leaves the front page showing the bug the user reported.\nChange the demo in the same commit, and note tests/web_demo_model.rs asserts\nproperties of that document.\n\nGRAMMAR NOTES for the formula-side format:\n - The `## Formulas` line already carries a bracket suffix for the target\n category: `- Tax = Revenue * 0.1 [CustomCat]`. A format needs a spelling that\n cannot be confused with a category name. Decide it in improv.pest FIRST --\n the grammar is the single source of truth and the tree-walker follows\n (design-principles section 3).\n - The grammar-walking generator reads improv.pest via pest_meta and will\n exercise whatever you write, including spellings you did not intend. Note it\n SKIPS PREDICATES (gotcha 34), so a rule guarded only by a lookahead is a rule\n the generator can violate -- use a real token, as WHERE's `sp+` does.\n - Formula::raw is what the writer emits, not the reconstructed text, so a\n format that round-trips through `raw` may appear to work while being dropped\n from the parsed structure. gotcha 34 records exactly that failure for the\n formula TARGET: the round-trip tests never saw it because raw was unaffected.\n Assert the PARSED format, not the re-emitted line.\n\nSTILL DISPLAY ONLY. format_f64 is called in rendering and nowhere else; eval uses\nfull f64 and must keep doing so (gotcha 8). ADR 005 (improvise-l2b8) is the\nseparate question of what the arithmetic itself is, and nothing here waits on it.\nUSER CONSTRAINT 2026-09-22: \"improvise-e0ex should not affect persistence at all,\nexcept to store format metadata.\"\n\nALREADY TRUE BY CONSTRUCTION -- verified before recording, so the job is to NOT\nBREAK IT rather than to build it:\n\n - DISPLAY formatting is format_f64 in improvise-core/src/format.rs. Its only\n callers are rendering paths.\n - PERSISTENCE formatting is a DIFFERENT function in a DIFFERENT crate:\n persistence/format.rs:38 `fn format_number(n: f64)`. It writes `{n}` --\n Rust's shortest round-trip representation -- for non-integers, `{n as i64}`\n for integers below 1e15, and named forms for inf/-inf/nan.\n - `grep format_f64` over crates/improvise-io, crates/improvise-core/src/*.rs\n and src/*.rs returns ONLY format.rs's own definition and its tests.\n PERSISTENCE NEVER CALLS THE DISPLAY FORMATTER.\n\nSo stored values keep full round-trip precision regardless of what any view's\nnumber_format says, and a ,.2 default cannot round anything on disk.\n\nTHE REGRESSION TO WATCH FOR, stated because it is the one way this constraint\ngets violated by someone trying to be helpful: routing a save through the display\nformatter \"so the file matches what the user sees\". That would make the on-disk\nvalue depend on a VIEW's setting -- and a model has many views (gotcha 29), so\nthere is no single right answer and the last save would win. gotcha 8 already\nforbids feeding a rounded value back into eval; this is the persistence twin of\nit and is now written down.\n\nWHAT IS IN SCOPE ON THE PERSISTENCE SIDE, and it is only metadata:\n 1. The per-formula format spelling in the `## Formulas` line -- grammar first\n in improv.pest, then the tree-walker.\n 2. The `format:` line's VALUE for newly created views changes with the default\n (\",.0\" -\u003e the decimal default). That is metadata, not data, and existing\n documents are unaffected because format_md has always written the line\n explicitly (format.rs:121) whenever number_format is non-empty.\n\nNOTHING ELSE IN .improv CHANGES. No data line is rewritten, no number is\nre-rendered, no migration is needed.\nFIXED in PR #125 (merge 5fdce21). 1654 tests, all gates green.\n\nMEASURED, not asserted -- web/demo.improv through project::grid_of, the path both\nfront ends use:\n Revenue Cost Profit Margin\n before 18,400 6,200 12,200 1\n after 18,400.00 6,200.00 12,200.00 0.66\n with FORMAT .4 on a ,.0 view 0.6630\n\nONE CORRECTION TO THIS TICKET AND TO THE BRIEF I WROTE FROM IT: the demo has NO\nMargin formula -- only `Profit = Revenue - Cost`. Verified on main. So the front\npage was never literally showing the reported symptom; it was showing ,.0 on\nevery measure. My brief asserted the demo would \"still show the bug\" after a\ndefault change, which was wrong because I assumed a ratio that is not there.\nThe agent declined to ADD a Margin formula: that is demo content nobody asked for\nand it moves tests/web_demo_model.rs's asserted column list plus harness\nassertions. It is a two-line change if the user wants the ratio on the front page.\n\nTHE GRAMMAR DESIGN IS BETTER THAN THE BRIEF ASKED FOR. improv.pest is UNCHANGED\n-- verified. formula_raw already takes the `## Formulas` line verbatim up to the\noptional [Category] suffix, so the format rides in the FORMULA TEXT:\n format_clause = ${ ^\"FORMAT\" ~ sp+ ~ format_spec }\nin formula.pest, after the optional WHERE, with sp+ rather than a lookahead for\nWHERE's reason (the grammar-walking generator skips predicates). Consequences:\nthe generator has no new spelling to exercise, and the text a user types into the\nTUI minibuffer is the text the file carries -- one namespace, one spelling rule.\nRejected: a `{.2}` line suffix, which puts the format somewhere a user cannot\ntype it.\n\nTHE raw-VS-PARSED TRAP WAS ANSWERED WITH A MEASUREMENT. Injecting a walker that\nDISCARDS the format clause fails exactly ONE test and passes 242 others in\nimprovise-io, including the whole format_md/parse_md characterization suite and\nthe grammar-walking properties. No text-comparing test can see the field go\nmissing -- which is the gotcha-34 shape this ticket warned about, now quantified.\n\nBOTH CODEX FINDINGS WERE REAL, and both are the kind that only review catches:\n 1. FormatPlan used a HashMap keyed by target. A target category need not be\n _Measure, so a key can be claimed twice and HASH ORDER DECIDED THE SPELLING\n while model order decided the value -- gotcha 18's shape in a new place.\n Now: every formula's (cat, target, Option\u003cformat\u003e) in MODEL order, FIRST\n match wins, and a match naming no format falls through to the view.\n 2. A column total took its format from ROW 0. The agent's own context/ prose\n had argued this was safe \"for the case it cannot arise in\" -- and it arises,\n because a formula's target category is any category and can own a ROW-AXIS\n item. New GridLayout::column_key(col) gives the coordinates surviving the\n row aggregation.\n\nTWO FIXTURES PINNED TO ,.0 RATHER THAN RE-BASELINED, and the first is the\nimportant one: improvise_gp::dataset::a_cell_reaches_the_table_unrounded asserted\nits fixture's format WAS ,.0 as a precondition, then checked a stored 5.25\nreaches a fit unrounded. UNDER A ,.2 DEFAULT THAT TEST WOULD HAVE GONE VACUOUS\nWHILE PASSING. The fixture now sets ,.0 explicitly.\n\nNO .improv DATA LINE CHANGED. The user's constraint held.","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T06:11:33Z","created_by":"Edward Langley","updated_at":"2026-09-22T07:16:26Z","closed_at":"2026-09-22T07:16:26Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-y11g","title":"The : command line must be present in the browser (core feature): a request frame carrying the line","description":"USER RULING 2026-09-22: \"The : command line needs to be present in the browser,\nit's another core feature.\"\n\nThis REVERSES the decline. resolve.rs::stranding() currently refuses\n`enter-mode command-mode` (with SavePrompt and ExportPrompt), and the\nimprovise-h3y7 audit classified it \"host-only unless the ruling is otherwise\n-- shipping it to a browser means shipping the registry\". The ruling is\notherwise, AND the audit's framing of the cost was wrong.\n\nTHE COST IS NOT WHAT IT LOOKED LIKE. The registry does not have to reach the\nclient, because IN BOTH DEPLOYMENTS THE HOST ALREADY HAS ONE:\n\n - socket (?ws=): the host is native improvise-ws-server. Obviously has it.\n - worker (no ?ws=): web/build.sh builds TWO wasm bundles -- web/pkg from\n improvise-wasm-client (thin) and web/worker-pkg from improvise-worker-server,\n which depends on improvise-command and therefore ALREADY CONTAINS THE FULL\n CmdRegistry, in the browser, today.\n\nSo a `:` line is a REQUEST FRAME CARRYING A STRING, executed by the host against\nits own App and CmdContext. Zero new bundle weight on the main thread, and no\nsecond implementation of the command language.\n\nTHIS MATTERS BECAUSE THE ALTERNATIVE IS EXPLICITLY FORBIDDEN. improvise-wasm-client's\nCargo.toml carries a written argument for why improvise-command is deliberately NOT\na dependency: a binding names an intent resolved against a CmdContext -- a Model, a\nGridLayout and an App the client does not have -- so depending on it would drag the\nwhole host into the bundle to reach a lookup that could not be used. Do not do that.\n\nSHAPE, and note it is the SAME shape as the export/download ticket (improvise-2mqz),\nwhich is a reason to land them together or at least to design them once:\n ClientFrame -\u003e the line, as a string\n ServerFrame -\u003e Rejected on a parse or execution failure, else the ordinary\n projection stream carries whatever the command changed\n\nWHY A FRAME AND NOT A Command, for improvise-368h's reason: ExecuteCommand runs an\narbitrary script line and is runtime-fallible, so it must not sit inside a\nClientFrame::Commands batch that promises all-or-nothing.\n\nBLOCKED ON improvise-jz6w: a resolver cannot emit a frame today.\n\nWHAT MUST LAND WITH IT, not after:\n - the minibuffer must actually RENDER. The user reported 2026-09-21 that the\n minibuffer for typing a category name \"doesn't actually show up anywhere so\n you're typing blind\". A `:` line you cannot see is not the feature.\n - remove the CommandMode arm from stranding() and say so in the named list.\n - help page 5 already advertises the whole `:` surface to browser users\n (improvise-uln5). Shipping this closes the advertised-but-unreachable gap for\n `:` specifically; check what uln5 has left afterwards rather than closing it.\n\nSECURITY NOTE, because `:` is a wider door than any other frame: a line can reach\n`:w`, `:e` and the import wizard, i.e. the host's Storage. In the worker deployment\nthat is the user's own OPFS and is fine. In the socket deployment it is SOMEONE\nELSE'S FILESYSTEM, and improvise-kc7n's Origin check answers what page is driving a\nclient, never WHO is connecting -- improvise-kpnr (authentication) is still open.\nDecide explicitly whether the socket deployment accepts `:` lines at all, or whether\nthe frame is refused there until kpnr lands. Do not let this arrive by accident.","status":"open","priority":1,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T05:13:57Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:13:57Z","labels":["browser","protocol"],"dependencies":[{"issue_id":"improvise-y11g","depends_on_id":"improvise-jz6w","type":"blocks","created_at":"2026-09-22T05:14:02Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-y11g","depends_on_id":"improvise-owep","type":"blocks","created_at":"2026-09-22T05:24:34Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-fdk1","title":"A records coordinate rename has no wire form, and the surrogate is why","description":"Found by the improvise-h3y7 completeness audit, WHICH CORRECTED THE TICKET'S OWN\nDESCRIPTION OF THIS GAP -- and the correction changes the fix.\n\nimprovise-h3y7 said \"grepping reduce.rs for Rename returns zero\". True, and\nMISLEADING. Two paths do this:\n\n - effect::apply_coordinate_rename (ui/effect.rs:655), inside ApplyAndClearDrill,\n which is deliberately off the wire (gotcha 38).\n - commit.rs::rename_record_coordinate (cmd/commit.rs:366), the plain records path,\n which ALREADY decomposes into [AddItem, ClearCell(old), SetCell(new)] -- three\n commands that all have ModelCommand forms.\n\nSo it reads as though the plain path were already wire-expressible. IT IS NOT, for a\nreason the ticket does not state: ModelCommand::SetCell takes a RecordCell, whose\nRecordHash is an OPAQUE SURROGATE the server resolves against cells that ALREADY\nEXIST (App::resolve_record, ui/app.rs:516). The renamed record does not exist yet, so\na client cannot name it.\n\nTwo things that look like they solve this and do not. `will_be_created_by_the_frame`\n(ui/reduce.rs:284) exempts such a record from the BASIS CHECK -- it says nothing about\nhow to NAME one, and the two are easy to confuse. And RecordHash::of being pub is not\na licence: a client computing surrogates is precisely the invention ADR 002 exists to\nprevent.\n\nWHAT TO ADD: one position-free variant naming the existing record by surrogate and the\nnew coordinate by value --\n\n ModelCommand::RenameRecordCoordinate { record: RecordHash, category: String, item: String }\n\n-- mapped to a SINGLE effect factored out of the two copies above. Position-free, so\nimprovise-hkbc's rule still holds. Add it to records_named's match: it names one\nrecord, and creating the ITEM is internal to the effect, as both existing copies\nalready do.\n\nCONSOLIDATING THE TWO COPIES IS WORTH DOING REGARDLESS. They differ today only in how\nthey report the empty-value refusal, and gotcha 19 already warns that the synthetic-\nrecords branch and the plain branch drift.\n\nREMEMBER gotcha 1: renaming a category coordinate in wide records mode moves EVERY\nmeasure cell in the row, not just the one under the cursor. That is also why the basis\nunit is the record (gotcha 40) -- a cell-level unit is violated by this one legal edit.\n\nRELATED: improvise-o9uq is about the same code. Read it in full and say whether this\nsubsumes it before starting.","status":"open","priority":1,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T04:47:16Z","created_by":"Edward Langley","updated_at":"2026-09-22T04:47:16Z","labels":["protocol"],"dependency_count":0,"dependent_count":1,"comment_count":0} -{"_type":"issue","id":"improvise-2mqz","title":"SaveAs and ExportCsv have no wire form, which is why two prompts are stranded","description":"Found by the improvise-h3y7 completeness audit (direction A: effects -\u003e vocabulary).\nOf 32 ViewOnly effects, these are the only two that write storage and have NO wire\nform of any kind -- exactly the hole `Save` was in before improvise-368h.\n\neffect::SaveAs(path) and effect::ExportCsv(path) are unreachable from any client.\nresolve.rs::stranding() refuses SavePrompt and ExportPrompt for precisely this\nreason, so THE DECLINE IS A SYMPTOM: closing it means closing this, not editing the\ndecline.\n\nSHAPE: request FRAMES, not Commands -- improvise-368h's stated reason applies\nunchanged. A runtime-fallible write inside a ClientFrame::Commands batch breaks the\nall-or-nothing promise; [Save, SetCell] reported a failed write and then applied the\nedit. And neither mutates shared state or any client's slice, so reduce_view has\nnothing to fold and Checked has no basis to answer for.\n\n ClientFrame::SaveAs(PathBuf) / ClientFrame::ExportCsv(PathBuf)\n\nanswered ServerFrame::Rejected(..) on failure, to the asker alone, and SILENTLY on\nsuccess -- nothing moved, so no widget is dirty.\n\nBLOCKED ON the KeyOutcome shape gap (a resolver cannot emit a frame today). Land that\nfirst.\n\nTHE OPEN QUESTION THIS RAISES AND DOES NOT ANSWER -- flag it in the PR and get a\nruling before shipping: a browser client naming a path in the HOST's Storage is a\ndifferent act from a TUI user doing it (gotcha 2 -- the host picks the backend). The\nalternative is that a browser gets a download and never names a host path. Say which\nin context/code/protocol.md; different protocols follow from each.","notes":"=== USER RULING 2026-09-22 -- THE SHAPE ABOVE IS SUPERSEDED. READ THIS FIRST. ===\n\n'Save can save to OPFS, but exports should be downloads. There should also be a\nway to download the .improv file.'\n\nSo the description's proposed ClientFrame::SaveAs(PathBuf) / ExportCsv(PathBuf) is\nWRONG and must not be built. A browser client never names a path in the host's\nStorage. That also ANSWERS the open question the description flagged, in the safe\ndirection -- do not re-open it.\n\nTHE CORRECT SHAPE: a request frame that asks the host for BYTES, and a page that\nturns bytes into a download. Uniform across both deployments, names no path, and\ncarries no filesystem semantics across the wire at all.\n\n ClientFrame -\u003e a request for the rendered document or export\n ServerFrame -\u003e the bytes (or Rejected)\n web/app.js -\u003e Blob + an \u003ca download\u003e, which is the ONLY new browser-side code\n\nTWO ARTEFACTS ARE WANTED, not one: the CSV export, and THE .improv FILE ITSELF.\nThe second is new scope the original ticket did not have and is arguably the more\nimportant of the two -- it is how a browser user gets their document out of OPFS\nand into a file they own. persistence::format_md already renders it; nothing new\nis needed server-side beyond the frame.\n\nWHY BYTES AND NOT CLIENT-SIDE RENDERING, which is the tempting shortcut:\nimprovise-io IS in the browser bundle, so the worker deployment could render CSV\nlocally. The socket deployment could not -- a thin client holds no Model. Rendering\nlocally where possible and asking otherwise would be TWO derivations of one answer,\nwhich is the disease design-principles §5 names and improvise-edp is the worked\nexample of. Ask the host in both.\n\nNOTE render_csv must build its layout with with_frozen_records, NOT GridLayout::new\n(gotcha 1, improvise-iot6) -- export.rs already does; do not regress it.\n\nSAVE ITSELF NEEDS NOTHING NEW: the worker already persists to OPFS on the dirty\nflag (gotcha 45), and ClientFrame::Save exists for the socket deployment. What is\nmissing there is only the KEY that reaches it -- improvise-jz6w.\n\nSTILL BLOCKED ON improvise-jz6w for the same reason as before: a resolver cannot\nemit a frame today.","status":"open","priority":1,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T04:47:04Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:13:41Z","labels":["protocol"],"dependencies":[{"issue_id":"improvise-2mqz","depends_on_id":"improvise-jz6w","type":"blocks","created_at":"2026-09-22T04:48:02Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-jz6w","title":"KeyOutcome cannot express 'send a frame', so Ctrl-S cannot reach the ClientFrame::Save that already exists","description":"Found by the improvise-h3y7 completeness audit. It is a SHAPE gap, not a vocabulary\ngap, which is why an audit that enumerates Command/ModelCommand against effects finds\nnothing here.\n\nClientFrame::Save landed with improvise-368h and Host::handle answers it (ui/host.rs:83).\nBut KeyOutcome is `Local(Vec\u003cCommand\u003e) | Upstream(KeyEvent) | Ignored`\n(improvise-protocol/src/resolve.rs:20), so a resolver has NO WAY to say \"send this\nframe\". Consequently `save` is still on the_command_names_that_still_need_the_host_are_these,\nresolves to Upstream, and Host::handle answers Upstream with an empty vec (gotcha 47:\nan upstream key is a BLACK HOLE, not a slow path).\n\nSo: an implemented server feature that no key can reach.\n\nWHAT TO ADD: a fourth KeyOutcome variant carrying a ClientFrame -- or a narrower\n`Request` enum, if a resolver handing out arbitrary frames is too wide. ARGUE THE\nCHOICE in the PR rather than picking silently; the narrow form is the one that keeps\na resolver from composing a Viewport or a Commands batch by accident.\n\nThen bind Ctrl-S through it and REMOVE `save` from the host list in the same commit --\ngotcha 47: closing a gap has to come to that list and say so.\n\nTHE GUARD: end to end, in the style of tests/browser_round_trip.rs -- Ctrl-S on a\nClientState yields the save request, and Host::handle on that request writes the\ndocument through a real App. Not two half-tests that never meet.\n\nTHIS UNBLOCKS the SaveAs/ExportCsv frames (filed separately), which need the same\nmechanism. Do this one first; it is small on its own.\n\nNOTE for whoever reads KeyOutcome afterwards: context/code/clients.md should say that\nKeyOutcome's variants are part of the protocol surface and belong in any completeness\nenumeration. That omission is why this gap was invisible to the audit's first pass.","status":"open","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T04:47:03Z","created_by":"Edward Langley","updated_at":"2026-09-22T04:47:03Z","labels":["browser","protocol"],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"improvise-2mqz","title":"SaveAs and ExportCsv have no wire form, which is why two prompts are stranded","description":"Found by the improvise-h3y7 completeness audit (direction A: effects -\u003e vocabulary).\nOf 32 ViewOnly effects, these are the only two that write storage and have NO wire\nform of any kind -- exactly the hole `Save` was in before improvise-368h.\n\neffect::SaveAs(path) and effect::ExportCsv(path) are unreachable from any client.\nresolve.rs::stranding() refuses SavePrompt and ExportPrompt for precisely this\nreason, so THE DECLINE IS A SYMPTOM: closing it means closing this, not editing the\ndecline.\n\nSHAPE: request FRAMES, not Commands -- improvise-368h's stated reason applies\nunchanged. A runtime-fallible write inside a ClientFrame::Commands batch breaks the\nall-or-nothing promise; [Save, SetCell] reported a failed write and then applied the\nedit. And neither mutates shared state or any client's slice, so reduce_view has\nnothing to fold and Checked has no basis to answer for.\n\n ClientFrame::SaveAs(PathBuf) / ClientFrame::ExportCsv(PathBuf)\n\nanswered ServerFrame::Rejected(..) on failure, to the asker alone, and SILENTLY on\nsuccess -- nothing moved, so no widget is dirty.\n\nBLOCKED ON the KeyOutcome shape gap (a resolver cannot emit a frame today). Land that\nfirst.\n\nTHE OPEN QUESTION THIS RAISES AND DOES NOT ANSWER -- flag it in the PR and get a\nruling before shipping: a browser client naming a path in the HOST's Storage is a\ndifferent act from a TUI user doing it (gotcha 2 -- the host picks the backend). The\nalternative is that a browser gets a download and never names a host path. Say which\nin context/code/protocol.md; different protocols follow from each.","notes":"=== USER RULING 2026-09-22 -- THE SHAPE ABOVE IS SUPERSEDED. READ THIS FIRST. ===\n\n'Save can save to OPFS, but exports should be downloads. There should also be a\nway to download the .improv file.'\n\nSo the description's proposed ClientFrame::SaveAs(PathBuf) / ExportCsv(PathBuf) is\nWRONG and must not be built. A browser client never names a path in the host's\nStorage. That also ANSWERS the open question the description flagged, in the safe\ndirection -- do not re-open it.\n\nTHE CORRECT SHAPE: a request frame that asks the host for BYTES, and a page that\nturns bytes into a download. Uniform across both deployments, names no path, and\ncarries no filesystem semantics across the wire at all.\n\n ClientFrame -\u003e a request for the rendered document or export\n ServerFrame -\u003e the bytes (or Rejected)\n web/app.js -\u003e Blob + an \u003ca download\u003e, which is the ONLY new browser-side code\n\nTWO ARTEFACTS ARE WANTED, not one: the CSV export, and THE .improv FILE ITSELF.\nThe second is new scope the original ticket did not have and is arguably the more\nimportant of the two -- it is how a browser user gets their document out of OPFS\nand into a file they own. persistence::format_md already renders it; nothing new\nis needed server-side beyond the frame.\n\nWHY BYTES AND NOT CLIENT-SIDE RENDERING, which is the tempting shortcut:\nimprovise-io IS in the browser bundle, so the worker deployment could render CSV\nlocally. The socket deployment could not -- a thin client holds no Model. Rendering\nlocally where possible and asking otherwise would be TWO derivations of one answer,\nwhich is the disease design-principles §5 names and improvise-edp is the worked\nexample of. Ask the host in both.\n\nNOTE render_csv must build its layout with with_frozen_records, NOT GridLayout::new\n(gotcha 1, improvise-iot6) -- export.rs already does; do not regress it.\n\nSAVE ITSELF NEEDS NOTHING NEW: the worker already persists to OPFS on the dirty\nflag (gotcha 45), and ClientFrame::Save exists for the socket deployment. What is\nmissing there is only the KEY that reaches it -- improvise-jz6w.\n\nSTILL BLOCKED ON improvise-jz6w for the same reason as before: a resolver cannot\nemit a frame today.","status":"open","priority":1,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T04:47:04Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:13:41Z","labels":["protocol"],"dependencies":[{"issue_id":"improvise-2mqz","depends_on_id":"improvise-jz6w","type":"blocks","created_at":"2026-09-22T04:48:02Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-2mqz","depends_on_id":"improvise-owep","type":"blocks","created_at":"2026-09-22T05:24:35Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-jz6w","title":"KeyOutcome cannot express 'send a frame', so Ctrl-S cannot reach the ClientFrame::Save that already exists","description":"Found by the improvise-h3y7 completeness audit. It is a SHAPE gap, not a vocabulary\ngap, which is why an audit that enumerates Command/ModelCommand against effects finds\nnothing here.\n\nClientFrame::Save landed with improvise-368h and Host::handle answers it (ui/host.rs:83).\nBut KeyOutcome is `Local(Vec\u003cCommand\u003e) | Upstream(KeyEvent) | Ignored`\n(improvise-protocol/src/resolve.rs:20), so a resolver has NO WAY to say \"send this\nframe\". Consequently `save` is still on the_command_names_that_still_need_the_host_are_these,\nresolves to Upstream, and Host::handle answers Upstream with an empty vec (gotcha 47:\nan upstream key is a BLACK HOLE, not a slow path).\n\nSo: an implemented server feature that no key can reach.\n\nWHAT TO ADD: a fourth KeyOutcome variant carrying a ClientFrame -- or a narrower\n`Request` enum, if a resolver handing out arbitrary frames is too wide. ARGUE THE\nCHOICE in the PR rather than picking silently; the narrow form is the one that keeps\na resolver from composing a Viewport or a Commands batch by accident.\n\nThen bind Ctrl-S through it and REMOVE `save` from the host list in the same commit --\ngotcha 47: closing a gap has to come to that list and say so.\n\nTHE GUARD: end to end, in the style of tests/browser_round_trip.rs -- Ctrl-S on a\nClientState yields the save request, and Host::handle on that request writes the\ndocument through a real App. Not two half-tests that never meet.\n\nTHIS UNBLOCKS the SaveAs/ExportCsv frames (filed separately), which need the same\nmechanism. Do this one first; it is small on its own.\n\nNOTE for whoever reads KeyOutcome afterwards: context/code/clients.md should say that\nKeyOutcome's variants are part of the protocol surface and belong in any completeness\nenumeration. That omission is why this gap was invisible to the audit's first pass.","notes":"FIXED in PR #134 (merge a799e24). Reproduced at HEAD first: the new end-to-end\ntest failed with left: Key { code: Char('s'), modifiers: CONTROL } right: Save --\nCtrl-S went Upstream -\u003e ClientFrame::Key -\u003e Host::handle's empty vec.\n\nTHE WIDTH WAS THE DECISION, and it is argued rather than defaulted:\nKeyOutcome::Request(Request) with a NARROW enum (Request::Save, plus\nRequest::frame as the one mapping to an envelope), NOT a ClientFrame. Two reasons:\n 1. An arbitrary frame lets a resolver compose a Viewport, whose extent is the\n PAGE's fact and reaches no derivation from a keymap.\n 2. The serious one: it lets a resolver compose a Commands batch, which is\n INDISTINGUISHABLE ON THE WIRE FROM A Local while skipping the local fold\n Local's contract requires. That desync has nothing to observe it.\n\nTwo consequences guarded rather than asserted in prose: `requested` is checked\nFIRST in step, so no name sits in two lists; and a request cannot travel inside a\nBinding::Sequence (a sequence is one frame), with\nno_binding_puts_a_request_inside_a_sequence keeping that refusal arm unreachable\nrather than merely untested. NOT routed through Commands -- gotcha 45's reason\nholds. `save` left the host-needed list in the same commit.\n\nTHE LOAD-BEARING RIVAL: Request::Save =\u003e ClientFrame::Commands(vec![]). The\nSTORAGE assertion alone cannot separate \"reached the host\" from \"reached the host\nas the wrong kind of thing\", because an empty batch writes nothing either way.\nAsserting the FRAME is the discriminator.\n\nTWO FINDINGS WORTH MORE THAN THE FIX:\n\n1. THE AUDIT THAT FOUND THIS TICKET COULD NOT HAVE FOUND IT. improvise-h3y7\n enumerated Command/ModelCommand against the effects and nothing was missing\n from either -- the gap was in KEYOUTCOME'S SHAPE. KeyOutcome's variants are\n protocol surface too, and an enumeration that stops at the frames and the\n commands will miss the next one the same way.\n\n2. THE HOST SIDE WAS ALREADY TESTED, by a_client_can_ask_its_host_to_save, WHICH\n CONSTRUCTS THE FRAME ITSELF. A passing test over a feature nothing can reach is\n the \"green suite is not evidence\" shape exactly -- improvise-368h shipped that\n way. Worth checking whether other Host::handle arms have only\n self-constructed-frame coverage.\n\nNOTE ON repo-map: the agent measured 1686 at d672985 over 38 binaries, where\nrepo-map still says 1464/35 -- stale by 222. The File Inventory's own preamble\nsays those are a dated snapshot, so this is the preamble being right rather than\na defect.","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T04:47:03Z","created_by":"Edward Langley","updated_at":"2026-09-22T20:59:52Z","closed_at":"2026-09-22T20:59:52Z","close_reason":"Closed","labels":["browser","protocol"],"dependency_count":0,"dependent_count":2,"comment_count":0} {"_type":"issue","id":"improvise-m0sx","title":"Command::ClearBuffer is invented, and disagrees with the only implementation of its own name","description":"Found by the improvise-h3y7 completeness audit (direction B: vocabulary -\u003e effects),\nand VERIFIED INDEPENDENTLY by the orchestrator before filing.\n\nTHE DIVERGENCE. reduce_view's arm (improvise-protocol/src/reduce.rs:269) does\n`view.buffers.remove(name)` -- it REMOVES the key. The host's `clear-buffer`\ncommand (command/cmd/effect_cmds.rs:226) composes `core::write_buffer(name, String::new())`,\nwhich is `effect::SetBuffer` -- and SetBuffer INSERTS, leaving `buffers[name] == \"\"`.\nresolve.rs::write_buffer composes the same SetBuffer. So the one behaviour both\nends actually implement leaves an empty string, and the wire variant says otherwise.\n\nThere is NO `ClearBuffer` effect anywhere in the workspace. The variant is therefore\nnot derived from the effect set, violating decision(improvise-cqi,\ncommand_set_derived_not_invented). It is the exact INVERSE of the ViewBack defect\nthat improvise-h3y7 was opened over: ViewBack was an effect with no Command; this is\na Command with no effect.\n\nREACHABILITY. `grep -rn \"Command::ClearBuffer\" crates/ src/ web/` returns exactly\nthree hits, ALL inside reduce.rs: the definition at :39 and two of reduce.rs's own\ntests at :405 and :465. No production code composes it. (Note `ClearBufferCmd` in\neffect_cmds.rs and registry.rs is the command-layer NAME, not the wire variant --\nthey are different things that read the same, which is part of why this survived.)\n\nImpact if it were ever sent: a Snapshot seeded from a ViewState where the host left\n`buffers[\"edit\"] == \"\"` against an incrementally-folded client with no key -- two\nclients whose ViewStates are PartialEq-unequal for the \"same\" state.\n\nDECIDE ONE, AND SAY WHICH IN context/code/protocol.md:\n(a) delete the variant and its two tests -- free today, since nothing composes it; or\n(b) keep it as the derived form and change `clear-buffer`'s effect to remove the key,\n which makes ViewState equality agree between a folded client and a snapshot-seeded one.\n\nTHEN ADD THE GUARD that makes this whole class visible: an exhaustive test over\n`variants::declared::\u003cCommand\u003e()` asserting every variant is composed by some\nproduction resolver or relay. The machinery is already in\ncrates/improvise-protocol/src/variants.rs. Prove it discriminating by deleting one\nresolver arm and watching it fail -- a variant nothing composes is either dead or a\npromise, and both need naming.\n\nNOTE the two existing tests currently PIN the divergent behaviour (they assert\n`!view.buffers.contains_key(\"edit\")`), so whichever answer is taken, they change.","status":"open","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T04:46:49Z","created_by":"Edward Langley","updated_at":"2026-09-22T04:46:49Z","labels":["protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-h3y7","title":"The wire vocabulary must be complete against what needs the server, not merely correct","description":"USER RULING 2026-09-21: \"the wire protocol needs to cover the full range of commands that need the server side.\"\n\nThis is a COMPLETENESS requirement on the vocabulary, and it lands on a\nweakness the wave just demonstrated rather than on a suspicion.\n\nTHE PRECEDENT THAT MAKES IT URGENT. PR #115 found that gotcha 38's account of\nwhy ViewBack/ViewForward were off the wire was FALSE: it said they were\nsession-local residue like ApplyAndClearDrill, but view_back_stack is in\nViewState -- the portable slice -- so by the view half's own rule\n(decision(improvise-cqi): the ViewOnly effects targeting the portable slice)\nthey were always Commands and were simply MISSING. The derivation had been\nchecked for CORRECTNESS -- every listed variant belongs -- and never for\nCOMPLETENESS. The cost was a live divergence nobody could see: reduce_view's\nSwitchView arm set active_view and nothing else while effect::SwitchView pushes\nthe back stack, so NO CLIENT EVER HAD A BACK HISTORY.\n\nSo this bead is: enumerate the vocabulary AGAINST the rule, in both directions,\nand close what the enumeration finds.\n\nTHE KNOWN GAPS AT 523d571, to be treated as a starting list rather than the\nanswer:\n\n1. NO ModelCommand FOR A COORDINATE RENAME. ApplyAndClearDrill's\n RecordCol::Category(_) arm is apply_coordinate_rename -- add an item, then\n move EVERY measure cell in the row (gotcha 1) -- and grepping reduce.rs for\n Rename returns zero. A drill's category-column commit therefore has no wire\n form. This is a missing ModelCommand, NOT a missing addressed form, and is\n much smaller than it was assumed to be.\n\n2. SIX NAMES IN declined() (resolve.rs:232) that a browser cannot ask for at all:\n drill-into-cell, toggle-prune-empty, toggle-records-mode,\n add-record-row, delete-formula-at-cursor, view-back\n plus enter-mode via stranding (SavePrompt, ExportPrompt, CommandMode).\n view-back becomes removable when PR #115 merges -- the comment blaming the\n drill commit is wrong as of that PR. The others each need their own answer:\n some want a ModelCommand, some want a projection, and some may be genuinely\n host-only. SAY WHICH, PER NAME.\n\n3. EIGHT NAMES in the_command_names_that_still_need_the_host_are_these:\n hide-selected-row-item, toggle-group-under-cursor, search,\n search-navigate, search-or-category-add, enter-advance, save, force-quit\n (search-or-category-add leaves with improvise-koh6/PR #114.) Note gotcha 47:\n an UPSTREAM key is a black hole, not a slow path -- Host::handle answers\n ClientFrame::Key with an explicit empty vec -- so every name on this list\n does NOTHING in a browser rather than being merely slower.\n\n4. ClientFrame::Key ITSELF. It exists, is deployment-independent by gotcha 42's\n argument, and is answered with an empty vec. Either it should carry\n something or it should not exist; today it is a promise the protocol does\n not keep.\n\nWHAT THIS BEAD IS NOT: a licence to put everything on the wire. gotcha 38\nrecords two effects deliberately EXCLUDED -- ApplyAndClearDrill and\nAcceptWizardModel -- because their operands are session-local residue addressed\nto precisely one session, and an unaddressed form lets client B commit client\nA's typing (demonstrated against Sessions::apply before removal). The rule is\n\"everything that NEEDS the server\", and residue does not. The test for each\ncandidate is the one gotcha 38 already states: does the argument for excluding\nit name the thing it differs in?\n\nACCEPTANCE: a per-name verdict for every entry in (2) and (3) -- on the wire,\ndeliberately excluded with the argument, or blocked on a named prerequisite --\nplus the enumeration in (1) closed or filed. A list of what is missing is worth\nmore than a partial fix, and doctrine(a_stale_list_of_what_is_missing_is_worse_than_no_list)\nmeans the verdict list has to be maintained where it can be checked, not left\nin this bead.\n","notes":"AUDIT COMPLETE 2026-09-22 against 44b8819. Both derivations run.\n\nMECHANISM: brace-matched impl Effect blocks in ui/effect.rs =\u003e 26 EffectKind::Model(_),\nverified against gotcha 38's stated 26/24. NOTE THE MECHANISM UNDER-REPORTS BY 9: it\nmisses the 8 simple_effect! macro expansions and the one impl Effect in\ncommand/keymap.rs (SetTransientPrefix). Total effects = 58: 26 Model(_), 32 ViewOnly.\nAll 9 missed are ViewOnly, which is exactly the half the ViewBack bug lived in.\nGotcha 38 warns the naive awk OVER-reports by picking up HelpPageSet; the brace-matcher\nit recommends instead UNDER-reports, and nothing in context/ said so.\n\nDIRECTION A (effects -\u003e vocabulary): no gap in the model half -- all 26 accounted for.\nIn the view half, 21 of 32 have a Command; 9 of the remaining 11 are soundly excluded\nand TWO ARE A GAP: SaveAs and ExportCsv write storage and have no wire form at all\n(=\u003e improvise-2mqz).\n\nDIRECTION B (vocabulary -\u003e effects), NEVER RUN BEFORE, three finds:\n - Command::ClearBuffer is INVENTED and contradicts the only implementation of its\n name (=\u003e improvise-m0sx, P1, orchestrator-verified independently)\n - effect::HelpPageSet unclamped where its mirror clamps (=\u003e improvise-4n4i)\n - effect::SwitchView pushes the back stack before a guarded switch (=\u003e improvise-swzk)\n\nSTRUCTURAL PROPERTY FOUND AND NOT PREVIOUSLY WRITTEN DOWN: apply_command handles only\nSwitchView and Model; every other Command falls to _ =\u003e Dirty::NOTHING and is folded\ninto the asking session's mirror by reduce_view -- THE SAME FUNCTION both ends run. So\nmirror-vs-client drift is structurally impossible, and the whole direction-B risk\ncollapses to ~21 pairs of 'does reduce_view's arm do what its effect does'. That is an\nafternoon, and re-running it after any wave touching reduce_view or effect.rs is the\nonly thing standing between this repo and another ViewBack.\n\nTHE HEADLINE, which corrects this ticket's own framing: THE VOCABULARY IS FAR MORE\nCOMPLETE THAN ASSUMED. Of 13 gaps examined, EIGHT are closed in the vocabulary and open\nonly in the RESOLVER. Taken literally, this ticket would have produced new variants for\nthings already on the wire. Only three things need new wire surface: SaveAs/ExportCsv\n(frames), the coordinate rename (a ModelCommand), add-record-row (a new naming mode).\n\nGENERALISABLE FINDING: declined() is NOT evidence of a vocabulary gap and is NOT\nmaintained against the vocabulary. It is a sentence written when a name was hard, and\nnothing re-checks it when the thing that made it hard lands. view-back is the proven\ninstance (improvise-cfly is cleaning up after PR #115); toggle-prune-empty and\ntoggle-records-mode are two more sitting in that state now. The structural fix is that\nthe decline list and the host list must be DERIVED FROM or CHECKED AGAINST the\nvocabulary, not hand-kept beside it.\n\nALSO: a SHAPE gap can hide behind a vocabulary audit. 'save' looks like a missing\nCommand and is not -- ClientFrame::Save exists and is answered; the only missing thing\nis a KeyOutcome variant that can express sending a frame (=\u003e improvise-jz6w).\nKeyOutcome's variants are part of the protocol surface and belong in any completeness\nenumeration; context/code/clients.md should say so.\n\nAND: the surrogate is the binding constraint on the whole CREATION class. RecordHash is\nresolved server-side against cells that exist, so a client cannot name a record it is\nabout to create. Invisible from the ModelCommand list. will_be_created_by_the_frame\nexempts such a record from the BASIS CHECK and says nothing about NAMING one -- the two\nare easy to confuse. Deserves a line in ADR 002.\n\nCHILDREN FILED: improvise-m0sx (P1), improvise-jz6w (P1), improvise-2mqz (P1, blocked\nby jz6w), improvise-fdk1 (P1), improvise-fo0w (P2), improvise-1ohv (P2, blocked by\nfdk1), improvise-4n4i (P3), improvise-swzk (P3).\nALREADY FILED, not duplicated: improvise-7xyx (raised P3-\u003eP2: it is a black-hole key,\nnot just a dead field), improvise-hs0u (prefixes), improvise-gd28 (drill),\nimprovise-bd9o (records mode), improvise-uln5 (advertised-vs-resolvable).\nFOUR PRODUCT RULINGS OWED -- see the orchestrator's hand-off.","status":"open","priority":1,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T02:51:02Z","created_by":"Edward Langley","updated_at":"2026-09-22T04:48:13Z","labels":["architecture","protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-koh6","title":"[bug] Following the help overlay's N to add a category overwrites a data cell","description":"DEMONSTRATED end to end under Chromium at 523d571, on web/demo.improv, with\nthree controls (C, P, T) in the same loop that DID move -- otherwise a blind\nprobe and a black hole look identical.\n\n cell (0,0) before: \"18,400\"\n keys: N R e g i o n Enter\n cell (0,0) after: \"18400on\" \u003c- Espresso/Revenue, overwritten with text\n categories: UNCHANGED -- no category was created\n status: \"...records mode is not available in the browser yet\"\n\nTHE SEQUENCE, measured one key at a time:\n\n - `N` resolves Upstream. Host::handle answers a Key frame with an empty vec, so\n the FULL DOM snapshot -- mode, hint, status, grid, tiles, minibuffer -- is\n BYTE-IDENTICAL before and after. Completely silent.\n - The user then types the category name into NORMAL, where every character is a\n live Normal binding:\n R -\u003e status \"records mode is not available in the browser yet\"\n e, g -\u003e nothing\n i -\u003e INSERT on the grid cell under the cursor, PRE-FILLED with its value\n o, n -\u003e appended to that edit buffer\n - Enter commits.\n\n`N` is advertised by the browser's OWN help overlay, page 4:\n\"N -- Quick-add a new category (from anywhere)\".\n\nWHY THIS IS NOT THE MINIBUFFER BUG IT WAS REPORTED AS: the minibuffer works\nperfectly. Measured: C then n gives mode NEW CATEGORY, minibuffer \"new category:\"\nin yellow, 1280x28.6 at y=691, and each typed character appears live; Enter\ngives 'Added category \"Reg\"'. Verified in two more modes (F,n -\u003e \"formula:\" cyan;\nC,a -\u003e \"add item to Product:\" green). The user's \"typing blind\" was LITERAL -- a\ndead key followed by characters landing as commands.\n\nTHE CATEGORY THIS ADDS TO THE DOCTRINE: clients.md enumerates black holes as\nkeys that do nothing. A black hole that leaves the keyboard in a MUTATING mode\nis strictly worse and is not currently distinguished. The cost here is a\ncorrupted value, not a confused user.\n\nSECOND, COMPOUNDING PROBLEM: the decline messages actively mislead. Pressing N\nthen R answers \"records mode is not available in the browser yet\" -- confident,\ntrue, and about a different subject entirely, arriving adjacent in time to an\nunanswered gesture. clients.md already names the failure (\"a reader who is told\nsomething plausible stops looking\"); this is a new route to it.\n\nIS THERE A ONE-LINE FIX? NO, and the investigating agent resisted calling it one.\nSearchOrCategoryAdd::execute (command/cmd/search.rs:181) is:\n\n if !ctx.search_query.is_empty() { SearchNavigate(false).execute(ctx) }\n else { vec![SetPanelOpen{Category,true}, change_mode(AppMode::category_add())] }\n\nThe else-branch is exactly two commands the browser already composes elsewhere,\nand search_query is on ViewState, so the client HAS everything it needs. But a\nconditional arm makes N's outcome STATE-DEPENDENT, and\nenter_in_an_empty_view_panel_resolves_locally_rather_than_upstream\n(resolve.rs:1364) exists precisely to forbid making the host-needed list\nstate-dependent. So it is a decision, not a line.\n\nCHEAP INTERIM that stops the data loss without deciding that: one arm in\ndeclined() giving search-or-category-add its own true subject. That ends the\nsilence but NOT the sequel, because the user's next keystrokes still land in\nNORMAL where `i` opens an editor.\n","status":"open","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T00:22:11Z","created_by":"Edward Langley","updated_at":"2026-09-22T00:22:11Z","labels":["browser","data-loss","ux"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-koh6","title":"[bug] Following the help overlay's N to add a category overwrites a data cell","description":"DEMONSTRATED end to end under Chromium at 523d571, on web/demo.improv, with\nthree controls (C, P, T) in the same loop that DID move -- otherwise a blind\nprobe and a black hole look identical.\n\n cell (0,0) before: \"18,400\"\n keys: N R e g i o n Enter\n cell (0,0) after: \"18400on\" \u003c- Espresso/Revenue, overwritten with text\n categories: UNCHANGED -- no category was created\n status: \"...records mode is not available in the browser yet\"\n\nTHE SEQUENCE, measured one key at a time:\n\n - `N` resolves Upstream. Host::handle answers a Key frame with an empty vec, so\n the FULL DOM snapshot -- mode, hint, status, grid, tiles, minibuffer -- is\n BYTE-IDENTICAL before and after. Completely silent.\n - The user then types the category name into NORMAL, where every character is a\n live Normal binding:\n R -\u003e status \"records mode is not available in the browser yet\"\n e, g -\u003e nothing\n i -\u003e INSERT on the grid cell under the cursor, PRE-FILLED with its value\n o, n -\u003e appended to that edit buffer\n - Enter commits.\n\n`N` is advertised by the browser's OWN help overlay, page 4:\n\"N -- Quick-add a new category (from anywhere)\".\n\nWHY THIS IS NOT THE MINIBUFFER BUG IT WAS REPORTED AS: the minibuffer works\nperfectly. Measured: C then n gives mode NEW CATEGORY, minibuffer \"new category:\"\nin yellow, 1280x28.6 at y=691, and each typed character appears live; Enter\ngives 'Added category \"Reg\"'. Verified in two more modes (F,n -\u003e \"formula:\" cyan;\nC,a -\u003e \"add item to Product:\" green). The user's \"typing blind\" was LITERAL -- a\ndead key followed by characters landing as commands.\n\nTHE CATEGORY THIS ADDS TO THE DOCTRINE: clients.md enumerates black holes as\nkeys that do nothing. A black hole that leaves the keyboard in a MUTATING mode\nis strictly worse and is not currently distinguished. The cost here is a\ncorrupted value, not a confused user.\n\nSECOND, COMPOUNDING PROBLEM: the decline messages actively mislead. Pressing N\nthen R answers \"records mode is not available in the browser yet\" -- confident,\ntrue, and about a different subject entirely, arriving adjacent in time to an\nunanswered gesture. clients.md already names the failure (\"a reader who is told\nsomething plausible stops looking\"); this is a new route to it.\n\nIS THERE A ONE-LINE FIX? NO, and the investigating agent resisted calling it one.\nSearchOrCategoryAdd::execute (command/cmd/search.rs:181) is:\n\n if !ctx.search_query.is_empty() { SearchNavigate(false).execute(ctx) }\n else { vec![SetPanelOpen{Category,true}, change_mode(AppMode::category_add())] }\n\nThe else-branch is exactly two commands the browser already composes elsewhere,\nand search_query is on ViewState, so the client HAS everything it needs. But a\nconditional arm makes N's outcome STATE-DEPENDENT, and\nenter_in_an_empty_view_panel_resolves_locally_rather_than_upstream\n(resolve.rs:1364) exists precisely to forbid making the host-needed list\nstate-dependent. So it is a decision, not a line.\n\nCHEAP INTERIM that stops the data loss without deciding that: one arm in\ndeclined() giving search-or-category-add its own true subject. That ends the\nsilence but NOT the sequel, because the user's next keystrokes still land in\nNORMAL where `i` opens an editor.\n","notes":"ALREADY FIXED by 7a44616, an ancestor of 7470b61. Stale-open -- the fifth ticket\nclosed by that single commit.\n\nresolve.rs:379 answers search-or-category-add in composed() in BOTH arms: the\nempty-query arm builds SetPanelOpen{Category,true} + ChangeMode(category_add),\nand the other declines out loud. So N is no longer Upstream, and therefore no\nlonger a black hole that left the keyboard in Normal where i opens an editor over\na data cell -- which was this ticket's whole mechanism.\n\nNOTE A MISREADING WORTH RECORDING: an earlier agent reported that\nsearch-or-category-add \"left the host-needed list WITH improvise-koh6\", which I\nread as a side effect of that PR. It was the whole close. A ticket named in\nanother ticket's departure note is easy to read as context rather than as\nresolution.\n\nNOT VERIFIED END TO END: the browser half was confirmed by reading the resolver\narm and its ancestry, not by driving Chromium -- harness/ was outside the\nverifying agent's file scope, and this ticket's original evidence was gathered\nthere.","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T00:22:11Z","created_by":"Edward Langley","updated_at":"2026-09-22T07:40:28Z","closed_at":"2026-09-22T07:40:28Z","close_reason":"Closed","labels":["browser","data-loss","ux"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-1wft","title":"Browser boot failures have no diagnosis channel, so five different causes all read as silence","description":"Five open beads, filed independently by three different reviewers, are one\nfailure mode: the browser's boot can fail for several reasons and the page\neither says nothing specific or nothing at all.\n\n improvise-i0v9 OPFS handle held by another context (or a prior session)\n improvise-86nw start() rejects for a non-OPFS reason; onFrame then drops\n every frame while `server` is null\n improvise-9cma invalid ?doc= -- generic watchdog text only\n improvise-iobc bad or missing ?seed= -- silent empty document, CORS/404\n visible only in the console\n improvise-vep8 the watchdog arms AFTER `await transport.ready`, so a\n connection hang never arms it at all\n\nTHE SHARED ROOT: there is no channel carrying a boot failure's REASON from the\nworker to the page. gotcha 46 already records the mechanism -- a worker that\nrejects in start() fires NO `error` on the main thread, because worker.js\ncatches its own -- so the only survivor is a generic first-frame watchdog, and\nimprovise-vep8 shows that watchdog does not even arm in one case.\n\nThe result is the worst shape a failure can take, measured on i0v9: `#status`\nreads \"in-page worker - \u003cdoc\u003e\" at 13ms, which looks like SUCCESS, and 8 seconds\nlater is replaced by \"this browser may not allow it\" -- blaming the browser for\na condition the code knows precisely, since it is holding the exact\nNoModificationAllowedError naming the exact document.\n\nWHAT THIS TICKET IS: the diagnosis channel, not the five triggers. The worker\nmust be able to report a structured boot failure across postMessage, and the\npage must render it instead of a timeout. Each of the five then becomes a\nmatter of producing the right reason rather than of plumbing.\n\nTwo things to decide while building it, both raised by the triggers above:\n - a boot failure is a STATE, not a rejected promise (i0v9 needs the page to\n open and paint read-only, which means boot must succeed without a handle)\n - the watchdog stays, but as a LAST resort that is armed before anything can\n hang (vep8), and is disarmed rather than pre-empted by a precise failure\n (gotcha 48 already states that rule and PR #21 implemented half of it)\n\nNot a new investigation: every one of the five is already demonstrated. This\nexists so the five are not fixed five different ways.\n","status":"closed","priority":1,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-21T21:26:34Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:58:54Z","closed_at":"2026-09-21T21:58:54Z","close_reason":"Fixed in the 2026-09-21 wave, integrated into main at 1574 passed / 0 failed / 7 ignored. Playwright 42 passed / 0 failed / 4 skipped (WebKit, no OPFS) -- up from 37, so the count MOVED. All gates green.\n\nTHE CHANNEL: a protocol frame crosses the port as a STRING, a shell notice as an OBJECT. Total discriminator, zero protocol change, since ServerFrame JSON is a string by construction of on_message.\n\nA boot failure is now a STATE, not a rejected promise: a document another context holds the handle to opens READ-ONLY over vfs::ReadOnly rather than dying. Only BootFailed is fatal. The watchdog is armed BEFORE transport.ready and every notice routes through shell.fail, so gotcha 48's disarm half holds.\n\nMoving the watchdog forced its message to change, and that is load-bearing: armed before ready, it can no longer claim 'accepted the connection but sent no document' -- the page does not know that yet. The existing spec asserted the OLD wording and failed, which is the right way round and is how it was found.","labels":["architecture","browser","ux"],"dependency_count":0,"dependent_count":6,"comment_count":0} -{"_type":"issue","id":"improvise-55l5","title":"dispatch swallows interactive() errors silently","description":"## Summary\n\n`dispatch` uses `.ok()?` on `interactive()`, swallowing `Err` (argument parse failures, mode mismatch for `commit-buffer item`, etc.). The user presses a key and nothing happens — no status message. Violates `doctrine(an_upstream_key_is_a_black_hole)` for failures that *are* resolvable as user-visible errors.\n\n## Expected / Actual\n\n- **Expected:** Construction/resolve errors surface as status (or equivalent loud feedback).\n- **Actual:** `.ok()?` turns Err into silent no-op.\n\n## Evidence / verify\n\nBind a key to `commit-buffer item` in Normal mode (not ItemAdd). Press the key — command fails to construct with no status message.\n\n## Doctrine\n\n`doctrine(an_upstream_key_is_a_black_hole)` — silent no-ops that look like black holes when the failure is knowable.\n\n## Location\n\n`crates/improvise-command/src/command/keymap.rs` ~L37\n\n## PR / Attribution\n\nPR #100 F05. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","status":"open","priority":1,"issue_type":"bug","owner":"box@local","created_at":"2026-09-21T20:48:19Z","created_by":"external-grok-bot","updated_at":"2026-09-21T20:48:19Z","external_ref":"gh-pr-100-F05","labels":["adversarial-review","pr-100"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-55l5","title":"dispatch swallows interactive() errors silently","description":"## Summary\n\n`dispatch` uses `.ok()?` on `interactive()`, swallowing `Err` (argument parse failures, mode mismatch for `commit-buffer item`, etc.). The user presses a key and nothing happens — no status message. Violates `doctrine(an_upstream_key_is_a_black_hole)` for failures that *are* resolvable as user-visible errors.\n\n## Expected / Actual\n\n- **Expected:** Construction/resolve errors surface as status (or equivalent loud feedback).\n- **Actual:** `.ok()?` turns Err into silent no-op.\n\n## Evidence / verify\n\nBind a key to `commit-buffer item` in Normal mode (not ItemAdd). Press the key — command fails to construct with no status message.\n\n## Doctrine\n\n`doctrine(an_upstream_key_is_a_black_hole)` — silent no-ops that look like black holes when the failure is knowable.\n\n## Location\n\n`crates/improvise-command/src/command/keymap.rs` ~L37\n\n## PR / Attribution\n\nPR #100 F05. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","notes":"FIXED in PR #126 (merge 4625593).\n\nTHE CAUSE: dispatch spelled construction as\nregistry.interactive(name, args, ctx).ok()?, so Option::None carried TWO FACTS --\n\"no binding matched\" and \"a binding matched but would not build\" -- and\nApp::dispatch_key answers None with Dirty::NOTHING and no status. A bound key was\na black hole.\n\nTHE ERROR CLASSES, separated, which is the part worth keeping:\n\n CLASS A -- reaches a user, and NOTHING STATIC CAN SEE IT. \"no cell at cursor\",\n from six registrations that read ctx.cell_key(): clear-cell, yank-cell,\n paste-cell, drill-into-cell, commit-cell-edit, commit-and-advance-right. It is\n STATE-dependent, so no registration-time check applies. The two commit entries\n are the worst case: the user is in Editing, has typed a value, presses Enter,\n and the batch is discarded with the minibuffer still up -- where\n draw_bottom_bar paints INSTEAD of the status line (gotcha 21). This is the\n whole ticket.\n\n CLASS B -- already guarded and unreachable from a shipped keymap. \"Unknown\n command\", require_args, \"Unknown mode\", and \"commit-buffer item requires\n item-add mode\". every_bound_command_name_is_registered refuses all of these at\n test time, checking BOTH halves of the old expression precisely because\n improvise-s13 made a typo'd mode name compile into a dead key (gotcha 15).\n\n SO THE REPRO THIS TICKET ITSELF PROPOSED IS THE ONE CASE THAT CANNOT HAPPEN.\n\nTHE FIX: None means \"no binding matched\" and nothing else; a construction failure\nbecomes Some(vec![set_status(\"\u003cname\u003e: \u003cerr\u003e\")]). Class B surfaces too, and that is\ndeliberate -- a list of blessed errors would be a second description of which\nfailures matter, free to disagree with the first.\n\nTHE TEST PAIR IS THE INTERESTING PART, because one test alone cannot separate the\ntwo facts the Option used to carry:\n - rival \"return Some, swallow the error\": fails\n a_bound_key_whose_command_cannot_be_built_says_so; the control passes.\n - rival \"report a failed LOOKUP as a status too\": fails\n an_unbound_key_stays_silent; the other passes. Without that control, the fix\n would write a status for every unmapped character in every mode.\n\nNOT VERIFIED: whether x/y/p on an empty grid is reachable in a SHIPPED document.\nThe fixture constructs the state (an item-less category on Axis::Row) rather than\nreaching it by keystrokes. Real for a user mid-add-cat; not driven.\n\nFOLLOW-UP NOT FILED: a \"no cell at cursor\" refusal from commit-cell-edit still\nleaves the user in Editing with the minibuffer covering the status line. The\nmessage is now written; whether it is DRAWN in that mode is gotcha 21's rendering\ntrap and a separate question.","status":"closed","priority":1,"issue_type":"bug","owner":"box@local","created_at":"2026-09-21T20:48:19Z","created_by":"external-grok-bot","updated_at":"2026-09-22T07:40:42Z","closed_at":"2026-09-22T07:40:42Z","close_reason":"Closed","external_ref":"gh-pr-100-F05","labels":["adversarial-review","pr-100"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-86nw","title":"Worker startup failure leaves page in silent dead state","description":"## Summary\n\nIf worker `started` rejects (e.g. WASM `init()`/`start()` throws for a non-OPFS reason), `.catch()` only logs. The `onFrame` listener stays installed and calls `handle()` while `server` is null. Frames accumulate in the error log with no UI indication beyond the eventual watchdog; if the watchdog never arms (worker never posts), the page sits dead.\n\n## Expected / Actual\n\n- **Expected:** Startup failure surfaces a loud, specific status (and stops accepting frames / arms watchdog).\n- **Actual:** Console-only catch; silent dead UI until generic watchdog (if any).\n\n## Distinct from improvise-16af\n\nimprovise-16af tracks intermittent blank grid from OPFS `SyncAccessHandle` / `NotAllowedError` conflicts. This bead is the general **startup-without-OPFS-conflict** path: WASM init/start rejection leaves `server === null` with only a logged catch. Related symptom, different root.\n\n## Location\n\n`web/worker.js` ~L43-60\n\n## PR / Attribution\n\nPR #100 F03. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","status":"closed","priority":1,"issue_type":"bug","owner":"box@local","created_at":"2026-09-21T20:48:18Z","created_by":"external-grok-bot","updated_at":"2026-09-21T21:58:55Z","closed_at":"2026-09-21T21:58:55Z","close_reason":"Fixed in the 2026-09-21 wave, integrated into main at 1574 passed / 0 failed / 7 ignored. Playwright 42 passed / 0 failed / 4 skipped (WebKit, no OPFS) -- up from 37, so the count MOVED. All gates green.\n\nstart() rejecting for any reason posts BootFailed; the page renders it and disarms the watchdog.","external_ref":"gh-pr-100-F03","labels":["adversarial-review","pr-100"],"dependencies":[{"issue_id":"improvise-86nw","depends_on_id":"improvise-16af","type":"relates-to","created_at":"2026-09-21T20:48:19Z","created_by":"external-grok-bot","metadata":"{}"},{"issue_id":"improvise-86nw","depends_on_id":"improvise-1wft","type":"blocks","created_at":"2026-09-21T21:31:50Z","created_by":"Edward Langley","metadata":"{}"}],"comments":[{"id":"91dcae3c-8f35-56e5-b794-18191771873a","issue_id":"improvise-86nw","author":"external-grok-bot","text":"Related to improvise-16af (OPFS handle conflict blank grid). This bead covers non-OPFS WASM startup rejection only. — PR #100","created_at":"2026-09-21T20:48:19Z"}],"dependency_count":1,"dependent_count":0,"comment_count":1} -{"_type":"issue","id":"improvise-nxah","title":"Optimistic fold is not rolled back on server rejection","description":"## Summary\n\nWasm client folds commands optimistically in `Client::key` before sending. On server rejection (`Rejection::LayoutMoved`, etc.), `socket.rs::fold_frame` sets `last_rejection` / `status_msg` but does **not** roll back already-folded local state. Client `page_coords` (and other folded fields) can diverge from the server.\n\n## Expected / Actual\n\n- **Expected:** Rejected optimistic folds are undone (or client resyncs to server projection) so local state matches the accepted basis.\n- **Actual:** Rejection is reported; folded mutations remain.\n\n## Evidence / verify\n\nSend a command the server will reject (e.g. page-next after layout moved). Observe client state advanced while server did not.\n\nDistinct from improvise-yqpd (server-side all-or-nothing transactional rollback of effect batches / ADR 001 clause 4). This is client optimistic-fold rollback on rejection.\n\n## Location\n\n`crates/improvise-wasm-client/src/lib.rs` — `Client::key` ~L182-193; rejection handling in socket fold path\n\n## PR / Attribution\n\nPR #100 F02. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","notes":"WAVE 2026-09-21: NOT DONE, and the reason is a finding rather than a shortfall. improvise-nxah and improvise-3al1 are IN DIRECT CONTRADICTION and neither bead mentions the other. They are ONE design and should be scheduled as one.\n\nTHE CONTRADICTION, measured: nxah's naive fix is to snapshot ViewState before the optimistic fold and restore it on ServerFrame::Rejected. That WIPES ViewState::buffers -- which is precisely the user's typing that 3al1 requires be kept staged so a force can re-compose it. resolve.rs folds 9 Command::SetBuffer sites alongside 19 Command::Model sites, so a whole-slice rollback is wrong and WHICH FIELDS roll back is a design decision, not a mechanical fix.\n\nTHE SECOND BLOCKER, and it is a genuine protocol gap: there is NO POSITIVE ACKNOWLEDGEMENT. The server answers nothing on success, so a client cannot know when to DISCARD an undo snapshot. With two frames in flight a single pending slot rolls back the wrong one. This is the same shape as the publication gap ADR 001 originally omitted -- it said a batch carries the basis it was composed against and never said how a client learns one, which Projection::Basis had to close.\n\nSo the work is: decide what a rejected frame rolls back on the client given that buffers must survive, and decide how success is acknowledged. Then both beads fall out. Do not start either alone.","status":"open","priority":1,"issue_type":"bug","owner":"box@local","created_at":"2026-09-21T20:48:18Z","created_by":"external-grok-bot","updated_at":"2026-09-21T21:59:55Z","external_ref":"gh-pr-100-F02","labels":["adversarial-review","pr-100"],"dependencies":[{"issue_id":"improvise-nxah","depends_on_id":"improvise-yqpd","type":"relates-to","created_at":"2026-09-21T20:49:19Z","created_by":"external-grok-bot","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-nxah","title":"Optimistic fold is not rolled back on server rejection","description":"## Summary\n\nWasm client folds commands optimistically in `Client::key` before sending. On server rejection (`Rejection::LayoutMoved`, etc.), `socket.rs::fold_frame` sets `last_rejection` / `status_msg` but does **not** roll back already-folded local state. Client `page_coords` (and other folded fields) can diverge from the server.\n\n## Expected / Actual\n\n- **Expected:** Rejected optimistic folds are undone (or client resyncs to server projection) so local state matches the accepted basis.\n- **Actual:** Rejection is reported; folded mutations remain.\n\n## Evidence / verify\n\nSend a command the server will reject (e.g. page-next after layout moved). Observe client state advanced while server did not.\n\nDistinct from improvise-yqpd (server-side all-or-nothing transactional rollback of effect batches / ADR 001 clause 4). This is client optimistic-fold rollback on rejection.\n\n## Location\n\n`crates/improvise-wasm-client/src/lib.rs` — `Client::key` ~L182-193; rejection handling in socket fold path\n\n## PR / Attribution\n\nPR #100 F02. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","notes":"WAVE 2026-09-21: NOT DONE, and the reason is a finding rather than a shortfall. improvise-nxah and improvise-3al1 are IN DIRECT CONTRADICTION and neither bead mentions the other. They are ONE design and should be scheduled as one.\n\nTHE CONTRADICTION, measured: nxah's naive fix is to snapshot ViewState before the optimistic fold and restore it on ServerFrame::Rejected. That WIPES ViewState::buffers -- which is precisely the user's typing that 3al1 requires be kept staged so a force can re-compose it. resolve.rs folds 9 Command::SetBuffer sites alongside 19 Command::Model sites, so a whole-slice rollback is wrong and WHICH FIELDS roll back is a design decision, not a mechanical fix.\n\nTHE SECOND BLOCKER, and it is a genuine protocol gap: there is NO POSITIVE ACKNOWLEDGEMENT. The server answers nothing on success, so a client cannot know when to DISCARD an undo snapshot. With two frames in flight a single pending slot rolls back the wrong one. This is the same shape as the publication gap ADR 001 originally omitted -- it said a batch carries the basis it was composed against and never said how a client learns one, which Projection::Basis had to close.\n\nSo the work is: decide what a rejected frame rolls back on the client given that buffers must survive, and decide how success is acknowledged. Then both beads fall out. Do not start either alone.","status":"closed","priority":1,"issue_type":"bug","owner":"box@local","created_at":"2026-09-21T20:48:18Z","created_by":"external-grok-bot","updated_at":"2026-09-22T23:00:24Z","closed_at":"2026-09-22T23:00:24Z","close_reason":"Closed","external_ref":"gh-pr-100-F02","labels":["adversarial-review","pr-100"],"dependencies":[{"issue_id":"improvise-nxah","depends_on_id":"improvise-yqpd","type":"relates-to","created_at":"2026-09-21T20:49:19Z","created_by":"external-grok-bot","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-zre1","title":"Auto-persist failure in worker not communicated to client","description":"## Summary\n\nWhen worker-server auto-persist (`effect::Save`) fails (OPFS quota, closed handle, etc.), the dirty flag stays `true` but the failure is only written to `status_msg` that the worker-server has no UI to show. The client sees projected `dirty: true` and cannot tell \"save failed\" from \"save pending\". Risk of data loss on page close if the user trusts auto-save.\n\n## Expected / Actual\n\n- **Expected:** Save failures are projected to the client as an explicit failure/status (not just dirty).\n- **Actual:** Dirty stays true; failure stays local to worker status_msg.\n\n## Evidence / verify\n\nInject a storage backend that fails writes after N successes; edit past the failure; observe dirty true with no save-failure indication on the client.\n\n## Location\n\n`crates/improvise-worker-server/src/worker.rs` — `on_frame` ~L52-59\n\n## PR / Attribution\n\nPR #100 F04. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","status":"closed","priority":1,"issue_type":"bug","owner":"box@local","created_at":"2026-09-21T20:48:18Z","created_by":"external-grok-bot","updated_at":"2026-09-21T21:58:57Z","closed_at":"2026-09-21T21:58:57Z","close_reason":"Fixed in the 2026-09-21 wave, integrated into main at 1574 passed / 0 failed / 7 ignored. Playwright 42 passed / 0 failed / 4 skipped (WebKit, no OPFS) -- up from 37, so the count MOVED. All gates green.\n\nPersist failure raises a notice. The trigger is THE DIRTY FLAG SURVIVING persist() -- observed, not predicted from a table of commands, which is gotcha 45's shape. Fault injected with a real refusing backend; a companion test proves a success raises nothing.","external_ref":"gh-pr-100-F04","labels":["adversarial-review","pr-100"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-eiv2","title":"CSV import silently converts NaN/Infinity fields to zero","description":"## Summary\n\nCSV import silently converts NaN/Infinity field values to 0. `serde_json::Number::from_f64` returns `None` for NaN and Infinity; `parse_csv_field` uses `.unwrap_or(serde_json::Number::from(0))`, so `\"NaN\"`, `\"Infinity\"`, `\"-Infinity\"`, or `\"inf\"` import as numeric 0 with no warning — data is silently corrupted.\n\n## Expected / Actual\n\n- **Expected:** Refuse non-finite numbers, keep as text, or emit a loud import warning; never invent 0.\n- **Actual:** Non-finite parses become `Value::Number(0)` with no status/warning.\n\n## Evidence / verify\n\nImport a CSV with a field value `nan` or `inf` and observe it imports as 0 rather than text or a warning.\n\nRelated closed bead improvise-4yc fixed malformed numbers → 0.0 in `.improv` persistence; this is the CSV import path (`csv_parser.rs`), still open.\n\n## Location\n\n`crates/improvise-io/src/import/csv_parser.rs` — `parse_csv_field` ~L128-131\n\n## PR / Attribution\n\nPR #100 F01. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","status":"closed","priority":1,"issue_type":"bug","owner":"box@local","created_at":"2026-09-21T20:48:17Z","created_by":"external-grok-bot","updated_at":"2026-09-21T21:26:04Z","closed_at":"2026-09-21T21:26:04Z","close_reason":"Duplicate of improvise-jc95, which is the same defect found independently the same day: parse_csv_field's .unwrap_or(serde_json::Number::from(0)) in crates/improvise-io/src/import/csv_parser.rs:133-136, so NaN/Infinity/overflowing tokens import as 0 with no warning.\n\nKeeping jc95 rather than this one because it names the exact lines and because PR #101's reproduction (csv_nonfinite_is_not_zero, covering NaN, inf, -inf and 1e999) already cites jc95. This bead's P1 rating was the better one and has been carried over: jc95 is now P1. Nothing from this report is lost -- the serde_json::Number::from_f64 mechanism is recorded on jc95.","external_ref":"gh-pr-100-F01","labels":["adversarial-review","pr-100"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-s8h7","title":"[bug] i on an aggregate cell drills in the TUI and refuses in the browser, while help advertises it as edit","description":"DEMONSTRATED at 1f28868 by driving a real App through reduce_key and a real\nClientState through resolve on the same key and the same fixture\n(furnished_host() with Product moved to Axis::None, so cell(0,0).id is\nSome(Aggregate(..))):\n\n PROBE i: browser = Local[SetStatus(\"drilling into an aggregate needs the host\n -- not in the browser yet\")]\n | tui = mode RecordsNormal, status \"Drilled into cell: 1 rows\"\n\nEditOrDrill::execute (cmd/mode.rs) branches on regular_none and, when the cell\nis aggregated, DELEGATES TO DrillIntoCell -- a mode change, a new _Drill view, a\nwhole workflow. resolve.rs::edit_or_drill's CellId::Aggregate arm returns a\nstatus line instead.\n\nWhy this matters more than the \u003e decline already filed: i is the ADVERTISED EDIT\nKEY. help.rs:514 is\n keys(n, \"edit-or-drill\", Some(\u0026[\"editing\"]), \"Edit current cell\")\nand it PASSES every_advertised_command_answers_in_its_own_mode, because a\nSetStatus counts as \"answered\". So the guard is satisfied by a refusal, and the\nsecond, undocumented route into drill is invisible to it.\n\nOn web/demo.improv nothing regular sits on None, so it does not bite today. It\nbites the moment a user tiles a category to none, which is five keys away.\n\nA defensible interim is one line: make the Aggregate arm reuse declined()'s\nwording so the two doors at least say the same thing -- today a user cannot tell\nthat i and \u003e are the same missing feature. The real fix is addressed drill,\nwhich is ADR 001 layer 2 territory.\n","status":"open","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:39:17Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:39:17Z","labels":["browser","bug","parity"],"dependencies":[{"issue_id":"improvise-s8h7","depends_on_id":"improvise-gd28","type":"blocks","created_at":"2026-09-22T00:17:43Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-uej8","title":"[bug] the category panel cursor does not follow a shrinking list, so d deletes the next category","description":"DEMONSTRATED in a real browser at 1f28868, found while probing improvise-g3no.\n\nAfter deleting the last item of an expanded category, cat_panel_cursor stays at\nits index and the row now under it is the NEXT CATEGORY'S HEADER. Observed live:\n\n GRP d on Valley -\u003e rows: .. \"item|West\", \"category selected|\u003e Store (2) R\", ..\n\nSo a user holding d to clear out a category deletes the next CATEGORY on the\nfollowing press. RemoveCategory is LayoutGeometry: Workbook::on_category_removed\nrewrites EVERY view's axis map, and Model::remove_category also drops every\nformula targeting it (gotcha 35). High blast radius, one keypress away, no\nconfirmation, no undo.\n\nSame class from the other side, also observed: with the cursor at its default 0,\nd deleted the virtual category _Dim outright. Deleting _Index/_Dim/_Measure may\nitself deserve a guard -- they are the categories that AppMode-agnostic code\nassumes always exist (gotcha 7, and GridLayout's records-mode auto-detection\nkeys off _Index on Row and _Dim on Column).\n\nThe cursor-follows-a-shrinking-list problem is general: any panel whose list can\nshrink under the cursor has it. Check the formula and view panels for the same\nshape rather than fixing the category panel alone.\n","status":"open","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:39:05Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:39:05Z","labels":["browser","bug","tui"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-uej8","title":"[bug] the category panel cursor does not follow a shrinking list, so d deletes the next category","description":"DEMONSTRATED in a real browser at 1f28868, found while probing improvise-g3no.\n\nAfter deleting the last item of an expanded category, cat_panel_cursor stays at\nits index and the row now under it is the NEXT CATEGORY'S HEADER. Observed live:\n\n GRP d on Valley -\u003e rows: .. \"item|West\", \"category selected|\u003e Store (2) R\", ..\n\nSo a user holding d to clear out a category deletes the next CATEGORY on the\nfollowing press. RemoveCategory is LayoutGeometry: Workbook::on_category_removed\nrewrites EVERY view's axis map, and Model::remove_category also drops every\nformula targeting it (gotcha 35). High blast radius, one keypress away, no\nconfirmation, no undo.\n\nSame class from the other side, also observed: with the cursor at its default 0,\nd deleted the virtual category _Dim outright. Deleting _Index/_Dim/_Measure may\nitself deserve a guard -- they are the categories that AppMode-agnostic code\nassumes always exist (gotcha 7, and GridLayout's records-mode auto-detection\nkeys off _Index on Row and _Dim on Column).\n\nThe cursor-follows-a-shrinking-list problem is general: any panel whose list can\nshrink under the cursor has it. Check the formula and view panels for the same\nshape rather than fixing the category panel alone.\n","notes":"ALREADY FIXED by 7a44616 (PR #114) -- verified an ancestor of ee0f57d by the orchestrator with git merge-base --is-ancestor, and verified IN BOTH DIRECTIONS by the agent dispatched to fix it: the named guards pass at HEAD on BOTH front ends (command::cmd::panel::tests::deleting_a_categorys_last_item_leaves_the_cursor_inside_that_category plus two siblings, mirrored in resolve::tests::), and deleting the retreat_from_deleted_item(ctx) call reproduces this ticket's exact symptom, with the guard naming it: 'the cursor stayed over a category header, so one more d removes a whole category'.\n\nStale-open: the work landed and nobody closed the ticket. It cost an agent dispatch. See doctrine(a_ready_ticket_may_already_be_fixed).","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:39:05Z","created_by":"Edward Langley","updated_at":"2026-09-22T06:39:20Z","closed_at":"2026-09-22T06:39:20Z","close_reason":"Closed","labels":["browser","bug","tui"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-i0v9","title":"[bug] a second browser tab on the same document is dead and silent, and blames the browser","description":"DEMONSTRATED, real Chromium, two tabs in one context, at 1f28868.\n\n tab2 console: error: improvise worker: failed to start\n Error: createSyncAccessHandle(two-tab.improv):\n NoModificationAllowedError: Access Handles cannot be created if there is\n another open Access Handle or Writable stream associated with the same file.\n\nbindings.rs::boot awaits storage.attach(\u0026path) before anything else; attach\ncalls create_sync_access_handle, which is exclusive per file ACROSS CONTEXTS.\nThe failure is at attach, NOT at open: OpfsFs::open() (getDirectory) and\nrefresh() both succeed in the second tab. Only handle acquisition fails. This\nconfirms for the OPEN the half of improvise-vvl3 that was anticipated for the\nINDEX.\n\nWHAT THE SECOND TAB SHOWS, MEASURED:\n\n t=13ms #status \"in-page worker - two-tab.improv\" page blank, 0 grid tables\n t=8016ms #status \"the in-page worker did not answer -- this browser may not\n allow it (see console)\" still blank\n\n8.4s wall from navigation to the message. Two things wrong beyond its lateness:\n\n - It BLAMES THE BROWSER for a condition the code knows precisely. It holds the\n exact NoModificationAllowedError naming the exact document.\n - The first 8 seconds ACTIVELY REASSURE. \"in-page worker - \u003cdoc\u003e\" is written at\n 13ms and reads as success. The user watches a blank page under a green-looking\n status for eight seconds and is then told it is probably their browser's fault.\n\nDiagnosis chain, exactly as gotcha 46 predicts: start() rejects -\u003e worker.js's\nstarted.catch logs to console and nothing else -\u003e a worker that rejects in\nstart() fires NO error on the main thread -\u003e onFrame's `if (!server) return;`\nsilently drops every frame -\u003e only the generic first-frame watchdog is left.\n\nIT IS NOT READ-ONLY, IT IS DEAD, AND IT IS SILENT. Measured in the failed tab:\n\n SECOND after C: mode=CATEGORIES #categories present status=(watchdog message)\n\nThe client is a fully live ClientState. View-only keys still work: C changes the\nmode and paints an empty category panel. So the tab does not present as\nread-only -- it presents as WORKING and then swallows every write into a socket\nwith no peer.\n\nDOES IT RECOVER? The handle IS released on tab close (the browser tears the\nworker context down; no detach needed) and a RELOAD of the second tab then\nsucceeds. But the page never retries: no reconnect, no watchdog rearm. So\n\"read-only until the other tab closes\" is in practice \"dead until you reload\".\n\nNote OpfsFs::detach (opfs.rs:106) has NO CALLER anywhere in the workspace, and\nweb/ has no beforeunload/pagehide/visibilitychange handler. Neither turns out to\nmatter for this case -- recorded so nobody \"fixes\" this by adding one.\n","notes":"SECOND TRIGGER, from improvise-16af (closed as duplicate; filed independently by an external Grok Bot browser smoke the same day): the bug also reproduces WITHOUT a second tab -- reload the page after a prior session left a handle open. The investigation behind this bead only drove the two-tab case, so the reload path is unverified here and should be checked when fixing; it may or may not be the same window between context teardown and handle release.\n\nThat report also observed the status flipping between 'in-page worker - demo.improv' and the watchdog message, i.e. intermittency the two-tab case does not show.\nWAVE 2026-09-21: PARTIALLY FIXED, deliberately left open.\n\nCLOSED by improvise-1wft's diagnosis channel and VERIFIED END TO END in both engines: the two-tab case. The second tab now opens READ-ONLY over vfs::ReadOnly with a true message, instead of painting a reassuring status for 8 seconds and then blaming the browser.\n\nMEASURED, which the read-only design rested on and nobody had probed: getFile() + arrayBuffer() DOES work on a file another context holds a sync access handle to -- 32 correct bytes on both Chromium and Firefox. The probe FIRST asserts that a second createSyncAccessHandle is refused, so it cannot pass vacuously in an engine that does not enforce exclusivity. Those two refusal strings are now the fixtures for boot::is_locked, so the DOMException name match is evidence-backed rather than guessed.\n\nTWO GAPS REMAIN and they need their own work, not more channel:\n 1. The RELOAD-AFTER-PRIOR-SESSION trigger (carried over from improvise-16af). It is a teardown/release race and is not deterministically drivable in the harness.\n 2. NO UPGRADE PATH. Nothing retries when the other tab closes, so it is still 'read-only until you reload' -- now with a true message instead of a false one. improvise-12ha's option C needs this same retry, so the work is not wasted either way.","status":"open","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:39:04Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:59:08Z","labels":["browser","bug","opfs"],"dependencies":[{"issue_id":"improvise-i0v9","depends_on_id":"improvise-1wft","type":"blocks","created_at":"2026-09-21T21:31:50Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-g3no","title":"[bug] d on a _Measure formula target deletes nothing and reports success","description":"DEMONSTRATED, twice, at 1f28868.\n\n_Measure's items are \"explicit items UNION formula targets\" (gotcha 7). Profit\nin web/demo.improv exists ONLY because Formula { target: \"Profit\" } exists; it\nis not in the category's own item list. Model::remove_item calls\ncat.remove_item(name) -- which no-ops, there being no such item -- and then\ndeletes cells whose key names it, of which there are none because the value is\nderived. effective_item_names(\"_Measure\") then re-synthesises Profit from the\nsurviving formula, so the row comes straight back.\n\nThe separate and worse half is that both front ends LIE about it. Each composes\nan unconditional optimistic status beside the mutation:\n\n browser: resolve.rs::delete_at_cat_cursor\n [Command::Model(RemoveItem..), Command::SetStatus(\"Deleted item ..\")]\n TUI: cmd/panel.rs::DeleteCategoryAtCursor\n [effect::RemoveItem.., effect::set_status(\"Deleted item ..\")]\n\nSetStatus is ViewOnly, folded locally, never contradicted by the host. Note the\nordering is the OPPOSITE of gotcha 20's rule, where CommitBuffer emits the\nfallible effect LAST so its failure message wins. Here the fallible effect is\nfirst and the optimistic status wins.\n\nMODEL-LEVEL EVIDENCE (no browser):\n\n let mut wb = Workbook::new(\"T\");\n wb.add_item(\"_Measure\", \"Revenue\").unwrap();\n wb.add_item(\"_Measure\", \"Cost\").unwrap();\n wb.model.add_formula(parse_formula(\"Profit = Revenue - Cost\", \"_Measure\").unwrap());\n wb.remove_item(\"_Measure\", \"Profit\");\n // FAILS: reported success, row still there: [\"Revenue\", \"Cost\", \"Profit\"]\n\nBROWSER EVIDENCE (real Chromium, real worker, real OPFS, real keystrokes via\nharness/, driving C -\u003e j*n -\u003e Enter -\u003e j -\u003e d, no state hand-placed):\n\n MEASURE on: \"item selected|Profit\"\n MEASURE after d: _Measure (2) -- Cost, Profit \u003c- UNCHANGED\n MEASURE status: \"Deleted item 'Profit' from '_Measure'\" \u003c- false\n\nEverything else about d works and was checked: regular category on Row; on Page\nwith that item as the page selection; reached by mouse click rather than j;\ngrouped items and group members; ordinary _Measure items (Revenue really is\ndeleted). The ONLY failing input is a formula target.\n\nNOT BROWSER-SPECIFIC. The TUI runs the same Model::remove_item and composes the\nsame optimistic status. Confirm in the TUI before any fix says \"browser\".\n\nFiles: crates/improvise-core/src/model/types.rs::remove_item,\ncrates/improvise-core/src/workbook.rs::remove_item,\ncrates/improvise-protocol/src/resolve.rs::delete_at_cat_cursor,\ncrates/improvise-command/src/command/cmd/panel.rs::DeleteCategoryAtCursor\n","acceptance_criteria":"Two separable changes. Truthfulness first, it is the reportable half:\n\n1. TRUTHFULNESS. Model::remove_item returns (). Give it a Removed answer, or\n give effect::RemoveItem one, and let the failure message win -- the shape\n gotcha 20 already uses for CommitBuffer, whose rule this violates. Cheapest\n correct version: have the resolver and the Cmd refuse BEFORE composing\n anything, since both ends can already see the tree.\n\n2. CORRECTNESS. Deleting the row a user sees under _Measure should delete the\n FORMULA. The browser cannot: delete-formula-at-cursor is in declined()\n (resolve.rs:232). So the honest browser answer today is declined()-style\n wording -- \"Profit is a formula target; delete the formula instead\" -- and\n deleting formulas from the browser is a separate ticket.\n\nAcceptance: a failing test at the model level first (the snippet in the\ndescription), then the front ends. Both surfaces, one change.","status":"open","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:38:52Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:38:52Z","labels":["browser","bug","core","tui"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-g3no","title":"[bug] d on a _Measure formula target deletes nothing and reports success","description":"DEMONSTRATED, twice, at 1f28868.\n\n_Measure's items are \"explicit items UNION formula targets\" (gotcha 7). Profit\nin web/demo.improv exists ONLY because Formula { target: \"Profit\" } exists; it\nis not in the category's own item list. Model::remove_item calls\ncat.remove_item(name) -- which no-ops, there being no such item -- and then\ndeletes cells whose key names it, of which there are none because the value is\nderived. effective_item_names(\"_Measure\") then re-synthesises Profit from the\nsurviving formula, so the row comes straight back.\n\nThe separate and worse half is that both front ends LIE about it. Each composes\nan unconditional optimistic status beside the mutation:\n\n browser: resolve.rs::delete_at_cat_cursor\n [Command::Model(RemoveItem..), Command::SetStatus(\"Deleted item ..\")]\n TUI: cmd/panel.rs::DeleteCategoryAtCursor\n [effect::RemoveItem.., effect::set_status(\"Deleted item ..\")]\n\nSetStatus is ViewOnly, folded locally, never contradicted by the host. Note the\nordering is the OPPOSITE of gotcha 20's rule, where CommitBuffer emits the\nfallible effect LAST so its failure message wins. Here the fallible effect is\nfirst and the optimistic status wins.\n\nMODEL-LEVEL EVIDENCE (no browser):\n\n let mut wb = Workbook::new(\"T\");\n wb.add_item(\"_Measure\", \"Revenue\").unwrap();\n wb.add_item(\"_Measure\", \"Cost\").unwrap();\n wb.model.add_formula(parse_formula(\"Profit = Revenue - Cost\", \"_Measure\").unwrap());\n wb.remove_item(\"_Measure\", \"Profit\");\n // FAILS: reported success, row still there: [\"Revenue\", \"Cost\", \"Profit\"]\n\nBROWSER EVIDENCE (real Chromium, real worker, real OPFS, real keystrokes via\nharness/, driving C -\u003e j*n -\u003e Enter -\u003e j -\u003e d, no state hand-placed):\n\n MEASURE on: \"item selected|Profit\"\n MEASURE after d: _Measure (2) -- Cost, Profit \u003c- UNCHANGED\n MEASURE status: \"Deleted item 'Profit' from '_Measure'\" \u003c- false\n\nEverything else about d works and was checked: regular category on Row; on Page\nwith that item as the page selection; reached by mouse click rather than j;\ngrouped items and group members; ordinary _Measure items (Revenue really is\ndeleted). The ONLY failing input is a formula target.\n\nNOT BROWSER-SPECIFIC. The TUI runs the same Model::remove_item and composes the\nsame optimistic status. Confirm in the TUI before any fix says \"browser\".\n\nFiles: crates/improvise-core/src/model/types.rs::remove_item,\ncrates/improvise-core/src/workbook.rs::remove_item,\ncrates/improvise-protocol/src/resolve.rs::delete_at_cat_cursor,\ncrates/improvise-command/src/command/cmd/panel.rs::DeleteCategoryAtCursor\n","acceptance_criteria":"Two separable changes. Truthfulness first, it is the reportable half:\n\n1. TRUTHFULNESS. Model::remove_item returns (). Give it a Removed answer, or\n give effect::RemoveItem one, and let the failure message win -- the shape\n gotcha 20 already uses for CommitBuffer, whose rule this violates. Cheapest\n correct version: have the resolver and the Cmd refuse BEFORE composing\n anything, since both ends can already see the tree.\n\n2. CORRECTNESS. Deleting the row a user sees under _Measure should delete the\n FORMULA. The browser cannot: delete-formula-at-cursor is in declined()\n (resolve.rs:232). So the honest browser answer today is declined()-style\n wording -- \"Profit is a formula target; delete the formula instead\" -- and\n deleting formulas from the browser is a separate ticket.\n\nAcceptance: a failing test at the model level first (the snippet in the\ndescription), then the front ends. Both surfaces, one change.","notes":"ALREADY FIXED by 7a44616 (\"fix(panel): five category-panel and axis defects, four\nof them P1\"), an ancestor of 7470b61. Stale-open -- the fourth from that one\ncommit, after improvise-uej8 and improvise-3b2b.\n\nVERIFIED BY EXECUTION, not by reading. Model::remove_item now ends with\nself.formulas.retain(|f| !(f.target_category == cat_name \u0026\u0026 f.target ==\nitem_name)) -- the item-level twin of the sweep remove_category always had. A\nthrowaway probe at HEAD took effective_item_names(\"_Measure\") from\n[\"Revenue\",\"Cost\",\"Profit\"] to [\"Revenue\",\"Cost\"] across\nwb.remove_item(\"_Measure\",\"Profit\"). Probe deleted.\n\nAND THE TICKET'S SECOND HALF NEEDS NOTHING: because the removal now really\nhappens, the optimistic status both front ends compose is TRUE, so no ordering\nchange is owed for the \"reports success\" part.","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:38:52Z","created_by":"Edward Langley","updated_at":"2026-09-22T07:40:27Z","closed_at":"2026-09-22T07:40:27Z","close_reason":"Closed","labels":["browser","bug","core","tui"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-pnf5","title":"Saving while an unfinished records row exists writes an unreadable .improv file","description":"Report-only hunt at 70025e2. No source changes.\n\nLocation: crates/improvise-command/src/command/cmd/grid.rs:518-546; crates/improvise-io/src/persistence/format.rs:202-211; improv.pest:97-101\n\nToggleRecordsMode then AddRecordRow on a populated workbook produces a cell with no coordinates. format_md emits a data line \" = 0\"; parse_md rejects it because coord_list requires at least one coord. CleanEmptyRecords runs on leaving records mode, not before Save. Saving in records mode can therefore overwrite a good document with one the application cannot reopen. Actual command/effect reproduction below. Related improvise-o9uq concerns the cursor editing an unrelated row; fixing that does not make the empty key serializable. Older improvise-s0h mentions cleanup on leaving records but no saving contract. Define persistence for placeholder rows, and prove save/load while one exists. Do not discard legitimate data blindly.\n\n## Steps to Reproduce\nStandalone Rust 2024 package with improvise and improvise-io path dependencies:\n```rust\nuse improvise_io::{workbook::Workbook,persistence::{format_md,parse_md}};\nfn revenue_book()-\u003eWorkbook {\n use improvise_io::model::cell::{CellKey,CellValue};\n let mut w=Workbook::new(\"T\");w.add_category(\"Region\").unwrap();w.add_item(\"Region\",\"East\").unwrap();w.add_item(\"_Measure\",\"Revenue\").unwrap();\n w.model.set_cell(CellKey::new(vec![(\"Region\".into(),\"East\".into()),(\"_Measure\".into(),\"Revenue\".into())]),CellValue::Number(10.0));w\n}\n#[test] fn save_with_unfinished_record_is_readable() {\n use improvise::{ui::app::App,command::{cmd::{Cmd,grid::{ToggleRecordsMode,AddRecordRow}},key::{KeyCode,KeyModifiers}}};\n let mut app=App::new(revenue_book(),None);\n let fx=ToggleRecordsMode.execute(\u0026app.cmd_context(KeyCode::Char('r'),KeyModifiers::NONE));app.apply_effects(fx);\n assert!(app.layout.is_records_mode());\n let fx=AddRecordRow.execute(\u0026app.cmd_context(KeyCode::Char('o'),KeyModifiers::NONE));app.apply_effects(fx);\n let text=format_md(\u0026app.model_state.workbook);\n assert!(parse_md(\u0026text).is_ok(),\"saved unparseable file: {text}\");\n}\n```\nRun cargo test save_with_unfinished_record_is_readable -- --nocapture. Reproduced failing against unchanged code.\n\n## Acceptance Criteria\nCorrect the described observable behavior; preserve ordinary cases with focused regressions and explicit edge-case policy.\n","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:35:47Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:58:13Z","closed_at":"2026-09-21T21:58:13Z","close_reason":"Fixed in the 2026-09-21 wave, integrated into main at 1574 passed / 0 failed / 7 ignored, all gates green, every guard fault-injected.\n\nGrammar first: data_coords = _{ (coord_list ~ \" \")? }, so a coordinate-free cell is spelled '= 42'. Unambiguous under PEG because bare_name cannot start with '='. The scalar cell was given a SPELLING rather than discarded, per the ticket's 'do not discard legitimate data' -- it is what AddRecordRow leaves mid-row, and before this saving in records mode could overwrite a good document with an unreadable one.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-16af","title":"[bug] In-page worker intermittent blank grid: OPFS SyncAccessHandle / NotAllowedError","description":"## Summary\nDefault `/improvise/` (in-page worker) intermittently shows a blank grid. Status flips between `in-page worker · demo.improv` and `the in-page worker did not answer`. Console reports OPFS `createSyncAccessHandle` NotAllowedError / AccessHandle conflict.\n\n## Repro\n1. Open https://fiddlerwoaroof.github.io/improvise/ (or local web/) in Chromium.\n2. Optionally open a second tab on the same origin/doc, or reload after a prior session left a handle open.\n3. Observe blank grid and/or watchdog message; DevTools console shows SyncAccessHandle / NotAllowedError.\n\n## Expected\nWorker starts, handshake completes, demo grid paints. Concurrent tabs either share safely or fail with a loud, specific \"document open elsewhere\" status.\n\n## Actual\nIntermittent blank UI; generic watchdog text; OPFS exclusive-access conflict in console.\n\n## Evidence\n- Browser smoke (external Grok Bot agent for Ed Langley), 2026-09-21 PT.\n- `crates/improvise-worker-server/src/bindings.rs`: `storage.attach(\u0026path).await?` / OPFS path.\n- `crates/improvise-io/src/opfs.rs`: `attach` acquires sync access handle.\n- `web/app.js` / `web/shell.js`: handshake watchdog arms when peer never answers.\n\n## Notes\nFound by an external Grok Bot agent smoke/code hunt for Ed Langley (not Claude). Do not claim this is a black-hole key issue.\n","status":"closed","priority":1,"issue_type":"bug","created_at":"2026-09-21T20:32:37Z","created_by":"box","updated_at":"2026-09-21T21:26:25Z","closed_at":"2026-09-21T21:26:25Z","close_reason":"Duplicate of improvise-i0v9, same root cause (OPFS createSyncAccessHandle is exclusive per file across contexts, and bindings.rs::boot awaits storage.attach before anything else).\n\ni0v9 is kept because it carries the measurement: the failure is at attach, NOT open -- OpfsFs::open (getDirectory) and refresh() both SUCCEED in the second tab, only handle acquisition fails -- plus the timings (a reassuring 'in-page worker - \u003cdoc\u003e' at 13ms, the blame-the-browser message at 8016ms) and the finding that the tab is not read-only but DEAD, since view-only keys still work while all 13 composable ModelCommand variants vanish silently.\n\nTHE ONE THING THIS REPORT ADDS, carried over to i0v9: the single-tab trigger. 'Reload after a prior session left a handle open' reproduces it without a second tab, which i0v9's investigation did not test.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-2cw9","title":"Import silently overwrites repeated coordinate tuples with the last row","description":"Report-only bug hunt at HEAD 70025e2, 2026-09-21. No implementation changes.\n\nLocation: crates/improvise-io/src/import/wizard.rs:230-236\n\nCSV Region,Revenue / East,10 / East,20 imports successfully with one cell East/Revenue=20 and no warnings. A sum over the imported data is 20 instead of 30; the first row disappeared. Each input record directly calls set_cell at the same key. The desired collision policy needs an explicit choice: preserve records, aggregate appropriately, or reject/warn; silent last-row-wins is not acceptable. The reproducer deliberately accepts a warning so it does not impose sum as the only valid policy. Duplicate checks: import, collision, coordinates, overwrite; improvise-5j9 covers missing category values, not complete repeated rows.\n\n## Steps to Reproduce\nUse a standalone Rust 2024 package with path dependencies on improvise and improvise-io, plus serde_json=1 and ratatui=0.30.\n```rust\nuse improvise_io::{workbook::Workbook,persistence::{format_md,parse_md},formula::AggFunc};\nuse improvise_io::import::csv_parser::parse_csv_bytes_with_warnings;\n#[test] fn repeated_import_coordinates_not_silently_overwritten() {\n use improvise_io::import::wizard::ImportPipeline;\n let (records,_)=parse_csv_bytes_with_warnings(b\"Region,Revenue\\nEast,10\\nEast,20\\n\").unwrap();\n let (wb,warnings)=ImportPipeline::new(serde_json::Value::Array(records)).build_model_with_warnings().unwrap();\n let total:f64=wb.model.data.iter_cells().filter_map(|(_,v)|v.as_f64()).sum();\n assert!(total==30.0 || !warnings.is_empty(),\"total {total}, no warning {warnings:?}\");\n}\n```\nRun cargo test repeated_import_coordinates_not_silently_overwritten -- --nocapture. The current implementation failed this test.\n\n## Acceptance Criteria\nAddress the behavior above with a focused regression that fails before the fix and preserves ordinary input behavior.\n","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:31:28Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:58:04Z","closed_at":"2026-09-21T21:58:04Z","close_reason":"Fixed in the 2026-09-21 wave, integrated into main at 1574 passed / 0 failed / 7 ignored, with fmt, clippy, --check-evidence and the 8-crate wasm gate green. Every guard was fault-injected and watched failing before being believed.\n\nbuild_model_with_warnings accumulates into a Vec\u003cPendingCell\u003e with a HashMap\u003cCellKey, usize\u003e beside it that ONLY deduplicates, then writes in first-seen order. Duplicate coordinates are SUMMED and reported.\n\nPOLICY, stated because the ticket left it open: the model cannot preserve both records -- a cell is named by its coordinate tuple and there is no second address -- so the lossless options are aggregating or inventing a row-identity category, and the latter reshapes every imported document. Summing is what a pivot table does and is measure_agg's default. It is WRONG for a measure the user later sets to Avg, and the warning is what makes that the user's problem rather than an invisible one.\n\nNote the fix CREATES gotcha 18's hazard: set_cell writes into an IndexMap persistence depends on, so a hash-ordered write would give the same CSV a different .improv per process. Guarded by a five-iteration test.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-8l0c","title":"Formula panel truncation panics on a valid Unicode formula","description":"Report-only bug hunt at HEAD 70025e2, 2026-09-21. No implementation changes.\n\nLocation: src/ui/formula_panel.rs:54-55\n\nRendering |é| = 1 in a formula panel with outer width 7 panics: end byte index 4 is not a char boundary; it is inside é (bytes 3..5). Formula identifiers support pipe-quoted Unicode names. The panel truncates bytes instead of display columns, so ordinary resizing can crash the TUI. Use a shared Unicode/display-width safe truncator. Duplicate checks: formula panel, UTF, unicode. Test drove the actual Panel widget, not a copied substring expression.\n\n## Steps to Reproduce\nUse a standalone Rust 2024 package with path dependencies on improvise and improvise-io, plus serde_json=1 and ratatui=0.30.\n```rust\nuse improvise_io::{workbook::Workbook,persistence::{format_md,parse_md},formula::AggFunc};\nuse improvise_io::import::csv_parser::parse_csv_bytes_with_warnings;\n#[test] fn formula_panel_unicode_does_not_panic() {\n use improvise::{protocol::FormulaCache,ui::{app::AppMode,formula_panel::FormulaContent,panel::{Panel,PanelContent}}};\n use ratatui::{buffer::Buffer,layout::Rect,widgets::Widget};\n let cache=FormulaCache{formulas:vec![\"|é| = 1\".into()]};\n let area=Rect::new(0,0,7,5);let mut buffer=Buffer::empty(area);\n Panel::new(FormulaContent::new(\u0026cache,\u0026AppMode::Normal),\u0026AppMode::Normal,0).render(area,\u0026mut buffer);\n}\n```\nRun cargo test formula_panel_unicode_does_not_panic -- --nocapture. The current implementation failed this test.\n\n## Acceptance Criteria\nAddress the behavior above with a focused regression that fails before the fix and preserves ordinary input behavior.\n","status":"open","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:31:27Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:31:27Z","comments":[{"id":"661a4de2-dee0-52c2-ac12-e095e1ee414c","issue_id":"improvise-8l0c","author":"Edward Langley","text":"Same truncation line also panics on ASCII when inner width is zero. Demonstrated by rendering FormulaCache { formulas: vec![\"A = 1\".into()] } through Panel into Rect::new(0,0,2,5): formula_panel.rs:55 subtracts 1 from width 0. In release the wrapped slice bound also panics. Include widths 0/1/2 in the shared safe-truncation regression; no separate bead filed.","created_at":"2026-09-21T20:35:50Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} +{"_type":"issue","id":"improvise-8l0c","title":"Formula panel truncation panics on a valid Unicode formula","description":"Report-only bug hunt at HEAD 70025e2, 2026-09-21. No implementation changes.\n\nLocation: src/ui/formula_panel.rs:54-55\n\nRendering |é| = 1 in a formula panel with outer width 7 panics: end byte index 4 is not a char boundary; it is inside é (bytes 3..5). Formula identifiers support pipe-quoted Unicode names. The panel truncates bytes instead of display columns, so ordinary resizing can crash the TUI. Use a shared Unicode/display-width safe truncator. Duplicate checks: formula panel, UTF, unicode. Test drove the actual Panel widget, not a copied substring expression.\n\n## Steps to Reproduce\nUse a standalone Rust 2024 package with path dependencies on improvise and improvise-io, plus serde_json=1 and ratatui=0.30.\n```rust\nuse improvise_io::{workbook::Workbook,persistence::{format_md,parse_md},formula::AggFunc};\nuse improvise_io::import::csv_parser::parse_csv_bytes_with_warnings;\n#[test] fn formula_panel_unicode_does_not_panic() {\n use improvise::{protocol::FormulaCache,ui::{app::AppMode,formula_panel::FormulaContent,panel::{Panel,PanelContent}}};\n use ratatui::{buffer::Buffer,layout::Rect,widgets::Widget};\n let cache=FormulaCache{formulas:vec![\"|é| = 1\".into()]};\n let area=Rect::new(0,0,7,5);let mut buffer=Buffer::empty(area);\n Panel::new(FormulaContent::new(\u0026cache,\u0026AppMode::Normal),\u0026AppMode::Normal,0).render(area,\u0026mut buffer);\n}\n```\nRun cargo test formula_panel_unicode_does_not_panic -- --nocapture. The current implementation failed this test.\n\n## Acceptance Criteria\nAddress the behavior above with a focused regression that fails before the fix and preserves ordinary input behavior.\n","notes":"FIXED in PR #124 (merge e8f66e6). Reachability is MUCH WIDER than this ticket said, and that is the finding worth keeping.\n\nTHE CRASH NEVER NEEDED UNICODE. draw.rs:297 lays the side panel out as\n[Constraint::Min(40), Constraint::Length(side_w)] -- verified by the orchestrator --\nand the solver starves the panel first: terminal \u003c=40 gives the panel width ZERO,\n50 gives 10, 72+ gives 32. Frame::render_widget has no empty-area guard. So ANY\nTERMINAL AT OR BELOW ~42 COLUMNS WITH A PANEL OPEN PANICKED THE TUI ON AN ORDINARY\nASCII FORMULA, via a subtract-with-overflow on the unchecked inner.width. The\nUnicode title in this ticket is the NARROWER case; resizing was the common one.\n\nTHE FIX: grid.rs's existing display-width truncate moved to a new src/ui/text.rs\nand shared; the formula panel calls it. Not a third truncator -- import_wizard_ui's\nbyte-measured one stays deliberately, as context/code/tui.md argues.\nprefix_by_width replaced a chars().take(max_width) tail that over-ran by a cell\nfor a wide char at width 1.\n\nTWO RIVAL FIXES INJECTED AND WATCHED FAIL, which is what makes the tests evidence:\n - a char-count truncator (the obvious 'unicode-safe' repair) passes BOTH panic\n tests and fails a_result_never_exceeds_the_width_it_was_given on '日本語 = 1'.\n A char count is not a cell count.\n - repairing only the subtraction (saturating_sub) still panics at the char\n boundary, and again at width 1 where the bound saturates to 0 for a string\n needing one char. A TEST WRITTEN ONLY AT WIDTH 0 WOULD HAVE ACCEPTED IT.\n\nSIBLINGS FOUND BY SEARCHING FOR THE CLASS, not the symptom: category_panel.rs and\nview_panel.rs both place a second string at inner.x + name.len() -- a BYTE COUNT\nUSED AS A COLUMN. No panic, because Buffer::set_string clips, so it shows only as\na gap widening with each non-ASCII character. Both fixed, each test drawing the\nrow twice (Cafe and Café) and requiring them to differ only by that character.\n\nFILED improvise-n8vh: neither of those panels truncates AT ALL, so a long name\noverwrites the panel border. Different bug -- no truncation rather than wrong\ntruncation.","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:31:27Z","created_by":"Edward Langley","updated_at":"2026-09-22T06:39:19Z","started_at":"2026-09-22T06:31:23Z","closed_at":"2026-09-22T06:39:19Z","close_reason":"Closed","comments":[{"id":"661a4de2-dee0-52c2-ac12-e095e1ee414c","issue_id":"improvise-8l0c","author":"Edward Langley","text":"Same truncation line also panics on ASCII when inner width is zero. Demonstrated by rendering FormulaCache { formulas: vec![\"A = 1\".into()] } through Panel into Rect::new(0,0,2,5): formula_panel.rs:55 subtracts 1 from width 0. In release the wrapped slice bound also panics. Include widths 0/1/2 in the shared safe-truncation regression; no separate bead filed.","created_at":"2026-09-21T20:35:50Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} {"_type":"issue","id":"improvise-jc95","title":"CSV nonfinite and overflowing numeric tokens are silently replaced with zero","description":"Report-only bug hunt at HEAD 70025e2, 2026-09-21. No implementation changes.\n\nLocation: crates/improvise-io/src/import/csv_parser.rs:133-136\n\nA CSV Value column containing NaN imports as JSON number 0 with no warning. inf, -inf and 1e999 take the same parse::\u003cf64\u003e / from_f64(...).unwrap_or(Number::from(0)) branch. A missing/nonrepresentable measurement becomes an invented real zero. Refuse with a located error or preserve/report the token; never fabricate 0. Duplicate checks: CSV, NaN, non-finite; existing gp-tree numeric tickets affect a separate pipeline.\n\n## Steps to Reproduce\nA standalone Rust 2024 test package depends on improvise-io by path. Imports:\n```rust\nuse improvise_io::{workbook::Workbook,persistence::{format_md,parse_md},formula::AggFunc};\nuse improvise_io::import::csv_parser::parse_csv_bytes_with_warnings;\n#[test] fn csv_nonfinite_is_not_zero() {\n for token in [\"NaN\",\"inf\",\"-inf\",\"1e999\"] {\n let (r,_)=parse_csv_bytes_with_warnings(format!(\"Value\\n{token}\\n\").as_bytes()).unwrap();\n assert_ne!(r[0][\"Value\"].as_f64(),Some(0.0),\"{token} became zero\");\n }\n}\n```\nRun cargo test csv_nonfinite_is_not_zero. This test failed against the unchanged source.\n\n## Acceptance Criteria\nThe focused test passes after correction, with a control covering the unchanged ordinary case. Preserve the behavior described above without silent data loss.\n","notes":"Raised P2 -\u003e P1 on closing improvise-eiv2 as a duplicate: an external Grok Bot review found the same defect and rated it P1, and silent numeric corruption on import deserves that. Mechanism from that report, worth keeping: serde_json::Number::from_f64 returns None for NaN and Infinity, and parse_csv_field's .unwrap_or(Number::from(0)) turns that None into a zero. Reproduction exists in PR #101 as csv_nonfinite_is_not_zero (ignored until fixed).","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:27:43Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:58:01Z","closed_at":"2026-09-21T21:58:01Z","close_reason":"Fixed in the 2026-09-21 wave, integrated into main at 1574 passed / 0 failed / 7 ignored, with fmt, clippy, --check-evidence and the 8-crate wasm gate green. Every guard was fault-injected and watched failing before being believed.\n\nparse_csv_field falls through to the string arm when Number::from_f64 returns None, instead of unwrap_or(Number::from(0)). NaN/inf/-inf/1e999 keep their own text. Reproduction csv_nonfinite_is_not_zero un-ignored. Downstream consequence worth knowing: a column carrying NaN is no longer all-numeric, so the analyzer proposes it as Category/Label rather than Measure -- that is the honest answer and it is VISIBLE in the wizard, where a fabricated zero was not.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-o9uq","title":"o in records mode edits someone else's record and can rename it away","description":"Found by the command-layer bug hunt. DEMONSTRATED end to end through App::handle_key. THIS IS SILENT DATA CORRUPTION FROM A DOCUMENTED KEY.\n\n## Steps to Reproduce\nWorkbook: Region{East, West}; _Measure{Revenue}; cells Region=East/Revenue=10, Region=West/Revenue=20. term 120x40.\nKeys: R (records mode), j (cursor to row 1 = West), o, then N o r t h, Enter.\n\n after j sel=(1,0) rows[0:East | 1:West]\n after o sel=(0,0) edit_buf=Some(\"East\")\n rows[0:East | 1:West | 2:\u003cempty coords\u003e]\n after \"North\"+Enter\n rows[0:West | 1:\u003cempty coords\u003e | 2:Region=EastNorth]\n\n`o` is bound and documented as \"add a record row\" (help.rs:91, 265). The user should be editing the row it just added. Instead: the new row 2 is created and left empty, THE CURSOR LANDS ON ROW 0, the editor is pre-filled with row 0's Region coordinate \"East\", the typed text APPENDS, and Enter goes through commit_plain_records_edit -\u003e RecordCol::Category -\u003e rename_record_coordinate, WHICH RENAMES THE PRE-EXISTING EAST RECORD TO EastNorth and moves all its measure cells.\n\nA user adding a row silently corrupts an unrelated one.\n\n## Two independent causes, both needed for the full damage\n1a (longstanding). AddRecordRow::execute (cmd/grid.rs:518) returns only SetCell + set_status — it NEVER POSITIONS THE CURSOR. EnterEditAtCursorCmd then opens an editor on whatever the cursor is on. Contrast CommitAndAdvance's Tab-at-bottom-right path (commit.rs:547), which calls the same AddRecordRow and DOES follow it with viewport_effects(cursor.row + 1, 0, ..). One of the two AddRecordRow call sites places the cursor; the `o` binding is the one that does not.\n\n1b (regression of improvise-ni6k / gotcha 24). AddRecordRow's SetCell is Model(CellData), so the frames_basis arm diffs every view's frame; appending a row changes the hash, moved.contains(view) is true, and reset_cursor() fires MID-BATCH, before EnterEditAtCursor. Isolated and demonstrated:\n apply_effects([SetCell(CellKey::new(vec![]), Number(0.0))]) from sel=(1,0)\n -\u003e sel (1,0) -\u003e (0,0), cursor_epoch 2 -\u003e 3\n\n1b IS A DEFECT ON ITS OWN TERMS: appending a row does not renumber any existing row, yet view_frame hashes entries.len() and every entry, so ANY APPEND REFRAMES and throws the cursor to the origin. In records mode that is every record insert. The Tab path only escapes it by re-placing the cursor afterwards.\n\n## Also observed, same root, lower severity\nPressing `o` twice with no page-axis category adds ONE row, because both emit SetCell(CellKey::new(vec![])) — the same empty-coordinate record — while the status says \"Added new record row\" both times.\n\n## Acceptance Criteria\n- AddRecordRow::execute appends viewport_effects(new_last_row, 0, ..) after its SetCell, so the cursor lands on the row it created. Harmless at the CommitAndAdvance call site, whose own later viewport_effects wins and survives because the observation is inside the loop. Fixes 1a and masks 1b at this site.\n- A test driving the exact key sequence above and asserting the East record is UNTOUCHED.\n- 1b proper wants the frame observation to distinguish \"rows appended\" from \"rows renumbered\" — a separate, larger question. File it rather than fold it in.\n- Two `o` presses should add two rows or say why not.\n\nScope: TUI only. resolve.rs:237 puts add-record-row in declined(), so no browser client reaches it.\n","status":"open","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:25:02Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:25:02Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-bd9o","title":"The browser reaches records mode through the tile bar, and everything it can do there is silent","description":"Found by the protocol bug hunt. DEMONSTRATED end to end with a real Host and a real Client, using only BrowserKeys — no hand-built fixture.\n\ndeclined() (resolve.rs:230) answers \"toggle-records-mode\" with \"records mode is not available in the browser yet\", and the code is written as though that closes the route. IT DOES NOT. The tile bar carries the VIRTUAL categories (project::tile_entries -\u003e View::tileable_cats includes _Index/_Dim), and set-axis-for-tile is fully composable by the browser. Records mode auto-detects from _Index on Row + _Dim on Column (gotcha 1), so FIVE ORDINARY KEYS get there.\n\n T -\u003e [SetTileCatIdx(0), ChangeMode(TileSelect)]\n r -\u003e [Model{SetAxis{_Index, Row}}, SetStatus(\"_Index -\u003e Row\")]\n l -\u003e [SetTileCatIdx(1)]\n c -\u003e [Model{SetAxis{_Dim, Column}}, SetStatus(\"_Dim -\u003e Col\")]\n Esc -\u003e [ChangeMode(Normal)]\n =\u003e records = true, mode = RecordsNormal\n\nOnce there:\n l l i -\u003e [SetBuffer{edit,\"1200\"}, ChangeMode(RecordsEditing)]\n 9 9 -\u003e [SetBuffer{edit,\"120099\"}]\n Enter -\u003e Key{Enter} \u003c- BLACK HOLE\n after Enter: mode still RecordsEditing, status unchanged, grid unchanged\n\ncommit (resolve.rs:696) returns None -\u003e Step::Upstream -\u003e KeyOutcome::Upstream -\u003e ClientFrame::Key -\u003e Host::handle answers an EXPLICIT EMPTY VEC (gotcha 47). Enter on a typed edit does nothing, says nothing, and leaves the editor open holding the text.\n\nAnd create_column (improvise-gcvb) is consequently UNREACHABLE from any key path: the only door into RecordsEditing is edit-or-drill, which returns None for CellId::Create; `o` is declined at its add-record-row step.\n\n## The shape\nFailure-catalogue #4 — an unreachability claim only as wide as the routes enumerated. The author considered the TOGGLE, not the AXIS ASSIGNMENT. THIRD instance of this shape in one week (see improvise-e5pf and the PR #98 round). And it is exactly the dead room stranding()/declined() exist to prevent: improvise-rmvx, \"a door this client will not open still has to say so\".\n\n## Acceptance Criteria\n- CHEAPEST HONEST CLOSE: refuse in edit_or_drill (resolve.rs:594) when state.grid.records, with the existing declined() wording — so the editor never opens on a cell the client cannot commit, and create_column's deadness becomes explicit rather than accidental.\n- A test driving the five-key route and asserting the decline is SPOKEN, not silent.\n- The larger fix — composing the records commit, the browser half of commit_plain_records_edit — needs RecordCol, which is session-local by gotcha 1. That is a design question, not a patch; do not attempt it as part of closing this.\n","status":"open","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:24:37Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:24:37Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-p7lh","title":"[bug] No UI at all for page-level dimensions: page_coords reaches the browser's cache and stops there","description":"Found by the browser bug hunt. DEMONSTRATED with a probe against a real Host.\n\nGridCache carries totals (the column-totals strip, gotcha 33/44) and page_coords (the page bar, gotcha 27). The server projects BOTH to every session and a browser client's cache holds them, correctly maintained. The TUI paints both (src/ui/grid.rs:393 and :452). RenderModel HAS NO FIELD FOR EITHER, so web/app.js cannot and does not paint them.\n\n $ grep -rn \"page_coords\\|totals\" crates/improvise-wasm-client/src/ web/\n (no matches)\n\n## The concrete input is the shipped front page\nweb/demo.improv line 8: `Quarter: page, Q1`. So the standalone deployment's front page shows a grid FILTERED TO Q1 with nothing on screen saying so.\n\nWorse: `[` / `]` DO resolve locally in the browser off state.grid.page_coords (resolve.rs:387, page_cycle, with its own passing test). So a browser user can press `]`, watch every number in the grid change, and have no way to tell which quarter they are looking at or how to get back. The numbers are right and the frame is a lie by omission.\n\nProbe output (real Host, Quarter on Axis::Page, one column total, bootstrap folded through the real Client::on_frame):\n page_coords = [PageAxis { cat: \"Quarter\", selected: \"Q1\", items: Some([\"Q1\",\"Q2\"]) }]\n totals len = 1\n panicked: the render model names no SELECTED PAGE ITEM\n\n## Why the guard missed it, and this is the more valuable half\nweb/tests/render_surface.test.js exists for exactly this fault class — \"a projected field that nothing paints is the bug four widgets shared\". It reads the pub fields off RenderModel and requires each to appear as model.\u003cfield\u003e in app.js/shell.js. ITS DOMAIN IS RenderModel, NOT THE CACHES. A fact that never reaches RenderModel at all is invisible to it: the check is closed under \"what the client decided to expose\", which is the thing under test.\n\nThe honest guard is one direction up: every field of every WidgetCache the server maintains reaches a front end, or is named as debt with an argument. That would also have caught this when GridCache::totals and page_coords were added (improvise-n10).\n\n## Acceptance Criteria\n- RenderModel gains page_coords and totals, fed straight off state.grid, and app.js/shell.js::gridPlan paint them.\n- SEPARATELY AND FIRST IF NEED BE: extend render_surface.test.js to the widget caches. File the guard even if the painting is deferred — an unpainted field with an argument in UNPAINTED is debt; an unpainted field nothing can see is the next one.\n- A harness assertion that the page bar names the selected item, since the demo ships with a page axis.\n","notes":"USER RULING 2026-09-21, PROMOTED P2 -\u003e P1: 'there is no UI at all for page-level dimensions'.\n\nVERIFIED at 523d571, and the shape matters -- this is not 'unbuilt', it is built to one step short of the screen:\n\n improvise-protocol/src/render.rs:433 pub page_coords: Vec\u003cPageAxis\u003e on GridCache\n improvise-protocol/src/render.rs:475 GridUpdate::PageCoords(..) seeded in the bootstrap\n improvise-protocol/src/render.rs:620 GridUpdate::PageCoords(coords) =\u003e self.page_coords = coords\n improvise-wasm-client/src/render.rs *** page_coords / PageAxis ABSENT ***\n web/app.js *** paints nothing page-related ***\n\nSo the server projects it, the browser's GridCache folds it and maintains it\ncorrectly, and RenderModel never exposes it. The page bar's data is sitting in\nthe client, one field away from being drawable.\n\nThis also explains why the parity sweep did not catch it. That audit checked\n'every field RenderModel EXPOSES is painted' and found that set empty -- a true\nand useful result. It could not see a field that never reaches RenderModel at\nall. A missing field is invisible to an exposed-but-unpainted check, which is\nworth remembering before trusting that audit's clean bill again.\n\nWHY THIS IS NOW P1 RATHER THAN A COSMETIC GAP: three user reports converge on\nit.\n - improvise-z336: clicking a category item PUTS the category on the Page axis.\n With no page bar, the user has moved a dimension onto a surface that is\n invisible, which is most of why the filter felt impossible to clear.\n - The TUI's [ and ] page cycling has no browser affordance, so the one\n navigable axis (gotcha 27: Page is navigable, Filter is not) cannot be\n navigated.\n - A pivot table whose page dimension is invisible is missing a third of its\n layout vocabulary -- Row, Column and Page are the three visible axes.\n\nSCOPE NOTE: this bead also covers column totals, which are the same shape --\nGridCache::totals is projected and held, and the browser paints nothing. Those\nmust be PROJECTED rather than client-derived, because the sum runs over every\nrow including ones a viewport scrolled past (gotcha 44). If whoever takes this\nwants to split totals out, that is fine; do not 'fix' them by summing in the\nclient.\n\nRelated and not the same: improvise-pcje is the residue of the parity sweep\n(col_widths, column ancestry, AxisColor, tile overflow) after this bead's two\nitems are removed.","status":"open","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:24:13Z","created_by":"Edward Langley","updated_at":"2026-09-22T00:32:49Z","dependency_count":0,"dependent_count":1,"comment_count":0} -{"_type":"issue","id":"improvise-pv3g","title":"fit-measure panics on a document whose target values are large but finite","description":"Found by the gp bug hunt. DEMONSTRATED end-to-end through the shipped binary. This is a HARD CRASH IN THE TUI, not a refusal — FitMeasure::execute has no catch, so the terminal app dies with unsaved work.\n\n $ improvise cmd 'fit-measure Score' -f big.improv\n thread 'main' panicked at crates/improvise-gp/src/lib.rs:288:5:\n number_text wants a finite value, got NaN\n\nAn ordinary .improv: 8 records, one Region category, a Cost feature, a Score target of 1e20/4e20.\n\n## Mechanism — the guard checks the wrong side of the computation\nimprovise-gp/src/fit.rs:279 checks the INPUTS: every r.target, every r.feat, every data.ranges entry is finite. 1e20 is a perfectly finite f32. The overflow is in the DERIVED quantity: standardize (gp-tree/src/fit.rs:185) sums (t - mean)^2 in f32, and 8 x (1.5e20)^2 = 1.8e40 exceeds f32::MAX = 3.4e38, so std = inf. Every normalized target becomes +/-0, fit returns leaves near zero, and denormalize computes 0.0 * inf + mean = NaN. number_text then asserts.\n\nMeasured in isolation:\n scale 1e18: mean=2.5e18 std=1.5e18 leaf(0.0)-\u003eLeaf(2.5e18) OK\n scale 1e20: mean=2.5e20 std=inf leaf(0.0)-\u003eLeaf(NaN) PANIC\n scale 1e25, 1e30: same\n\n## Reachability SCALES WITH RECORD COUNT, which is the part that matters\nThe condition is sum((t-mean)^2) \u003e f32::MAX, so at k records the crashing spread is about sqrt(3.4e38 / k):\n ~6e18 at 8 records\n ~4e18 at 100\n ~1.3e17 at the 20,000-record MAX_RECORDS ceiling\nA sibling path: sum(t) overflowing gives mean = inf, and NaN.max(1e-6) returns 1e-6 (Rust f32::max prefers the non-NaN operand), so std is FINITE while mean is infinite — same panic, different arithmetic.\n\nThis is exactly the failure family the guard's own comment names (\"Reachable from a .improv cell\").\n\n## Acceptance Criteria\n- fit-measure REFUSES rather than panics, with a message the user can act on. Suggested wording: the values are too large for 32-bit arithmetic because their spread overflows; scale the measure (thousands, millions) and fit that.\n- The check is on the DERIVED quantities (mean, std) after standardize, not only on the inputs.\n- A test at a spread that overflows and one just below it, so the guard is not satisfied by refusing everything.\n- Note the same overflow silently corrupts cv_regress's quality numbers today with NO panic to announce it — so a fix that only catches the panic leaves wrong R2 values being reported. The structurally better answer is a type standardize returns that cannot carry a non-finite (mean, std), the way Row\u003cT\u003e made a truncating zip unrepresentable.\n","notes":"WAVE 2026-09-21: not done -- outside the assigned agent's ground, reported rather than guessed.\n\nimprovise-2wu3: the fix is edit() capturing the RecordCell in web/app.js plus a by-surrogate sibling of typed_cell_frame in wasm-client/src/lib.rs; the test belongs in wasm-client/tests/. It is also the live counterexample to improvise-6nsd's position-free argument -- the protocol vocabulary is position-free, and web/app.js still captures viewport (row, col) at paint time and resolves it against the CURRENT cache at commit.\n\nimprovise-exdg: needs a new CellId variant in improvise-protocol/src/render.rs AND exhaustiveness in improvise-wasm-client/src/render.rs. Note CellId already has a Create variant for the records-mode creating column, so a coordinate-cell variant slots in beside it naturally.\n\nimprovise-pv3g: NOT in improvise-core. The panic is improvise-gp/src/lib.rs:290 (number_text), the wrong-sided guard is improvise-gp/src/fit.rs:279, and the actual overflow is standardize in gp-tree/src/fit.rs:188-196 -- sum((t-mean)^2) in f32, plus a .max(1e-6) laundering a NaN into a finite std. Needs a gp-crate agent.","status":"open","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:23:58Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:59:58Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-dy3j","title":"A Label category is not persisted, so a document with 12 categories plus a label can never be reopened","description":"Found by the io/formula bug hunt. DEMONSTRATED end to end through the real import path. Orchestrator re-verified each link independently.\n\nCategoryKind::Label is not persisted. format.rs writes every category as a bare `## Category: Name`; improv.pest has no syntax for a kind; parse.rs:325 reads every section back through `wb.add_category(\u0026pc.name)?` and NEVER add_label_category. So the kind is dropped on the way out AND on the way in, which is why no round-trip equality test can see it (doctrine a_roundtrip_equality_is_blind_to_a_loss_both_sides_share, exactly).\n\n## Why this is a lost document, not a lost flag\nLabel is deliberately OUTSIDE the 12-category pivot limit:\n - Model::add_label_category (core/model/types.rs:295) has NO limit check. Verified.\n - The wizard's precondition ImportPipeline::proposed_category_count (io/import/wizard.rs:97) scores `FieldKind::Measure | FieldKind::Label =\u003e 0`. Verified.\n\nSo the wizard correctly accepts 12 categories + N labels. On load each label is re-created as Regular, and Model::add_category checks the limit BEFORE the idempotent-duplicate check, so the 13th is refused and parse_md fails.\n\n ANY DOCUMENT WHERE regular + label \u003e 12 IS PERMANENTLY UNLOADABLE.\n\n11 regular + 2 labels fails too.\n\n## Steps to Reproduce\nA JSON/CSV with 12 low-cardinality text columns, one measure, and one high-cardinality text column (\u003e20 distinct values — analyzer.rs:161 auto-classifies it FieldKind::Label, accepted: true). An id, description or free-text note column does this in any real CSV.\n\n proposals include (\"Note\", Label, true)\n proposed_category_count() == 12 \u003c- precondition asserted, within the limit\n wb.model.category(\"Note\").kind == Label \u003c- precondition asserted\n parse_md(format_md(wb)) -\u003e Err(\"Maximum of 12 categories reached\")\n\nAlso reproduced at the Workbook level: 12 add_category + 1 add_label_category.\n\n## The quieter half, below the limit\n Region + label Note -\u003e round trip -\u003e kind: Regular, expected Label\nA label silently becomes a pivot category: it enters regular_category_names() pickers, counts against the limit, and gains axis auto-assignment.\n\n## Acceptance Criteria\n- The format carries the kind. Suggested: a kind suffix on category_section (`## Category: Note [label]`), written by format_md when cat.kind == Label, read by build_category into add_label_category. GRAMMAR FIRST — improv.pest is the source of truth (design-principles §3).\n- A round-trip test asserting the KIND, not just the name and items. Note the existing suite cannot see this class; a test that only compares formatted output will pass either way.\n- Decide what an older file without the marker means on load. Regular is the compatible reading and is what happens today.\n\n## Two independent mitigations, each correct on its own merits\n- Swap the two guards in Model::add_category so the duplicate check precedes the limit check. Filed separately as improvise-f86q.\n- Give add_label_category a limit of its own, so the unloadable document cannot be CREATED.\nNeither alone recovers documents already written; only the format fix does.\n","status":"open","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:23:30Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:23:30Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-o9uq","title":"o in records mode edits someone else's record and can rename it away","description":"Found by the command-layer bug hunt. DEMONSTRATED end to end through App::handle_key. THIS IS SILENT DATA CORRUPTION FROM A DOCUMENTED KEY.\n\n## Steps to Reproduce\nWorkbook: Region{East, West}; _Measure{Revenue}; cells Region=East/Revenue=10, Region=West/Revenue=20. term 120x40.\nKeys: R (records mode), j (cursor to row 1 = West), o, then N o r t h, Enter.\n\n after j sel=(1,0) rows[0:East | 1:West]\n after o sel=(0,0) edit_buf=Some(\"East\")\n rows[0:East | 1:West | 2:\u003cempty coords\u003e]\n after \"North\"+Enter\n rows[0:West | 1:\u003cempty coords\u003e | 2:Region=EastNorth]\n\n`o` is bound and documented as \"add a record row\" (help.rs:91, 265). The user should be editing the row it just added. Instead: the new row 2 is created and left empty, THE CURSOR LANDS ON ROW 0, the editor is pre-filled with row 0's Region coordinate \"East\", the typed text APPENDS, and Enter goes through commit_plain_records_edit -\u003e RecordCol::Category -\u003e rename_record_coordinate, WHICH RENAMES THE PRE-EXISTING EAST RECORD TO EastNorth and moves all its measure cells.\n\nA user adding a row silently corrupts an unrelated one.\n\n## Two independent causes, both needed for the full damage\n1a (longstanding). AddRecordRow::execute (cmd/grid.rs:518) returns only SetCell + set_status — it NEVER POSITIONS THE CURSOR. EnterEditAtCursorCmd then opens an editor on whatever the cursor is on. Contrast CommitAndAdvance's Tab-at-bottom-right path (commit.rs:547), which calls the same AddRecordRow and DOES follow it with viewport_effects(cursor.row + 1, 0, ..). One of the two AddRecordRow call sites places the cursor; the `o` binding is the one that does not.\n\n1b (regression of improvise-ni6k / gotcha 24). AddRecordRow's SetCell is Model(CellData), so the frames_basis arm diffs every view's frame; appending a row changes the hash, moved.contains(view) is true, and reset_cursor() fires MID-BATCH, before EnterEditAtCursor. Isolated and demonstrated:\n apply_effects([SetCell(CellKey::new(vec![]), Number(0.0))]) from sel=(1,0)\n -\u003e sel (1,0) -\u003e (0,0), cursor_epoch 2 -\u003e 3\n\n1b IS A DEFECT ON ITS OWN TERMS: appending a row does not renumber any existing row, yet view_frame hashes entries.len() and every entry, so ANY APPEND REFRAMES and throws the cursor to the origin. In records mode that is every record insert. The Tab path only escapes it by re-placing the cursor afterwards.\n\n## Also observed, same root, lower severity\nPressing `o` twice with no page-axis category adds ONE row, because both emit SetCell(CellKey::new(vec![])) — the same empty-coordinate record — while the status says \"Added new record row\" both times.\n\n## Acceptance Criteria\n- AddRecordRow::execute appends viewport_effects(new_last_row, 0, ..) after its SetCell, so the cursor lands on the row it created. Harmless at the CommitAndAdvance call site, whose own later viewport_effects wins and survives because the observation is inside the loop. Fixes 1a and masks 1b at this site.\n- A test driving the exact key sequence above and asserting the East record is UNTOUCHED.\n- 1b proper wants the frame observation to distinguish \"rows appended\" from \"rows renumbered\" — a separate, larger question. File it rather than fold it in.\n- Two `o` presses should add two rows or say why not.\n\nScope: TUI only. resolve.rs:237 puts add-record-row in declined(), so no browser client reaches it.\n","notes":"FIXED in PR #126 (merge 4625593). TUI-only.\n\nTHE MECHANISM, which is more interesting than the symptom: o is\n[add-record-row, enter-edit-at-cursor records-editing], and AddRecordRow::execute\nreturned SetCell plus a status and NEVER PLACED THE CURSOR. Appending a record\nchanges view_frame's hash, so the frame observation INSIDE apply_effects\ninvalidated the cursor to the origin MID-BATCH (gotcha 24). EnterEditAtCursor then\npre-filled the editor with ROW 0's category coordinate, and Enter went through\nrename_record_coordinate -- which by gotcha 1 moves EVERY measure cell of that row.\n\nREPRO through App::handle_key, not a hand-placed state: Region{East,West},\nRevenue 10/20, R, sel=(1,0), then `o N o r t h Enter` -- East's Revenue cell GONE,\nrows [\"0\",\"1\",\"2\"], East renamed EastNorth.\n\nTHE ORDER IS THE ENTIRE FIX, and the rival proves it: moving viewport_effects\nAHEAD of the SetCell fails both tests and IS THE BUG VERBATIM. That rival is the\none worth keeping, because the acceptance criteria say \"append viewport_effects\"\nand do not say WHERE, and the two orders are indistinguishable by reading.\n\ntarget_row is row_count() (an append is last) UNLESS the record already exists, in\nwhich case it is that record's index found by scanning record_row(r).coords --\npredicting row_count() unconditionally puts the cursor one past the end in that\narm. That arm also stops claiming \"Added new record row\" for a row it did not add.\nIt is deliberately NOT made a refusal: CommitAndAdvance's Tab-at-bottom-right path\ncalls the same command and depends on seeding an empty-coordinate row.\n\nDOES NOT NEED improvise-fdk1, and fdk1 does not subsume it. o9uq is TUI-side\ncursor placement; add-record-row is in declined(), so no browser client reaches\nthe path. fdk1 is a COMMIT-side protocol gap. They meet only at\ncommit.rs::rename_record_coordinate -- the function o9uq stops being reached\nwrongly, and the one fdk1 wants factored into a single effect. Closing fdk1 would\nstill leave o editing row 0.\n\nTHE TELL WORTH GENERALISING: CommitAndAdvance's Tab path ALREADY did this fix, at\nthe CALL SITE. One of two callers of AddRecordRow placed the cursor and the other\ndid not -- so the placement was a property of the caller where it is a property of\nthe command. Look for that wherever two call sites of one command differ in what\nthey do afterwards.\n\nFOLLOW-UPS NOT FILED (no bd writes from the agent):\n 1. o twice with no page-axis category still adds one row (both build\n CellKey::new(vec![])); it now says so rather than claiming otherwise.\n 2. view_frame hashes entries.len() and every entry, so it cannot distinguish\n \"rows appended\" from \"rows renumbered\" -- every append throws the cursor to\n the origin. That is o9uq's cause 1b and is still open.","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:25:02Z","created_by":"Edward Langley","updated_at":"2026-09-22T07:40:43Z","closed_at":"2026-09-22T07:40:43Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-bd9o","title":"The browser reaches records mode through the tile bar, and everything it can do there is silent","description":"Found by the protocol bug hunt. DEMONSTRATED end to end with a real Host and a real Client, using only BrowserKeys — no hand-built fixture.\n\ndeclined() (resolve.rs:230) answers \"toggle-records-mode\" with \"records mode is not available in the browser yet\", and the code is written as though that closes the route. IT DOES NOT. The tile bar carries the VIRTUAL categories (project::tile_entries -\u003e View::tileable_cats includes _Index/_Dim), and set-axis-for-tile is fully composable by the browser. Records mode auto-detects from _Index on Row + _Dim on Column (gotcha 1), so FIVE ORDINARY KEYS get there.\n\n T -\u003e [SetTileCatIdx(0), ChangeMode(TileSelect)]\n r -\u003e [Model{SetAxis{_Index, Row}}, SetStatus(\"_Index -\u003e Row\")]\n l -\u003e [SetTileCatIdx(1)]\n c -\u003e [Model{SetAxis{_Dim, Column}}, SetStatus(\"_Dim -\u003e Col\")]\n Esc -\u003e [ChangeMode(Normal)]\n =\u003e records = true, mode = RecordsNormal\n\nOnce there:\n l l i -\u003e [SetBuffer{edit,\"1200\"}, ChangeMode(RecordsEditing)]\n 9 9 -\u003e [SetBuffer{edit,\"120099\"}]\n Enter -\u003e Key{Enter} \u003c- BLACK HOLE\n after Enter: mode still RecordsEditing, status unchanged, grid unchanged\n\ncommit (resolve.rs:696) returns None -\u003e Step::Upstream -\u003e KeyOutcome::Upstream -\u003e ClientFrame::Key -\u003e Host::handle answers an EXPLICIT EMPTY VEC (gotcha 47). Enter on a typed edit does nothing, says nothing, and leaves the editor open holding the text.\n\nAnd create_column (improvise-gcvb) is consequently UNREACHABLE from any key path: the only door into RecordsEditing is edit-or-drill, which returns None for CellId::Create; `o` is declined at its add-record-row step.\n\n## The shape\nFailure-catalogue #4 — an unreachability claim only as wide as the routes enumerated. The author considered the TOGGLE, not the AXIS ASSIGNMENT. THIRD instance of this shape in one week (see improvise-e5pf and the PR #98 round). And it is exactly the dead room stranding()/declined() exist to prevent: improvise-rmvx, \"a door this client will not open still has to say so\".\n\n## Acceptance Criteria\n- CHEAPEST HONEST CLOSE: refuse in edit_or_drill (resolve.rs:594) when state.grid.records, with the existing declined() wording — so the editor never opens on a cell the client cannot commit, and create_column's deadness becomes explicit rather than accidental.\n- A test driving the five-key route and asserting the decline is SPOKEN, not silent.\n- The larger fix — composing the records commit, the browser half of commit_plain_records_edit — needs RecordCol, which is session-local by gotcha 1. That is a design question, not a patch; do not attempt it as part of closing this.\n","notes":"FIXED in PR #128 (merge 76d909e). Eleven lines of production code.\n\nSTILL REPRODUCED AT HEAD, and improvise-55l5 (PR #126) fixed NONE of it -- the\nreason being structural rather than \"different file\": Host::handle answers\nClientFrame::Key { .. } =\u003e Vec::new() (host.rs:82, verified by the orchestrator),\nso the key never reaches dispatch at all. 55l5 could not have touched this.\n\nTHE SILENCE HAD TWO SOURCES AND THE TICKET PREDICTED ONE. A probe of the\nprojected records row, extent {rows: 1, cols: 5}:\n col 0 coordinate CellId None -\u003e NOTHING AT ALL: cell.id.as_ref()?\n short-circuited\n col 1 +cat CellId Create(Category) -\u003e nothing; the arm returned None\n col 2-3 measures CellId Record(_) -\u003e editor opened, Enter died in\n commit's early return\n col 4 + CellId Create(Measure) -\u003e nothing\nOnly the measure columns are this ticket's case. COLUMN 0 IS improvise-fdk1 SEEN\nFROM THE KEY PATH -- a coordinate column carries no surrogate because a rename\nnames a record that does not exist yet. Nothing here computes one.\n\nTHE FIX IS AN ENTRANCE GUARD, NOT AN ARM GUARD, and the distinction is the whole\ndesign: a guard on the CellId::Record arm answers \"is this CELL one we cannot\ncommit\" where the question is \"is this GRID one we cannot commit in\" -- and\ncolumn 0, which has no CellId at all, is what separates them. Verified: the guard\nsits on state.grid.records BEFORE cell_at_cursor(state)?. CellId::Create now\ndeclines out loud rather than returning None, keeping it a door rather than a\nhole.\n\nFOUR RIVALS INJECTED, AND TWO OF THEM PASSED THE ENTIRE WORKSPACE AT 1659. Those\ntwo -- state.view.mode.is_records() and the keymap's mode.is_records() -- are the\nmost important thing in this ticket. The whole fix is argued on reading THE GRID\nand not THE MODE, and nothing checked that clause.\n\nTHE REASON THE ROUTE TEST CANNOT SEE IT IS WORTH GENERALISING: it enters records\nmode the way a user does, where the grid and the mode AGREE. A USER ROUTE CANNOT\nSEPARATE WHAT A USER CANNOT SEPARATE. The separating input is a DRIFTED client,\nwhich had to be built deliberately --\nopening_an_editor_is_declined_off_the_grid_and_not_off_the_mode (verified present\nin lockstep.rs), twin of the commit-side test next door, with a regular-grid\ncontrol. Both injections now fail it.\n\nCODEX CAUGHT TWO PROSE PASSAGES THE NEW SECTION FALSIFIED, and the shape is\nsharp: THE EDIT THAT CREATED THE DRIFT WAS THE ONE THAT ADDED THE THIRD PLACE.\nWriting a new section is exactly when its siblings stop being searched for.\n\nNOT CLOSED BY THIS: a real records COMMIT still needs improvise-fdk1 (the\ncoordinate columns are precisely that gap) and RecordCol, which is session-local\n(gotcha 1). Both are outside this ticket's own AC. The decline is the fix.\n\nFILED: improvise-x0xd (P3) -- create_column is now EXPLICITLY dead rather than\naccidentally so.\n\nREPORTED, NOT FIXED, and not this branch's file: context/code/protocol.md:470 says\n\"No client composes it yet\" about CellId::Create and cites gcvb as an open gap.\nThat predates gcvb landing and is stale INDEPENDENTLY of this branch.","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:24:37Z","created_by":"Edward Langley","updated_at":"2026-09-22T08:16:33Z","started_at":"2026-09-22T07:59:25Z","closed_at":"2026-09-22T08:16:33Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-p7lh","title":"[bug] No UI at all for page-level dimensions: page_coords reaches the browser's cache and stops there","description":"Found by the browser bug hunt. DEMONSTRATED with a probe against a real Host.\n\nGridCache carries totals (the column-totals strip, gotcha 33/44) and page_coords (the page bar, gotcha 27). The server projects BOTH to every session and a browser client's cache holds them, correctly maintained. The TUI paints both (src/ui/grid.rs:393 and :452). RenderModel HAS NO FIELD FOR EITHER, so web/app.js cannot and does not paint them.\n\n $ grep -rn \"page_coords\\|totals\" crates/improvise-wasm-client/src/ web/\n (no matches)\n\n## The concrete input is the shipped front page\nweb/demo.improv line 8: `Quarter: page, Q1`. So the standalone deployment's front page shows a grid FILTERED TO Q1 with nothing on screen saying so.\n\nWorse: `[` / `]` DO resolve locally in the browser off state.grid.page_coords (resolve.rs:387, page_cycle, with its own passing test). So a browser user can press `]`, watch every number in the grid change, and have no way to tell which quarter they are looking at or how to get back. The numbers are right and the frame is a lie by omission.\n\nProbe output (real Host, Quarter on Axis::Page, one column total, bootstrap folded through the real Client::on_frame):\n page_coords = [PageAxis { cat: \"Quarter\", selected: \"Q1\", items: Some([\"Q1\",\"Q2\"]) }]\n totals len = 1\n panicked: the render model names no SELECTED PAGE ITEM\n\n## Why the guard missed it, and this is the more valuable half\nweb/tests/render_surface.test.js exists for exactly this fault class — \"a projected field that nothing paints is the bug four widgets shared\". It reads the pub fields off RenderModel and requires each to appear as model.\u003cfield\u003e in app.js/shell.js. ITS DOMAIN IS RenderModel, NOT THE CACHES. A fact that never reaches RenderModel at all is invisible to it: the check is closed under \"what the client decided to expose\", which is the thing under test.\n\nThe honest guard is one direction up: every field of every WidgetCache the server maintains reaches a front end, or is named as debt with an argument. That would also have caught this when GridCache::totals and page_coords were added (improvise-n10).\n\n## Acceptance Criteria\n- RenderModel gains page_coords and totals, fed straight off state.grid, and app.js/shell.js::gridPlan paint them.\n- SEPARATELY AND FIRST IF NEED BE: extend render_surface.test.js to the widget caches. File the guard even if the painting is deferred — an unpainted field with an argument in UNPAINTED is debt; an unpainted field nothing can see is the next one.\n- A harness assertion that the page bar names the selected item, since the demo ships with a page axis.\n","notes":"USER RULING 2026-09-21, PROMOTED P2 -\u003e P1: 'there is no UI at all for page-level dimensions'.\n\nVERIFIED at 523d571, and the shape matters -- this is not 'unbuilt', it is built to one step short of the screen:\n\n improvise-protocol/src/render.rs:433 pub page_coords: Vec\u003cPageAxis\u003e on GridCache\n improvise-protocol/src/render.rs:475 GridUpdate::PageCoords(..) seeded in the bootstrap\n improvise-protocol/src/render.rs:620 GridUpdate::PageCoords(coords) =\u003e self.page_coords = coords\n improvise-wasm-client/src/render.rs *** page_coords / PageAxis ABSENT ***\n web/app.js *** paints nothing page-related ***\n\nSo the server projects it, the browser's GridCache folds it and maintains it\ncorrectly, and RenderModel never exposes it. The page bar's data is sitting in\nthe client, one field away from being drawable.\n\nThis also explains why the parity sweep did not catch it. That audit checked\n'every field RenderModel EXPOSES is painted' and found that set empty -- a true\nand useful result. It could not see a field that never reaches RenderModel at\nall. A missing field is invisible to an exposed-but-unpainted check, which is\nworth remembering before trusting that audit's clean bill again.\n\nWHY THIS IS NOW P1 RATHER THAN A COSMETIC GAP: three user reports converge on\nit.\n - improvise-z336: clicking a category item PUTS the category on the Page axis.\n With no page bar, the user has moved a dimension onto a surface that is\n invisible, which is most of why the filter felt impossible to clear.\n - The TUI's [ and ] page cycling has no browser affordance, so the one\n navigable axis (gotcha 27: Page is navigable, Filter is not) cannot be\n navigated.\n - A pivot table whose page dimension is invisible is missing a third of its\n layout vocabulary -- Row, Column and Page are the three visible axes.\n\nSCOPE NOTE: this bead also covers column totals, which are the same shape --\nGridCache::totals is projected and held, and the browser paints nothing. Those\nmust be PROJECTED rather than client-derived, because the sum runs over every\nrow including ones a viewport scrolled past (gotcha 44). If whoever takes this\nwants to split totals out, that is fine; do not 'fix' them by summing in the\nclient.\n\nRelated and not the same: improvise-pcje is the residue of the parity sweep\n(col_widths, column ancestry, AxisColor, tile overflow) after this bead's two\nitems are removed.\nCLOSED: both halves are done, verified by re-running THIS TICKET'S OWN REPRO\nCOMMAND against d46ec59.\n\nThe ticket's evidence was:\n $ grep -rn \"page_coords|totals\" crates/improvise-wasm-client/src/ web/\n (no matches)\n\nRe-run now:\n render.rs:37 pub totals: Vec\u003cRenderCell\u003e\n render.rs:40 pub page_coords: Vec\u003cRenderPageAxis\u003e\n render.rs:282 totals: totals_strip(state)\n render.rs:283 page_coords: page_axes(state)\n app.js:518-526 paints the totals strip\n\nHALF 1, the page bar: painted, and since improvise-rw6l (PR #123) each navigable\naxis is a button carrying a down-arrow that opens a menu of its items, with\nClick::PageItem composing SetPageSelection. A Filter axis gets none of that, off\nthe same PageAxis::cycle predicate (gotcha 27).\n\nHALF 2, the totals strip: RenderModel::totals relays GridCache::totals and is\nnever summed in the browser -- the sum runs over every row INCLUDING the ones a\nviewport scrolled past, which is why it must be projected rather than derived\n(gotcha 44).\n\nSO THE SHIPPED FRONT PAGE NO LONGER SHOWS A GRID FILTERED TO Q1 WITH NOTHING\nSAYING SO -- the concrete input this ticket named. web/demo.improv line 8 still\nreads `Quarter: page, Q1`, and the page bar now says so and can change it.\n\nClosed by the accumulation of the browser UI work (PRs #120, #123) rather than by\na commit citing this id, which is why it stayed open. Sixth stale-open found\ntoday; see doctrine(a_ready_ticket_may_already_be_fixed) and\nkb_issue_found(five_tickets_closed_by_one_unrecorded_commit).","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:24:13Z","created_by":"Edward Langley","updated_at":"2026-09-22T07:48:30Z","closed_at":"2026-09-22T07:48:30Z","close_reason":"Closed","dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"improvise-pv3g","title":"fit-measure panics on a document whose target values are large but finite","description":"Found by the gp bug hunt. DEMONSTRATED end-to-end through the shipped binary. This is a HARD CRASH IN THE TUI, not a refusal — FitMeasure::execute has no catch, so the terminal app dies with unsaved work.\n\n $ improvise cmd 'fit-measure Score' -f big.improv\n thread 'main' panicked at crates/improvise-gp/src/lib.rs:288:5:\n number_text wants a finite value, got NaN\n\nAn ordinary .improv: 8 records, one Region category, a Cost feature, a Score target of 1e20/4e20.\n\n## Mechanism — the guard checks the wrong side of the computation\nimprovise-gp/src/fit.rs:279 checks the INPUTS: every r.target, every r.feat, every data.ranges entry is finite. 1e20 is a perfectly finite f32. The overflow is in the DERIVED quantity: standardize (gp-tree/src/fit.rs:185) sums (t - mean)^2 in f32, and 8 x (1.5e20)^2 = 1.8e40 exceeds f32::MAX = 3.4e38, so std = inf. Every normalized target becomes +/-0, fit returns leaves near zero, and denormalize computes 0.0 * inf + mean = NaN. number_text then asserts.\n\nMeasured in isolation:\n scale 1e18: mean=2.5e18 std=1.5e18 leaf(0.0)-\u003eLeaf(2.5e18) OK\n scale 1e20: mean=2.5e20 std=inf leaf(0.0)-\u003eLeaf(NaN) PANIC\n scale 1e25, 1e30: same\n\n## Reachability SCALES WITH RECORD COUNT, which is the part that matters\nThe condition is sum((t-mean)^2) \u003e f32::MAX, so at k records the crashing spread is about sqrt(3.4e38 / k):\n ~6e18 at 8 records\n ~4e18 at 100\n ~1.3e17 at the 20,000-record MAX_RECORDS ceiling\nA sibling path: sum(t) overflowing gives mean = inf, and NaN.max(1e-6) returns 1e-6 (Rust f32::max prefers the non-NaN operand), so std is FINITE while mean is infinite — same panic, different arithmetic.\n\nThis is exactly the failure family the guard's own comment names (\"Reachable from a .improv cell\").\n\n## Acceptance Criteria\n- fit-measure REFUSES rather than panics, with a message the user can act on. Suggested wording: the values are too large for 32-bit arithmetic because their spread overflows; scale the measure (thousands, millions) and fit that.\n- The check is on the DERIVED quantities (mean, std) after standardize, not only on the inputs.\n- A test at a spread that overflows and one just below it, so the guard is not satisfied by refusing everything.\n- Note the same overflow silently corrupts cv_regress's quality numbers today with NO panic to announce it — so a fix that only catches the panic leaves wrong R2 values being reported. The structurally better answer is a type standardize returns that cannot carry a non-finite (mean, std), the way Row\u003cT\u003e made a truncating zip unrepresentable.\n","notes":"WAVE 2026-09-21: not done -- outside the assigned agent's ground, reported rather than guessed.\n\nimprovise-2wu3: the fix is edit() capturing the RecordCell in web/app.js plus a by-surrogate sibling of typed_cell_frame in wasm-client/src/lib.rs; the test belongs in wasm-client/tests/. It is also the live counterexample to improvise-6nsd's position-free argument -- the protocol vocabulary is position-free, and web/app.js still captures viewport (row, col) at paint time and resolves it against the CURRENT cache at commit.\n\nimprovise-exdg: needs a new CellId variant in improvise-protocol/src/render.rs AND exhaustiveness in improvise-wasm-client/src/render.rs. Note CellId already has a Create variant for the records-mode creating column, so a coordinate-cell variant slots in beside it naturally.\n\nimprovise-pv3g: NOT in improvise-core. The panic is improvise-gp/src/lib.rs:290 (number_text), the wrong-sided guard is improvise-gp/src/fit.rs:279, and the actual overflow is standardize in gp-tree/src/fit.rs:188-196 -- sum((t-mean)^2) in f32, plus a .max(1e-6) laundering a NaN into a finite std. Needs a gp-crate agent.\nALREADY FIXED ON MAIN. Closed by the orchestrator 2026-09-22 after an agent dispatched to implement it found the work merged and declined to fabricate a PR.\n\nFIXED IN f616487 'fix(gp): a poisoned affine map is unrepresentable (pv3g); a budget declines a column (e8no)' -- verified an ancestor of ee0f57d with git merge-base --is-ancestor. The ticket was never closed when the work landed.\n\nDEMONSTRATED IN BOTH DIRECTIONS by the agent, in a throwaway worktree: at f616487^ (4f194e9) the ticket's own document reproduces the crash byte-for-byte (number_text wants a finite value, got NaN; exit 101); at HEAD the same document is refused, stores no formula, and does not panic.\n\nWHERE THE NaN WAS BORN, traced: gp-tree/src/fit.rs::standardize sums (t-mean)^2 in f32. Eight rows at a 1.5e20 spread give 1.8e40, past f32::MAX, so std = inf; every normalized target becomes +/-0 and denormalize computes 0.0*inf + mean = NaN, surfacing two crates away. EVERY INPUT WAS FINITE, which is why fit_measure's input guard saw nothing -- the overflow is in the DERIVED quantity.\n\nTHE SHAPE OF THE FIX: Scaling is a type with private fields and one constructor that refuses a non-finite part, so standardize returns Option\u003c(rows, Scaling)\u003e and denormalize cannot be handed a poisoned map at all. Rejected: a finiteness check at the call site, and the is_finite guard at lib.rs:288 -- both leave the arithmetic intact. cv_regress also pools nothing for a fold it cannot standardize, which closes the SILENT half: before, the same overflow corrupted R-squared with no panic to announce it.\n\nONE CORRECTION TO THIS TICKET'S OWN TRACE: the proposed sibling path (mean = inf while NaN.max(1e-6) launders std to 1e-6) does not occur from finite inputs -- (t-inf)^2 is +inf, so the variance is +inf, never NaN. The mean half of the constructor's check is a contract of the type, not a reachable path. Documented in context/code/gp.md section 7.\n\nSCORING BIT PATTERNS UNCHANGED. scoring_known_answers.rs shows 3 insertions / 3 deletions, which looks alarming and is call-site adaptation only; every literal is identical. That is itself the evidence the fix is confined to the non-finite path.","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:23:58Z","created_by":"Edward Langley","updated_at":"2026-09-22T06:26:29Z","closed_at":"2026-09-22T06:26:29Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-dy3j","title":"A Label category is not persisted, so a document with 12 categories plus a label can never be reopened","description":"Found by the io/formula bug hunt. DEMONSTRATED end to end through the real import path. Orchestrator re-verified each link independently.\n\nCategoryKind::Label is not persisted. format.rs writes every category as a bare `## Category: Name`; improv.pest has no syntax for a kind; parse.rs:325 reads every section back through `wb.add_category(\u0026pc.name)?` and NEVER add_label_category. So the kind is dropped on the way out AND on the way in, which is why no round-trip equality test can see it (doctrine a_roundtrip_equality_is_blind_to_a_loss_both_sides_share, exactly).\n\n## Why this is a lost document, not a lost flag\nLabel is deliberately OUTSIDE the 12-category pivot limit:\n - Model::add_label_category (core/model/types.rs:295) has NO limit check. Verified.\n - The wizard's precondition ImportPipeline::proposed_category_count (io/import/wizard.rs:97) scores `FieldKind::Measure | FieldKind::Label =\u003e 0`. Verified.\n\nSo the wizard correctly accepts 12 categories + N labels. On load each label is re-created as Regular, and Model::add_category checks the limit BEFORE the idempotent-duplicate check, so the 13th is refused and parse_md fails.\n\n ANY DOCUMENT WHERE regular + label \u003e 12 IS PERMANENTLY UNLOADABLE.\n\n11 regular + 2 labels fails too.\n\n## Steps to Reproduce\nA JSON/CSV with 12 low-cardinality text columns, one measure, and one high-cardinality text column (\u003e20 distinct values — analyzer.rs:161 auto-classifies it FieldKind::Label, accepted: true). An id, description or free-text note column does this in any real CSV.\n\n proposals include (\"Note\", Label, true)\n proposed_category_count() == 12 \u003c- precondition asserted, within the limit\n wb.model.category(\"Note\").kind == Label \u003c- precondition asserted\n parse_md(format_md(wb)) -\u003e Err(\"Maximum of 12 categories reached\")\n\nAlso reproduced at the Workbook level: 12 add_category + 1 add_label_category.\n\n## The quieter half, below the limit\n Region + label Note -\u003e round trip -\u003e kind: Regular, expected Label\nA label silently becomes a pivot category: it enters regular_category_names() pickers, counts against the limit, and gains axis auto-assignment.\n\n## Acceptance Criteria\n- The format carries the kind. Suggested: a kind suffix on category_section (`## Category: Note [label]`), written by format_md when cat.kind == Label, read by build_category into add_label_category. GRAMMAR FIRST — improv.pest is the source of truth (design-principles §3).\n- A round-trip test asserting the KIND, not just the name and items. Note the existing suite cannot see this class; a test that only compares formatted output will pass either way.\n- Decide what an older file without the marker means on load. Regular is the compatible reading and is what happens today.\n\n## Two independent mitigations, each correct on its own merits\n- Swap the two guards in Model::add_category so the duplicate check precedes the limit check. Filed separately as improvise-f86q.\n- Give add_label_category a limit of its own, so the unloadable document cannot be CREATED.\nNeither alone recovers documents already written; only the format fix does.\n","notes":"FIXED in PR #129 (merge bf83294). Reproduced at HEAD first -- NOT stale-open:\nNote.kind == Regular after a round trip, and 12 categories + 1 label gave\nErr(\"Maximum of 12 categories reached\").\n\nTHIS TICKET'S PROPOSED SYNTAX WAS NOT AVAILABLE, and that is the most useful\nfinding. It suggested `## Category: Note [label]`. THE GRAMMAR FORBIDS IT:\nheader_name takes the name as the WHOLE line, totally (improvise-pczv, gotcha 34),\nso the suffix is indistinguishable from a category literally named \"Note [label]\".\nAdopting it would have made a legal name unspellable and re-opened the ambiguity\nheader_name exists to close. The ticket was written by someone who had read\nformat.rs and not improv.pest.\n\nTHE SHAPE INSTEAD: a `kind: label` entry INSIDE the section. Grammar first --\nkind_line = { \"kind: \" ~ kind_kw ~ NEWLINE ~ blank_lines }, kind_kw = @{ \"label\" }\nas a category_entry alternative, verified by the orchestrator. A REAL TOKEN and\nnot a lookahead, because the generator reads the grammar through pest_meta and\nSKIPS predicates.\n\nABSENCE MEANS REGULAR, so every pre-dy3j file parses unchanged and a pivot\ncategory's section is byte-identical. parse_kind_kw is an exhaustive\nResult-returning match rather than a bool read off the rule's PRESENCE, so a\nfuture second keyword fails at the walker instead of being silently read as\n\"label\" because it arrived on a kind_line.\n\nTHE RIVAL THAT MATTERS: a reader that parses the kind and DISCARDS it. The file\ntext is then correct, so every output-comparing test passes -- which is why the\nnew assertions are on the parsed Category::kind and never on the emitted line.\nThis is a second worked instance of\ndoctrine(a_roundtrip_equality_is_blind_to_a_loss_both_sides_share): the kind was\ndropped symmetrically, out and in, so the whole characterization suite was silent.\n\nAND THE SEVERITY WAS REAL: Label is outside MAX_CATEGORIES = 12 while the wizard\nwill happily build 12 + a label, so any such document was PERMANENTLY UNLOADABLE.\na_document_at_the_category_limit_plus_a_label_reloads also asserts\nregular_category_names().len() == 12, because a \"fix\" that merely raised the limit\nwould load the document while putting a high-cardinality label into the pivot\ncount.\n\nNOTE THIS TICKET'S MITIGATION LIST IS STALE: it names improvise-f86q as\noutstanding, and f86q is already fixed -- Model::add_category has idempotence\nbefore the limit check, with the ticket cited in a comment.","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:23:30Z","created_by":"Edward Langley","updated_at":"2026-09-22T08:32:22Z","closed_at":"2026-09-22T08:32:22Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-dj1l","title":"kb-merge loses and corrupts clauses: keys are matched by unification","description":"Found by review of PR #68 after it merged. Two faults with one root: scripts/kb-merge.pl matches clause keys by UNIFICATION rather than variant equality.\n\n1. Intra-delta loss. check_self_consistent compares keys with ==, but findall hands back fresh variables for every collected key, so two clauses with variant heads are never == and the duplicate gate passes. foldl_merge then matches by unification, so the second clause removes the first. Two clauses in, one out, reported as success. Reproduced: a delta of two rules over one predicate went 536 before, 2 in delta, 537 after.\n\n2. Destruction and MUTATION. Adding 'probe_fact(X) :- doctrine(X,_).' to a kb holding probe_fact(a). and probe_fact(b). deleted probe_fact(a) AND wrote the rule as 'probe_fact(a):-doctrine(a,A)' -- X was bound by the match, so the clause written is not the clause supplied. The round-trip verify cannot see this: it compares the file against an intended list already corrupted by the same unification.\n\nFix: same_key/2 (copy_term + numbervars) in has_key, conflicts and absent; canonicalize before duplicate detection; a RULE keys on the whole clause, since two clauses with one head shape and different bodies is ordinary Prolog; and check_total/2 asserts the arithmetic the tool already PRINTS -- 'N in delta' beside a total that did not account for N was visible in its own output twice and nothing compared them.","status":"closed","priority":1,"issue_type":"bug","assignee":"claude-agent-scripts","owner":"el-github@elangley.org","created_at":"2026-09-20T23:26:47Z","created_by":"claude-agent-scripts","updated_at":"2026-09-20T23:35:13Z","started_at":"2026-09-20T23:26:51Z","closed_at":"2026-09-20T23:35:13Z","close_reason":"fixed in PR #69, each proved by injection","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-vz54","title":"node --test with an unmatched glob passes with zero tests","description":"Both CI node steps use a shell glob: `node --test web/tests/bundle/*.test.js` (browser job) and `node --test web/tests/*.test.js` (js job). CI runs them under `shell: /usr/bin/bash -e {0}`. Bash leaves an unmatched glob as a literal; node 22 treats it as a glob, matches nothing, and reports success with 0 tests and exit 0. Demonstrated. Rename or move either directory and the gate goes green having asserted nothing.\n\nPre-existing, not introduced by PR #63 - but #63 is the moment web/tests/bundle/ stopped being co-located with the job that builds its inputs, which makes a future move more likely.\n\nNote the local-shell trap that hid it: zsh ABORTS on an unmatched glob, so the probe only reproduces under bash. Local shell != CI shell is a fault-injection axis in its own right.\n\nFix: guard the step so an empty match fails loudly - a non-empty check on the expansion, or a minimum-test-count assertion. Prove it by renaming the directory and watching the step fail.","status":"closed","priority":1,"issue_type":"bug","assignee":"claude-agent-scripts","owner":"el-github@elangley.org","created_at":"2026-09-20T21:05:55Z","created_by":"orchestrator","updated_at":"2026-09-20T23:35:10Z","started_at":"2026-09-20T23:22:05Z","closed_at":"2026-09-20T23:35:10Z","close_reason":"fixed in PR #69, each proved by injection","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-lltt","title":"[bug] A view-scoped ModelCommand names no view, so it lands on the host's","description":"Opened by improvise-jytm, which made `ViewState::active_view` authoritative per\nclient. The view-configuration half of `ModelCommand` — SetAxis, CycleAxis,\nTransposeAxes, SetPageSelection, HideItem, ShowItem, ToggleGroup, TogglePruneEmpty,\nSetNumberFormat — acts on \"the active view\", which is now an ambiguous phrase.\n\n`Sessions::apply_model` routes with no SessionId (gotcha 38, correctly: the change\nis addressed to no one session, because a `View` is document state shared by every\nclient sitting on it). But it must still name WHICH view, and today it names it\nimplicitly: `apply_command` runs the effect against the `App`, and the effect reads\n`App::view()`, which is the HOST's slice. So a connected client on another view\nmoves the host's view rather than its own.\n\nBefore jytm every client was on the same view, so the implicit answer was right.\nIt is now wrong exactly when two clients are on different views, which is the\ncapability jytm exists to add.\n\nThe honest fix is for those variants to name their view — a protocol change — so\nthe command carries the operand rather than borrowing an ambient one. Rejected\nduring jytm: retargeting `app.view_state.active_view` around `apply_command` at the\ndoor. It makes the host's own UI slice a parameter channel, and `reset_cursor`/\n`cursor_epoch` would then write the asker's key into the host's cursor map.\n\nNote the `_Drill`/`_Records` virtual views make this sharper than it looks: a host\nin records mode is on a view no client can name.\n\n## Steps to Reproduce\n\n1. Two clients, A and B, on one document with views \"Default\" and \"Second\".\n2. B sends `Command::SwitchView(\"Second\")`. Its mirror moves; the host's App stays\n on \"Default\" (correct, per improvise-jytm).\n3. B sends `Command::Model { cmd: ModelCommand::SetAxis { category: \"Region\",\n axis: Row } }`.\n4. The axis moves on \"Default\" — the view B is not on — and B's next projection\n shows nothing changed while A's shows a change A did not ask for.\n\nNot reachable from the TUI, which is the only client that drives the App's own\nslice, and not reachable from `web/app.js` today because it sends no SwitchView.\n","acceptance_criteria":"The view-configuration ModelCommand variants name the view they act on, so a connected client's SetAxis moves the view that client is on and not the host's. A test drives two sessions onto different views and asserts an axis change by one is invisible to the other. Deciding NOT to fix is also an acceptable close, but then gotcha 38 must say so.","notes":"THE TRIGGER, added after the adversarial review of PR #48 (2026-09-20). This is deferrable, but it is deferred on a CONDITION rather than on a judgement, and the condition is one line of somebody else's future work away. It is unreachable today ONLY because no shipped client emits Command::SwitchView. That is the whole of the argument. The browser already emits view-configuration model commands -- keys.rs resolves a keypress to ModelCommand::TransposeAxes locally and sends it -- so the machinery on the other side of the ambiguity is live. THE DAY ANY WAVE WIRES A VIEW SWITCH INTO THE BROWSER, THIS GOES LIVE ON THE NEXT TRANSPOSE KEYSTROKE, AND SILENTLY: a client on Second presses transpose, the axes of Default move instead, that client's grid does not change and every other client's does. Two consequences. (1) Do not close this by arguing it is unreachable -- reachability is the thing that is about to change, and gotcha 47 already records that 'unreachable today' was the argument under which the lost update CheckedBatch later fixed had shipped. (2) There is a cheap interim guard worth landing before the real fix: assert that no shipped browser client emits Command::SwitchView, a test over improvise-wasm-client's key resolver. It turns the silent failure into a loud one at exactly the moment the premise stops holding, which is the only moment anybody would otherwise not be looking.\nTHE PREMISE IS ALREADY FALSE (verified 2026-09-20 at 585f25b by wave3 protocol agent, by enumerating the resolver rather than reasoning about it).\n\nThis ticket's own note says it is deferred on a CONDITION -- 'it is unreachable today ONLY because no shipped client emits Command::SwitchView' -- and asks for an interim guard that asserts exactly that. I wrote the guard. IT FAILED IMMEDIATELY. Two live paths, both in the browser's own resolver at crates/improvise-protocol/src/resolve.rs:359-368:\n\n VIEWS: switch-view-at-cursor -\u003e switch_view_at() -\u003e Command::SwitchView\n VIEWS: create-and-switch-view -\u003e CreateView + Command::SwitchView\n\nand the other half is live too: resolve.rs:331 composes ModelCommand::TransposeAxes locally. So the exact scenario the note describes -- 'a client on Second presses transpose, the axes of Default move instead, that client's grid does not change and every other client's does' -- is reachable TODAY from the shipped browser view panel. It is not a future risk; it is a live P2-turned-P1 bug.\n\nEscalated to P1 on that evidence.\n\nThe guard landed anyway, as the_keys_that_compose_a_switch_view_are_these in resolve.rs, but as an ASSERT_EQ against those two names rather than an assert-empty -- it pins reality and fails on a third live path, and its message says the list is not an exemption list. When lltt is fixed the list goes empty and the test goes with it.\n\nNOT FIXED HERE, deliberately: the honest fix is the protocol change this ticket describes (the nine view-configuration ModelCommand variants naming their view), which means every view-writing effect taking a view name instead of calling App::view_mut(). That is a whole work unit and it should not be smuggled into one alongside nine other tickets.\nNOT CARRIED BY PR #78. Releasing the claim so the tracker stops implying someone is on it.\n\nWHAT #78 CONTAINS: only the DETECTOR. the_keys_that_compose_a_switch_view_are_these (improvise-protocol/src/resolve.rs) enumerates every binding every mode can reach and asserts the set composing Command::SwitchView is exactly {switch-view-at-cursor, create-and-switch-view}. That is what overturned this ticket's deferral premise and got it escalated to P1. It fails if a THIRD live path appears; it does not fix any of the two that exist.\n\nWHAT #78 DOES NOT CONTAIN: the fix. No ModelCommand variant names a view; no effect takes a view name; App::view_mut() is still what every view-configuration effect writes through. The bug is exactly as described in the original report.\n\nWHY NOT: the honest fix is the protocol change this ticket describes -- the nine view-configuration variants (SetAxis, CycleAxis, TransposeAxes, SetPageSelection, HideItem, ShowItem, ToggleGroup, TogglePruneEmpty, SetNumberFormat) naming their view, which means every view-writing effect taking a view name instead of reading the ambient App::view_mut(). That touches ui/effect.rs broadly and ui/reduce.rs's mapping, and it is a work unit. Landing it inside a PR already carrying nine other tickets would have got it the least review of anything in the PR, which is the wrong trade for a P1 in the concurrency layer.\n\nONE THING FOR WHOEVER TAKES IT, which the ticket does not say: the bug is already written into a PASSING TEST'S COMMENT. a_reshape_of_one_view_leaves_a_client_on_another_view_alone carries 'Aimed at the host's own view... HideItem writes view_mut()' -- a correct description of the defect, read for as long as it has been there as a fixture note. Worth grepping for siblings of that shape before starting.\n\nUnclaimed, still P1, still open.","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:05:49Z","created_by":"wave2-e-active-view","updated_at":"2026-09-21T20:15:00Z","started_at":"2026-09-21T03:28:15Z","closed_at":"2026-09-21T20:15:00Z","labels":["area:active-view"],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -35,7 +39,7 @@ {"_type":"issue","id":"improvise-gv9r","title":"[bug] A wire model command never marks the document dirty","description":"Found by improvise-9x6 (2026-09-20) the hard way: the worker first persist trigger was `if dirty`, and it SILENTLY NEVER FIRED.\n\nreduce::model_effects maps ModelCommand::SetCell to effect::SetCell, which writes the cell and does NOT touch model_state.dirty. Dirtiness is raised by a SEPARATE MarkDirty effect that only the command/key path emits -- so on every transport, a client edit leaves the authoritative document reporting CLEAN.\n\nTwo consequences, and the second is the one that will bite someone:\n * \"Save if dirty\" is a trigger that never fires. Any host built on it loses every client edit, silently.\n * Anything else that reads model_state.dirty about a client-edited document gets the wrong answer -- an unsaved-changes prompt, an autosave, a UI marker.\n\nWHY IT HAPPENED: MarkDirty being a separate effect is deliberate and reasonable on the key path, where a command composes the pair. The derivation that turned effects into ModelCommands (improvise-kmgs) took the effects ONE AT A TIME, so the pairing was lost -- the wire has SetCell without its MarkDirty companion. That is the interesting shape: a derivation that is correct per element and wrong for a set.\n\nCANDIDATE FIXES, and this wants deciding alongside improvise-9x6 sibling ticket about Save: make model_effects emit the MarkDirty companion where the key path does; or fold dirtiness into the effects that change the document so it cannot be forgotten (the unrepresentable-not-validated answer, and probably right); or decide that dirty is a host-local concept the wire has no opinion on, and then say so where someone will read it.","status":"closed","priority":1,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T02:10:13Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:29:47Z","started_at":"2026-09-20T05:32:28Z","closed_at":"2026-09-20T07:29:47Z","close_reason":"Closed by PR #26 (WU-C), with the unrepresentable answer the ticket leaned toward.\n\nmodel_state.dirty is now raised by App::apply_effects for every EffectKind::Model effect, off the SAME classification the Dirty and the two lower basis tiers already read -- one classification, three readers. MarkDirty, mark_dirty() and ALL 17 CALL SITES ARE DELETED, so no path can compose a document change without it.\n\nVERIFIED RATHER THAN ASSERTED: the reviewer diffed all 19 MarkDirty call sites on main against the branch and traced each one's companion effect -- ClearCell, SetCell, TransposeAxes, AddItem, AddCategory, RemoveCategory, RemoveItem, RemoveFormula, CreateView/SwitchView, DeleteView, HideItem, ToggleGroup, SetNumberFormat, the tile axis_effect -- every one is EffectKind::Model, so apply_effects covers it. The two conditional sites reached no mark_dirty before either. Nothing was lost.\n\nTHE WORKER TRIGGER BECAME AN OBSERVATION: touches_document enumerated command variants BECAUSE dirty was unusable. With dirty trustworthy the trigger is the document itself -- the same choice cursor_epoch makes over CycleAxis and document_generation makes over LoadModel. Strictly tighter, and it retired the \"was anything rejected\" guard beside it.\n\nTHE ROOT CAUSE, which is the generalisable part and is now doctrine(a_derivation_correct_per_element_can_be_wrong_for_a_set): improvise-kmgs derived ModelCommand from the effects ONE AT A TIME and was right about every element -- but the key path never emits SetCell alone, it emits the PAIR with MarkDirty. So the wire got the mutation without its companion and no client edit ever marked the document dirty. When deriving a vocabulary from a set of primitives, ask what the existing callers COMPOSE, not only what each primitive is.\n\nTHREE BEHAVIOUR CHANGES CAME WITH IT, all recorded in the PR body:\n 1. Switching views, paging and stepping view history now mark the document dirty, so :q refuses after them. The user ruled this CORRECT -- Initial View and page selections are persisted, so discarding them silently was the bug, and :q! remains.\n 2. LoadModel leaves the document dirty, because it does not move file_path -- so the in-memory document really is a different one from the file the path names. A test pins the REASON, not just the flag.\n 3. A drill exited with no edits, and view-back, also mark it dirty -- a WRONG answer to no divergence, unlike (2). Filed as improvise-748f.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-368h","title":"[bug] No client on any transport can ask its host to save","description":"Found by improvise-9x6 (2026-09-20) while building the worker persist trigger, and it affects improvise-ws-server too -- which has shipped without anyone noticing.\n\neffect::Save is EffectKind::ViewOnly, and CORRECTLY so: it writes to THIS HOST file path and clears THIS HOST dirty flag, mutating no document state. But the wire vocabulary is DERIVED from the model effects (gotcha 38), so the derivation cannot reach it. There is no Command::Save and no ModelCommand::Save.\n\nConsequence: a connected client can edit the document and can never cause it to be written. On the socket deployment that means a ws-server session edits in memory and the file on disk never changes unless a human is at a TUI that is not there.\n\nTHIS IS A PROTOCOL DECISION, NOT A PATCH. Two coherent answers:\n * ADD Command::Save. But Save is genuinely ViewOnly and genuinely per-host -- a client asking a host to write to a path the client cannot see is a different thing from a client mutating the document, and it needs an answer for \"which path\" and \"what if there is none\" (improvise-lae already exists for the no-path case).\n * SAY EXPLICITLY THAT HOSTS OWN PERSISTENCE, and make each host trigger on its own terms. That is what improvise-9x6 did for the worker, and in a browser it is arguably RIGHT rather than a workaround: there is no file the user owns and no Ctrl-S semantics -- the document IS the OPFS entry. But then ws-server needs its own answer, and today it has none.\n\nDecide which. The gap is that neither was chosen, so the socket server silently does nothing.","status":"closed","priority":1,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T02:10:11Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:29:47Z","started_at":"2026-09-20T05:32:28Z","closed_at":"2026-09-20T07:29:47Z","close_reason":"Closed by PR #26 (WU-C). The user ruled \"add Command::Save to the wire\"; what shipped is ClientFrame::Save -- still on the wire, one layer out -- and the user accepted the deviation.\n\nWHY IT MOVED: shipped as Command::Save first, and review found it put a RUNTIME-FALLIBLE operation inside a batch documented as all-or-nothing -- [Save, SetCell] reported a failed write and then applied the edit anyway. The argument for the frame: a Command is something the shared state FOLDS, and saving mutates no shared state and no client's slice, so reduce_view had nothing to fold and Checked had no basis to answer for -- both arms were shims. As a frame it cannot be composed with a mutation BY CONSTRUCTION.\n\nTHE TWO QUESTIONS THE TICKET SAID IT WOULD FORCE, answered:\n * WHICH PATH: none. It is a REQUEST, not an instruction. A client cannot see the host's storage, so the host supplies the path.\n * WHAT IF THERE IS NONE: Rejection::NoDocumentPath, decided before anything runs, plus Rejection::SaveFailed for a write that fails. Reuses the existing refusal channel (gotcha 39, addressed to the asker alone) rather than inventing a second one. App::last_batch_aborted is the one new observable and its sole consumer is Host::save, where the batch is literally [Save].\n\nTHE GAP THE TICKET NAMED IS HALF CLOSED, and the remaining half is filed: the feature works end to end in Rust and is UNREACHABLE FROM A BROWSER -- keys.rs still lists \"save\" (Ctrl-S) as upstream, and an upstream key is a black hole (doctrine(an_upstream_key_is_a_black_hole)). One line to resolve it locally, but it owes an answer on a status-line divergence (success is silent on the wire while the host sets \"Saved to ...\"). Not wired here because keys.rs was another unit's ground.\n\nThe socket server, which the ticket noted had shipped with this hole, is covered by the same frame.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-ondj","title":"[bug] reduce_full/apply_command apply a Command::Model without the basis check","description":"Found by the code review of ADR 001 layer 1 (2026-09-19). CONFIRMED, not live today, and it should be closed BEFORE the browser worker lands.\n\nui/reduce.rs is documented as \"the one place a command is applied to the authoritative state\" and \"Every deployment mode -- the native TUI, a websocket server, a browser worker -- reaches the model through the functions here\". Both pub fn apply_command and pub fn reduce_full accept a \u0026Command including Command::Model, and:\n\n Command::Model { cmd, .. } =\u003e { // the basis is DISCARDED by `..`\n let effects = model_effects(app, cmd);\n app.apply_effects(effects)\n }\n\nThe check lives ONLY in Sessions::apply_model.\n\nWHY THIS IS THE INTERESTING SHAPE: making Command::Model a struct variant made the basis impossible to OMIT FROM THE MESSAGE. It did nothing to make it impossible to IGNORE ON ARRIVAL -- and `..` in a pattern is exactly the syntax for ignoring it, with no compile error. So the design got half of what a struct variant is usually reached for.\n\nNothing in-tree routes a client command through reduce_full (only tests), so nothing is broken now. But improvise-9x6 (the browser worker) is precisely the host that will reach for the function the module doc points it at, and it would apply client-supplied model commands unchecked.\n\nREMEDIES, pick deliberately:\n * Have apply_command take a ModelCommand from a private caller and expose only Sessions::apply for a wire Command -- makes the door unreachable rather than guarded.\n * Or have reduce_full return a Rejected-shaped result for Command::Model.\n * At absolute minimum, a compile-visible note AND a test that names the door, so the next person meets it.\n\nThe first is the one that matches this repo: invariants are made unrepresentable, not validated (doctrine(an_invariant_is_made_unrepresentable_not_validated)).","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T01:05:13Z","created_by":"Edward Langley","updated_at":"2026-09-20T01:54:29Z","closed_at":"2026-09-20T01:54:29Z","close_reason":"Fixed as option 1 -- make the door unreachable -- but as a TYPE rather than as visibility.\n\napply_command and reduce_full now take Checked\u003c'a\u003e, a private-field newtype over a \u0026Command whose ONLY constructor is Checked::against(\u0026App, \u0026Command) -\u003e Result\u003cSelf, Rejection\u003e, which is also the only caller of stale_basis. So there is no longer a spelling of 'apply this batch and ignore what it claims': the that discarded the basis is now a Rejection the caller must handle. Sessions::apply answers ONCE, before the dispatch, and hands the proof to apply_switch/apply_model -- so neither has to remember to ask, and apply_model lost its basis/model parameters entirely.\n\nWHY NOT pub(crate), which is what the ADR literally suggested: it works, but reduce_full then has NO non-test caller anywhere in the workspace, so it becomes a dead_code warning in the non-test build, and the only ways out are deleting a documented host API or cfg(test)-gating it. The newtype keeps both functions public and useful to a future host -- including the browser worker, which now gets a door that WORKS and is safe -- while making the unchecked path unconstructible. It also holds INSIDE the crate, which visibility does not: a second in-crate mutation path cannot be written without the check either.\n\nEVERYTHING THE TICKET SAID MUST SURVIVE, DOES: the check is still a property of the session door and not of every mutation (the key path, model_effects and App::apply_effects are untouched, and a host's own ModelCommand still needs no basis); caught_up(\u0026App, cmd) is unchanged and still not a bypass; the 10 basis_check tests are untouched and all pass.\n\nTHE REGRESSION GUARD IS THE SIGNATURE. The demonstration test (committed first, failed on the old code) NO LONGER COMPILES after the fix, which is the better outcome. What survives asserts that Checked::against returns Err(Rejection::LayoutMoved) and the document is untouched, with a doc-comment saying in prose that the signature is the real guard. Check-the-checker: making against() never refuse fails 8 tests -- the new one plus 7 of the 10 basis_check ones.\n\nNATIVE CLI EXERCISED FOR REAL, not merely compiled: improvise cmd and improvise script both land their changes on disk. They go through the keymap/registry path and never touched reduce_full.\n\nTHE THING THE TICKET DID NOT PREDICT, and it is the reason this defect could sit unnoticed: reduce_full HAS NO PRODUCTION CALLER IN THE ENTIRE WORKSPACE, and apply_command's view-slice arm is unreached outside tests. Every host reaches the model through Sessions::apply or reduce_key. So ui/reduce.rs's 'the signature the three hosts share' is ASPIRATIONAL -- a door pointed at by documentation and used by nobody, which is exactly the shape that hides a defect until the next host arrives. Filed as a successor.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-gd28","title":"Drill in the browser: the view stack is client-local, so what does the server still owe?","description":"From the ADR 002 spine landing (2026-09-19). The RESOLVER exists and is tested -- App::resolve_aggregate, layout-only by necessity since an aggregate has no existence outside the layout that derives it -- and CellId::Aggregate is emitted. What is missing is the command.\n\nModelCommand::Drill was deliberately NOT added. Drilling creates the _Drill view and writes drill_state, which gotchas 30 and 38 rule session-local and therefore unaddressable by a NULLARY Command -- exactly the reason ApplyAndClearDrill was removed from the wire in improvise-3x3u.\n\nSo a wire Drill needs the ADDRESSED form: the variant carries a SessionId and the staging map moves off the single App onto the session. That is ADR 001 layer 2 (context/adr/001-concurrent-edits.md), so this is blocked on it rather than being independent work.\n\nThe seam is ready: emit, resolve and the type-level guarantee (Drill takes an Aggregate and nothing else) are all in place and tested.","acceptance_criteria":"Drilling works over the wire. It needs the ADDRESSED command form — the variant carries a SessionId and the staging map moves off the single App onto the session — because drilling writes drill_state, which gotchas 30 and 38 rule session-local and therefore unaddressable by a nullary Command. That is ADR 001 layer 2, so this is blocked on improvise-7b8v rather than independent. The seam is ready: App::resolve_aggregate, CellId::Aggregate emission and the type-level guarantee that Drill takes an Aggregate and nothing else all exist and are tested.","notes":"USER RULING 2026-09-21, PROMOTED P3 -\u003e P1: 'drill / undrill is a core feature that has to be present in the web ui'.\n\nThis bead is the mechanism that ruling needs, so it stops being an architectural nicety and becomes the blocker for a stated product requirement.\n\nWHAT IS DECLINED TODAY, all in improvise-protocol/src/resolve.rs's declined() list, which is the one home for that wording:\n drill-into-cell ('\u003e') -\u003e 'drill'\n view-back ('\u003c') -\u003e 'stepping back to the previous view', and the\n comment there says explicitly it is NOT 'not built'\n -- it COMMITS A DRILL FIRST, so composing it in the\n browser would silently commit pending edits\n edit-or-drill ('i' on an aggregate) -\u003e a SetStatus refusal (improvise-s8h7)\n\nWHY IT IS NOT MERELY UNBUILT, which is what makes this bead the real work: drill\nstate is SESSION-LOCAL RESIDUE (gotcha 30). DrillState holds an Rc, so it fails\nthe portability half of ViewState membership, and drill_state.pending_edits are\nuncommitted writes belonging to whoever staged them -- which is exactly why\nApplyAndClearDrill was REMOVED from ModelCommand (gotcha 38, improvise-tom1): a\nCommand::Model is routed with no SessionId, so an unaddressed\nApplyAndClearDrill lets client B commit client A's uncommitted typing. That was\ndemonstrated against Sessions::apply before removal, not argued.\n\nSo browser drill needs the ADDRESSED command form -- a mutation carrying the\nSessionId it belongs to -- which is ADR 001 layer 2 and is this bead.\n\nGotcha 1 is the other half anyone implementing this must read: drill pending\nedits are keyed by (row, RecordCol), and gotcha 18 records that a drill snapshot\nfrozen in hash order made the same drill address DIFFERENT RECORDS in a second\nprocess. Any wire form has to carry an order that is stable across processes.\n\nNote improvise-s8h7 (P1) is the user-visible symptom on the 'i' key and\nimprovise-l3hm (P3) is its sibling on CellId::Create. Both should close with or\nafter this, not before -- a louder refusal is not what the ruling asks for.\nUSER RULING 2026-09-21: \"The state drill manipulates (the view stack) should be local to the client.\"\n\nThis REDIRECTS the ticket. It had been scoped to the addressed command form (ADR 001 layer 2) -- a mutation carrying a SessionId. The ruling puts the view stack in the class of state this repo has ALREADY taken off the wire, which is a much cheaper shape.\n\nTHE PRECEDENT IS EXACT. Gotcha 38: ViewBack and ViewForward were removed from ModelCommand for precisely this reason -- \"their operand is ViewState::view_back_stack, session-local residue belonging to whoever navigated.\" And gotcha 29 records the earlier ruling that the model is shared and the UI is not, with ViewState::active_view already AUTHORITATIVE PER CLIENT; two clients may sit on different views of one document.\n\nWHAT IS SHARED AND WHAT IS NOT, on this reading:\n - the view stack, and which _Drill view this client sees: CLIENT-LOCAL (the ruling)\n - drill_state.pending_edits: already session-local residue (gotcha 30 -- holds an Rc, uncommitted writes belonging to whoever staged them)\n - the COMMIT: a genuine model change -- but as ordinary SetCell/ClearCell commands it is already on the wire, already basis-checked, and needs NO addressed form\n\nIf that holds, the SessionId-carrying mutation leaves this ticket entirely and ApplyAndClearDrill never returns to the wire -- which is what gotcha 38 removed it for.\n\nTHE OPEN QUESTION, and it may be the real work: a client has no Model. Entering a drill needs the record set for a cell, which is GridLayout::drill_records(r, c) and is server-side. So either the server PROJECTS the drilled record set -- the \"enrich the projection rather than move resolution back\" answer gotcha 47 already prescribes -- or a client cannot compose a drill locally at all. Settle that first; it decides whether this is a projection ticket or something larger.\n\nTRAPS THAT SURVIVE EITHER SHAPE:\n - gotcha 1: pending edits are keyed by (row, RecordCol), and _Dim carries the column INDEX, not a label, because a category and a measure may share a name.\n - gotcha 18: a drill snapshot frozen in HASH ORDER made the same drill address DIFFERENT RECORDS in a second process -- silent misdirected writes. Whatever the client holds needs an order stable across processes.\n\nNote the view-back decline in resolve.rs says explicitly it is NOT \"not built\": it COMMITS A DRILL FIRST, so composing it in a browser would silently commit pending edits. Under the client-local shape that commit is the client's own to sequence, which removes the reason for the decline rather than working around it.\nUSER RULING 2026-09-21 (second): \"Drilling should be an ephemeral view, not something that gets persisted.\"\n\nHALF OF THIS ALREADY HOLDS, and the other half is the work. Verified at 523d571:\n\n - NOT PERSISTED: already true. VIRTUAL_VIEW_NAMES is [\"_Records\", \"_Drill\"] (core/src/view/types.rs:14). Both are never written and are dropped on load if an older file carries them; saving from drill mode writes the first real view as Initial View.\n - BUT IT IS SHARED: DrillIntoCell (command/cmd/grid.rs:348) builds the _Drill view and puts it in the WORKBOOK's views map, which is shared document state. So today drill is ephemeral with respect to the FILE and not with respect to other CLIENTS.\n\nTAKEN WITH THE FIRST RULING -- that the view stack is client-local -- the consequence is that _Drill should not be in Workbook::views at all. It is a client-side construct: built by the client that drilled, seen by nobody else, and discarded when that client leaves it. \"Ephemeral\" and \"client-local\" are the same statement viewed from the file and from the session.\n\nWHAT THAT BUYS, and why it is cheaper than the addressed command form this bead originally proposed:\n - no SessionId-carrying mutation is needed for the view itself\n - ApplyAndClearDrill never returns to the wire, which is what gotcha 38 removed it for\n - the view-back decline stops needing a workaround: its comment says it is not \"not built\" but that it COMMITS A DRILL FIRST, and a client that owns its own stack sequences that itself\n\nWHAT THE SERVER STILL OWES, which is the open question: DrillIntoCell captures a RECORDS SNAPSHOT at drill time (grid.rs:365 onward, \"Capture the records snapshot NOW\"), built from ctx.model.data -- which a client does not have. So either that snapshot is projected, or a client cannot compose a drill. gotcha 47's rule applies: enrich the projection rather than move resolution back.\n\nNote gotcha 10 while you are here: drilling a FORMULA cell strips the _Measure coordinate when it names a formula target, or matching_cells returns nothing instead of the raw records. Whatever composes a drill has to keep that, and a client does not know which measures are formula targets unless told -- another thing the projection may owe.\n\nAnd gotcha 18's warning is sharpened by \"ephemeral\": the snapshot is frozen at drill time and pending_edits are keyed by ROW INDEX into it, so the order must be stable for as long as the drill lives, even though the drill itself is not.\nUSER RULING 2026-09-21, answering the product question PR #115 left open: \"The staging scheme should be identical, because they're solving different problems.\"\n\nTHE QUESTION IT ANSWERS. The agent proposed that a browser could WRITE THROUGH with ordinary SetCell commands instead of staging, on the grounds that those are already basis-checked. The ruling rejects that, and the reason is the important part: BASIS-CHECKING AND STAGING SOLVE DIFFERENT PROBLEMS, so the first does not substitute for the second.\n\n - the basis check is ANTI-STALENESS COORDINATION between clients. It answers \"is your picture of this record current\" (ADR 001, gotcha 43). It is explicitly NOT a security boundary and explicitly not about intent.\n - staging is an EDITING-SESSION abstraction for one user. It answers \"does this person want to keep what they just typed\" -- several edits accumulate and commit atomically on leaving, or are discarded on cancel.\n\nWrite-through would have silently removed the ability to ABANDON a drill. That is a user-facing capability, not an implementation detail, and losing it is not visible in any test that only asks whether an edit lands.\n\nSO: the browser stages, identically to the TUI. DrillState::pending_edits becomes client state -- which is consistent with the other two rulings rather than in tension with them, since the view stack is client-local and drill is an ephemeral view. All three say the same thing: a drill is one person's transient workspace.\n\nTHE CONVERGENCE WORTH NOTING, because it makes the commit cheap: clause 4 IS NOW DELIVERED (improvise-yqpd). ClientFrame::Commands is answered whole and applied whole -- CheckedBatch::against checks the frame before anything runs, and a failure rolls the document, the mirrors and the session slice back. So a staged drill's commit is a BATCH OF SetCells and gets its atomicity from the transaction that already exists, with no new mechanism. Before clause 4 that batch could have half-applied, which is precisely the partial-and-silent failure a staged commit must not have.\n\nTWO THINGS THE COMMIT STILL NEEDS, from the same PR's findings:\n - the category-column arm has NO ModelCommand. ApplyAndClearDrill's RecordCol::Category(_) path is apply_coordinate_rename -- add an item, then move EVERY measure cell in the row (gotcha 1) -- and no wire variant exists for it. Filed separately.\n - gotcha 18's order warning binds the staging, not just the snapshot: pending_edits are keyed by ROW INDEX into a frozen list, and a list frozen in hash order made the same drill address DIFFERENT RECORDS in a second process. Client-side staging inherits that requirement exactly.","status":"open","priority":1,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T00:18:34Z","created_by":"Edward Langley","updated_at":"2026-09-22T02:50:15Z","labels":["area:protocol"],"dependencies":[{"issue_id":"improvise-gd28","depends_on_id":"improvise-7b8v","type":"blocks","created_at":"2026-09-20T16:20:30Z","created_by":"Edward Langley","metadata":"{}"}],"comments":[{"id":"7fce3874-00de-5f06-a481-e66fb873f24f","issue_id":"improvise-gd28","author":"wave3-webui","text":"BROWSER EVIDENCE, gathered under Chromium rather than read. The user reported\n\"drill also doesn't work in the webui: it was never confirmed working.\" This\nticket is the answer; nothing here asks to change the design. Confirming the\nticket's own claims empirically, plus one asymmetry it does not record.\n\nWHAT WORKS, verified in the running browser and not inferred:\n - project::grid_of emits CellId::Aggregate and the client RECEIVES it. In\n render_state: {\"Aggregate\":\"750ab1f5ffa96d5e00f605b3c8e36aac\"}.\n - render.rs::editable correctly answers false for it (ADR 002), so the page\n offers no editor on an aggregate.\n - resolve.rs reaches the CellId::Aggregate arm of edit_or_drill.\n - The TUI path is intact: 12 drill tests pass across cmd/grid.rs, cmd/mode.rs,\n ui/effect.rs and ui/app.rs. So this is isolated to the client/wire and is\n NOT a general regression.\n\nTHE ONE MISSING LINK, confirmed: reduce.rs contains no `Drill` variant at all —\nneither Command nor ModelCommand. `grep -n Drill crates/improvise-protocol/src/\nreduce.rs` returns nothing. The ticket's \"what is missing is the command\" is\nexactly right, and the seam really is ready.\n\nTHE ASYMMETRY THIS TICKET DOES NOT RECORD, and the reason it reads to a user as\nbroken rather than as refused — the two entry points fail DIFFERENTLY:\n\n - `i` / `a` on an aggregate declines OUT LOUD. edit_or_drill answers the\n Aggregate arm with Command::SetStatus(\"drilling into an aggregate needs the\n host — not in the browser yet\"). The user sees why.\n\n - `\u003e` (drill-into-cell) is SILENT. It is a command name resolve::step does not\n handle, so it falls through to KeyOutcome::Upstream -\u003e ClientFrame::Key -\u003e\n Host::handle, whose Key arm is an explicit `Vec::new()`. Gotcha 47: an\n upstream key is a black hole, not a slow path. Pressing `\u003e` produces no\n status, no mode change, nothing at all.\n\n`\u003e` is the key the help hint advertises for drill (\"\u003e:drill\" in the Normal-mode\nhint line), so the advertised gesture is the silent one and the honest refusal\nis on a key the user was not told to press. Whoever picks this ticket up may\nwant to route drill-into-cell through the same declining arm in the interim, so\nthe gap SAYS something on the key that names it — that is a one-line change and\nis independent of the addressed-command design work. I have not made it: it is\nthis ticket's call, not mine.\n\nA FIXTURE FACT worth knowing before anyone tries to reproduce: web/demo.improv\nhas NO aggregate cell. Gotcha 46 puts exactly one category on each visible axis\nprecisely so every cell is a Record, and tests/web_demo_model.rs asserts it. So\non the shipped demo you cannot reach the refusal at all without first changing\nan axis — which plausibly explains \"never confirmed working\". To reproduce:\npress T, select the Quarter tile, Enter once (Page -\u003e None); every cell is then\nan aggregate.\n\nREGRESSION GUARD LANDED: harness/tests/drill.spec.js, two tests, documenting the\nrefusal honestly rather than leaving folklore. It makes the aggregate through\nthe tile bar the way a user would and ASSERTS the precondition (td.editable\ncount 0) so it cannot pass against a grid of Records. Both proved by injection:\nsilencing the aggregate arm fails both tests (the second on its\ncheck_the_checker control, which is the point — with nothing answering, the\ncontrol can no longer distinguish \"`\u003e` is silent\" from \"the page is dead\");\nmaking `\u003e` resolve fails only the second. The tests assert what is TRUE today,\nso they will fail the day drill lands over the wire — which is the correct\nsignal to whoever closes this ticket, not an obstacle.\n","created_at":"2026-09-20T20:14:35Z"}],"dependency_count":1,"dependent_count":1,"comment_count":1} +{"_type":"issue","id":"improvise-gd28","title":"Drill in the browser: the view stack is client-local, so what does the server still owe?","description":"From the ADR 002 spine landing (2026-09-19). The RESOLVER exists and is tested -- App::resolve_aggregate, layout-only by necessity since an aggregate has no existence outside the layout that derives it -- and CellId::Aggregate is emitted. What is missing is the command.\n\nModelCommand::Drill was deliberately NOT added. Drilling creates the _Drill view and writes drill_state, which gotchas 30 and 38 rule session-local and therefore unaddressable by a NULLARY Command -- exactly the reason ApplyAndClearDrill was removed from the wire in improvise-3x3u.\n\nSo a wire Drill needs the ADDRESSED form: the variant carries a SessionId and the staging map moves off the single App onto the session. That is ADR 001 layer 2 (context/adr/001-concurrent-edits.md), so this is blocked on it rather than being independent work.\n\nThe seam is ready: emit, resolve and the type-level guarantee (Drill takes an Aggregate and nothing else) are all in place and tested.","acceptance_criteria":"Drilling works over the wire. It needs the ADDRESSED command form — the variant carries a SessionId and the staging map moves off the single App onto the session — because drilling writes drill_state, which gotchas 30 and 38 rule session-local and therefore unaddressable by a nullary Command. That is ADR 001 layer 2, so this is blocked on improvise-7b8v rather than independent. The seam is ready: App::resolve_aggregate, CellId::Aggregate emission and the type-level guarantee that Drill takes an Aggregate and nothing else all exist and are tested.","notes":"USER RULING 2026-09-21, PROMOTED P3 -\u003e P1: 'drill / undrill is a core feature that has to be present in the web ui'.\n\nThis bead is the mechanism that ruling needs, so it stops being an architectural nicety and becomes the blocker for a stated product requirement.\n\nWHAT IS DECLINED TODAY, all in improvise-protocol/src/resolve.rs's declined() list, which is the one home for that wording:\n drill-into-cell ('\u003e') -\u003e 'drill'\n view-back ('\u003c') -\u003e 'stepping back to the previous view', and the\n comment there says explicitly it is NOT 'not built'\n -- it COMMITS A DRILL FIRST, so composing it in the\n browser would silently commit pending edits\n edit-or-drill ('i' on an aggregate) -\u003e a SetStatus refusal (improvise-s8h7)\n\nWHY IT IS NOT MERELY UNBUILT, which is what makes this bead the real work: drill\nstate is SESSION-LOCAL RESIDUE (gotcha 30). DrillState holds an Rc, so it fails\nthe portability half of ViewState membership, and drill_state.pending_edits are\nuncommitted writes belonging to whoever staged them -- which is exactly why\nApplyAndClearDrill was REMOVED from ModelCommand (gotcha 38, improvise-tom1): a\nCommand::Model is routed with no SessionId, so an unaddressed\nApplyAndClearDrill lets client B commit client A's uncommitted typing. That was\ndemonstrated against Sessions::apply before removal, not argued.\n\nSo browser drill needs the ADDRESSED command form -- a mutation carrying the\nSessionId it belongs to -- which is ADR 001 layer 2 and is this bead.\n\nGotcha 1 is the other half anyone implementing this must read: drill pending\nedits are keyed by (row, RecordCol), and gotcha 18 records that a drill snapshot\nfrozen in hash order made the same drill address DIFFERENT RECORDS in a second\nprocess. Any wire form has to carry an order that is stable across processes.\n\nNote improvise-s8h7 (P1) is the user-visible symptom on the 'i' key and\nimprovise-l3hm (P3) is its sibling on CellId::Create. Both should close with or\nafter this, not before -- a louder refusal is not what the ruling asks for.\nUSER RULING 2026-09-21: \"The state drill manipulates (the view stack) should be local to the client.\"\n\nThis REDIRECTS the ticket. It had been scoped to the addressed command form (ADR 001 layer 2) -- a mutation carrying a SessionId. The ruling puts the view stack in the class of state this repo has ALREADY taken off the wire, which is a much cheaper shape.\n\nTHE PRECEDENT IS EXACT. Gotcha 38: ViewBack and ViewForward were removed from ModelCommand for precisely this reason -- \"their operand is ViewState::view_back_stack, session-local residue belonging to whoever navigated.\" And gotcha 29 records the earlier ruling that the model is shared and the UI is not, with ViewState::active_view already AUTHORITATIVE PER CLIENT; two clients may sit on different views of one document.\n\nWHAT IS SHARED AND WHAT IS NOT, on this reading:\n - the view stack, and which _Drill view this client sees: CLIENT-LOCAL (the ruling)\n - drill_state.pending_edits: already session-local residue (gotcha 30 -- holds an Rc, uncommitted writes belonging to whoever staged them)\n - the COMMIT: a genuine model change -- but as ordinary SetCell/ClearCell commands it is already on the wire, already basis-checked, and needs NO addressed form\n\nIf that holds, the SessionId-carrying mutation leaves this ticket entirely and ApplyAndClearDrill never returns to the wire -- which is what gotcha 38 removed it for.\n\nTHE OPEN QUESTION, and it may be the real work: a client has no Model. Entering a drill needs the record set for a cell, which is GridLayout::drill_records(r, c) and is server-side. So either the server PROJECTS the drilled record set -- the \"enrich the projection rather than move resolution back\" answer gotcha 47 already prescribes -- or a client cannot compose a drill locally at all. Settle that first; it decides whether this is a projection ticket or something larger.\n\nTRAPS THAT SURVIVE EITHER SHAPE:\n - gotcha 1: pending edits are keyed by (row, RecordCol), and _Dim carries the column INDEX, not a label, because a category and a measure may share a name.\n - gotcha 18: a drill snapshot frozen in HASH ORDER made the same drill address DIFFERENT RECORDS in a second process -- silent misdirected writes. Whatever the client holds needs an order stable across processes.\n\nNote the view-back decline in resolve.rs says explicitly it is NOT \"not built\": it COMMITS A DRILL FIRST, so composing it in a browser would silently commit pending edits. Under the client-local shape that commit is the client's own to sequence, which removes the reason for the decline rather than working around it.\nUSER RULING 2026-09-21 (second): \"Drilling should be an ephemeral view, not something that gets persisted.\"\n\nHALF OF THIS ALREADY HOLDS, and the other half is the work. Verified at 523d571:\n\n - NOT PERSISTED: already true. VIRTUAL_VIEW_NAMES is [\"_Records\", \"_Drill\"] (core/src/view/types.rs:14). Both are never written and are dropped on load if an older file carries them; saving from drill mode writes the first real view as Initial View.\n - BUT IT IS SHARED: DrillIntoCell (command/cmd/grid.rs:348) builds the _Drill view and puts it in the WORKBOOK's views map, which is shared document state. So today drill is ephemeral with respect to the FILE and not with respect to other CLIENTS.\n\nTAKEN WITH THE FIRST RULING -- that the view stack is client-local -- the consequence is that _Drill should not be in Workbook::views at all. It is a client-side construct: built by the client that drilled, seen by nobody else, and discarded when that client leaves it. \"Ephemeral\" and \"client-local\" are the same statement viewed from the file and from the session.\n\nWHAT THAT BUYS, and why it is cheaper than the addressed command form this bead originally proposed:\n - no SessionId-carrying mutation is needed for the view itself\n - ApplyAndClearDrill never returns to the wire, which is what gotcha 38 removed it for\n - the view-back decline stops needing a workaround: its comment says it is not \"not built\" but that it COMMITS A DRILL FIRST, and a client that owns its own stack sequences that itself\n\nWHAT THE SERVER STILL OWES, which is the open question: DrillIntoCell captures a RECORDS SNAPSHOT at drill time (grid.rs:365 onward, \"Capture the records snapshot NOW\"), built from ctx.model.data -- which a client does not have. So either that snapshot is projected, or a client cannot compose a drill. gotcha 47's rule applies: enrich the projection rather than move resolution back.\n\nNote gotcha 10 while you are here: drilling a FORMULA cell strips the _Measure coordinate when it names a formula target, or matching_cells returns nothing instead of the raw records. Whatever composes a drill has to keep that, and a client does not know which measures are formula targets unless told -- another thing the projection may owe.\n\nAnd gotcha 18's warning is sharpened by \"ephemeral\": the snapshot is frozen at drill time and pending_edits are keyed by ROW INDEX into it, so the order must be stable for as long as the drill lives, even though the drill itself is not.\nUSER RULING 2026-09-21, answering the product question PR #115 left open: \"The staging scheme should be identical, because they're solving different problems.\"\n\nTHE QUESTION IT ANSWERS. The agent proposed that a browser could WRITE THROUGH with ordinary SetCell commands instead of staging, on the grounds that those are already basis-checked. The ruling rejects that, and the reason is the important part: BASIS-CHECKING AND STAGING SOLVE DIFFERENT PROBLEMS, so the first does not substitute for the second.\n\n - the basis check is ANTI-STALENESS COORDINATION between clients. It answers \"is your picture of this record current\" (ADR 001, gotcha 43). It is explicitly NOT a security boundary and explicitly not about intent.\n - staging is an EDITING-SESSION abstraction for one user. It answers \"does this person want to keep what they just typed\" -- several edits accumulate and commit atomically on leaving, or are discarded on cancel.\n\nWrite-through would have silently removed the ability to ABANDON a drill. That is a user-facing capability, not an implementation detail, and losing it is not visible in any test that only asks whether an edit lands.\n\nSO: the browser stages, identically to the TUI. DrillState::pending_edits becomes client state -- which is consistent with the other two rulings rather than in tension with them, since the view stack is client-local and drill is an ephemeral view. All three say the same thing: a drill is one person's transient workspace.\n\nTHE CONVERGENCE WORTH NOTING, because it makes the commit cheap: clause 4 IS NOW DELIVERED (improvise-yqpd). ClientFrame::Commands is answered whole and applied whole -- CheckedBatch::against checks the frame before anything runs, and a failure rolls the document, the mirrors and the session slice back. So a staged drill's commit is a BATCH OF SetCells and gets its atomicity from the transaction that already exists, with no new mechanism. Before clause 4 that batch could have half-applied, which is precisely the partial-and-silent failure a staged commit must not have.\n\nTWO THINGS THE COMMIT STILL NEEDS, from the same PR's findings:\n - the category-column arm has NO ModelCommand. ApplyAndClearDrill's RecordCol::Category(_) path is apply_coordinate_rename -- add an item, then move EVERY measure cell in the row (gotcha 1) -- and no wire variant exists for it. Filed separately.\n - gotcha 18's order warning binds the staging, not just the snapshot: pending_edits are keyed by ROW INDEX into a frozen list, and a list frozen in hash order made the same drill address DIFFERENT RECORDS in a second process. Client-side staging inherits that requirement exactly.\nTRAP OPENED BY PR #117 (improvise-cfly), found in orchestrator review 2026-09-22.\n\nThe browser's view-back resolver emits [Command::ViewBack] where the TUI's\nViewNavigate{forward:false} emits [ApplyAndClearDrill, ViewBack]. That omission is\ncorrect TODAY only because a browser cannot stage a drill: drill-into-cell is\ndeclined, and edit_or_drill's CellId::Aggregate arm answers with a sentence.\n\nWHEN THIS TICKET LANDS, THAT STOPS BEING TRUE AND NOTHING FAILS. Stepping back in\na browser would silently discard staged drill edits that the TUI commits -- a\ndata-loss divergence, invisible across the whole suite.\n\nWorse, the guard points the wrong way.\nresolve::tests::the_view_history_keys_answer_in_both_directions asserts the arm\nemits EXACTLY vec![Command::ViewBack], so it PINS the current behaviour rather\nthan guarding the transition. It keeps passing while the bug exists, and a change\nthat FIXED the bug would fail it.\n\nSO THIS TICKET'S ACCEPTANCE GAINS A CLAUSE: making drill reachable in a browser\nmust also answer what stepping back does with staged edits, and must change that\ntest rather than work around it.\n\nRecorded here rather than as its own bead because the addressed-command form this\nticket is about is exactly what makes ApplyAndClearDrill expressible at all\n(gotcha 38 / improvise-3x3u) -- they are the same piece of work, and a separate\nbead would sit unread until someone hit the bug.","status":"open","priority":1,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T00:18:34Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:22:12Z","labels":["area:protocol"],"dependencies":[{"issue_id":"improvise-gd28","depends_on_id":"improvise-7b8v","type":"blocks","created_at":"2026-09-20T16:20:30Z","created_by":"Edward Langley","metadata":"{}"}],"comments":[{"id":"7fce3874-00de-5f06-a481-e66fb873f24f","issue_id":"improvise-gd28","author":"wave3-webui","text":"BROWSER EVIDENCE, gathered under Chromium rather than read. The user reported\n\"drill also doesn't work in the webui: it was never confirmed working.\" This\nticket is the answer; nothing here asks to change the design. Confirming the\nticket's own claims empirically, plus one asymmetry it does not record.\n\nWHAT WORKS, verified in the running browser and not inferred:\n - project::grid_of emits CellId::Aggregate and the client RECEIVES it. In\n render_state: {\"Aggregate\":\"750ab1f5ffa96d5e00f605b3c8e36aac\"}.\n - render.rs::editable correctly answers false for it (ADR 002), so the page\n offers no editor on an aggregate.\n - resolve.rs reaches the CellId::Aggregate arm of edit_or_drill.\n - The TUI path is intact: 12 drill tests pass across cmd/grid.rs, cmd/mode.rs,\n ui/effect.rs and ui/app.rs. So this is isolated to the client/wire and is\n NOT a general regression.\n\nTHE ONE MISSING LINK, confirmed: reduce.rs contains no `Drill` variant at all —\nneither Command nor ModelCommand. `grep -n Drill crates/improvise-protocol/src/\nreduce.rs` returns nothing. The ticket's \"what is missing is the command\" is\nexactly right, and the seam really is ready.\n\nTHE ASYMMETRY THIS TICKET DOES NOT RECORD, and the reason it reads to a user as\nbroken rather than as refused — the two entry points fail DIFFERENTLY:\n\n - `i` / `a` on an aggregate declines OUT LOUD. edit_or_drill answers the\n Aggregate arm with Command::SetStatus(\"drilling into an aggregate needs the\n host — not in the browser yet\"). The user sees why.\n\n - `\u003e` (drill-into-cell) is SILENT. It is a command name resolve::step does not\n handle, so it falls through to KeyOutcome::Upstream -\u003e ClientFrame::Key -\u003e\n Host::handle, whose Key arm is an explicit `Vec::new()`. Gotcha 47: an\n upstream key is a black hole, not a slow path. Pressing `\u003e` produces no\n status, no mode change, nothing at all.\n\n`\u003e` is the key the help hint advertises for drill (\"\u003e:drill\" in the Normal-mode\nhint line), so the advertised gesture is the silent one and the honest refusal\nis on a key the user was not told to press. Whoever picks this ticket up may\nwant to route drill-into-cell through the same declining arm in the interim, so\nthe gap SAYS something on the key that names it — that is a one-line change and\nis independent of the addressed-command design work. I have not made it: it is\nthis ticket's call, not mine.\n\nA FIXTURE FACT worth knowing before anyone tries to reproduce: web/demo.improv\nhas NO aggregate cell. Gotcha 46 puts exactly one category on each visible axis\nprecisely so every cell is a Record, and tests/web_demo_model.rs asserts it. So\non the shipped demo you cannot reach the refusal at all without first changing\nan axis — which plausibly explains \"never confirmed working\". To reproduce:\npress T, select the Quarter tile, Enter once (Page -\u003e None); every cell is then\nan aggregate.\n\nREGRESSION GUARD LANDED: harness/tests/drill.spec.js, two tests, documenting the\nrefusal honestly rather than leaving folklore. It makes the aggregate through\nthe tile bar the way a user would and ASSERTS the precondition (td.editable\ncount 0) so it cannot pass against a grid of Records. Both proved by injection:\nsilencing the aggregate arm fails both tests (the second on its\ncheck_the_checker control, which is the point — with nothing answering, the\ncontrol can no longer distinguish \"`\u003e` is silent\" from \"the page is dead\");\nmaking `\u003e` resolve fails only the second. The tests assert what is TRUE today,\nso they will fail the day drill lands over the wire — which is the correct\nsignal to whoever closes this ticket, not an obstacle.\n","created_at":"2026-09-20T20:14:35Z"}],"dependency_count":1,"dependent_count":1,"comment_count":1} {"_type":"issue","id":"improvise-xks6","title":"One home for the client/server frame envelopes","description":"Two crates now define the same frames independently, which is a live divergence risk:\n improvise-wasm-client::wire -- ClientMessage / ServerMessage\n improvise-ws-server::wire -- ClientMsg / ServerMsg\nThe ws-server agent asked for one version to be landed and will delete its wire.rs and re-export; the change on its side is two type names.\n\nPUT THEM IN improvise-protocol. Both sides already depend on it, and this is the one thing there that is FRAMING rather than vocabulary -- the protocol says what a Command is, but not what distinguishes one JSON blob from another on the wire.\n\nTHE SHAPE, as the ws-server landed it and the client should adopt:\n client -\u003e server: Viewport { rows: usize, cols: usize } | Command(Command)\n server -\u003e client: Snapshot(Box\u003cSnapshot\u003e) | Commands(Vec\u003cCommand\u003e)\nNotes that matter: Snapshot is BOXED because clippy large_enum_variant fires otherwise and a Snapshot is sent once per connection while Commands is every frame. Server-\u003eclient is ONE shape carrying both projections (as Command::Project) and a peer broadcast, because on the wire they are the same thing and a client folds both -- a separate Projections variant was rejected for that reason.\n\nViewport is the only addition to the shared vocabulary and it is derived, not invented: the extent is the one session input no Command carries by construction (gotcha 40).","notes":"RAISED TO P1 AND SEQUENCED AHEAD OF improvise-9x6 (2026-09-20).\n\nThere are now THREE envelope vocabularies for TWO deployments:\n * improvise-ws-server::wire -- ClientMsg/ServerMsg, the socket, server side\n * improvise-wasm-client::socket -- ClientFrame/ServerFrame, the same socket MIRRORED, because a native crate cannot be a wasm dependency\n * improvise-wasm-client::wire -- ClientMessage/ServerMessage, the WORKER deployment (improvise-tm6), currently unused by anything\n\nimprovise-9x6 gives the worker its own Host. If it lands before this ticket, it will speak a FOURTH arrangement and consolidation gets harder rather than easier -- so this comes first, or is at least DECIDED first.\n\nThe answer is almost certainly improvise-protocol owning the framing, since both ends already depend on it and its charter is exactly 'the wire vocabulary'. The open question this ticket must actually answer is whether the WORKER and the SOCKET want the SAME vocabulary. They differ today in ways that may be essential or may be accident: the socket has Viewport and no Key; the worker envelope was written before the projection architecture landed. Decide that before moving either, or the move produces two homes instead of one.\n\nThe mirror is currently held honest by two tests (gotcha 42) -- the_socket_framing_matches_the_server and every_server_variant_is_one_this_crate_mirrors. Both DELETE when this lands, and that is the measure of success.","status":"closed","priority":1,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T23:46:23Z","created_by":"Edward Langley","updated_at":"2026-09-20T01:52:29Z","closed_at":"2026-09-20T01:52:29Z","close_reason":"Done. Three envelope vocabularies for two deployments become ONE pair: improvise-protocol::frame owns ClientFrame { Viewport, Command, Key } and ServerFrame { Snapshot, Commands(Vec\u003c_\u003e), Rejected }; improvise-ws-server and improvise-wasm-client each pub use them and neither defines one. Both wire.rs files DELETED.\n\nTHE QUESTION THE TICKET SAID TO DECIDE FIRST -- do the worker and the socket want the same vocabulary -- IS ANSWERED YES, and the test applied to each difference was whether the ARGUMENT for it names the thing they differ in. None did:\n * Viewport is deployment-independent. Its justification is that the extent is a fact about a terminal or a browser window, is not in ViewState, and no effect produces it, so no derivation reaches it. Nothing transport-shaped. Its handshake role is a property of GridCache::seed (a Snapshot seeds the VISIBLE cells), not of sockets. A worker hosting the same Sessions owes set_extent the same answer.\n * Key is deployment-independent. Its justification (gotcha 38) is that a binding names an INTENT resolved against a CmdContext a thin client does not hold -- which says nothing about what the bytes travel over. Decisive evidence: the wasm client ALREADY PRODUCES a Key frame on both paths and tests/lockstep.rs round-trips it. So the socket's 'no Key' was never a statement about the socket, it was an unfinished feature (improvise-bpq2) that the separate envelope made invisible.\n * Commands(Vec\u003c_\u003e) vs Command(_): the batching is a property of the FOLD, not the transport -- a fan-out must arrive whole and in sequence because a cell update that overtakes its labels is DROPPED (gotcha 33), and an in-process postMessage interleaves exactly as a socket does. Commands(vec![c]) expresses everything the singleton could; the reverse is false.\n\nAND THE WORKER PAIR WAS INCOHERENT, NOT MERELY DIFFERENT -- the strongest evidence its differences were staleness. Written before ADR 001, it could carry Command::Model { basis, .. } and had NO Rejected variant, so a client speaking it composed a claim that could never be answered. It was missing a variant its own payload type demands.\n\nA FIND THE TICKET DID NOT PREDICT: the worker envelope was NOT 'unused by anything'. It was the return type of Client::set_cell/set_cell_at/key/on_key_event and the input to apply/handle_message/on_message. The wasm client was carrying TWO PARALLEL FOLD SURFACES OVER ONE STATE, identical except that the worker one had no Rejected arm. Once the envelopes unified they were literal duplicates, so the worker trio is deleted; web/app.js calls neither.\n\nBOTH MIRROR TESTS DELETED, which was the stated measure of success. What they checked is now TYPE IDENTITY: a rename is a compile error at every call site in both crates, and 'a variant added on one side and not the other' is not a constructible state. Strictly stronger than a hand-written fixture list. The check-the-checker MOVES rather than dies -- the_retired_spellings_do_not_parse keeps the injected-fault discipline and aims it at the one thing still possible, the retired Bootstrap/Command spellings returning as serde ALIASES, which would be two homes again wearing one type. End-to-end compatibility is now browser_round_trip.rs handing the wasm client's own serialized frame to a real Host::handle.\n\nHost::handle gains an explicit ClientFrame::Key =\u003e Vec::new() arm. Behaviour unchanged -- the frame previously failed to parse and was skipped -- but the gap is now A LINE OF CODE WITH improvise-bpq2 ON IT rather than a variant that does not exist.\n\n1134 -\u003e 1132 passing (5 deleted, 3 added), fully accounted for. Clippy, fmt, wasm32 all clean.\n\nWHAT improvise-9x6 CAN NOW ASSUME: nothing to define and no fourth arrangement to invent; first inbound frame is ClientFrame::Viewport for the same reason; ClientFrame::Key is already on the wire and already produced by the client, and a worker holds the authoritative App so it can resolve it through reduce_key where the socket cannot -- the worker may close bpq2's mechanism first; ServerFrame::Commands must be delivered whole and in order across a MessagePort; ServerFrame::Rejected must be answered, since ADR 001 is not socket-only.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-bpq2","title":"Client-side key resolution: what must the projections carry?","description":"USER RULING (2026-09-19): keys are converted into commands IN THE BROWSER; the keymap is a client-only concept the server must not know. Follows from the standing principle -- the model is shared, the UI is not, and a keymap is UI. It also buys something real: two clients may have DIFFERENT bindings over the same document, which is impossible if the server owns the keymap.\n\nThis inverts improvise-gsw conclusion (which had keys travelling upstream unresolved) and closes the keypress-door ticket. The work is now to make client-side resolution POSSIBLE.\n\nTHE PROBLEM TO SOLVE. A binding names an INTENT resolved against a CmdContext -- Model, GridLayout, cursor, yanked cell, expanded cats, mode. A Command is the RESOLVED MUTATION, e.g. SetSelected { row, col }. A thin client holds ViewState plus the widget caches. So for each binding ask: can a client holding only that state compose the Command? Where the answer is no, the fix is to ENRICH THE PROJECTION, not to move resolution to the server.\n\nKNOWN GAPS, from the gsw agent own analysis of what it could not resolve:\n * CURSOR MOVEMENT -- GridCache is indexed by position WITHIN THE VIEWPORT, so a client cannot distinguish \"last visible row\" from \"last row\" and cannot clamp. It needs the total row/col count, or an explicit \"can I move further\" fact.\n * PANEL CURSOR MOVEMENT -- move-panel-cursor walks a flattening that depends on expanded_cats. Either the cache carries the flattened list, or the client re-derives it -- and re-deriving is how two ends drift.\n * MODIFIED CHARACTERS -- KeyPattern::AnyChar matches a Char under ANY modifiers on the host. A client mirroring that is fine now that the client owns the keymap outright; the old constraint (local resolution may only ever be a strict subset) DISSOLVES once there is no host keymap to be a subset of.\n\nMETHOD: enumerate the bindings, not the commands -- the keymap is the thing being made client-side, so walk it. doctrine(binding_dump_diff) already says most bindings have no direct test, so a green suite is not evidence here either.\n\nALSO IN SCOPE: move ClientMessage/ServerMessage into improvise-protocol. The vocabulary is there and the framing that says which a JSON blob is, is not; the ws-server will want the same envelopes and two crates should not define them.\n\nBlocks improvise-1ey.","notes":"\n--- THE COMPOSABILITY LIST (improvise-q08 agent, 2026-09-19) ---\nWhich Commands a thin client holding ViewState + the five widget caches CANNOT compose. This is the real content of this ticket.\n\nHARD BLOCKERS:\n1. SetCell { key: CellKey } and ClearCell(CellKey) -- THE BIG ONE, and it blocks\n the most basic action there is. A client sees a grid of LABEL STRINGS indexed\n by viewport position. A CellKey is the full sorted coordinate tuple: every Row\n and Column category, plus every Page AND Filter selection (gotcha 27). Nothing\n in GridCache says which category a row label belongs to; nested row axes\n flatten several categories into one label; the page/filter constraints are not\n projected at all; and records mode encodes a COLUMN INDEX in _Dim (gotcha 1).\n There is no client-side derivation. NOTE THIS BLOCKS improvise-1ey DIRECTLY --\n its stated scope is \"type a number into a cell and see the value update\".\n2. ToggleGroup { category, group } and AddItemInGroup -- CategoryRow.items is a\n flat Vec\u003cString\u003e; groups, group membership and collapsed state are not\n projected. The server does the cat_tree flattening.\n3. RemoveFormula { target, target_category } -- FormulaCache is Vec\u003cString\u003e of RAW\n text. The client would have to re-parse to get target (dragging\n improvise-formula into a thin client), and target_category is simply absent, so\n a formula written [CustomCat] cannot be addressed at all.\n4. LoadModel(PathBuf) / ImportJsonHeadless -- paths name documents in the HOSTs\n Storage (gotcha 2) and nothing projects a listing, so a client cannot compose a\n path it has reason to believe exists.\n5. ApplyAndClearDrill / AcceptWizardModel -- nullary so constructible, but nothing\n projects drill or wizard state, so a client cannot know when either is\n meaningful. (Both are already off the wire per improvise-3x3u.)\n\nCOMPOSABLE BUT TRAPPED:\n* SetSelected { row, col } is ABSOLUTE; GridCache is VIEWPORT-RELATIVE. The client\n must add row_offset/col_offset from its own ViewState -- it has them, but the two\n index spaces are not distinguished by any type and getting it wrong is silent.\n* The grids TOTAL row/column count is not projected, so a client cannot clamp its\n cursor or size a scrollbar.\n\nFINE TODAY: SetAxis/CycleAxis/TransposeAxes/TogglePruneEmpty/SortData/\nCleanEmptyRecords, AddCategory/RemoveCategory, AddItem/RemoveItem/HideItem/\nShowItem/SetPageSelection WHEN DRIVEN FROM THE CATEGORY PANEL (not from the grid\ncursor -- same reason as 1), CreateView/DeleteView/SwitchView/ViewBack/ViewForward,\nAddFormula, SetNumberFormat (though the CURRENT format is in no cache, so it cannot\nbe pre-filled), and the whole view half.\n\nSUGGESTED ORDER: item 1 first and alone -- it is what 1ey needs and it is a\nprojection design question (how does a client name a cell?), not a plumbing one.\n\n--- ADR 002 ACCEPTED (user, 2026-09-19): see context/adr/002-naming-a-cell.md ---\nA projected cell carries an OPAQUE SURROGATE KEY, and its ABSENCE means the\ncell is an aggregate:\n CellDisplay { text, numeric, error, key: Option\u003cCellId\u003e }\nSome -\u003e a stored cell this client may set, named by a token it echoes back.\nNone -\u003e an aggregate: drill, do not set.\n\nWHY THE NULLABILITY IS THE POINT: a client cannot compose SetCell for an\naggregate because there is NO KEY TO PUT IN IT. That is make-invalid-states-\nunrepresentable (design-principles 3), strictly stronger than a server-side\nrejection. It also mirrors a fork the model ALREADY makes -- EditOrDrill in\ncmd/mode.rs branches on exactly this -- so the wire projects an existing\ndistinction rather than inventing one. And it follows the precedent\nCellDisplay::numeric set, whose doc says the server decides \"because it is the\nside that knows\".\n\n_INDEX WAS CONSIDERED AND REJECTED, and the reason is worth keeping: right\nontology, unsafe representation. CategoryKind::VirtualIndex is documented as\n\"Items are 0, 1, ... N where N = number of matching cells\" -- POSITIONAL over\nthe currently matching set, not an identity. _Index = \"3\" names a different\nrecord after a filter change, an insert or a delete, and a different one again\nin another view. A client echoing a stale _Index writes to the WRONG CELL,\nSILENTLY -- worse than a surrogate that fails to resolve, and the same hazard\nclass as viewport-relative-vs-absolute and the stale cursor under a renamed key.\n\nBUT KEEP WHAT IT POINTS AT: the surrogate should name the RECORD, not the cell.\nA record is the coord tuple minus _Measure (record_row_identity), which is\nalready ADR 001 unit of atomicity and already what record_versions is keyed by.\nAddress a cell as (record surrogate, measure) and ONE IDENTITY serves both\naddressing and staleness, instead of each ADR inventing its own.\n\nOPEN, for the implementer to decide with evidence: hash vs interned id (the\ncodebase interns Symbols already); how the server resolves a surrogate back --\nnote that REGENERATING FROM THE CURRENT LAYOUT means a stale surrogate simply\nfails to resolve, which IS ADR 001 stale-basis rejection at cell granularity,\nso the two compose; and what DRILL addresses with, which this does not answer.\n\n--- SURROGATE DECIDED (user, 2026-09-19): hash of the normalized record, maintained as an index ---\nNOT a hash of the cell -- of the RECORD (the coord tuple minus _Measure), which\nis already ADR 001 unit of atomicity and already what record_versions is keyed\nby. A cell is (record hash, measure).\n\nWHY: it makes stale edits cheap to identify, three ways at once. The surrogate\na client already holds IS the lookup key for its own version, so record_versions\nbecomes HashMap\u003cRecordHash, u64\u003e -- fixed size, no allocation, no CellKey\nreconstruction on the hot path. A basis is a list of (record hash, version)\npairs: compact on the wire, O(1) to check. And a record that no longer exists\nsimply fails to resolve, so that whole class of staleness needs no version\ncomparison at all.\n\nIt is well-defined because CellKey ALREADY has a canonical form -- CellKey::new\nsorts by category name and equality/hashing rely on it (gotcha 5). Normalized\nmeans that form with _Measure removed; the normalization is a property the type\nguarantees, not a new convention.\n\nRESOLUTION IS A MAINTAINED INDEX, IN DataStore -- a hash is one-way, and\nregenerating from the layout would be O(visible cells) per resolve AND would\nonly cover the visible window, while a client may hold a surrogate for a record\nthat scrolled away. DataStore is the right home: it already interns every name\ninto a SymbolTable and already maintains HashMap\u003c(Symbol,Symbol), HashSet\u003cInternedKey\u003e\u003e\nfor hot-path lookups. The RecordHash index is the same shape, updated at the same\npoints, and that existing (Symbol,Symbol) index is what makes INVALIDATION\ntractable -- when a category or item disappears, the records mentioning it are\nfindable without a scan.\n\nTWO THINGS THAT FALL OUT: the index and record_versions want to be ONE structure\n(RecordHash -\u003e (record, version)) since both are keyed the same and invalidated\ntogether, giving both ADRs their answer in one lookup. And structural vs data\nstaleness separate cleanly: does not resolve = the record is GONE (reject,\nnothing to write to); resolves with a differing version = the record CHANGED\n(reject as stale, offer force). Regenerating from the layout would have\nconflated those, because a record scrolled out of view would look identical to\none that no longer exists.\n\nTHREE SILENT-BREAKAGE TRAPS, each producing a WRONG WRITE rather than an error:\n * NOT DefaultHasher / RandomState -- randomly seeded PER PROCESS, so hashes\n would differ across a restart and between processes.\n * NOT #[derive(Hash)] as the definition -- Rust guarantees no stability across\n compiler versions or platforms. Hash an explicit BYTE SERIALIZATION of the\n sorted (category, item) pairs.\n * Pick the WIDTH BY CONSEQUENCE: a collision is a silent write to the wrong\n record, which is exactly why _Index was rejected. 64 bits hits birthday risk\n near 2^32 records; 128 bits costs 8 more bytes per VISIBLE cell (~8KB on a\n 50x20 viewport). Argue it, do not default to u64 for convenience.\n\n--- CellId IS A TWO-VARIANT ENUM (user, 2026-09-19), not Option ---\n enum CellId { Record(RecordHash), Aggregate(AggregateHash) }\nSetCell takes a Record and nothing else; Drill takes an Aggregate and nothing\nelse. An aggregate surrogate is a hash of its COORDINATE CONSTRAINT -- the row\nand column coordinates plus page/filter selections -- so it costs one hash per\ncell, the same width and the same DataStore index as a record.\n\nWHY NOT Option\u003cCellId\u003e with None meaning aggregate: an aggregate is NOT the\nabsence of an identity, it is a different KIND of identity. Option made SetCell\nsafe and left Drill with nothing to address, which is exactly how drill\naddressing became an open question. Two variants close it by construction and\nmake BOTH operations unrepresentable-if-wrong rather than just one.\n\nDEFERRED, filed separately: shipping a bounded keyset per aggregate for hover\npreviews. The unbounded version is untenable -- the payload would scale with the\nDATA while the rest of a projection scales with the VIEWPORT, ~16MB to render\n1000 cells over a million-record document -- and drill-on-demand works without\nit.\n\n--- ADR 002 SPINE LANDED (2026-09-19). What remains here is the KEY RESOLUTION half. ---\nMerged: RecordHash/AggregateHash, the record index in DataStore, CellId on\nCellDisplay, SetCell/ClearCell taking a surrogate, and Client::set_cell /\nset_cell_at in the wasm client. 1017 tests, 16 faults injected and all fired.\n\nSO A CLIENT CAN NOW COMPOSE A WRITE -- improvise-cr3 can call set_cell_at\ntoday. What is NOT done, and is this ticket own subject: WIRING IT TO A\nKEYSTROKE. The wasm client still forwards editing keys upstream unresolved,\nwhich is exactly the thing the user ruling (keymap is client-only) says must\nchange. The composability list earlier in these notes is still the map; item 1\n(SetCell) is now answered, items 2-5 are not.\n\nDECISIONS THE IMPLEMENTER MADE THAT ARE WORTH KNOWING:\n * The wire form of a surrogate is a 32-char HEX STRING, not a number. serde_json\n writes u128 as a bare number and a JavaScript host parses every JSON number as\n f64, so anything above 2^53 comes back CHANGED. This trap was not in the ADR.\n * CellId::Record carries RecordCell { record: RecordHash, measure: Option\u003cString\u003e },\n not a bare hash. The ADR says both \"hash the record\" and \"a cell is (record\n hash, measure)\"; both cannot hold if the hash covers the measure, because then\n record_versions could not be keyed by it -- which is the reason the ADR chose\n the record over the cell. Carrying the measure beside the hash satisfies both.\n * id: None is NOT \"aggregate\" (that is Some(CellId::Aggregate)). It means no\n projected cell: a padded/invalidated GridCache slot, or a records-mode\n COORDINATE column, which renames across the whole row (gotcha 1) and is a\n different intent from SetCell.\n * A zero cell count is a TOMBSTONE, not a removal -- dropping the entry would let\n a delete-and-recreate reset the version to 0, so a client holding version 0\n against the OLD record would be told its basis is current and its write\n applied. resolve_record filters on liveness; record_version reads through.\n * Resolution is INDEX FIRST, LAYOUT SECOND. The index covers every record holding\n data on screen or not; the layout answers cells with NO DATA YET -- and typing\n into an empty cell IS improvise-1ey.","status":"closed","priority":1,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-19T23:42:49Z","created_by":"Edward Langley","updated_at":"2026-09-20T02:36:27Z","closed_at":"2026-09-20T02:36:27Z","close_reason":"Done for the cursor half, with a named list of what still needs the host so closing the rest has to come here and say so.\n\nTHE FINDING THAT REFRAMED THE TICKET: Host::handle answers ClientFrame::Key with an EXPLICIT EMPTY VEC, so an upstream key is a BLACK HOLE, not a slow path. Combined with the improvise-9x6 finding (nothing in the projection stream carries the host's ViewState, so host-side resolution DIVERGES), every KeyOutcome::Upstream was a binding that does nothing at all in a browser. That turns the ticket's measure into simply 'how much of the keymap can the client answer'.\n\nMETHOD WAS THE TICKET'S: walked KeymapSet::default_keymaps() -- all 16 modes, ~170 bindings -- via a throwaway dump test, per doctrine(binding_dump_diff). Resolved client-side now: hjkl/arrows, 0/$/Home/End, G, PageUp/Down, Ctrl-d/Ctrl-u, panel cursor, tile cursor. ~15 more need NO enrichment and were deliberately left rather than half-done.\n\nTHREE PROTOCOL ADDITIONS, each argued as the smallest thing:\n * GridExtent { rows, cols } via GridUpdate::Extent -- the ticket's gap 1. Deliberately NOT framed by the labels: it is the size of the grid the labels are a window ONTO, so fit_to_labels leaves it alone and its batch position is not load-bearing (it addresses no position, so it can neither overtake nor be overtaken). The producer reads GridLayout::row_count()/col_count(), never the projected ranges -- reading it off the ranges is INVISIBLE IN THE TUI, which builds at offsets zero, and is exactly the browser bug.\n * ClientFrame::Command -\u003e Commands(Vec\u003cCommand\u003e). FORCED: one cursor key is SetSelected plus up to two scroll offsets, and a host that folds the selection but not the offset projects the wrong window. Same argument ServerFrame::Commands already carried.\n * ClientState::window (session-local) + Client::set_viewport. NOT DERIVABLE: row_labels.len() is the window CLIPPED to the grid, which equals the window everywhere except the bottom edge -- exactly where the scroll arithmetic matters. Nothing added to ViewState; both gating tests hold; transient_prefix untouched.\n\nTWO CORRECTIONS TO THE TICKET'S OWN GAP LIST:\n * Panel cursor movement WAS NEVER A GAP. improvise-edp already made viewmodel::flatten_cat_tree the one home both ends call, so calling it is the SHARED derivation, not a second one. Nobody had noticed. Worth checking other old 'known gaps' against what has landed since.\n * The unmodified-characters rule was MIS-SCOPED, not wrong. It was about KeyPattern::AnyChar, which is bound ONLY in the text-entry modes; in Normal there is no such fallback, so Ctrl-d has an exact binding and mirroring it is a faithful subset. Kept where it was earned, dropped where it was never true.\n\na_key_resolved_on_the_host_moves_state_no_client_can_hear STILL HOLDS and was not flipped -- it states a true property of host-side resolution, which bpq2 now AVOIDS by construction rather than contradicts.\n\nEach resolver mirrors its Cmd LINE FOR LINE, including where siblings disagree about a no-op move (Move and MoveTileCursor emit anyway; MovePanelCursor does not). Unifying them would make the client a second derivation, which is what lockstep testing exists to rule out.\n\nSIX INJECTED FAULTS, ALL FIRED. The five new lockstep tests build the client's cache with the server's OWN grid_of, drive client and real reduce_key over the same keypresses, and compare ViewState. nothing_in_a_non_text_mode_resolves_locally became FALSE and was replaced by a named list of the Normal-mode keys that still need the host.\n\nSTILL NEEDS THE HOST, truthfully: all prefix keys (transient_prefix is an address, not a keymap -- gotcha 13, left intact); save/wq/commit-export (Storage by definition); : execute-command; the wizard's Any binding (WizardCache has no Projection by design); CommandMode Backspace; enter-advance (its AbortChain is a fact about the effect chain, not a cursor); records/drill toggles (session-local residue).\n\nOrchestrator applied the one line the agent flagged and could not make: web/app.js now calls client.set_viewport rather than the associated Client.viewport, without which the client never learns its window and every cursor key vanishes upstream.\n\n1172 passing, clippy/fmt clean, wasm32 clean on all seven bundle crates.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-c4er","title":"[bug] There is no server door for a keypress; the asker gets no commands back","description":"Found by improvise-gsw (2026-09-19) and it is THE seam the browser MVP depends on. Blocks improvise-1ey.\n\nA browser client cannot resolve keys locally -- a binding names an INTENT resolved against a CmdContext while a Command is the RESOLVED MUTATION (see the improvise-gxi close reason and gotcha 38). So the client sends the raw keypress upstream as ClientMessage::Key { code, modifiers }.\n\nTHE GAP: Sessions::apply(app, id, cmd) takes a Command. ui::reduce::reduce_key(app, key) exists but returns only PROJECTIONS -- not the view-slice commands the ASKER must fold into its own ViewState. With keys travelling unresolved the host owes the asker back the commands its own keypress produced, and nothing returns them.\n\nNOTE this is NOT Applied::Diff.broadcast, which is what the OTHER clients fold (gotcha 36). The asker needs its own, and there is nowhere for them to come from.\n\nSUGGESTED SHAPE (from the gsw agent): Sessions::apply_key(app, id, key) -\u003e Applied, with the askers own view-slice commands in the result. Derive it rather than inventing -- doctrine(derive_the_enum_from_the_effects). The keypress is already KeyCode + KeyModifiers, which Command::SetTransientPrefix puts on the wire today; note improvise_protocol::KeyEvent itself does NOT derive Serialize/Deserialize, only its two components do, which is worth fixing at the same time.\n\nAlso from gsw: ClientMessage/ServerMessage envelopes currently live in the client crate because the VOCABULARY is in improvise-protocol but the FRAMING that says which a JSON blob is, is not. Moving them is a one-file lift and the ws-server will want the same ones -- do it here rather than letting two crates define them.","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T23:40:37Z","created_by":"Edward Langley","updated_at":"2026-09-19T23:42:47Z","closed_at":"2026-09-19T23:42:47Z","close_reason":"WRONG PREMISE -- CLOSED AND INVERTED by a user ruling (2026-09-19): keys are converted into commands IN THE BROWSER, and the keymap is a CLIENT-ONLY concept the server must not know at all.\n\nThis ticket asked for a server door for a keypress (Sessions::apply_key). There must not be one. The reasoning follows from the standing principle -- the model is shared, the UI is not -- and A KEYMAP IS UI. If the server knows the keymap, two clients cannot have different bindings, and that is a thing this architecture should permit: a browser client with browser-native keys and a TUI with vim keys, over the same document.\n\nSo the gap this ticket named does not exist. The asker never needs its own commands handed back, because the asker COMPOSED them and already knows what it sent. ClientMessage::Key should not exist; a client sends Commands only. ui::reduce::reduce_key stays, but it belongs to the native TUI -- which is a client that happens to run in-process -- not to the server client-facing surface.\n\nTHE REAL WORK THIS DISPLACES INTO: a client must hold enough PROJECTED STATE to resolve a binding into a Command. That is a requirement on what the projections carry, not a reason to move resolution serverward. Filed separately.\n\nThe one part of this ticket that survives independently: ClientMessage/ServerMessage envelopes belong in improvise-protocol rather than the client crate -- the vocabulary is there, the framing is not. Refiled with the projection work.","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -67,32 +71,47 @@ {"_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-t0ma","title":"records_steps is guarded by one incidental test","description":"records_steps (improvise-core/src/view/layout.rs) is guarded by exactly ONE test in the whole workspace, and that test is incidental: injecting is_index_or_dim() -\u003e is_measure() into records_steps failed only save_with_unfinished_record_is_readable, which is a persistence test that happens to enter records mode. Measured during PR #140.\n\nThat is thin for the spine GridLayout auto-detects records mode by (_Index on Row, _Dim on Column). A rival that got the spine wrong in a way that persistence does not observe would ship.\n\nWanted: a step-equivalence test asserting records_steps(model) produces the spine plus exactly one Axis::None step per non-index/dim category, and drill_steps likewise. It must be a test OF THE STEPS, not of the resulting effects -- the effects are cmd/grid.rs's translation and are already covered; the point of the split is that the steps are the model-side answer a browser will also consume.\n\nInject the is_measure() rival and show it fails the new test. Asked for by the ChatGPT Codex review on PR #140.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-23T01:36:26Z","created_by":"Edward Langley","updated_at":"2026-09-23T01:36:26Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-up8g","title":"Viewport extent is an external input on BOTH front ends, and it changes which record an edit writes","description":"The viewport extent is an EXTERNAL INPUT that reaches the document, and it is not a browser problem -- the TUI has exactly the same hole and always did.\n\nrun_headless_commands reads crossterm::terminal::size() into app.term_{width,height}, and CmdContext::{visible_rows, visible_cols} derive from it. Measured: term 80x24 -\u003e visible_rows=16; term 200x60 -\u003e visible_rows=52.\n\nAND IT REACHES THE DOCUMENT. Probe: a 40-row single-category grid, identical command sequence, one PageDown then clear-cell at the cursor:\n term 80x24 -\u003e selected=(12,0) -\u003e cleared Row=R12\n term 200x60 -\u003e selected=(39,0) -\u003e cleared Row=R39\nSame commands, DIFFERENT RECORD DELETED.\n\nSo Client::set_viewport is not a browser-introduced external input; it is the browser's SPELLING of an input the TUI has had all along. Gotcha 47's \"not derivable\" is true of BOTH front ends. Any ADR that treats extent as a browser problem will produce a log that replays in a browser and not in a terminal.\n\nTHE INTERACTION THAT MAKES THIS URGENT: today this hazard is INVISIBLE in `improvise script`, because the script path discards the cursor entirely (improvise-6uva). Fixing 6uva makes this live. The extent must be in the log -- or pinned by the replay harness -- IN THE SAME CHANGE that restores context-aware script parsing, or the repair will look like it works and then be flaky.\n\nThis is the cheapest of the five external inputs to close: it is one pair of integers and it already has a wire spelling (ClientFrame::Viewport). Make it a logged command (set-extent \u003crows\u003e \u003ccols\u003e) emitted on resize. The TUI has no such command today -- that is the only new vocabulary needed, and gotcha 42 already argues the extent is deployment-independent.\n\nA regression test must drive TWO sizes and assert the edit lands on the same record. One size cannot see this.\n\nFound by the improvise-ilvl replay audit.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T01:24:54Z","created_by":"Edward Langley","updated_at":"2026-09-23T01:24:54Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-ilvl","title":"ADR 006: everything is a command, and a recorded sequence replays the whole system","description":"USER RULING 2026-09-22: \"No action anywhere should happen without firing a\ncommand: like with emacs, everything is a command. But I should be able to\nreproduce any state of the whole system by spinning up a fresh copy of an\napplication and replaying a recorded sequence of commands.\"\n\nTWO CLAUSES, and the second is much stronger than the first. \"Everything is a\ncommand\" is a discipline. \"Replay reproduces any state\" is a TESTABLE PROPERTY,\nand it is the one that makes the discipline enforceable rather than aspirational.\n\nWHAT THE TREE ALREADY GIVES US, measured:\n - The Cmd/Effect pipeline IS the Emacs shape: a named command in a registry,\n resolved against a read-only CmdContext, returning effects that are the only\n mutation site. Nothing about that needs inventing.\n - `improvise script \u003cfile\u003e` ALREADY REPLAYS a command sequence into a fresh\n process (src/main.rs::run_headless_script), and PR #131 made it and\n `improvise cmd` one function so they cannot diverge. So half the machinery\n exists and is shipped.\n - Determinism work is largely done and was done for this reason without\n knowing it: gotcha 18 (DataStore iteration order, analyze_records first-seen\n order) and gotcha 37b (a gp fit records seed AND FitConfig, because the same\n seed under a different config lands on a different tree).\n - THERE IS NO RECORDER. parse.rs parses a script; nothing writes one.\n\nSO THE WORK IS (a) the recording side, and (b) closing the paths that bypass the\ncommand layer.\n\nTHE HARD QUESTION, WHICH THE ADR MUST ANSWER FIRST: WHICH VOCABULARY IS RECORDED?\nThere are THREE command-ish layers and they are not interchangeable:\n 1. `Cmd` -- a NAME plus args, resolved against a CmdContext. The Emacs\n analogue, and what `improvise script` already replays.\n 2. `Effect` -- the resolved mutation, the only thing that touches App.\n 3. `Command`/`ModelCommand` -- the WIRE vocabulary, derived from the effects.\n\nGotcha 38 draws the line that matters: A KEYMAP LOOKUP CANNOT RETURN A `Command`,\nbecause a binding names an INTENT resolved against a CmdContext (\"move-cursor\ndown\") while a Command is the RESOLVED MUTATION (\"SetSelected{row,col}\").\nimprovise-gxi's AC 4 asked for something that split forbids.\n\nThat cuts both ways for replay:\n - Recording layer 1 is small, human-readable and editable, and replays only if\n the CONTEXT evolves identically -- which is the Emacs model and is fine from\n a known start state.\n - Recording layer 2 or 3 is robust to context drift but is not \"commands\" in\n the user's sense, and a log of SetSelected{3,4} is not something a person\n reads or edits.\nThe ADR should probably choose layer 1 and say what that costs.\n\nWHAT BREAKS REPLAY TODAY -- enumerate before designing:\n - ANY ACTION THAT DOES NOT GO THROUGH A COMMAND. Audit for it: the browser's\n click surfaces compose Commands (Click::PageItem does), but viewport/resize,\n the ResizeObserver, and the OPFS persist trigger are observations rather than\n commands. Decide whether an observation must become a command or is outside\n the log by construction.\n - EXTERNAL INPUT. LoadModel(path) and the import wizard read files; replay\n reproduces the state only given the same inputs. Does the log reference them,\n or must it capture them?\n - TIME AND RANDOMNESS. A gp fit already records seed + config; anything else\n that samples a clock or an RNG has to.\n - MULTI-CLIENT. \"The state of the whole SYSTEM\" with several sessions means the\n log has to carry WHO acted, which is ADR 001's addressed-command question\n from a new direction -- and gotcha 29 says the model is shared and the UI is\n not, so a replay reproducing every client's UI needs per-session logs.\n\nWHAT THE PROPERTY BUYS, and why it is worth the ADR: a replay harness is the\nstrongest possible regression test for this codebase -- record a session, replay\nit, diff the resulting document. It would have caught the whole class this repo\nkeeps rediscovering (a test that passes while the thing it names is broken),\nbecause a replay diff cannot be vacuous.\n\nDO NOT START BY WRITING A RECORDER. Start by auditing what currently bypasses the\ncommand layer, because that list decides whether the property is reachable at all.","notes":"TWO REFINEMENTS FROM THE USER, 2026-09-22, and the second changes what this bead\nis:\n\n \"(Ignoring nondeterminism like rng seeding)\"\n \"This was how the app used to work, but maybe the web refactor broke it\"\n\nSO RNG/CLOCK DETERMINISM IS OUT OF SCOPE. Strike that bullet from the enumeration\nabove; a gp fit's seed+config recording (gotcha 37b) is a separate concern and\nalready handled.\n\nAND THIS IS ARCHAEOLOGY, NOT DESIGN. The property existed. The first task is to\nfind WHEN and WHERE it stopped holding, not to invent a mechanism. That reframes\nthe whole bead: start with `git log` and a bisect-shaped question, not with a\nrecorder.\n\nWHAT THE WEB WORK INTRODUCED THAT IS A CANDIDATE, found in a first pass:\n\nThe browser refactor deliberately replaced several hand-kept tables with\nOBSERVATIONS, and each is a state change that happens WITHOUT A COMMAND FIRING:\n - App::document_generation += 1 (app.rs:444, bumped by install_workbook)\n - App::cursor_epoch += 1 (app.rs:653, on cursor invalidation)\n - App::layout_version += 1 (app.rs:945, per LayoutGeometry effect)\n - view_frame / view_frames (app.rs:73-124) -- the cursor is invalidated\n by OBSERVING the grid's frame (gotcha 24, improvise-ni6k), which is why no\n effect calls reset_cursor any more\n - model_state.dirty raised in apply_effects off EffectKind (gotcha 45), which\n is why MarkDirty was DELETED as an effect\n\nEACH OF THOSE WAS A DELIBERATE MOVE AND THE ARGUMENT FOR IT IS GOOD: a table of\n\"which effects renumber the grid\" is a second description free to disagree with\nthe first, so the repo chose to observe instead. Gotchas 24, 39 and 45 all say\n\"observed, not predicted\" in as many words.\n\nSO THERE IS A REAL TENSION TO RESOLVE IN THE ADR, and it is the interesting part:\n\"everything is a command\" and \"observe rather than predict\" pull in opposite\ndirections.\n\nBUT PROBABLY NOT A CONTRADICTION, and the ADR should test this claim first\nbecause it may dissolve the whole problem: AN OBSERVATION IS A PURE FUNCTION OF\nSTATE. If replaying the commands reproduces the state, every observation\nre-derives identically and nothing is lost. Observations are DERIVED, not\nprimitive, so they do not need to be in the log -- they need to be REPRODUCIBLE\nFROM it. That is a weaker and much cheaper requirement.\n\nWHAT WOULD GENUINELY BREAK REPLAY is an action whose result is NOT determined by\nthe command sequence. The candidates to audit, in order:\n - VIEWPORT EXTENT. Client::set_viewport records what the PAGE announced, off\n the browser window size and a ResizeObserver. That is external input, not a\n command, and gotcha 47 says it is \"not derivable\". A replay on a different\n window size gets a different extent -- and therefore a different projected\n grid.\n - EXTERNAL FILES. LoadModel(path) and the import wizard read the filesystem;\n replay reproduces state only given the same inputs.\n - MULTI-CLIENT ORDERING. \"The state of the whole system\" with several sessions\n needs the log to carry WHO acted and in what order -- ADR 001's addressed-\n command question from a new direction.\n\nFIRST CONCRETE STEP: `improvise script` already replays a command sequence into a\nfresh process (src/main.rs::run_headless_script, one function with\nrun_headless_commands since PR #131). So the check is cheap -- take a document,\ndrive it with a script, and see whether the result is reproducible. If it is, the\nTUI path still holds the property and the regression is browser-side only, which\nnarrows the search to the list above.\nAUDIT COMPLETE 2026-09-22 (read-only, base a9cad15). Baseline measured on the auditing host: 1699 passed / 0 failed / 7 ignored over 38 binaries, longest single binary 0.70s.\n\nTHE USER'S HYPOTHESIS IS WRONG FOR THE MAIN DEFECT. Replay is broken, but not by the web refactor: see improvise-6uva, broken by 35946af on 2026-04-04, five months before the first wasm-client commit.\n\nTWO DEFECTS FOUND, FILED SEPARATELY: improvise-6uva (script cannot move the cursor) and improvise-up8g (viewport extent is external on both front ends). THE SECOND IS MASKED BY THE FIRST -- fix 6uva and up8g becomes live, so they must be closed together or the repair will look like it works and then be flaky.\n\nTHE TICKET'S STATED SAFETY ARGUMENT IS FALSE, and the false version is dangerous. The ticket claims \"an observation is a pure function of state\". IT IS NOT -- it is a pure function of the TRAJECTORY. Measured, two routes to an IDENTICAL document:\n no commands -\u003e layout_version 0, cursor_epoch 0, dirty false\n set-axis Product column, then set-axis Product row -\u003e layout_version 2, cursor_epoch 2, dirty true\nThe weaker true claim (\"reproducible from the log\") is all replay needs. But the stronger false one is exactly what you would cite to justify SNAPSHOT-PLUS-SUFFIX LOG COMPACTION, and that would be wrong, silently, in the direction that looks like it works: Basis puts document_generation and layout_version ON THE WIRE and clients compare them for equality, so a compacted replay is not basis-compatible with any client holding a claim. model_state.dirty is the same shape and is the worker's OPFS persist trigger (gotcha 45), so a replay reaching the same document by a different route persists a different number of times.\n\nGOTCHA 40 IS STALE, and the truth is stronger than the doc. It says layout_version bumps \"per ModelChange::LayoutGeometry effect\". It actually bumps from an OBSERVED view_shapes diff (observe_reshape, app.rs:938), so three idempotent set-axis calls bump nothing and batching two real changes into one line or two gives the same 2. The observed form is what the ADR should lean on. Correction applied to context/repo-map.md.\n\nDETERMINISM OTHERWISE CHECKS OUT: no clock or RNG is reachable from any command or effect except App::last_autosave (Instant::now(), debounce only, writes to a separate autosave_path and never the document), and fit-measure takes its seed as an explicit argument with a fixed default. One genuine clause-1 violation that costs replay nothing: autosave_if_needed writes a file on a wall-clock timer with NO COMMAND FIRING, so \"everything is a command\" is literally false there.\n\nTHE FIVE GENUINE EXTERNAL INPUTS, ranked by cost to close:\n1. Viewport extent (improvise-up8g) -- cheapest, one pair of integers, already has a wire spelling.\n2. Document identity at startup (get_initial_workbook, -f \u003cpath\u003e) -- cheap: the log header names the starting document by content hash.\n3. External files mid-session (LoadModel, ExportCsv, SaveAs, ImportJsonHeadless, StartImportWizard) -- moderate. Storage is ALREADY the seam (gotcha 2), so a Storage that replays recorded bytes is a clean mechanism needing no effect change.\n4. Import-wizard interaction -- moderate/hard: the keys go through a command but the wizard state is session-local residue with no Projection. Replayable in principle; nothing tests it.\n5. Multi-client ordering and authorship -- HARDEST, and partly a design question. apply_model's basis check means a differently-interleaved replay can REJECT a batch the original accepted, so the log must carry SessionId AND the arrival order across sessions.\n\nWHICH VOCABULARY TO RECORD -- the ADR's central question. The auditor's reading, offered for the ADR to accept or reject: the user's ruling has two clauses wanting different layers. Clause 1 (\"everything is a command, like Emacs\") is a LAYER-1 discipline about what a user can type, bind and read. Clause 2 (\"replay reproduces any state\") is a LOG property, and LAYER 3 (Command/ModelCommand) is strictly better at it -- already Serialize/Deserialize, already carries a Basis, already has one door and one interpreter, already transactional via ClientFrame::Commands, and ALREADY WHAT THE BROWSER EMITS. Note a layer-1 log CANNOT BE PRODUCED BY THE BROWSER AS BUILT: gotcha 38 says a keymap lookup cannot return a Command, so the browser resolves keys to layer 3 and never constructs a layer-1 name.\nThe two are not in conflict if the log records layer 3 and layer 1 keeps its status as the human surface, with ExecuteCommand being just another producer of layer-3 traffic. WHAT THAT COSTS is closing layer 3's three DELIBERATE holes -- ApplyAndClearDrill and AcceptWizardModel off the wire (gotcha 38), ClientFrame::Save a frame not a command (gotcha 45), SwitchView carrying no basis -- each of which has a written argument that would have to be REOPENED. That is the real price and belongs in the ADR rather than being discovered later.\nLayer 2 (Effect) declined: 58 types, no text syntax, no parser, no Serialize, and a log of effects cannot be edited by a person, which is most of what the user asked for.\n\nNOT VERIFIED: the browser half was not driven (no Playwright, no wasm build) -- everything about Client::set_viewport is read, not run, though the TUI analogue WAS run and is the same mechanism. Multi-client interleaving was not exercised. The import wizard was not replayed. No bisect between 35946af and HEAD for ADDITIONAL regressions -- a second archaeology pass after 6uva is fixed is cheap and is where to look next. Only two terminal sizes probed.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-23T00:55:57Z","created_by":"Edward Langley","updated_at":"2026-09-23T01:25:18Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-f9qf","title":"Pin the shell: macOS bash 3.2 hid two bugs that failed 17/19 in CI on the same commit","description":"USER RULING 2026-09-22: \"Bash really should be gotten from nix: macOS bash is old\nand deprecated, or just use zsh for scripts. You can use a nix shebang line\n(nix-shell manpage has details here).\"\n\nWHY, MEASURED RATHER THAN ASSERTED. improvise-xgra (PR #133) shipped two bugs that\nwere invisible on the machine that wrote them:\n - `local n=\"$1\" name=\"$2\" f=\"...$n...\"` is NOT left-to-right on bash 5 -- every\n name is created before the assignments run, so $n is unbound under set -u.\n - macOS ships bash 3.2 (2007, GPLv2, frozen), where it works.\nRESULT: scripts/pr-reviews-test.sh passed 19/19 LOCALLY AND 2/19 IN CI ON THE SAME\nCOMMIT. Both bugs surfaced as exit 2 with no output, because a dead command\nsubstitution hands jq an empty --argjson and jq answers with a USAGE error.\n\nTHE INVENTORY, as of 22ab58f: 16 scripts, with 11 `#!/usr/bin/env bash`,\n4 `#!/bin/sh` and 1 `#!/usr/bin/env sh`. So the bash/sh split is already\ninconsistent, and the sh ones are running as macOS bash-in-sh-mode locally and\ndash in CI -- a SECOND undetected platform difference of the same kind.\n\nTHE TWO OPTIONS THE USER NAMED:\n a. A NIX SHEBANG, e.g.\n #!/usr/bin/env nix-shell\n #!nix-shell -i bash -p bash\n which pins ONE bash for every developer and CI alike. Cost: nix-shell\n startup on every invocation, which matters for scripts CI runs per job and\n for anything in a tight loop -- MEASURE IT before committing, and say what it\n costs on a cold and a warm store.\n b. ZSH, which macOS ships current and which is the login shell there. No startup\n cost, but it is a different language in the places that bite (word splitting,\n array indexing from 1, `setopt` needed for sh-compatible globbing), so\n PORTING IS NOT A SHEBANG CHANGE -- every script would need reading.\n\nDECIDE ONE AND APPLY IT UNIFORMLY. A per-script choice reintroduces exactly the\ninconsistency above.\n\nCHECK BEFORE CHOOSING, because it may decide it:\n - Does CI have nix on every job that runs a script? The kb job uses\n `nix shell nixpkgs#trealla`, so nix is present there; confirm for build, test\n and browser.\n - Does the git merge driver (scripts/beads-merge-driver.sh) tolerate a\n nix-shell startup? It runs inside git's merge machinery.\n - Does .envrc / direnv already put a nix bash on PATH? If so, `#!/usr/bin/env\n bash` may already be correct INSIDE the dev shell and wrong only outside it --\n which would make the fix \"run scripts through direnv\" rather than a shebang\n change, and would be cheaper than either option.\n\nWHATEVER LANDS, THE GUARD IS THE POINT: the bug this closes was invisible to a\nlocal run, so the fix must be verified by something that RUNS BOTH SHELLS, or by\npinning so there is only one. A checker wired into CI in the same change is the\nminimum -- improvise-xgra's lesson exactly.","notes":"FIXED in PR #137 (merge a9cad15).\n\nTHE ENVIRONMENT, NOT THE INTERPRETER, WAS THE LOAD-BEARING HALF -- and this\nticket, the originating commit, and the agent's own first hypothesis all blamed\nthe interpreter.\n\nDIRENV DOES NOT SOLVE IT, and the reason is the finding. .envrc puts nix bash\n5.3.15 on PATH, which IS CI's semantics. But pkgs.mkShell ALSO EXPORTS ~30\nGENERICALLY-NAMED LOWERCASE VARIABLES THE RUNNER DOES NOT HAVE -- name, out,\noutputs, system, shell, builder, stdenv, phases, patches, buildInputs, doCheck,\nstrictDeps and more. A script reading one by accident under set -u is SILENT IN\nTHE DEV SHELL AND FATAL IN CI.\n\nThat is exactly what happened. pr-reviews' broken third clause read $name, and in\nthe dev shell $name is \"nix-shell-env\". Reproduced at 051f541^, one machine, one\nsitting:\n macOS /bin/bash 3.2 19 passed, 0 failed\n direnv exec . (nix bash 5.3) 19 passed, 0 failed\n the same, with `name` unset 2 passed, 17 failed \u003c- matches CI exactly\nTWO DIFFERENT REASONS FOR GREEN, ONE OF THEM THE CORRECT INTERPRETER.\n\nNote also: on CI it is $name that is unbound, NOT $n -- `fetch` declares `local\nn`, so dynamic scoping keeps n bound. A fix aimed only at the shell would have\nshipped.\n\nVERIFIED BY THE ORCHESTRATOR, and my first check was confounded: `nix shell\nnixpkgs#bash` appeared to set `name` too, until I re-ran with `env -u name` and\nit came back UNSET while `direnv exec .` still gave nix-shell-env. My check had\ninherited the variable from my own direnv-loaded shell -- it answered \"does name\nend up set\" where the question is \"does nix shell set it\". The agent's control\nwas right and mine was not.\n\nTHE NIX SHEBANG WAS DECLINED ON AVAILABILITY, NOT SPEED. Measured warm, 20\ninvocations: ambient `bash -c true` 5.1ms; `nix shell nixpkgs#bash --command`\n61.8ms; `nix-shell -p bash --run` (THE MANPAGE FORM) 675.2ms, 132x. 675ms inside\ngit's merge machinery for beads-merge-driver.sh is bad -- but the decider is that\nNIX IS ON ONLY THE kb JOB. build, test, wasm, js, harness-lint, browser and\npages-deploy have none, so a nix shebang needs install-nix-action added to three\nmore jobs, paid every run, to reach a bash those runners already ship. zsh\ndeclined too: not a shebang change, sixteen scripts to read, and no zsh on the\nrunner either.\n\nWHAT LANDED: scripts/checkers.sh, the ONE entry point for every check_the_checker,\ncalled verbatim by CI and by the local gate. It pins a bash \u003e= 5 (ambient first,\nnix only when the ambient one is older, exit 3 when neither), SCRUBS THE\nENVIRONMENT TO AN ALLOWLIST, and refuses any shebang in scripts/ that is not\n#!/usr/bin/env bash. CI logs `checkers: bash 5.2 at /usr/bin/bash` -- ambient, no\nnix, zero startup.\n\nTHE INJECTED FAULT, which is the proof: restoring the multi-assignment `local`\ngives 22 passed under the OLD local gate, 22 passed under direnv, and\n2 passed / 20 failed / exit 1 under checkers.sh.\n\nTHE OLD GATE WAS ITSELF THE BUG. `for t in scripts/*-test.sh; do sh \"$t\"; done`\nOVERRIDES EVERY SHEBANG with /bin/sh, so on macOS it ran every suite under bash\n3.2 in sh-mode regardless of what the file said. The gate in my brief was the\ndisease. Seven other sites ran a repo script through an explicit `sh`, and one of\nthem is dist-workspace.toml -- which a first-line shebang gate STRUCTURALLY CANNOT\nSEE. The shebang rule closes declarations, never invocations.\n\nUNRUN, STATED: scripts/build-web-release.sh runs only under dist in\nbuild-global-artifacts, which no branch push exercises, so its new shebang and two\nchanged lines are mechanical and untested.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T22:31:17Z","created_by":"Edward Langley","updated_at":"2026-09-22T23:16:23Z","closed_at":"2026-09-22T23:16:23Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-clso","title":"Ctrl-S saves and also opens the browser's own save dialog","description":"Found by the improvise-jz6w agent (PR #134) as a consequence of its own fix, and\ncorrectly NOT fixed there because every available fix crosses a doctrine line.\n\nNOT A REGRESSION -- verified by the orchestrator. web/app.js:786 calls\npreventDefault only when `!e.ctrlKey \u0026\u0026 !e.altKey`, with the deliberate comment\n\"Ctrl/Alt combinations are left alone: they are the browser's\". So Ctrl-S ALWAYS\nopened Chrome's \"Save page as\" dialog; before improvise-jz6w it did that and\nnothing else. Now it saves the document AND opens the dialog. The save is\ncorrect; the dialog is the wart, and it is newly VISIBLE rather than newly\nintroduced.\n\nWHY IT WAS NOT FIXED IN #134, and this is the useful part:\n - Parsing the frame in app.js gives the page PROTOCOL KNOWLEDGE\n (design-principles section 5).\n - A `e.key === \"s\" \u0026\u0026 e.ctrlKey` test puts a KEYBINDING FACT in the page.\n - Widening the wasm surface so Rust can say \"this key is claimed exclusively\"\n is a new decision the ticket did not authorise.\n\nTHE HONEST SHAPE: the DECISION -- \"is this key exclusively claimed by the app?\" --\nbelongs in Rust as a third thing a resolver already knows, beside Local and\nRequest. That is a natural follow-up to the KeyOutcome variant #134 added, not a\npatch to app.js.\n\nWHATEVER LANDS, THE ASSERTION GOES IN harness/tests/shell.spec.js (gotcha 48: a\nchange to web/*.js belongs in that spec directory). Note harness/node_modules does\nnot exist in a fresh worktree, so `npx playwright test` fails with an error about\nthe PACKAGE rather than the harness -- run npm install in harness/ first.\n\nCHECK BEFORE BUILDING: whether any other Ctrl chord is now in the same position.\nThe preventDefault rule is blanket, so the answer is \"every Ctrl binding the\nresolver claims\", and today that is one.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T20:59:53Z","created_by":"Edward Langley","updated_at":"2026-09-22T20:59:53Z","labels":["browser"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-udt2","title":"A status message carries no severity, so a headless refusal cannot set a non-zero exit","description":"Left open deliberately by improvise-q914/j6x8 (PR for fix/cli-honesty). The\nreasoning is context/code/cli.md section 3; this is the ticket it points at.\n\nimprovise cmd now PRINTS everything a command reports, to stderr, so a refusal\nis no longer indistinguishable from a success. What it still cannot do is put\na refusal in the EXIT CODE. Exit 1 means a parse error or an aborted batch;\na refusal that only writes a status exits 0.\n\nWHY IT WAS NOT CLOSED THERE. ViewState::status_msg is a bare String. Both\nclassifiers available in the tree are disproved by code in the tree:\n\n - \"a refusal is a batch that aborted\" -- only three effects set\n abort_effects (gotcha 21). effect::AddItem writes Workbook::add_item's\n Err straight to the status and does NOT abort, and that is q914's own\n worked example of a refusal. This classifier calls a refusal a success.\n\n - \"a refusal is anything that spoke\" -- cmd/fit.rs sets a status on\n SUCCESS, carrying the seed that reproduces the fit. effect::Save does\n too. This classifier calls a deliverable a failure.\n\nSo the CLI cannot guess, and guessing would be the adjacent-check defect\ndesign-principles section 7 names.\n\nWHAT WOULD CLOSE IT. A severity beside status_msg -- the smallest honest\nshape is probably an enum on the same write (Report | Refusal), set at the\n~60 sites that assign status_msg. That is a ViewState change, so it is a wire\nchange (gotcha 25): it must derive Serialize/Deserialize, it reaches the\nbrowser client's Snapshot, and the TUI can then colour the status line by it,\nwhich is arguably the bigger win. Scope it as a protocol ticket, not a CLI one.\n\nACCEPTANCE\n- A refusal that only writes a status exits non-zero from improvise cmd.\n- effect::Save on success and cmd/fit.rs on success still exit 0 -- these are\n the two rivals; a fix that fails either is the wrong fix.\n- tests/headless_cli.rs::a_command_that_succeeds_while_reporting_still_exits_zero\n must still pass unchanged.\n- README's CLI contract section is updated; it currently states the hole.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T15:20:31Z","created_by":"Edward Langley","updated_at":"2026-09-22T15:20:31Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-j6x8","title":"improvise cmd discards every status message and exits 0, so a refusal is indistinguishable from success","description":"Found by the improvise-pv3g agent, while checking whether that P1's own fix was\nobservable through the binary the P1 was demonstrated with. It is not.\n\nrun_headless_commands (src/main.rs:331) applies effects and NEVER PRINTS\napp.view_state.status_msg. Measured on clean fixtures:\n\n document outcome stdout/stderr exit\n Score 1000/4000 fit succeeds, formula stored nothing 0\n Score 1e20/4e20 fit refused, nothing stored nothing 0\n\nSo a refused command is INDISTINGUISHABLE FROM A SUCCESSFUL ONE. The user gets\nsilence and exit 0 whether the command worked, refused, or did nothing at all.\nexit_code is set to 1 only for PARSE errors, so a refused command cannot signal\nfailure through the exit status either.\n\nWHY THIS MATTERS MORE THAN IT LOOKS: improvise-pv3g's acceptance asked for an\nactionable refusal naming the cause (\"too large for the 32-bit arithmetic ...\nscale the measure and fit that\"). That wording was written, tested and is\ncorrect -- and reaches NOBODY through `improvise cmd`. The TUI renders the status\nline, so the criterion is genuinely met there; the headless path is where it\nevaporates. This is the repo's characteristic defect one layer out: the fix is\nright and the surface that would show it is adjacent.\n\nWHY NO TEST SEES IT: a_refusal_names_what_went_wrong_and_stores_nothing asserts\nthe EFFECT's status. The effect is correct. Nothing asserts that the headless\nbinary emits it.\n\nA SECOND, SMALLER TRAP in the same path, worth fixing or documenting: `improvise\ncmd -f \u003cfile\u003e` REWRITES THE DOCUMENT EVEN WHEN THE COMMAND CHANGED NOTHING,\nexpanding 1e20 to 100000000000000000000. A fixture edited by string-replacing a\nvalue after one run silently becomes a no-op. It cost the reporting agent two\nmisleading test results before it was caught.\n\nWHAT TO DECIDE, not assume:\n - Which stream? A status message is not an error; stdout is probably right,\n with stderr for a refusal. Say which and why.\n - Should a refusal set a non-zero exit? It is the difference between `improvise\n cmd ... \u0026\u0026 next-step` working and silently proceeding on a no-op. Note this\n is a CLI contract change, so it belongs in README's CLI section too.\n - Is silence ever right? A script running a hundred commands may not want a\n hundred lines. If so the answer is a flag, and the DEFAULT should be the\n honest one.\n - `improvise script` shares this path -- check whether it has the same hole.\n\nNOTE improvise-q914 is adjacent (\"improvise cmd advertises JSON it does not\naccept, and swallows every effect error at exit 0\") and may subsume or be\nsubsumed by this. READ IT IN FULL FIRST and merge the two rather than filing a\nthird view of one problem.","notes":"FIXED in PR #131 (merge 9d2bdd3) as one fix with improvise-q914 -- see that ticket for the full reasoning, including why the stdout/stderr split I proposed in the brief cannot be built honestly and why the exit code is narrower than q914's AC. improvise script shares the hole exactly, being the same function. The per-command dirty accumulation also closes the no-op-rewrite trap noted here.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T06:26:41Z","created_by":"Edward Langley","updated_at":"2026-09-22T15:38:58Z","closed_at":"2026-09-22T15:38:58Z","close_reason":"Closed","dependencies":[{"issue_id":"improvise-j6x8","depends_on_id":"improvise-q914","type":"blocks","created_at":"2026-09-22T06:26:52Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-n6t8","title":"Header layout must be reserved, not fitted: the mode pill, the hint and the status all reflow","description":"USER 2026-09-22: \"the top bar jumps around a lot because the layout isn't fixed\nenough: The messages like 'the command line is not available in the browser yet'\nshould be toasts that do not cause relayout and should disappear eventually\n(maybe where the minibuffer is?)\"\n\nHOW IT WORKS TODAY, read before filing:\n - web/shell.js::composeStatus joins TWO independent facts into #status with\n \" . \": `shell` (the transport -- \"in-page worker\") and `model`\n (RenderModel::status, which is ViewState::status_msg). They are composed\n rather than overwritten deliberately, so a repaint cannot silently erase a\n connection error (gotcha 46).\n - #hint also carries variable-width content, one line per mode, off\n help::hint_line.\n - Both live in the flex \u003cheader\u003e. So the bar reflows when EITHER a status\n message arrives or the MODE changes -- the second is worth naming because it\n reflows even with no message at all.\n\nTHE TRAP IN THE OBVIOUS FIX, and it is the thing to get right:\nViewState::status_msg is STATE, not an event. web/app.js's repaint() calls\nshell.setModel(model.status) unconditionally, and repaint() runs on every\nkeydown. So a toast dismissed on a timer RESURRECTS on the next keypress, because\nnothing cleared the underlying string -- and Rust only replaces status_msg when\nsome later command sets one. A naive setTimeout(hide) produces a message that\nflickers back whenever the user does anything, which is worse than a message that\nsimply stays.\n\nSo dismissal must be keyed to MESSAGE IDENTITY (\"I have already shown this\nstring\") rather than to a timer alone, or the same string arriving genuinely\ntwice must be distinguishable from a repaint of the old one. Decide which, and\nsay so -- if two identical messages in a row should both be shown, identity alone\nis not enough and something must carry a sequence.\n\nSCOPE -- PRESENTATION ONLY, and that is a deliberate limit. Do NOT split\nstatus_msg into transient-vs-persistent in ViewState. That is a protocol change\nwith a portability argument attached (gotcha 30), the TUI shows status_msg in its\nbottom bar where it costs no layout at all, and nothing here forecloses it later.\nThe browser's problem is where it draws the string, not what the string is.\n\nTHREE THINGS THE FIX OWES:\n 1. THE HEADER MUST STOP REFLOWING ON ITS OWN, independently of toasts. #hint\n changes per mode, so a fixed/reserved layout is needed regardless. Reserving\n space is the fix; making the text shorter is not.\n 2. THE TOAST MUST NOT TAKE PART IN LAYOUT AT ALL -- out of flow, so it cannot\n push anything.\n 3. THE CONNECTION-ERROR FACT MUST SURVIVE. composeStatus exists because a\n repaint used to erase it. A toast that auto-hides must not take a transport\n failure with it: \"disconnected\" is not transient and must stay visible.\n That is the mirror of the bug gotcha 46 records.\n\nPLACEMENT: the user suggests near the minibuffer. PR #120 put the minibuffer as a\nraised bordered card over the grid, so that region is consistent -- but DECIDE\nWHAT HAPPENS WHEN BOTH ARE PRESENT. A user typing in the minibuffer who triggers\na status message must not have the prompt covered or displaced. Say what you\nchose.\n\nTESTABLE, and therefore required: harness/tests/ can assert that the header's\nbounding box does not change when a status message arrives and when the mode\nchanges (the two reflow causes above), and that the toast overlaps rather than\ndisplaces. gotcha 48: a change to web/*.js belongs in that spec directory. The\ncolour and the animation are not testable there and should be argued instead.\n\nSEQUENCING: branch ui/page-menu is in flight and owns web/app.js, web/shell.js\nand web/style.css. START THIS AFTER IT LANDS, or expect a conflict in all three.","notes":"USER 2026-09-22, widening this ticket: \"the other display annoyance is that the\nnormal/categories/etc. pill changes sizes constantly: it should have a fixed\nallocated size and not move the layout around. As a general rule, there shouldn't\nbe motion of layout elements I expect to be fixed.\"\n\nSO THIS TICKET IS NOW ABOUT HEADER LAYOUT STABILITY GENERALLY, and the toast is\none instance of it. THE RULE IS THE DELIVERABLE, not three separate patches.\n\nTHREE KNOWN MOVERS, all in the flex \u003cheader\u003e:\n 1. THE MODE PILL. Its label comes from AppMode::label() -- NORMAL, CATEGORIES,\n FORMULA, INSERT, RECORDS, ... -- and the pill is sized by its text, so every\n mode change resizes it and shifts everything after it.\n 2. #hint. One line per mode off help::hint_line, so it reflows on the same\n event as the pill, compounding it.\n 3. #status. Composed from the transport fact and ViewState::status_msg (see\n the original description).\n\nTHE OBVIOUS FIX IS THE FRAGILE ONE, AND IT WILL BE CHOSEN BY DEFAULT: a\nhardcoded min-width in ch, sized by eye to today's longest label. That silently\nresumes the jitter the day an AppMode variant with a longer label is added.\nAppMode's per-variant facts are exhaustive-match methods, so the COMPILER makes\nyou fill in label() for a new variant (repo-map, AppMode) -- but nothing anywhere\nchecks that the CSS still fits it. The gap between \"the compiler caught the Rust\"\nand \"the pixel is wrong\" is exactly where this regresses.\n\nSO THE GUARD IS THE POINT, and it is genuinely testable: a Playwright spec that\nwalks every reachable mode and asserts the header's bounding box -- and the left\nedge of the element AFTER the pill -- do not change. That catches a new long\nlabel, not just today's set. Prove it discriminates by injecting a mode label\nlonger than the reserved width and watching it fail. Without that injection this\nis a test that passes because nothing moved anyway.\n\nNote which modes a browser can actually reach: improvise-6acs and improvise-y11g\nmean some text-entry modes are not reachable there yet, so a spec that walks\n\"every mode\" may be walking four. Say how many it really covered -- an\noverstated scope is worse than a narrow one honestly described.\n\nTHE GENERAL RULE TO WRITE DOWN, since the user stated it as one: an element whose\nCONTENT varies but whose POSITION the reader treats as fixed must have its space\nRESERVED, not fitted. That covers the pill, the hint, the status, the document\ntitle and the view chip, and it is the reason a toast must be out of flow rather\nthan merely short.\nFIXED in PR #127 (merge d46ec59).\n\nTHE TICKET'S FRAMING WAS WRONG ABOUT WHICH ELEMENT COMPOUNDED, and the spec found\nit rather than the author. The ticket (and the user's report) named the mode pill.\nOn its first run the new spec failed on the header's HEIGHT: #status was\nflex: 0 1 auto beside a flex: 1 1 auto hint, so a longer hint took the shrink out\nof #status, which WRAPPED TO TWO AND THEN THREE LINES -- 52.5px to 71.0px. THE\nOLD HEADER WAS TWO LINES TALL AT NORMAL WITH NO MESSAGE AT ALL.\n\nSo reserving the pill alone would have left the bar moving and the PR would have\nshipped looking correct. Fixed with flex: 1 1 0 on the hint (zero basis, absorbs\nthe remainder, contributes nothing to overflow) and nowrap + flex: 0 0 auto on\n#status.\n\nTHE RESERVE IS A FLOOR, NOT A CAP, and that is the load-bearing choice: a width\nor max-width absorbs an over-long value inside a box that never grows, so every\nbounding-box measurement stays identical and the regression is INVISIBLE. min-width\nlets an over-long mode label grow the box, which is the only way the guard can see\nit. Proved by injection: removing the 21ch reserve fails \"the bar moved in\nCATEGORIES\" AND the 16-char check-the-checker.\n\nDISMISSAL IS KEYED ON WHAT WAS LAST ACCEPTED, not on a timer, because\nViewState::status_msg is STATE re-offered by every repaint. Consequence, stated\nrather than hidden: TWO IDENTICAL MESSAGES IN A ROW SHOW ONCE. That is forced --\nnothing beside status_msg carries a sequence, model.revision moves for a cursor\nkey as readily as for a re-issued command, and of the two readings of an ambiguous\noffer only one avoids reintroducing the reported bug. A sequence number is the\nprotocol change the scope forbade.\n\nGOTCHA 46 IS NOW STRUCTURAL RATHER THAN CONVENTIONAL. composeStatus is DELETED:\nthe transport fact and the document fact go to two ELEMENTS, not one string joined\nwith a separator, so a repaint reaches onModel and CANNOT ADDRESS onShell AT ALL.\nThe join's unit test was REPLACED, not dropped -- \"a repaint's model status cannot\nreach the transport's sink at all\", verified present by the orchestrator. A\nseparation with no guard is how one regresses into a single sink.\n\nNINE MODES WALKED, not the four I guessed: NORMAL, CATEGORIES, FORMULAS, VIEWS,\nTILES, HELP, INSERT, NEW CATEGORY, FORMULA. Excluded with reasons:\nSavePrompt/ExportPrompt/CommandMode refused by resolve::stranding, records mode\ndeclines out loud, ImportWizard has no browser entry, Quit is not a screen. Each\niteration asserts #mode BEFORE measuring, so a key that failed to reach its mode\ncannot make the comparison vacuous.\n\nMINIBUFFER AND TOAST COEXIST: #bottom-dock is position: fixed and anchored at its\nBOTTOM, stacking upward. The minibuffer's box is byte-identical with and without a\ntoast. A top-anchored stack, or an offset from the minibuffer's height, would both\nhave moved the prompt.\n\nTHE TOAST KEEPS ITS TEXT WHEN IT HIDES, deliberately: the element is the record of\nwhat was last said, and drill.spec.js's discrimination between two refusals reads\nit -- an emptied node would satisfy not.toContainText(\"aggregate\") VACUOUSLY the\nmoment the timer fired.\n\nNOT VERIFIED: prefers-color-scheme is exercised nowhere (dark was screenshotted by\nhand; pre-existing, now stated in clients.md). Colour, contrast, spacing and the\ngrouped/multi-level header DOM remain argued. Text ALIGNMENT inside a fixed box is\ninvisible to a bounding-box guard -- honest scope, not a gap. Firefox and WebKit\ndo not run header.spec.js (Chromium-only, the config's existing rule).","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T06:09:15Z","created_by":"Edward Langley","updated_at":"2026-09-22T07:45:56Z","closed_at":"2026-09-22T07:45:56Z","close_reason":"Closed","labels":["browser"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-rw6l","title":"Page bar: clicking a page category should open a menu of its items, with a down-arrow affordance","description":"USER 2026-09-22, pointing at the page bar (the \"Quarter = Q1 (1/4)\" chip) on the\ndeployed page: \"The page items here (e.g. Quarter) should open a menu when clicked\nso you can pick a different option. Also have a down-arrow indicating there's a\nmenu.\"\n\nTHE GOOD NEWS, VERIFIED BEFORE FILING: almost everything needed already exists.\nThis is NOT a protocol change and needs no new derivation.\n\n - improvise_core::view::layout::PageAxis ALREADY carries\n `items: Option\u003cVec\u003cString\u003e\u003e` -- Some exactly for Axis::Page, None for a\n Filter axis, which constrains without being navigable (gotcha 27). So the\n ITEM NAMES ARE ALREADY PROJECTED: GridCache::page_coords is Vec\u003cPageAxis\u003e.\n - ModelCommand::SetPageSelection { .. } ALREADY EXISTS on the wire\n (improvise-protocol/src/reduce.rs:127), and is in records_named's match at\n :185. So the mutation half is done.\n\nTHE ONE GAP is that the browser THROWS THE NAMES AWAY. wasm-client/src/render.rs\nRenderPageAxis (:157) keeps `cat`, `selected` and `cycle: Option\u003cRenderPageCycle\u003e`,\nand RenderPageCycle (:168) is `{ at: usize, count: usize }` -- a POSITION and a\nCOUNT. That was enough for improvise-p1ym, which asked \"can [ and ] cycle\"; it is\nnot enough for \"can the user pick\", which needs the names.\n\nSo: widen RenderPageAxis to carry the item names it is already handed. That is a\npass-through, not a second derivation -- the names come from the projection, and\nnothing in the browser may compute them (design-principles section 5).\n\nTHEN THE UI, which is the actual work:\n - a down-arrow affordance on a page chip that HAS a menu. Note the affordance\n must follow `items.is_some()`, NOT the presence of a page bar entry: a Filter\n axis appears in page_coords and must NOT get an arrow, because there is\n nothing to pick. Getting that wrong offers a menu that cannot do anything.\n - click opens a menu of the items; choosing one composes\n Command::Model(SetPageSelection{..}).\n - the currently selected item should be marked in the menu.\n\nEVENT HANDLING: the user has already ruled on the shape (improvise-b8ot) -- one\ndelegated handler per surface on a stable root, not one listener per node, and\nIDs/classes/data attributes as needed to disambiguate. A menu built with per-node\nlisteners would be new work in exactly the style b8ot exists to remove. Read b8ot\nbefore starting, and prefer landing this in the shape b8ot wants rather than\nadding another per-node listener to migrate later.\n\nKEYBOARD: the menu must be operable without a mouse, and must not swallow keys\nthe grid needs. web/app.js's window keydown handler returns early when `editing`\nis true; a menu is a third mode of that kind and needs the same treatment or it\nwill eat hjkl. Say which you chose.\n\nNOT IN SCOPE, adjacent, filed separately or noted:\n - the TILE bar above it (the \"Quarter Pag\" chips) is a different widget. Its\n three-character labels come from Axis::short_label(), which exists so the\n TUI's tile bar has fixed-width columns; in a browser that reads as a\n truncation bug. Axis::plain_label() already says \"Page\". That is a separate\n change and NOT this ticket.","notes":"FIXED in PR #123 (merge 7470b61).\n\nTHE DESIGN DECISION WORTH KEEPING: `items` is relayed INSIDE `cycle`, not beside\nit. One predicate, not two. A Filter axis has no cycle, so it gets no arrow, no\nmenu, no items, is painted as a \u003cspan\u003e rather than a \u003cbutton\u003e, and\nClick::PageItem composes nothing if a click reaches it -- FOUR answers off\nPageAxis::cycle, the one home gotcha 27 already named. `items.is_some()` appears\nnowhere. That is stronger than the guard I asked for in the brief, which was a\ntest that a Filter axis gets no arrow: here the wrong state is not constructible.\n\nEvery test carries a Filter axis beside the Page one, and the pass-through fault\n(items: Vec::new()) is caught by\nrender::tests::a_navigable_page_axis_relays_the_items_it_may_be_fixed_to.\n\nKEYBOARD: the menu is the third state of the kind `editing` already was --\napp.js's window keydown returns early into shell.js::menuKey. Same mechanism, not\na new one, because the menu hangs over the grid and hjkl reaching through would\nscroll the thing it points at out from under it. menuKey answers EVERY key with\n`ignore` as the default (swallowed, never passed on), and count: 0 closes rather\nthan wrapping modulo zero.\n\nCODEX FOUND A REAL BUG and it was correct: the pageMenu branch originally sat\nABOVE app.js's metaKey guard, so with the menu open Cmd-R was answered `ignore`\nand then preventDefault'ed -- reload blocked -- and Ctrl-j/Alt-Home moved the\nmenu, two lines below a comment saying Ctrl/Alt stay the browser's. Fixed by\nordering plus one bail. VERIFIED BY THE ORCHESTRATOR on the merged branch: the\nmetaKey guard is above the branch, and pageMenuKeydown returns on\nctrlKey || altKey before claiming anything.\n\nBOUNDARY CROSSING, flagged and accepted: resolve.rs gained Click::PageItem and\nlib.rs a click_page_item. Unavoidable -- the ticket asks that choosing an item\nCOMPOSES SetPageSelection, the page holds no Basis, and design-principles\nsection 5 forbids it deriving one. No wire change: Command, ModelCommand and the\nframe pair are untouched. Indices, matching every existing Click variant, so the\npage never spells a category or item name.\n\nNOT VERIFIED: Firefox and WebKit run none of pages.spec.js (the config is\nChromium-only outside opfs/shell). The menu's position: fixed placement off\ngetBoundingClientRect is the part that could plausibly differ by engine.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T06:07:10Z","created_by":"Edward Langley","updated_at":"2026-09-22T06:41:45Z","closed_at":"2026-09-22T06:41:45Z","close_reason":"Closed","labels":["browser"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-nn4s","title":"ADR 004: an Emacs-like face system, so a theme is UI-agnostic data the front ends apply","description":"USER DIRECTION 2026-09-22: \"I think we want to iterate towards having a way to\ntheme the app like emacs does: faces, etc. described in a UI-agnostic format that\nthe different frontends can apply (there's probably a swift version down the road\nand maybe Qt, etc.)\"\n\nNote \"iterate towards\". This is a DIRECTION, not a feature request, and the first\ndeliverable is an ADR (004 is free) rather than code.\n\nWHERE THE REPO ACTUALLY IS TODAY, measured:\n - AxisColor { Row, Column, Page, Filter, Inactive } in improvise-core/view/axis.rs\n -- names ROLES. This is the pattern working.\n - MinibufColor { Green, Cyan, Yellow } in improvise-protocol/lib.rs -- names\n COLOURS. draw.rs::minibuf_fg is an IDENTITY MAP (Green -\u003e Color::Green). Its\n doc comment says \"semantic palette only\" and the type makes that false: there\n is nothing to theme. The roles exist at the call sites (green is editing,\n yellow is the prompt/destructive family, cyan is search) and were spelled as\n colours.\n - 124 `Color::` literals across 10 TUI files (draw.rs, grid.rs, every panel,\n help, the wizard). No theme, palette or color_scheme concept anywhere.\n - PR #120 just built a CSS custom-property palette for the browser. So the two\n front ends are AT A FORK: either colour roles become shared vocabulary each\n end maps to its own palette, or the web palette and the TUI's 124 literals\n drift permanently and one document looks unrelated in two clients.\n\nSEVEN QUESTIONS THE ADR MUST ANSWER. They are listed because each is a place this\ngets decided by accident otherwise.\n\n1. THE TERMINAL-PALETTE TENSION, WHICH IS THE HARD ONE. A face's colour may want\n to be \"the terminal's green\" rather than an RGB value. Today Color::Green means\n whatever the USER'S TERMINAL calls green, so their terminal theme applies. A\n format carrying only RGB would LOSE that -- a regression, not a gap, and one\n that would be discovered late and be expensive to undo. Emacs answers it with\n named colours plus tty approximation. Decide whether a face attribute can be\n a NAMED terminal colour, an RGB value, or either, and what a browser does with\n \"terminal green\".\n\n2. INHERITANCE is what makes a face system tractable rather than 124 entries.\n Emacs derives most faces from a few. Decide whether faces inherit, and if so\n whether the resolution happens in shared code (once, correctly) or per front\n end (three times, differently).\n\n3. A THEME IS CLIENT-LOCAL. Gotcha 29 / the user's own ruling: the model is shared\n and the UI is not. So a theme must NOT be a Command, must NOT live in\n ViewState, and must NOT be projected -- otherwise one client's theme reaches\n another client's screen, which is the exact bug improvise-g0f is about. State\n this explicitly in the ADR; it is cheap now and a protocol change later.\n\n4. WHERE THE VOCABULARY LIVES. AxisColor is in improvise-core, which is supposed\n to have no UI; MinibufColor is in improvise-protocol. That inconsistency\n predates this and the ADR should resolve it rather than inherit it.\n\n5. WHICH ATTRIBUTES ARE PORTABLE. Foreground, background, weight, slant,\n underline map onto ratatui Modifier, CSS, Swift and Qt. Box, shadow and\n rounded corners do not reach a terminal. Decide the minimum common set, and\n decide what a front end does with an attribute it cannot render -- Emacs\n ignores it, which is probably right and should be SAID.\n\n6. THE FORMAT. A Swift or Qt front end means the theme is DATA, not Rust -- so it\n needs a file format. TOML, JSON, or a small pest grammar in this repo's usual\n style. It is NOT part of .improv: a theme is configuration, not document data,\n and putting it there would make a document dictate a reader's colours.\n\n7. HOW IT ARRIVES WITHOUT A FLAG DAY. A big-bang refactor of 124 literals is how\n this dies. The ADR needs a migration path where the vocabulary lands first and\n the literals move opportunistically.\n\nTHE INCREMENTAL STEPS THAT FORECLOSE NOTHING, cheapest first:\n a. Rename MinibufColor's variants to roles. Pure vocabulary, no behaviour, and\n it converts \"semantic palette\" from a false doc comment into a true type.\n b. Name PR #120's CSS custom properties after the same roles, so the web\n palette is already a theme in the making rather than a second vocabulary.\n c. Let the 124 literals migrate as files are touched for other reasons.\n\nNOT IN SCOPE YET: a theme picker, user-supplied theme files, or live reloading.\nThose follow the vocabulary; none of them is the hard part.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T05:44:55Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:44:55Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-oujb","title":"A release should carry the web app too: today a tag captures only the native binaries","description":"USER RULING 2026-09-22, asked whether the release workflow builds a package for the\nweb version and told it does not: \"Attach the site to releases too.\"\n\nWHAT IS TRUE TODAY. .github/workflows/release.yml has ZERO references to wasm, web,\npkg, pages or assemble -- verified by grep. It builds four native targets\n(aarch64/x86_64 x macOS/Linux) plus a shell installer, per dist-workspace.toml.\nThe web app ships by a completely separate route: ci.yml's pages-deploy, gated on\n`github.event_name == 'push' \u0026\u0026 github.ref == 'refs/heads/main'`. Continuous\ndeployment from the default branch, not a release artifact.\n\nFOUR CONSEQUENCES, none of them written down anywhere:\n - A TAG DOES NOT DEPLOY THE SITE. pages-deploy fires only on a push to main, so\n tagging leaves the site wherever main last was. Tag from main's tip and they\n coincide BY ACCIDENT; tag an older commit and they diverge silently.\n - There is no web version of a release to download, self-host or roll back to.\n - THE PAGE NAMES NO VERSION AT ALL. web/assemble.sh stamps the cache-bust token\n with $GITHUB_SHA, but nothing in web/index.html shows a version or commit, so\n a visitor cannot tell what they are running and neither can a bug report.\n - A binary user and a site user are running different code, potentially months\n apart: v0.1.0-rc2's binaries are from 2026-04-11 and the site is from whatever\n merged most recently. README's Installation section does not say so.\n\nTHE ASK: a tagged release also carries the assembled site as an artifact, so a\nrelease has a self-hostable web bundle and a rollback target.\n\nFIVE TRAPS, each of which is how this gets done wrong:\n\n1. release.yml IS AUTOGENERATED BY cargo-dist and says so on line 1. A hand edit\n is clobbered by the next `dist init`. Use dist's own supported mechanism for\n extra artifacts (see its `extra-artifacts` / custom build configuration in\n dist-workspace.toml) so the addition SURVIVES REGENERATION. If no supported\n mechanism fits, say so explicitly and argue for the hand edit rather than\n making it silently -- and leave a comment in dist-workspace.toml so the next\n `dist init` has a warning.\n2. DO NOT WRITE A SECOND WAY TO BUILD THE BUNDLE. ci.yml's `build` job carries a\n long comment explaining that everything downstream DOWNLOADS from one\n wasm-pack run precisely so \"the harness runs against the tree Pages publishes\"\n is true by construction. A release job that builds its own bundle a different\n way reintroduces exactly what that comment prevents. Use web/build.sh and\n web/assemble.sh, the same scripts a developer runs.\n3. THE CACHE-BUST STAMP TAKES AN ARGUMENT AND THE RIGHT ONE DIFFERS. CI passes\n $GITHUB_SHA; a local run defaults to `dev`. A release should almost certainly\n pass the TAG. Decide, and note scripts/assemble-stamp.sh verifies each stamped\n reference independently -- check it still passes with whatever you choose.\n4. THE RELEASE JOB NEEDS A TOOLCHAIN IT DOES NOT HAVE: wasm32-unknown-unknown,\n a PINNED wasm-pack (ci.yml pins v0.15.0 and explains that pinning is what makes\n a local measurement evidence about a CI run), and node for the prose site.\n Reuse those pins rather than picking fresh ones.\n5. wasm-opt REJECTS LLVM's memory.copy WITHOUT --enable-bulk-memory, and the\n worker bundle ALSO needs --enable-nontrapping-float-to-int because it carries\n the whole App and so chrono's date arithmetic (gotcha 45). Those live in the\n crates' Cargo.toml metadata; do not re-specify them.\n\nALSO IN SCOPE, because it is the cheap half of the same problem: GIVE THE PAGE A\nVISIBLE VERSION OR COMMIT. A site that cannot say what it is makes every bug\nreport about it unanswerable. Small, and worth more than the tarball.\n\nDECIDE AND STATE, do not default: whether tagging should ALSO deploy Pages, or\nwhether the site keeps tracking main and a release merely captures a copy. The\nuser chose \"attach the site to releases\", which does not by itself answer this.\nBoth are defensible; supporting both by accident is not.\n\nNOT IN SCOPE: bumping a version or cutting a tag (improvise-3tj / improvise-l36,\nboth need the user), and RELEASING.md, which improvise-gx5d owns on another branch.","notes":"USER RULING 2026-09-22, settling the open decision this ticket flagged: \"I like\nPages showing the latest main for now.\"\n\nSO: pages-deploy is UNCHANGED. It keeps its `github.event_name == 'push' \u0026\u0026\ngithub.ref == 'refs/heads/main'` gate; a tag does not deploy the site. A release\nmerely CAPTURES a copy of the assembled site as an artifact.\n\nNote the words \"for now\". This is a current preference, not a permanent\narchitecture decision, and whoever reads it later should be able to tell the\ndifference. Do not record it as settled forever.\n\nTHREE THINGS THAT FOLLOW, and they change the emphasis of the work:\n\n1. THE TWO LINEAGES DIVERGING IS NOW DELIBERATE, so it gets DOCUMENTED rather than\n fixed. A binary user runs whatever was tagged; a site visitor runs latest main.\n One sentence in README's Installation section.\n\n2. THE VERSION STAMP IS THE MOST VALUABLE PART OF THIS TICKET, not the tarball.\n With the lineages deliberately different, a page that cannot say what it is\n makes every bug report about it unanswerable. web/index.html names no version\n at all today. If only one thing lands from this bead, it is the stamp.\n\n3. THE ARTIFACT'S PURPOSE IS NARROWER THAN IT SOUNDS. With Pages tracking main,\n the release tarball is NOT what anyone visits -- it is a self-hostable copy and\n a rollback target. Saying \"the web version of v0.1.0\" invites a reader to\n assume the tarball is what is deployed, which is false and costs an afternoon.\n State the narrow claim.\n\nThe cache-bust token question narrows too: since pages-deploy is untouched, the\nrelease capture is the only new caller of web/assemble.sh, so the tag is the\nobvious token. Check scripts/assemble-stamp.sh still passes with it.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T05:31:13Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:31:44Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-gx5d","title":"The tag-push release workflow: diagnosis, a preflight guard, and the missing procedure","description":"USER 2026-09-22: \"we need to fix the tag push release workflow\" ... then, on being\nshown the diagnosis: \"do nothing for now, just make sure the workflow will work\"\nand yes to a preflight guard.\n\nTHE WORKFLOW WAS NEVER BROKEN, and the evidence is stronger than a local check.\nv0.1.0-rc1 and v0.1.0-rc2 (2026-04-11) BOTH have full published GitHub Releases:\ntarballs for all four targets, their .sha256 files, dist-manifest.json,\nsource.tar.gz and improvise-installer.sh. So the workflow has run end to end and\nproduced everything it is supposed to produce.\n\nWHAT ACTUALLY HAPPENED: v0.1.0-rc3 was pushed to gh, origin and gitea while every\ncrate still said 0.1.0-rc2. dist matches the tag against the ROOT PACKAGE version\n(dist-workspace.toml says members = [\"cargo:.\"], so only the root package counts\nand gp-tree sitting at 0.1.0 is not a finding). It refused with \"This workspace\ndoesn't have anything for dist to Release!\" and named the right answer itself, and\nthe four jobs behind `plan` skipped.\n\nReproduced both directions locally: `dist plan --tag=v0.1.0-rc2` gets past the\ncheck; `--tag=v0.1.0-rc3` gives the CI error verbatim.\n\nA CORRECTION WORTH KEEPING, because it nearly became a false fact in context/: I\nfirst concluded rc1/rc2 had never triggered the workflow, inferring it from an\nempty `gh run list`. GitHub Actions logs AGE OUT AT 90 DAYS and those tags are\nfive months old. An absent run and a discarded run look identical from the run\nlist, which is the same shape as doctrine(a_missing_loose_ref_is_not_a_deleted_branch).\nCheck `gh release view \u003ctag\u003e --json assets` -- the artifacts outlive the logs.\n\nTHE REAL ROOT CAUSE IS DOCUMENTATION. There is no release procedure anywhere:\n - .github/workflows/release.yml is cargo-dist's autogenerated file -- a\n mechanism, not a procedure.\n - README section Installation describes the current state and is accurate.\n - context/plan.md Phase 3 has one-time SETUP steps from the launch plan,\n including \"test the workflow by tagging v0.1.0-rc1 ... Delete the rc tag\n afterward\" -- which was not done; all three rc tags are still on all three\n remotes.\nNothing anywhere says BUMP THE VERSION BEFORE YOU TAG.\n\nIN FLIGHT on branch fix/release-preflight: scripts/release-preflight.sh (refuses a\ndirty tree, compares tag to root version, runs `dist plan` as a second opinion and\nsays when it could not), its check_the_checker, a CI step in the `test` job, the\nreasoning in context/code/tooling.md, and a short RELEASING procedure.\n\nNOTE THE TEST LESSON, which is the transferable part: the preflight's first test\nasserted only \"did it refuse\", and every negative case still passed with the tag\ncomparison replaced by `if false;` -- because the script refuses a DIRTY WORKING\nTREE first and this repo's tree is almost always dirty from bd's auto-export. Four\nof five checks were answering \"is the tree dirty\" while claiming to answer \"does\nit catch a bad tag\". It now runs against a clean throwaway checkout AND asserts the\nrefusal names the version mismatch.\n\nNO VERSION IS BUMPED AND NO TAG IS CUT. Cutting v0.1.0 is improvise-3tj and\npublishing is improvise-l36; both need the user.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T05:26:50Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:26:50Z","dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"improvise-owep","title":"Path traversal: a connected client can make the server read any file it can read (LoadModel)","description":"USER RULING 2026-09-22: \"We'll rely on sufficient containerization, but we should\nprevent path traversal attacks mainly: the server should have a documents\ndirectory and make sure the path canonicalizes to that directory.\"\n\nTHIS IS LIVE TODAY, NOT A FUTURE CONCERN. Verified by the orchestrator while\nscoping the ruling:\n\n - ModelCommand::LoadModel(PathBuf) is ON THE WIRE (protocol/src/reduce.rs:159).\n - effect::LoadModel (command/src/ui/effect.rs:914) calls\n persistence::load_from(\u0026*app.storage, \u0026self.0) with the CLIENT'S path.\n - PhysicalFs (io/src/storage.rs:94) has NO root and NO confinement:\n File::open(path) and File::create(path) on whatever arrives.\n - improvise-ws-server builds its App with App::new(workbook, path), which\n supplies that unconfined PhysicalFs.\n\nSo ANY CONNECTED WEBSOCKET CLIENT CAN ASK THE HOST TO READ ANY FILE THE SERVER\nPROCESS CAN READ. A .improv-shaped file loads wholesale and is then projected\nback to the client. A file that does NOT parse is still a leak risk: the error\ngoes into view_state.status_msg as \"Load error: {e}\", and pest errors quote the\noffending input.\n\nWRITE IS NOT YET CLIENT-REACHABLE -- Save/SaveAs are ViewOnly and off the wire --\nBUT IT IS ABOUT TO BE. improvise-y11g (the : command line, a core feature per the\nuser) reaches `:w \u003cpath\u003e`, and improvise-2mqz adds the export/download frames.\nLanding either without this is how arbitrary file WRITE ships.\n\nPAIR WITH improvise-kc7n: the Origin check answers what PAGE is driving a client,\nnever WHO is connecting, and improvise-kpnr (authentication) is open. Confinement\nis the control that does not depend on either.\n\nWHERE IT BELONGS: THE BACKEND, NOT THE CALL SITES. Gotcha 2 -- every document read\nor write goes through App::storage, and the host picks the backend. So this is a\nROOTED PhysicalFs (e.g. PhysicalFs::rooted(dir)), and every caller is covered by\nconstruction rather than by each remembering to check. That is\ndoctrine(an_invariant_is_made_unrepresentable_not_validated), the same answer\nprune_axis_items and gp-tree's Row\u003cT\u003e got.\n\nCRITICALLY, THIS IS A PROPERTY OF THE SERVER, NOT OF Storage. A TUI user may open\nany file they own; confining App::new would be WRONG. The server constructs the\nrooted backend; the TUI keeps the unrooted one. Gotcha 2 already says the host\npicks, so this fits without a trait change.\n\nSIX IMPLEMENTATION TRAPS, each of which is the standard way this fix is got wrong:\n\n1. std::fs::canonicalize REQUIRES THE PATH TO EXIST. A write to a NEW document\n fails it. Canonicalize the PARENT and join the final component -- and reject a\n final component that is itself `..` or contains a separator.\n2. REJECT, DO NOT SANITIZE. Stripping or collapsing `..` is the classic broken\n fix; `....//` and percent/unicode variants defeat it. Canonicalize, then\n compare.\n3. COMPARE COMPONENT-WISE, NOT AS STRINGS. `/srv/docs-evil`.starts_with(\"/srv/docs\")\n is TRUE as a string and false as a path. Use Path::starts_with, which is\n component-wise, and canonicalize the ROOT once at construction (a relative or\n symlinked root defeats the comparison otherwise).\n4. SYMLINKS: canonicalize resolves them, so a link inside the directory pointing\n out IS caught -- but only because the check runs on the resolved path. Do not\n \"optimise\" it into a lexical normalisation.\n5. TOCTOU IS A STATED RESIDUAL, NOT A SOLVED PROBLEM. Between the check and the\n open, a component can be replaced. openat2(RESOLVE_BENEATH) is the real answer\n and portable Rust does not have it. The user has ruled that containerization\n carries this; SAY SO in the code's context/ prose rather than implying the\n check is total.\n6. ABSOLUTE PATHS INSIDE THE ROOT must be allowed or the feature is unusable, and\n absolute paths outside it refused -- so the rule is \"canonicalizes under the\n root\", never \"is relative\".\n\nWHAT THE SERVER'S CLI GAINS: a --documents-dir (name it as you like), replacing or\nconstraining the current `files: Vec\u003cPathBuf\u003e` list. Decide and state whether the\nexisting multi-file form survives as \"these files, which must be under the root\"\nor is replaced by \"this directory\". Both are defensible; silently supporting both\nis not.\n\nTESTS -- and the check_the_checker discipline applies, because a confinement test\nthat passes against an unconfined backend is worse than none:\n - ../ escapes, absolute escapes, a symlink inside the root pointing out, a root\n that is itself a symlink, and the `/srv/docs-evil` string-prefix case.\n - A NEW file inside the root must be WRITABLE -- the case trap 1 breaks.\n - Drive it through ModelCommand::LoadModel over Sessions, not just against\n PhysicalFs directly: the vulnerability is reachable from the wire and the test\n should be too.\n - INJECT THE FAULT: point the rooted backend's root at `/` and watch the escape\n tests fail. A confinement test that cannot fail is the characteristic defect\n of this repo.\n - Consider whether io/src/storage.rs::contract::storage_contract should gain a\n confinement clause. Note OpfsFs is confined by the browser already and\n MemoryFs is a map, so this may be a PhysicalFs property rather than a contract\n one -- decide explicitly and write down which.","notes":"DEFERRED BY USER RULING 2026-09-22: \"The server isn't running anywhere yet so this\ncan be deferred.\"\n\nThat is an EXPOSURE judgement, not a correctness one. Everything in the analysis\nabove stands: the path really is client-supplied, PhysicalFs really is unrooted,\nand the read really would succeed. What is absent is a deployment -- nobody is\nrunning improvise-ws-server, so there is no process for a client to reach and no\nfilesystem to read. Priority dropped P0 -\u003e P2 and the bead set BLOCKED so it stays\noff `bd ready`, NOT closed.\n\nTHE DEFERRAL IS CONDITIONAL, AND THE CONDITION IS ALREADY WRITTEN DOWN AS A\nDEPENDENCY. improvise-y11g (the : command line) and improvise-2mqz (SaveAs /\nExportCsv) are both `blocked-by` this ticket, and that edge is now MORE load-\nbearing rather than less: it is what stops the exposure arriving before the fix.\nDo not remove those edges to unblock either ticket. If one of them becomes urgent,\nthis one comes with it.\n\nTHREE THINGS THAT REOPEN IT, so the condition is testable rather than remembered:\n 1. improvise-ws-server being deployed or demoed anywhere reachable, including a\n tunnel for a Show HN.\n 2. improvise-y11g or improvise-2mqz landing, either of which makes a\n client-supplied path reach a WRITE.\n 3. Any new ModelCommand or frame that carries a path.\n\nWHAT DOES NOT REOPEN IT: the browser worker deployment. Its Storage is OpfsFs,\nconfined by the browser to the origin's private filesystem, and the document is\nthe user's own. This ticket is about improvise-ws-server only.\n\nNOTE FOR WHOEVER PICKS IT UP: the analysis above was written against a live\nreading of the tree at d17ad0f. Re-verify rather than trust it -- a deferred\nsecurity finding whose premises have quietly changed is worse than an open one,\nbecause the priority says it was judged and the judgement may no longer hold.","status":"blocked","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T05:24:28Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:25:03Z","labels":["protocol"],"dependency_count":0,"dependent_count":2,"comment_count":0} +{"_type":"issue","id":"improvise-0w7y","title":"Browser UI polish: dismissable banner, a minibuffer a web user will see, and more contrast","description":"USER, 2026-09-22, looking at the deployed page at fiddlerwoaroof.github.io/improvise/app/:\n\n 1. \"I just noticed the minibuffer is at the bottom of the screen, that's a bit\n surprising and not really 'in your face' for a web page.\"\n 2. \"we need to do a frontend design polish for the UI. The general aesthetics\n are pretty good, but it should 'pop' a bit\"\n 3. \"we should make the giant 'Everything runs here in this tab ...' thing\n dismissable\"\n\nWHAT THE SCREENSHOT SHOWS. The banner is ~10 dense lines of prose ABOVE THE FOLD,\npushing the tile bar, category tree, formula panel and grid down. It explains\n?doc=, ?seed=, ?ws=, wss:// vs ws:// and reverse proxies -- reference material,\non first paint, every visit. The minibuffer was showing \"Display a menu\" in small\ntext at the very bottom-left.\n\nSCOPE IS PRESENTATION ONLY. RenderMinibuffer is built in wasm-client/src/render.rs\nfrom state.view.mode.minibuffer(), and both front ends read the same semantic\nvalue. Moving or restyling it must NOT add a second derivation of what it says\n(design-principles section 5; improvise-edp is the worked example). The bottom\nline stays right for the TUI -- this is about the web page.\n\nNOT THIS TICKET: making the minibuffer REACHABLE. That is improvise-6acs (verify\nthe \"typing blind\" report) and improvise-y11g (the : command line). Seeing it and\nreaching it are different bugs and the tracker should not let one stand in for\nthe other.\n\nTHE CONSTRAINT THAT SHAPES THE WORK: harness/tests/shell.spec.js is the only thing\nthat executes web/*.js, and it CANNOT see CSS or the grouped/multi-level DOM\n(gotcha 48). So a dismissed banner surviving a reload is testable and most of the\npolish is not -- which means the untestable part must be ARGUED, and the split\nbetween the two must be stated rather than blurred.\n\nBanner dismissal is per-viewer browser storage, which is what localStorage is\nlegitimately for: wrap every read and write in try/catch and render correctly when\nit throws or returns nothing.\n\nIN FLIGHT on branch ui/shell-polish.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T05:21:27Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:21:27Z","labels":["browser"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-6acs","title":"Verify the browser minibuffer report: painting is tested, reaching the mode is not","description":"USER REPORT 2026-09-21: \"the 'minibuffer' for things like typing in a new category\nname, etc. doesn't actually show up anywhere so you're typing blind.\"\n\nTHIS WAS NEVER FILED. It is filed now as a VERIFY-FIRST ticket, because the obvious\ndiagnosis is wrong and the real cause may already be fixed.\n\nWHY IT IS NOT A PAINTING BUG. web/app.js (:247-251) paints the minibuffer, and\nrender.rs::every_minibuffer_mode_paints_what_is_being_typed asserts all four\ntext-entry modes paint their prompt and the typed value. That test exists because\npainting WAS the bug once (improvise-fdt6, evidence in\ncontext/test-evidence/clients.md) and it was fixed.\n\nWHY THE TEST CANNOT SEE THE REPORTED SYMPTOM, which is the interesting part: it\nfolds Command::ChangeMode(mode) DIRECTLY. It never presses a key. So it proves the\npaint works GIVEN the mode, and says nothing about whether a keypress can REACH the\nmode. That is this repo's characteristic defect -- a test that passes while the\nthing it names is broken -- sitting in the exact place the symptom points.\n\nSO THE HYPOTHESIS TO TEST IS REACHABILITY, not rendering: does a browser keypress\nactually put the client into AppMode::CategoryAdd? resolve.rs::stranding() refuses\nonly SavePrompt, ExportPrompt and CommandMode, so category-add is not stranded --\nbut `search-or-category-add` sat on the_command_names_that_still_need_the_host_are_these\nuntil PR #114 moved it off (improvise-koh6). The report predates that merge.\n\nSO IT MAY ALREADY BE FIXED. Verify against current main FIRST and close on evidence\nif so -- do not build anything before reproducing.\n\nIF IT REPRODUCES: the fix is whatever makes the key reach the mode, and the test\nthat earns its place is one that goes from a KEYPRESS to a painted minibuffer, not\nfrom a folded ChangeMode. Add it beside the existing one rather than replacing it;\nthey answer different questions and both are worth having.\n\nIF IT DOES NOT REPRODUCE: say so on the ticket with the keypress you used, and\nstrengthen the existing test anyway -- a keypress-to-paint test is the guard that\nwould have caught this class, and its absence is why a user found it instead.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T05:14:30Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:14:30Z","labels":["browser"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-1ohv","title":"add-record-row cannot be named on the wire: a client cannot create a record","description":"Found by the improvise-h3y7 completeness audit.\n\nAddRecordRow (cmd/grid.rs:492) builds a CellKey from the active view's\nfiltering_cats() page selections (gotcha 27: Page union Filter, NOT\ncategories_on(Axis::Page)) and writes SetCell(key, Number(0.0)).\n\nA client cannot compose it, for the same reason the coordinate rename cannot be\ncomposed: ModelCommand::SetCell takes a RecordCell whose RecordHash is an opaque\nsurrogate the server resolves against cells that ALREADY EXIST (App::resolve_record,\nui/app.rs:516). The record this command creates does not exist, so there is nothing\nto name it with.\n\nThe client DOES have the operands -- GridCache::page_coords carries the page\nselections (gotcha 33), and page_coords is framed by nothing and survives a reshape.\nWhat it lacks is a variant that TAKES them.\n\nWHY THIS IS NOT A ONE-LINE ADDITION. A ModelCommand that names coordinates rather\nthan a surrogate is a NEW NAMING MODE in the vocabulary. ADR 002 contemplated two:\n\"names its target by name\" (a category, an item, a view) and \"names a record that\nalready exists by a position-free surrogate\". A third -- \"names a record that does\nnot exist yet, by coordinates\" -- wants the ADR conversation improvise-hkbc's guard\nis designed to force. DO NOT SLIP IT IN AS A VARIANT.\n\nCHEAPEST HONEST INTERIM: leave add-record-row declined and land the coordinate-rename\nticket first, which answers most of the same question and may settle the naming mode\non a case where the record's identity is at least partly known.\n\nTHE CLASS THIS BELONGS TO, worth writing into ADR 002 or context/code/protocol.md\nbecause it will be rediscovered otherwise: a client cannot name a record it is about\nto create. That is ADR 002 working as designed, and it is INVISIBLE from the\nModelCommand list, because every existing variant either names things by name or\nnames a record that already exists. `will_be_created_by_the_frame` (ui/reduce.rs:284)\nexempts such a record from the basis check, which reads as though the naming problem\nwere solved. It is not, and the two are easy to confuse.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T04:47:53Z","created_by":"Edward Langley","updated_at":"2026-09-22T04:47:53Z","labels":["protocol"],"dependencies":[{"issue_id":"improvise-1ohv","depends_on_id":"improvise-fdk1","type":"blocks","created_at":"2026-09-22T04:48:02Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-fo0w","title":"Three black-hole keys are resolvable today over vocabulary that already exists","description":"Found by the improvise-h3y7 completeness audit. THE HEADLINE OF THAT AUDIT is that the\nvocabulary is far more complete than assumed: of thirteen gaps examined, EIGHT are\nclosed in the vocabulary and open only in the resolver. These three need NO new wire\nsurface at all.\n\nEach sits on the_command_names_that_still_need_the_host_are_these (resolve.rs:2114)\nand is therefore a BLACK HOLE -- gotcha 47: Host::handle answers ClientFrame::Key with\nan explicit empty vec, so these keys do nothing at all in a browser rather than being\nmerely slower.\n\n1. `search` -- EnterSearchMode::execute (cmd/mode.rs:386) emits exactly\n [SetSearchMode(true), SetSearchQuery(\"\")]. Both are Commands. Composable in one\n line; the cheapest single win in the audit.\n\n BUT IT MUST NOT SHIP ALONE. search-navigate scans the model for matching cells and\n the client has only a viewport-windowed GridCache, so it is NOT composable --\n shipping `/` without `n` gives a browser user a search box that finds nothing,\n which is a WORSE failure than the current silence. Either land both (search-navigate\n needs a results projection or a host request frame -- file separately once scoped)\n or decline `/` out loud in the meantime.\n\n2. `enter-advance` -- EnterAdvance::execute (cmd/navigation.rs:144) is pure cursor\n arithmetic returning viewport_effects(...), i.e. SetSelected/SetRowOffset/\n SetColOffset, all on the wire. Its row_count/col_count are GridCache::extent, which\n improvise-bpq2 added for precisely this. The bottom-right AbortChain becomes\n KeyOutcome::Ignored. NOTE improvise-bd9o measures this as the black hole that\n swallows a records-mode commit.\n\n3. `hide-selected-row-item` -- ModelCommand::HideItem { view, category, item } exists.\n The operands are AxisFrame::cats.first() and the selected row's label tuple [0],\n both on GridCache.row_frame (gotcha 44).\n\nRULES THAT APPLY TO ALL THREE:\n- Each arm mirrors its Cmd LINE FOR LINE, including where siblings disagree\n (doctrine(mirror_the_cmd_line_for_line), gotcha 47). Tidying them into one rule is\n what makes the client a second derivation.\n- Each must come OFF the host list in the same commit. That list is where closing a\n gap has to come and say so.\n- tests/lockstep.rs is where the evidence goes.\n\nNOT IN THIS TICKET: force-quit, which is in the same file and is a product question\n(what does Ctrl-Q mean in a browser tab?). It should stop being an upstream black hole\neither way, but which way is a ruling.\n\nCAVEAT FROM THE AUDIT, stated so nobody treats the sizes as measured: the auditor read\neach Cmd::execute and confirmed every effect it emits has a Command, but did not write\nthe arms. Operand availability in GridCache is read off the types, not exercised.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T04:47:16Z","created_by":"Edward Langley","updated_at":"2026-09-22T04:47:16Z","labels":["browser","protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-cfly","title":"The wave ships view history with no key that reaches it: declined() still refuses view-back","description":"Found by the wave's integration agent at preview 30560e0f, and it is a WAVE-LEVEL\nresidue rather than a defect in any branch -- which is why nobody could see it.\n\nPR #115 makes the view history genuinely work for a connected client:\nViewBack/ViewForward join the view half of Command, reduce_view folds all three\nview effects line for line, each client's own view_back_stack is maintained, and\na deleted view is pruned from every client's history via Command::ForgetView.\n\nBUT resolve.rs's composed() has NO view-back or view-forward arm, and declined()\nstill answers \"stepping back to the previous view is not available in the browser\nyet\" -- byte-identical to base.\n\nSo the merged wave contains the CAPABILITY and not the KEY. A browser user\npresses `\u003c` and is told the feature does not exist, over machinery that now\nfully supports it.\n\nWHY NEITHER AGENT COULD SEE IT: #115 was told not to touch resolve.rs (another\nagent owned it) and correctly reported the exact lines instead. #114 owned\nresolve.rs and had no reason to look at declined() at all. Reading #115 alone you\nwould conclude browser view-history works; reading #114 alone you would never\nlook.\n\nTHE EXACT LINES, from #115's report and confirmed by the integration agent as\nstill byte-identical to base:\n\n crates/improvise-protocol/src/resolve.rs\n 239: // NOT \"not built\": it commits a drill first, so composing it would\n 240: // commit another client's typing. Needs addressed commands -- clients.md.\n 241: \"view-back\" =\u003e \"stepping back to the previous view\",\n\nDELETE THE COMMENT WITH THE ARM. Its stated reason is WRONG as of #115: composing\nview-back does not commit another client's typing, because a browser session has\nno drill state to commit. Leaving a false reason behind is worse than leaving the\ndecline.\n\nWHAT TO ADD: a composed() arm returning vec![Command::ViewBack], and the forward\nsibling. Note gotcha 47's rule -- each resolver mirrors its Cmd LINE FOR LINE,\nincluding where siblings disagree -- so check ViewNavigate's own execute() rather\nthan assuming the two directions are symmetric.\n\nPRECONDITION: #115 merged. Nothing else; #115 named this explicitly.\n\nNOT IN SCOPE: line 234, \"drill-into-cell\" =\u003e \"drill\". That one still needs the\n_Drill view session-local and the staging design, and the user has since ruled\nthat browser staging must be identical to the TUI's. See improvise-gd28.\n\nALSO: make sure the wave's PR body does not claim browser view-history works.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T04:17:26Z","created_by":"Edward Langley","updated_at":"2026-09-22T04:17:26Z","labels":["browser","protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-cfly","title":"The wave ships view history with no key that reaches it: declined() still refuses view-back","description":"Found by the wave's integration agent at preview 30560e0f, and it is a WAVE-LEVEL\nresidue rather than a defect in any branch -- which is why nobody could see it.\n\nPR #115 makes the view history genuinely work for a connected client:\nViewBack/ViewForward join the view half of Command, reduce_view folds all three\nview effects line for line, each client's own view_back_stack is maintained, and\na deleted view is pruned from every client's history via Command::ForgetView.\n\nBUT resolve.rs's composed() has NO view-back or view-forward arm, and declined()\nstill answers \"stepping back to the previous view is not available in the browser\nyet\" -- byte-identical to base.\n\nSo the merged wave contains the CAPABILITY and not the KEY. A browser user\npresses `\u003c` and is told the feature does not exist, over machinery that now\nfully supports it.\n\nWHY NEITHER AGENT COULD SEE IT: #115 was told not to touch resolve.rs (another\nagent owned it) and correctly reported the exact lines instead. #114 owned\nresolve.rs and had no reason to look at declined() at all. Reading #115 alone you\nwould conclude browser view-history works; reading #114 alone you would never\nlook.\n\nTHE EXACT LINES, from #115's report and confirmed by the integration agent as\nstill byte-identical to base:\n\n crates/improvise-protocol/src/resolve.rs\n 239: // NOT \"not built\": it commits a drill first, so composing it would\n 240: // commit another client's typing. Needs addressed commands -- clients.md.\n 241: \"view-back\" =\u003e \"stepping back to the previous view\",\n\nDELETE THE COMMENT WITH THE ARM. Its stated reason is WRONG as of #115: composing\nview-back does not commit another client's typing, because a browser session has\nno drill state to commit. Leaving a false reason behind is worse than leaving the\ndecline.\n\nWHAT TO ADD: a composed() arm returning vec![Command::ViewBack], and the forward\nsibling. Note gotcha 47's rule -- each resolver mirrors its Cmd LINE FOR LINE,\nincluding where siblings disagree -- so check ViewNavigate's own execute() rather\nthan assuming the two directions are symmetric.\n\nPRECONDITION: #115 merged. Nothing else; #115 named this explicitly.\n\nNOT IN SCOPE: line 234, \"drill-into-cell\" =\u003e \"drill\". That one still needs the\n_Drill view session-local and the staging design, and the user has since ruled\nthat browser staging must be identical to the TUI's. See improvise-gd28.\n\nALSO: make sure the wave's PR body does not claim browser view-history works.\n","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T04:17:26Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:22:26Z","closed_at":"2026-09-22T05:22:26Z","close_reason":"Closed","labels":["browser","protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-en4s","title":"No guard that a client's formula panel loses a formula its target item's deletion removed","description":"Found while reviewing improvise-g3no; the reasoning is in\ncontext/code/command.md beside the other Dirty notes.\n\nDirty::of(ModelChange::CellData) reports formulas: true. That was written as\ndeliberate over-reporting in the cheap direction -- a cell write cannot change\na formula, so the entry cost one redundant Projection and bought nothing.\n\nIt is now load-bearing. improvise-g3no made Model::remove_item drop the\nformulas targeting the item, and effect::RemoveItem is CellData, so a delete\nreally can take a formula out of the document. Tightening that arm would leave\nevery connected client's formula panel showing a formula the document no longer\nhas, forever, with nothing in the stream to correct it -- gotcha 35's expensive\ndirection, and exactly the failure that put `formulas` on the LayoutGeometry arm\nin the first place.\n\nNothing guards it. removing_a_category_empties_the_clients_formula_panel covers\nthe LayoutGeometry twin only. The missing test is its item-level sibling: a\nsession whose formula panel names a _Measure formula target, a\nModelCommand::RemoveItem for that target, and an assertion that the client's\nFormulaCache no longer names it. Inject the fault by setting formulas: false on\nthe CellData arm of Dirty::of.\n\nLives in ui/project.rs, which is why it is filed rather than written.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T02:27:48Z","created_by":"Edward Langley","updated_at":"2026-09-22T02:27:48Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-xolb","title":"The wave brief's gate list omitted the node-test jobs, the only gate that sees an unpainted RenderModel field","description":"Found by the wave's integration agent diffing my wave brief against .github/workflows/ci.yml.\n\nMY GATE LIST OMITTED A REAL CI JOB:\n\n ./scripts/node-test.sh 'web/tests/*.test.js' ci.yml:239 \"Browser JS unit tests (node --test)\"\n web/tests/bundle/*.test.js ci.yml:371 (needs a built bundle)\n\nAt base: 36 tests over 5 files, 0 fail. Neither my brief nor context/repo-map.md's \"Testing -- the short version\" mentions either.\n\nWHY IT MATTERS, and it is not a formality: web/tests/render_surface.test.js is the MECHANICAL guard at the RenderModel -\u003e DOM boundary. It parses the pub fields off crates/improvise-wasm-client/src/render.rs and requires model.\u003cfield\u003e to appear in web/app.js + web/shell.js.\n\nPROVEN BY INJECTION: adding `pub injected_unpainted_field: u64` to RenderModel fails that gate with actual: ['injected_unpainted_field'], expected: []. cargo test --workspace is COMPLETELY INDIFFERENT to it.\n\nSo an agent working from a gate list that omits it can expose a projected field, never paint it, pass every gate it was told to run, and ship the defect. That is precisely the cross-branch shape a wave produces: one agent adds the field, another owns the painting.\n\nTWO BLIND SPOTS THE GUARD STATES ABOUT ITSELF, confirmed by reading, so it should not be over-trusted either:\n - it is a SUBSTRING search (page.includes(\"model.\" + field)), so a field read only inside a function nothing calls reads as painted\n - its field regex requires exactly four-space indentation and a lowercase name\n\nTHE GENERALISABLE PART: a gate list carried in a brief is a COPY of CI, and a copy drifts. The brief is written from memory and the repo-map, and both were stale here. Checking the brief against ci.yml is a once-per-wave action, not a once-per-agent one -- no single agent would find this, because q and s never touch RenderModel and r and t each individually pass cargo test.\n\nWHAT TO DO: add both node jobs to the repo-map's testing section and to the wave brief template, so the next brief inherits CI rather than memory.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T00:49:15Z","created_by":"Edward Langley","updated_at":"2026-09-22T00:49:15Z","labels":["docs","testing","tooling"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-xolb","title":"The wave brief's gate list omitted the node-test jobs, the only gate that sees an unpainted RenderModel field","description":"Found by the wave's integration agent diffing my wave brief against .github/workflows/ci.yml.\n\nMY GATE LIST OMITTED A REAL CI JOB:\n\n ./scripts/node-test.sh 'web/tests/*.test.js' ci.yml:239 \"Browser JS unit tests (node --test)\"\n web/tests/bundle/*.test.js ci.yml:371 (needs a built bundle)\n\nAt base: 36 tests over 5 files, 0 fail. Neither my brief nor context/repo-map.md's \"Testing -- the short version\" mentions either.\n\nWHY IT MATTERS, and it is not a formality: web/tests/render_surface.test.js is the MECHANICAL guard at the RenderModel -\u003e DOM boundary. It parses the pub fields off crates/improvise-wasm-client/src/render.rs and requires model.\u003cfield\u003e to appear in web/app.js + web/shell.js.\n\nPROVEN BY INJECTION: adding `pub injected_unpainted_field: u64` to RenderModel fails that gate with actual: ['injected_unpainted_field'], expected: []. cargo test --workspace is COMPLETELY INDIFFERENT to it.\n\nSo an agent working from a gate list that omits it can expose a projected field, never paint it, pass every gate it was told to run, and ship the defect. That is precisely the cross-branch shape a wave produces: one agent adds the field, another owns the painting.\n\nTWO BLIND SPOTS THE GUARD STATES ABOUT ITSELF, confirmed by reading, so it should not be over-trusted either:\n - it is a SUBSTRING search (page.includes(\"model.\" + field)), so a field read only inside a function nothing calls reads as painted\n - its field regex requires exactly four-space indentation and a lowercase name\n\nTHE GENERALISABLE PART: a gate list carried in a brief is a COPY of CI, and a copy drifts. The brief is written from memory and the repo-map, and both were stale here. Checking the brief against ci.yml is a once-per-wave action, not a once-per-agent one -- no single agent would find this, because q and s never touch RenderModel and r and t each individually pass cargo test.\n\nWHAT TO DO: add both node jobs to the repo-map's testing section and to the wave brief template, so the next brief inherits CI rather than memory.\n","notes":"WIDENED 2026-09-22 by the improvise-rw6l agent, which hit the gap from the other\nside and lost a CI cycle to it.\n\nTHE GATE LIST OMITS A SECOND NODE SUITE, not just one. web/tests/bundle/ does NOT\nmatch the glob web/tests/*.test.js, so an agent given the standard gate list never\nruns it. wasm_surface.test.js is a ratchet on \"the handful of calls the page\nmakes\" and needs a built web/pkg, so it also has a PRECONDITION the other suite\ndoes not. It ran only in CI and failed there (commit 0a920f1).\n\nSo the standard brief needs BOTH lines, and the second needs its precondition\nstated:\n\n ./scripts/node-test.sh 'web/tests/*.test.js'\n web/build.sh \u0026\u0026 ./scripts/node-test.sh 'web/tests/bundle/*.test.js'\n\nTWO MORE ENVIRONMENT NOTES FROM THE SAME AGENT, both of which cost time and\nneither of which is written anywhere:\n\n 1. harness/node_modules DOES NOT EXIST IN A FRESH WORKTREE, and `npx playwright\n test` then fails with \"Cannot find package '@playwright/test'\" -- an error\n about the package, not about the harness, so it reads as a broken checkout.\n `npm install` in harness/ first. (AGENTS.md says npm ci, never npm install,\n for the repo's own dependency discipline; reconcile the two before writing\n this into a brief.)\n 2. The shell guard refuses a heredoc'd Python script for reasons not\n reproducible from its content: the same `python3 - \u003c\u003c'PY'` shape ran\n repeatedly and was then refused twice on a block containing only Rust test\n text. `python3 /dev/stdin \u003c\u003c'PYEOF'` worked. Worth a line in AGENTS.md's\n environment notes beside the `gh` and `bd` ones.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T00:49:15Z","created_by":"Edward Langley","updated_at":"2026-09-22T06:42:09Z","labels":["docs","testing","tooling"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-piyp","title":"every_bound_command_name_is_registered is blind to prefix sub-bindings and F-keys","description":"PROVEN BY INJECTION 2026-09-21 by the wave's integration agent.\n\nevery_bound_command_name_is_registered (improvise-command/src/command/keymap.rs:417) has two blind spots:\n\n - the walk does `Some(Binding::Prefix(_)) | None =\u003e {}` at line 458 -- it NEVER descends into a prefix sub-keymap\n - its private probe_keys() (line 491) covers 0x20..0x7f plus 14 named keys but NO F-keys, while the neighbouring public probe_keys_pub() (line 905) DOES include F(1)/F(2)\n\nINJECTION: two command names bound that no registry entry provides -- one under the `g` prefix, one on F(5) in Normal.\n - every_bound_command_name_is_registered -\u003e ok\n - cargo test --workspace -\u003e 1595 passed, 0 failed, 7 ignored, 37 binaries, BYTE-IDENTICAL to baseline\n\nPOSITIVE CONTROL, which is what separates \"untested path\" from \"broken test\": the same bogus name on Char('~'), a probed non-prefix key -\u003e the test FAILS. So the guard works; its reach is the defect.\n\nCONSEQUENCE: a command name bound only under g/y/Z, or on a function key, can be unregistered -- OR have args that will not parse, since improvise-s13 moved enter-mode into the args-carrying class -- and the whole suite stays green. That is the exact failure the guard was written for, surviving in the region it does not reach.\n\ndump_every_binding DOES descend into prefixes via which_key, but it is a DUMP gated on IMPROVISE_BINDING_DUMP, not an assertion -- inert in CI, so it does not cover the gap.\n\nTHE CONTRAST THAT MAKES THIS WORTH FIXING RATHER THAN NOTING: nothing_declined_is_also_composed in improvise-protocol/src/resolve.rs is the STRONGER guard -- its bound_commands helper DOES recurse into Binding::Prefix and into parents. So the resolution side is guarded where the registration side is not, and an agent reading either test alone would reasonably conclude its bindings are checked. Only comparing the two shows which half is real.\n\nFIX SHAPE: descend into Binding::Prefix in the walk, and make probe_keys cover what probe_keys_pub already does. Both are small. The acceptance is the injection above failing -- test it with a name behind a prefix AND one on an F-key, because they are two different blind spots and one fix may not close both.\n\nRelevant: gotcha 13 (prefixes are stored as an address; KeymapSet::prefix_keymap walks parents so RecordsNormal reaches Normal's g/y/Z) and gotcha 15 (the guard checks BOTH halves of dispatch -- the name resolves and the args parse).\n","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T00:49:14Z","created_by":"Edward Langley","updated_at":"2026-09-22T02:27:16Z","closed_at":"2026-09-22T02:27:16Z","close_reason":"Fixed in PR #112 (wave/q-keymap, head b4bd6bc), both blind spots closed and each injection fails SEPARATELY -- which was the acceptance, because a fix for one need not close the other.\n\n - PREFIX: reachable_names now recurses into Binding::Prefix. Reported via RecordsNormal, which confirms the INHERITED-prefix path rather than only a locally-bound one.\n - F-KEY: probe_keys now DELEGATES to probe_keys_pub, which covers F(1)..=F(12).\n\nTHE FINDING THE BEAD DID NOT PREDICT, and it is the better half: the F-key gap was not an oversight about F-keys. probe_keys and probe_keys_pub were DUPLICATES differing by two lines, and the gap WAS the drift between them. Adding F-keys to both would have closed the symptom and left the mechanism intact. So the duplication doctrine applies to TEST SCAFFOLDING, not only to production derivations -- two copies of a test's probe key space is the same disease as two keymap spines, and it failed the same way.","labels":["command","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-b8ot","title":"Delegate DOM events: one handler per surface on the stable root, not one per node","description":"USER RULING 2026-09-21: \"I want one handler outside the area that gets redrawn\nand dispatch events there.\"\n\nTODAY: one listener PER NODE, created inside the render loops and reallocated on\nevery repaint. Only `window` keydown (app.js:509) and the transport listeners\nare delegated.\n\n app.js:361 td mousedown ONE PER GRID CELL, inside the cell loop\n app.js:436 li click one per panel row\n app.js:476 span click one per tile\n app.js:493 li click one per category row\n app.js:414 input keydown the editor\n app.js:424 input blur the editor\n\nEight replaceChildren sites rebuild the DOM wholesale, so each closure is\ndiscarded and reallocated per frame, and repaints happen roughly per keystroke.\n\nTHE ROOTS ARE ALREADY STABLE, which is what makes this cheap. replaceChildren\nreplaces an element's CHILDREN, not the element, so every `el.*` registry entry\n(app.js:30-43) survives every repaint:\n\n el.grid \u003c- replaceChildren at :214 delegate td mousedown here\n el.categories \u003c- :218 delegate li click here\n el.tiles \u003c- :479 delegate span click here\n el.formulas / el.views \u003c- via `host` at :439 delegate li click here\n\nSo each surface already has exactly the persistent ancestor the ruling asks for.\nNo new wrapper element is needed. Read indices off data- attributes\n(data-row/data-col on a cell, data-index on a row or tile) with\nevent.target.closest(..).\n\nFOUR THINGS A NAIVE REWRITE BREAKS. All are documented and load-bearing:\n\n1. THE GRID USES mousedown, NOT click, DELIBERATELY (improvise-0x9u). From an\n editing state the input's blur repaints first, so the \u003ctd\u003e is gone before a\n click could fire. Delegation does not change that -- the target is detached\n before the event -- so it must stay mousedown.\n\n2. IT CALLS preventDefault(). Dropping that reintroduces focus/selection\n behaviour the editor depends on.\n\n3. IT CONDITIONALLY SKIPS THE REPAINT. On an editable cell it calls edit(..) and\n does NOT repaint, because a repaint would take the \u003cinput\u003e away. The comment\n at :356-360 says so. A delegated handler must keep that branch.\n\n4. IT CLOSES A LIVE EDITOR FIRST (`if (editing) editing.close(false)`), before\n composing the click. Order matters.\n\nTHE EDITOR'S OWN TWO LISTENERS (keydown, blur at :414/:424) are a different\ncase: one transient node, one at a time, attached at creation and gone with it.\nDelegating those buys nothing and would need the blur/keydown to find the input\nthrough a root that does not own it. Leave them direct unless there is a reason;\nsay so in the change rather than silently keeping them.\n\nWHY THIS IS NOT PRIMARILY A PERFORMANCE TICKET, and should not be argued as one:\nthe cell count is bounded by the VIEWPORT, not the document -- set_viewport\nannounces a real window and GridCache is viewport-indexed for the browser\n(gotcha 47) -- so it is a few hundred closures per repaint, not thousands. And\nit does not leak: replaceChildren drops the nodes and their listeners with them.\nimprovise-i5tk's rule applies -- do not claim a performance win without a\nbenchmark, and \"it is fine\" is exactly as unevidenced as \"this dominates\".\n\nThe real argument is the repo's own: design-principles section 5 wants web/app.js to\ntrend to \"paint and wire\", and one handler per surface is wire where one handler\nper node is bookkeeping that grows with the data.\n\nTESTING: a change to web/*.js belongs in harness/tests/shell.spec.js (gotcha 48),\nnot a new ad-hoc surface. The assertions that matter are the four above, each of\nwhich is a behaviour a rewrite can silently lose: a click from an OPEN EDITOR\nstill lands (that is 0x9u's own case), the editor is not taken away when opening\none, and a click on the panel/tile surfaces still dispatches after a repaint.\nPure decision logic (target -\u003e indices) can go in web/shell.js, which is\nnode --test-able with no DOM.\n","notes":"USER LATITUDE 2026-09-21: \"We can use IDs, classes or data attributes as necessary to disambiguate the action to be taken.\"\n\nThe mechanism is open. One observation that should shape which is used, because the plumbing already exists and a delegated handler that ignores it would be a second derivation.\n\nTHE CELL ALREADY CARRIES ITS OWN IDENTITY ACROSS. web/shell.js gridPlan (lines 88-108) builds each cell with row, col, text, editText, editable, and a classes map of numeric/error/selected/typing/editable. web/app.js line 352 does nothing but toggle those classes onto the node. So row, col and editable are ALREADY decided upstream and already reach the node -- and row deliberately counts DATA rows rather than trusting position, because a group header is not a data row (shell.js lines 72-74).\n\nCONSEQUENCE: a delegated handler should read what gridPlan already decided rather than re-derive it from DOM structure. Writing data-row / data-col / an editable class is carrying an existing answer. Walking up the table counting rows to work out which data row was clicked would be a SECOND derivation of the very thing shell.js 72-74 exists to get right, and it would silently disagree the moment a group header appears.\n\nWHERE THE DECISION BELONGS: gridPlan, and the panel/tile equivalents. It is pure, it has no DOM, and it is already node --test-able -- so \"which action does this node mean\" can be unit-tested without a browser, leaving the delegated handler as a lookup. That is design-principles section 5's paint-and-wire applied to the dispatch as well as to the paint.\n\nPICKING BETWEEN THE THREE, given the latitude:\n - data attributes for the OPERANDS (data-row, data-col, data-index). They are values, and an attribute is where a value goes.\n - classes for the PREDICATES that already exist. editable is already a class; reusing it costs nothing and keeps one answer.\n - IDs only for the surfaces themselves, which they already are. Not for rows or cells -- an id must be unique per document and these are rebuilt every frame.\n\nA node's action is then: surface from the delegation root, operands from data attributes, branch from the class. No structural inference anywhere.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T00:36:44Z","created_by":"Edward Langley","updated_at":"2026-09-22T00:41:44Z","labels":["browser","ux"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-636d","title":"Introduce a global keymap that almost all modes inherit from","description":"User ask, 2026-09-21: confirm keymap inheritance works and the keymaps are\nactually shared, and put `?` in a base keymap overridden only in insert mode.\n\nVERIFIED at 523d571. Three findings, and the first is good news.\n\n## 1. THE KEYMAPS ARE GENUINELY SHARED. No drift is possible.\n\n browser: resolve.rs:53 Keymaps::shared().lookup(..)\n TUI: improvise-command/src/command/keymap.rs:127 Keymaps::default_keymaps()\n\nDifferent constructors, but `shared()` (keymap.rs:446) is a OnceLock over\n`default_keymaps` itself:\n\n static KEYMAPS: OnceLock\u003cKeymaps\u003e = OnceLock::new();\n KEYMAPS.get_or_init(Keymaps::default_keymaps)\n\nSo there is ONE definition and two callers -- a cached copy and a fresh one of\nthe same function. Content is identical by construction, not by a test holding\ntwo copies in step. This is the shape design-principles section 5 asks for and it\nis already right.\n\n## 2. INHERITANCE WORKS AND IS USED TWICE IN SEVENTEEN MODES.\n\nThe mechanism is real -- Keymap::with_parent, and lookup() walks parents. But\n`with_parent` is called at exactly two sites:\n\n keymap.rs:656 RecordsNormal inherits Normal\n keymap.rs:834 RecordsEditing inherits Editing (via TextEntry::with_parent)\n\nEvery other mode is a ROOT keymap. PanelKeymap::new and TextEntry::new both\nbuild with Keymap::new(), so the three panel modes, TileSelect, SearchMode,\nHelp, ImportWizard and the seven other text-entry modes inherit from nothing.\n\nThere is no base keymap. What looks like sharing across modes is actually\nSPINES -- PanelKeymap::new and TextEntry::new each re-bind a common set into a\nfresh map. That is duplication-by-builder rather than inheritance: correct\ntoday, and nothing makes it stay correct.\n\n## 3. `?` RESOLVES IN 2 OF 17 MODES.\n\nIt is bound once, keymap.rs:551, on `normal`:\n\n normal.bind_args(KeyCode::Char('?'), none, \"enter-mode\", vec![\"help\"])\n\nSo it works in Normal and -- via the one inheritance edge -- RecordsNormal.\nIt does NOT resolve in: CategoryPanel, FormulaPanel, ViewPanel, TileSelect,\nSearchMode, ImportWizard, or Help itself. Those are exactly the modes a user is\nmost likely to be lost in.\n\nIt correctly does not resolve in the eight text-entry modes, where `?` must type\na literal `?`.\n\n## THE PROPOSED FIX IS FREE, and this is the load-bearing detail\n\nPut `?` in a base keymap and let every non-insert mode inherit it. **Text-entry\nmodes need NO explicit override**, because of the existing lookup order:\n\n lookup_local: exact(key,mods) -\u003e Char(NONE) -\u003e AnyChar -\u003e Any\n lookup: lookup_local(..) THEN parent\n\n`lookup_local` is exhausted BEFORE the parent is consulted (keymap.rs:261-264).\nSo a text-entry child's own `AnyChar` -\u003e append-char shadows a parent's exact\n`?` automatically. The override the user asked for already falls out of the\nfallback order; it does not have to be written.\n\n## WHAT TO DECIDE WHILE DOING IT\n\n - Which keys belong in the base besides `?`. `:` (command mode) is already\n re-bound by both PanelKeymap::new and normal, and is a candidate. Do not\n sweep everything in: a base keymap that binds too much makes every mode's\n keys harder to reason about, which is the opposite of the ask.\n - Whether `?` in Help CLOSES the overlay. Today Help has no `?` at all. Toggling\n is the obvious reading and is not what the current binding does anywhere.\n - Whether the panel spines should BECOME inheritance rather than re-binding.\n That is the larger change and can follow; it is what would stop the\n duplication from drifting.\n\n## GUARD TO ADD\n\nThere is no test that a key resolves in every mode that should have it.\n`every_hint_key_resolves_in_its_mode` checks each mode's HINT against its own\nmap, which cannot see a key that no hint mentions. `prefix_keymaps_are_flat`\nand `every_bound_command_name_is_registered` are the other two and neither\ncovers this. So the acceptance is a test naming the base keys and asserting they\nresolve in every non-text-entry ModeKey -- and, in the other direction, that\nthey resolve to append-char in the text-entry ones.\n\nRelated: improvise-uln5 records that the help OVERLAY advertises eight keys that\ndo nothing in the browser. `?` being unreachable from most modes is the other\nhalf of the same complaint -- the help is hard to reach and then wrong.\n","notes":"USER RULING 2026-09-21: 'Almost all the command modes should inherit from a global mode.'\n\nThat supersedes this bead's original framing. It is no longer 'put ? in a base\nkeymap' -- ? is one symptom. The work is the global keymap itself, and the panel\nand text-entry SPINES become inheritance rather than re-binding.\n\nWHAT IS DUPLICATED TODAY, which is the concrete scope:\n\n bind_panel_toggles(\u0026mut km) is called TWICE -- keymap.rs:367 (PanelKeymap::new)\n and :555 (normal). It binds one toggle-panel-and-focus per Panel::ALL. So the\n panel toggles are already written once and APPLIED twice into two roots; that\n is the duplication-by-builder this ruling removes.\n\n ':' -\u003e enter-mode command bound in PanelKeymap::new (:369) and again in normal.\n '?' -\u003e enter-mode help bound ONLY in normal (:551). The asymmetry between\n these two is itself evidence nothing is keeping the spines in agreement.\n\nWHY THIS IS SAFE FOR 'ALMOST ALL' RATHER THAN NEEDING EXCEPTIONS -- the existing\nfallback order does the work:\n\n lookup_local: exact(key,mods) -\u003e Char(NONE) -\u003e AnyChar -\u003e Any\n lookup: lookup_local(..) THEN parent (keymap.rs:261-264)\n\nLocal is exhausted BEFORE the parent. Two consequences that make the ruling\ncheap:\n - a TEXT-ENTRY mode inheriting the global still types a literal '?', because its\n own AnyChar -\u003e append-char is found locally first. No override needed.\n - IMPORT WIZARD binds KeyPattern::Any, which catches everything locally, so it\n inherits harmlessly -- nothing ever reaches the parent.\n\nSo the modes that must NOT inherit are those where a global key would be actively\nwrong AND is not already shadowed. Name them explicitly rather than assuming;\nthe honest answer may be 'none', which would make it 'all' rather than 'almost\nall' and is worth saying out loud if so.\n\nWHAT STILL NEEDS DECIDING, and these are the product calls:\n - Which keys are global. '?' and ':' and the panel toggles are the candidates\n the duplication already nominates. Resist sweeping more in: a global that\n binds too much makes every mode harder to reason about, which is the opposite\n of the ask.\n - Whether '?' in Help CLOSES the overlay. Today Help binds no '?' at all.\n - Esc is NOT a candidate: it means something different in nearly every mode.\n\nGUARDS THE CHANGE OWES. There is no test that a key resolves in every mode that\nshould have it -- every_hint_key_resolves_in_its_mode checks a mode's hint\nagainst its own map and cannot see a key no hint mentions. So:\n - every global key resolves in every mode that inherits the global\n - in the eight text-entry modes it resolves to append-char instead\n - bind_panel_toggles has ONE call site afterwards, which is the mechanical\n check that the duplication is actually gone\n\nNote gotcha 13: prefix keys are stored as an ADDRESS and resolved via\nKeymaps::prefix_keymap, which walks parents -- so RecordsNormal already reaches\nNormal's g/y/Z that way. prefix_keymaps_are_flat must still pass; a global\nkeymap must not introduce a nested prefix.\n\nThe good news from the original investigation stands and is not affected: the\nkeymaps are genuinely SHARED between the two front ends. Keymaps::shared() is a\nOnceLock over default_keymaps itself, so there is one definition and two callers,\nnot two copies held in step by a test.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T00:33:38Z","created_by":"Edward Langley","updated_at":"2026-09-22T00:33:55Z","labels":["protocol","ux"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-uln5","title":"The help overlay advertises 8 black-hole keys and a stranded command page, and no guard sees it","description":"MEASURED under Chromium at 523d571 from a fresh page, one key at a time, with\nthree controls (C, P, T) that DID move -- so \"nothing happened\" is a measurement\nand not a blind probe:\n\n NOTHING / search\n NOTHING n search-navigate\n NOTHING N search-or-category-add\n NOTHING H hide-selected-row-item\n NOTHING z toggle-group-under-cursor\n NOTHING Enter enter-advance\n NOTHING Control+s save\n NOTHING Control+q force-quit\n MOVED C / P / T (controls)\n\nEight of the eleven names in the_command_names_that_still_need_the_host_are_these\nare advertised in the help overlay the BROWSER RENDERS. Help page 5 additionally\nadvertises the entire ':' command line (:add-cat, :w, :q, :import, :export...),\nand CommandMode is stranding()'d, so that whole page is unreachable too.\n\nWHY THE EXISTING GUARD CANNOT SEE THIS. every_advertised_command_answers_in_its_own_mode\nreads help::advertised, which is the HINT-LINE table only. search-or-category-add\nis correctly absent from AppMode::Normal's hint -- the guard is right about what\nit checks. The help OVERLAY (help.rs::pages()) is a SECOND, INDEPENDENT body of\nadvertising in the same file, and nothing cross-references it against resolve.\n\nclients.md's own severity rule -- \"a key named in the mode's own hint line doing\nnothing reads as BROKEN\" -- applies with full force to the overlay and was never\nextended to it. Two bodies of advertising, one guarded.\n\nTHE STRUCTURAL OBSTACLE, which is the real cost and is why this is not trivial:\nhelp.rs::keys(mode, command, args, description) knows the command name AT\nCONSTRUCTION and then THROWS IT AWAY -- HelpLine::Keys { keys: Vec\u003cString\u003e,\ndescription } keeps only rendered key labels. So the analogous guard cannot be\nwritten without first carrying command/mode/args onto the variant. Small, but\nnot zero, and it has to happen before any check can exist.\n\nNote improvise-wk18 is the sibling already filed for help.rs's literal() lines,\nwhich the same guard cannot see for a related reason (a key string with no\ncommand name). Whoever takes either should read both -- they are one change to\nHelpLine's shape.\n\nSee also improvise-koh6 (P1): this is not merely cosmetic. Following the overlay's\nadvertised N corrupts a data cell, because the keystrokes that follow a dead key\nland on live NORMAL bindings and `i` opens an editor.\n","notes":"BLOCKED AND RE-SCOPED 2026-09-21 by the keymap agent (PR #112), which took improvise-636d and deliberately did NOT half-do this one. Its reasoning and its proposed design, which is worth more than the ticket had:\n\nWHY IT COULD NOT BE DONE FROM THE KEYMAP GROUND:\n - the guard needs resolve() AND the `furnished` fixture, both private to resolve.rs's mod tests. Duplicating the fixture elsewhere is the disease this repo names repeatedly.\n - the HelpLine reshape both tickets call for breaks the struct patterns at improvise-wasm-client/src/render.rs:346 and src/ui/help.rs:72 -- NEITHER uses `..`, so the reshape is a compile error in a file another agent owns.\n\nTHE PROPOSED SHAPE, designed but not written: keep HelpLine FLAT and PUBLIC, and introduce an internal\n\n HelpRow {\n Keys { mode, command, args, description },\n Prefix { mode, prefix, then, .. },\n Ex { line, .. },\n Prose (HelpLine),\n }\n\nthat pages() RENDERS into HelpLine. One source, no renderer edit, and the guard walks the rows rather than the rendered lines.\n\nThat satisfies uln5's stated blocking cost -- \"carry command/mode/args onto the variant\" -- WITHOUT touching render.rs, which is what made it look expensive.\n\nIT ALSO SPLITS wk18's 17 literal() lines honestly rather than treating them as one lump:\n - gg / yy / ZZ are checkable via Keymaps::prefix_keymap\n - the 14 ex-commands are checkable against the registry from improvise-command's tests\n\nSo the two beads are one change to HelpRow plus two different guards, not one guard over a uniform list.\n\nNOTE THE GROUND THIS NEEDS: resolve.rs's test module, help.rs, and a test that can reach the registry. That is a different partition from the one this wave used, so schedule it with whoever owns resolve.rs rather than with the keymap.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T00:22:25Z","created_by":"Edward Langley","updated_at":"2026-09-22T02:27:13Z","labels":["browser","testing","ux"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-z336","title":"[bug] A page selection is never cleared, so cycling a category's axis hides a filter and cycling back restores it","description":"Reported by the user against the in-browser app: after clicking an item in the\ncategories panel there is no way to clear the filter it applies.\n\nMECHANISM, read at 523d571 in improvise-protocol/src/resolve.rs.\nclick_category_row -\u003e filter_to_item_at (resolve.rs:906) emits, for an Item row:\n\n SetAxis { category, axis: Axis::Page }\n SetPageSelection { category, item }\n SetStatus(\"Filter: {cat} = {item}\")\n\nThree separate problems, and the third is why the user could not find the way out.\n\n1. IT IS NOT A TOGGLE. Clicking the SAME item again emits the identical pair, so\n the gesture is idempotent rather than reversible. Every other click surface in\n this panel has an inverse -- a Category row calls toggle_cat_expand_at -- so\n the one gesture that changes the DOCUMENT is the one with no way back.\n\n2. IT SILENTLY DESTROYS THE CATEGORY'S PREVIOUS AXIS. SetAxis overwrites Row,\n Column or None with Page and nothing records what it was. So even a future\n \"clear\" cannot restore the prior layout; the best it could do is guess None.\n The status line says \"Filter: ...\" while what actually happened is a LAYOUT\n change, which is also why it reads as undoable when it is not.\n\n3. THE ESCAPE ROUTE EXISTS AND IS UNDISCOVERABLE. click_tile (resolve.rs:148)\n cycles a tile's axis on a SECOND click of an already-selected tile, so\n Page -\u003e None is reachable by clicking the category's tile twice. But:\n - nothing says so, and the status line points at \"Filter\", not the tile bar\n - it cycles Row-\u003eColumn-\u003ePage-\u003eNone, so \"clear\" is up to three more clicks\n - the TUI's other route, [ and ] page cycling, has no browser affordance:\n improvise-p7lh records that the browser draws NO PAGE BAR at all, and\n GridCache::page_coords does not reach RenderModel\n\nSo the feature is reachable in principle and absent in practice, which is worse\nthan absent -- the user concludes it cannot be done.\n\nWHAT A FIX HAS TO DECIDE, and it is a product call rather than a bug:\n - Should a second click on the SAME item clear the filter? That makes the\n gesture reversible and is what \"filter\" implies. It also needs an answer for\n what axis the category returns to, given (2) destroyed it.\n - Or should clicking never move a category to Page at all, and instead set a\n page SELECTION only when the category is already on Page, leaving the\n layout alone? That is the smaller, more honest gesture and it makes (2)\n moot -- but it changes what the click does for every category not already\n paged.\n - Either way the page bar wants painting (improvise-p7lh), because that is\n where a paged category is visible and cyclable in the TUI, and its absence\n is what makes the state feel trapped.\n\nNote gotcha 27: Page and Filter are different axes, and only Page is navigable.\nThis code uses Page, correctly -- but the STATUS LINE calls it a filter, which\nis the vocabulary of the non-navigable one. Worth aligning whichever way the\nproduct call goes.\n","notes":"MECHANISM CORRECTED 2026-09-21 by driving it under Chromium. My original code-read was right about the click path and WRONG about the way out, and the real defect is one neither the report nor I named.\n\nCONFIRMED: filter_to_item_at emitting SetAxis{Page} + SetPageSelection + the status is correct. Clicking an item gives 'Filter: Product = Espresso', the tile goes Row -\u003e Pag, the grid collapses 4 rows to 1. A SECOND click on the same item is a genuine no-op -- not a toggle. Clicking a DIFFERENT item switches the filter, so you can move a filter but not lift it.\n\nWRONG IN MY READ: the tile-bar double-click is NOT the only way back, and the tile bar IS visible and clickable in the shipped page (#tiles .tile, five of them). There is a second, KEYBOARD route: Space in the category panel (cycle-axis-at-cursor), which is advertised in that mode's own hint as 'Space:cycle-axis' and works directly after the click with no re-focus. So the escape is more discoverable than I claimed.\n\nTHE ACTUAL DEFECT: NEITHER ROUTE CLEARS ANYTHING. They only hide the filter, and it comes back. Measured, cycling Product all the way round:\n\n Pag(Espresso) -\u003e None (63,700, unfiltered)\n -\u003e Row (all 4 products)\n -\u003e Col\n -\u003e Page -\u003e 18,400 \u003c- Espresso is BACK, unasked\n\nView::set_axis (improvise-core/src/view/types.rs:118) does NOT touch\npage_selections. Only on_category_removed and on_item_removed ever clear one. So\nthe selection survives every axis move and is silently reinstated the moment the\ncategory returns to Page.\n\nThere is NO ClearPageSelection anywhere -- not in ModelCommand, not in the View\nAPI, AND NOT IN THE TUI. So this is a product gap at parity, not a browser\nregression. The browser is simply the first place anyone tried it with a mouse.\n\n[ and ] cannot clear it either: measured, they cycle the page selection among the\ncategory's items (18,400 -\u003e 17,100 -\u003e 15,800 -\u003e 20,300 -\u003e 24,600) with no 'All'\nentry to land on.\n\nTHIRD FINDING, and it may be what produced the original report: THE STATUS LINE\nLIES. 'Filter: Product = Espresso' stays on screen after the axis has gone to\nNone, Row and Col, and after five ] presses moved the page to a different item.\nThe one piece of feedback about the filter is stale exactly while the user is\ntrying to get rid of it -- so a user who DID clear it correctly is told for the\nrest of the session that it is still on. That mechanism can manufacture this\nexact bug report from a working code path.\n\nSilent-Space is split out as improvise-25bf.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T00:17:00Z","created_by":"Edward Langley","updated_at":"2026-09-22T00:22:33Z","labels":["browser","ux"],"dependencies":[{"issue_id":"improvise-z336","depends_on_id":"improvise-p7lh","type":"related","created_at":"2026-09-22T00:32:50Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-k844","title":"importance_ranking overflows to inf for a large importance: 100.0 * imp / total multiplies first","description":"improvise-zv8t forces every document category to `Forced::Categorical`, which is\nright (a `Year` category with items 2024/2025 is a coordinate, not a number).\nThe cost: a WIDE numeric category one-hots past `MAX_FEATURES` and refuses the\nwhole fit, where before it encoded as one numeric feature and a fit that never\nsplit on it succeeded.\n\nMeasured on 100 records with a 100-item numeric `Year` beside a `Cost` that\npredicts `Score` on its own:\n- pre-zv8t: fit lands on `IF(Cost \u003c 48.015...)`; `Year` never appears.\n- post-zv8t: preflight refuses at 101 features.\n\nPinned by `a_wide_numeric_category_now_refuses_a_fit_that_used_to_ignore_it`\nin crates/improvise-gp/src/fit.rs, and argued in context/code/gp.md section 11.\n\nThe workaround the refusal names works: mark the category a Label and the\npre-zv8t feature set is restored exactly. What is lost is the case where the\nuser wants neither -- a wide numeric category left alone while the fit uses the\nmeasures.\n\n## Steps to Reproduce\n\nBuild a model with a Regular category `Year` holding 100 numeric items\n(1900..1999), a Label category for record identity, and two measures `Cost` and\n`Score` where Score = 4000 if Cost \u003e 50 else 1000. Put `Year` on Axis::Row and\n_Measure on Axis::Page. Run `fit_measure(.., \"Score\", ..)`.\n\nPre-zv8t it lands on `IF(Cost \u003c 48.015..)`. Post-zv8t it is refused with\n\"would encode 101 features from 100 records\". The fixture is\n`wide_category_beside_a_good_measure()` in crates/improvise-gp/src/fit.rs.\n\n## Acceptance Criteria\n\nThe honest fix is a budget that can decline A COLUMN rather than the whole\ntable: `encoded_width` (and the exact check beside it) would drop the widest\ncategory and report it as a `Dataset::warnings` notice -- gotcha 37(a), it comes\nback as DATA and nothing prints. That keeps the zv8t decision intact (a category\nis still never inferred numeric) and changes only what the budget does when it\ncannot afford one.\n\nDo NOT close this by making the forcing conditional on cardinality: that is\ninference by another name, and makes the ENCODING depend on a budget rather than\non what the document says.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T23:33:29Z","created_by":"Edward Langley","updated_at":"2026-09-21T23:33:29Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-k844","title":"importance_ranking overflows to inf for a large importance: 100.0 * imp / total multiplies first","description":"improvise-zv8t forces every document category to `Forced::Categorical`, which is\nright (a `Year` category with items 2024/2025 is a coordinate, not a number).\nThe cost: a WIDE numeric category one-hots past `MAX_FEATURES` and refuses the\nwhole fit, where before it encoded as one numeric feature and a fit that never\nsplit on it succeeded.\n\nMeasured on 100 records with a 100-item numeric `Year` beside a `Cost` that\npredicts `Score` on its own:\n- pre-zv8t: fit lands on `IF(Cost \u003c 48.015...)`; `Year` never appears.\n- post-zv8t: preflight refuses at 101 features.\n\nPinned by `a_wide_numeric_category_now_refuses_a_fit_that_used_to_ignore_it`\nin crates/improvise-gp/src/fit.rs, and argued in context/code/gp.md section 11.\n\nThe workaround the refusal names works: mark the category a Label and the\npre-zv8t feature set is restored exactly. What is lost is the case where the\nuser wants neither -- a wide numeric category left alone while the fit uses the\nmeasures.\n\n## Steps to Reproduce\n\nBuild a model with a Regular category `Year` holding 100 numeric items\n(1900..1999), a Label category for record identity, and two measures `Cost` and\n`Score` where Score = 4000 if Cost \u003e 50 else 1000. Put `Year` on Axis::Row and\n_Measure on Axis::Page. Run `fit_measure(.., \"Score\", ..)`.\n\nPre-zv8t it lands on `IF(Cost \u003c 48.015..)`. Post-zv8t it is refused with\n\"would encode 101 features from 100 records\". The fixture is\n`wide_category_beside_a_good_measure()` in crates/improvise-gp/src/fit.rs.\n\n## Acceptance Criteria\n\nThe honest fix is a budget that can decline A COLUMN rather than the whole\ntable: `encoded_width` (and the exact check beside it) would drop the widest\ncategory and report it as a `Dataset::warnings` notice -- gotcha 37(a), it comes\nback as DATA and nothing prints. That keeps the zv8t decision intact (a category\nis still never inferred numeric) and changes only what the budget does when it\ncannot afford one.\n\nDo NOT close this by making the forcing conditional on cardinality: that is\ninference by another name, and makes the ENCODING depend on a budget rather than\non what the document says.\n","notes":"=== TRACKER DEFECT, RECORDED BEFORE THE CLOSE ===\nTHIS TICKET'S STORED DESCRIPTION IS VERBATIM improvise-e8no'S BODY (the wide-\nnumeric-category budget) and contradicts its own title. Confirmed by the\norchestrator with a direct diff of the two DESCRIPTION blocks: IDENTICAL. The\nagent worked the TITLE, which was the right call. Everything below is the actual\nticket.\n\n=== FIXED in PR #139 (merge 1ae2d2e) ===\n\nREPRODUCED at HEAD, end to end: fit_measure returned [(\"Cost\", inf)] -- a user's\nfit report saying one feature carries inf%.\n\nWHERE IT IS BORN: gp_tree::fit::importance_ranking, `100.0 * imp[j] / total` in\nf32, which associates LEFT. TWO DISTINCT OVERFLOWS, and this ticket names only the\nfirst:\n 1. THE MULTIPLY. A lone importance above f32::MAX/100 ~ 3.4e36 makes\n 100.0 * 3e37 = inf before the divide that would have returned it to range.\n 2. THE SUM -- THE SILENT TWIN, and it is real. Two importances at 2e38 are each\n well inside f32, but their f32 TOTAL is inf, so every share came back NaN.\n No panic, no refusal, just NaN% in the report. Exactly cv_regress's shape\n from improvise-pv3g.\n\nREACHABLE PAST pv3g, traced rather than guessed: both callers score importance in\nthe document's OWN units (fit_csv -\u003e real_unit_mse; fit_measure -\u003e mse over\nUNNORMALIZED rows), so an importance is an MSE difference and grows as the\ntarget's SQUARE. That leaves a live window between pv3g's standardize refusal\n(spread ~5e18 at 12 rows) and this overflow (~1.8e18). As with pv3g, every INPUT\nwas finite and the overflow was in the DERIVED quantity.\n\nTHE FIX makes the arithmetic incapable of it rather than checking afterwards: sum\nand ratio in f64, narrowing only the share, which is bounded by 100 and therefore\nalways finite. One guard remains for an importance that ARRIVES non-finite -- note\nNaN \u003c= 0.0 is false, so the pre-existing early return passed NaN straight through.\n\nTHE RIVAL IS THIS TICKET'S OWN TITLE. Reassociating to 100.0 * (imp[j] / total)\nin f32 -- \"multiplies first\", so divide first -- passes the lone-importance test\nAND the shares-sum-to-100 test, and FAILS the twin and the non-finite guard:\n58 passed, 2 failed. It converts NaN% into a serene 0% FOR EVERY FEATURE, which is\nworse for being readable. THE AC AS WRITTEN WOULD HAVE SHIPPED A HALF-FIX.\n\nAND THE REASON IT SAT OPEN IS THE BEST PART. context/code/gp.md section 7 --\npv3g's own write-up -- did not merely mention this bug, it DEFERRED IT ON A COST\nTHAT IS FALSE: \"reordering it moves the bit patterns\nmisclass_rate_and_importance_are_bit_for_bit_what_they_were pins\". That test pins\npermutation_importance's RAW SCORES, which importance_ranking consumes afterwards.\nVERIFIED BY THE ORCHESTRATOR: scoring_known_answers.rs is absent from the diff\nentirely -- byte-identical across the fix. A cost predicted from a test's NAME\nrather than from what it CALLS is how a one-line fix stays filed, and it reads as\ndiligence, which is why it survives review. Corrected in place rather than\ndeleted.\n\nThe tell was visible all along: pv3g's own\nthe_cli_does_not_abort_when_an_importance_share_overflows says IN ITS NAME that a\nshare overflows at 1e18. The clamp stopped the abort; the inf% it was clamping\nnever stopped.\n\nNOT VERIFIED: the f64 round-trip is not bit-pinned for the ORDINARY range.\nimportance_ranking's shares have never been pinned and this did not start; a \u003c=1\nulp shift in a printed {:\u003e5.1}% is possible in principle and nothing would catch\nit. A share-level bit pin is a reasonable follow-up.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T23:33:29Z","created_by":"Edward Langley","updated_at":"2026-09-22T23:11:18Z","closed_at":"2026-09-22T23:11:18Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-4h8a","title":"Six of AppSnapshot's eight restored fields are unguarded; the rival fix passes the suite","description":"Found by the wave's adversarial reviewer at PR #109 head d4d1cd4, by injecting\nthe RIVAL FIX rather than only the bug.\n\nimprovise-yqpd's clause-4 work grew DocumentSnapshot into AppSnapshot, which now\nrestores eight fields. The evidence says \"all six now round-trip through\nAppSnapshot\". Only TWO are pinned by any test.\n\nTHE RIVAL FIX THAT PASSES: replace `self.view_state = snapshot.view_state` with\nrestoration of ONLY view_state.active_view and view_state.status_msg -- the two\nfields the test asserts -- and the ENTIRE workspace suite passes (1585, 0\nfailed).\n\nPushing further, each of these can be removed from App::restore with the whole\nsuite still green:\n cursor_epoch\n abort_effects\n last_reshaped_views\n last_reframed_views\n written_view_frames \u003c- new behaviour with NO test at all\n last_reframed_elsewhere (reverted to the old .clear() shape)\n\nNOT A MERGE BLOCKER, and the current code is the SAFE direction: over-restoring\ncosts nothing, and gotcha 35's asymmetry applies -- over-reporting pays a\nredundant projection, under-reporting leaves a client wrong forever.\n\nTHE HAZARD IS THE NEXT READER. A later \"simplification\" of App::restore back\ntoward the two asserted fields would regress clause 4 for cursors, mode,\nbuffers, panels and search SILENTLY, with a green suite. That is exactly this\nrepo's characteristic defect, and the evidence file currently invites it by\nsaying six fields round-trip without saying four of them are unguarded.\n\nWHAT TO DO: pin the unguarded fields, or state in the evidence that they are\nunguarded and why over-restoring is deliberate. The test to reach for is a\nrejected frame that moved a cursor and a mode, asserting both are back --\nwhich is the user-visible half nothing currently covers.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T23:03:30Z","created_by":"Edward Langley","updated_at":"2026-09-21T23:03:30Z","labels":["adr-001","protocol","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-mszy","title":"PR 105's evidence claims one test catches the arm swap; sixteen do","description":"Found by the wave's adversarial reviewer of record at PR #105 head 4e08a9b.\n\ncontext/test-evidence/formula-and-io.md and the commit message both claim that\nswapping the `string` and `pipe_quoted` arms in build_primary \"fails\neach_primary_alternative_builds_its_own_expr, and nothing else in the 85-test\ncrate suite does.\"\n\nNOT REPRODUCIBLE. The reviewer performed exactly that swap and measured:\n - 16 failures in improvise-formula: the new guard plus 12 other\n parser::tests::* (parse_where_clause, parse_quoted_string_in_where,\n pipe_quoted_in_where_filter_value, where_keyword_is_case_insensitive, ...)\n and 3 grammar_prop_tests::*\n - PLUS 3 more in improvise-core (formula_where_applied_to_matching_region and\n siblings)\n\nMost fail as debug_assert! PANICS in strip_string_quotes/unquote_pipe at\nparser.rs:352/:359, not as assertion failures. The \"85-test crate suite\" figure\nitself is correct.\n\nHOW THE AUTHOR COULD HONESTLY HAVE SEEN A SMALL FAILURE SET: under that\ninjection `cargo test --workspace` HALTS at improvise-core and never reaches\nimprovise-formula. So a casual measurement sees the core failures only.\n\nTHE CODE IS RIGHT AND THE TEST IS HARMLESS. What is wrong is the EVIDENCE: the\nguard is presented as load-bearing for a fault that 15 pre-existing tests already\ncatch. In this repo a false injection claim in a checked-in evidence file is\nworse than no claim, because the next reader trusts it and stops looking.\n\nTWO ACCEPTABLE RESOLUTIONS, either fine:\n - find a fault that ONLY each_primary_alternative_builds_its_own_expr catches\n (a transposition among arms whose bodies do not debug_assert would be the\n place to look), or\n - state plainly that the guard is belt-and-braces over existing coverage.\n\nWhat is not acceptable is leaving the claim as written.\n\nNote what the reviewer DID confirm about this PR, so this bead is not read as\nundermining it: injecting a non-atomic probe rule into formula.pest produces\nFOUR E0004 non-exhaustive-pattern errors, one per dispatch site. A new grammar\nrule genuinely cannot land silently. That is the PR's real value and it holds.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T23:03:29Z","created_by":"Edward Langley","updated_at":"2026-09-21T23:03:29Z","labels":["docs","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-b4ks","title":"The documented opfs-contract fmt gate exits 1 as a usage error, indistinguishable from a real failure","description":"The recipe circulated in six wave briefs and in an orchestrator message reads:\n\n cargo fmt -p opfs-contract -- --check\n\nFrom the workspace root this exits **1** with:\n\n package `opfs-contract` is not a member of the workspace\n This utility formats all bin and lib files of the current crate using rustfmt.\n\nThat is a USAGE error carrying the SAME EXIT CODE as a formatting failure. A\ncaller checking `$?` cannot tell \"this crate is misformatted\" from \"you spelled\nthe command wrong and nothing was checked\". Verified by the orchestrator.\n\nThe spelling that reaches the second workspace root:\n\n cargo fmt --manifest-path harness/opfs-contract/Cargo.toml -- --check # exit 0\n\nWHY THIS MATTERS MORE THAN A TYPO: an earlier agent reported this gate as\n\"clean\" during the wave's integration review. Either it used a different\nspelling and did not say so, or it read exit 1 as a pass -- and neither is\ndistinguishable from the report as written. A gate whose failure mode looks\nexactly like the thing it checks for will eventually be \"fixed\" by deleting it,\nand in the meantime it launders a non-run into a pass.\n\nThis is the same family as the pipe-into-tail trap that scripts/pr-reviews\ncarries a warning about: `$?` belonging to something other than the thing you\nmeant to test.\n\nWHERE THE RECIPE NEEDS CORRECTING: wherever the harness gate is written down --\nAGENTS.md's gate list, the wave brief template, and any CI step that spells it\nthis way. CI itself uses `cargo fmt -p opfs-contract` from INSIDE\nharness/opfs-contract, where it resolves, which is why CI has been right while\nevery local invocation of the documented form was not.\n\nCONTEXT: harness/opfs-contract carries its own [workspace] table, so\n`cargo fmt --all` does not reach it. Exactly two workspace roots exist --\n`grep -l '^\\[workspace\\]' $(find . -name Cargo.toml -not -path '*/target/*')` --\nso one extra gate is complete, not the first of an unknown number.\n","notes":"SHARPENED 2026-09-21: the boundary is POSITIONAL, and that changes who is at fault.\n\n cargo fmt -p opfs-contract -- --check from the REPO ROOT -\u003e exit 1, 'not a member of the workspace', --check never runs\n cargo fmt -p opfs-contract -- --check from INSIDE the crate -\u003e exit 0, works\n cargo fmt --manifest-path harness/opfs-contract/Cargo.toml -- --check from the root -\u003e exit 0, works\n\nSo CI AS WRITTEN IS CORRECT -- its working-directory: is exactly what makes the -p form resolve. The hazard is a BRIEF or a local pre-push habit telling someone to run it from the root, which is what the orchestrator did in six wave briefs.\n\nThe failure mode is worse than 'wrong spelling' in both directions: from the root the gate reports FAILURE when the crate is clean, and it cannot report SUCCESS when the crate is dirty. It is decoupled from the thing it checks entirely, while wearing the exit code of a real formatting failure.\n\nThis also explains a discrepancy in the wave's records: one agent reported this gate 'clean' and the orchestrator later measured exit 1. Both were telling the truth -- that agent had cd'd into the crate directory and never saw the error. A gate whose result depends on the working directory, with no indication in the output which one it ran in, cannot be audited from a report.\n\nFIX: correct the recipe wherever it is written for humans or agents (AGENTS.md gate list, the wave brief template). Prefer the --manifest-path form, which is position-independent and therefore says what it means. Leave CI alone.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T23:03:27Z","created_by":"Edward Langley","updated_at":"2026-09-21T23:13:27Z","labels":["ci","tooling"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-bok4","title":"A failing effect narrates instead of reporting, so a frame containing it is accepted","description":"Found by the wave's integration/review agent auditing PR #109, and confirmed and\nscoped by that PR's author. ADR-001 clause 4 now rolls back the document, the\nmirrors and the session slice -- but the partial-and-silent SHAPE it exists to\nkill survives one layer down, for every command whose target is not a record.\n\nMECHANISM: reduce::broken_chain asks records_named, which names only SetCell and\nClearCell. Everything else is unguarded. Separately, an effect that FAILS writes\na sentence to view_state.status_msg and does NOT set abort_effects, so the frame\ncontaining it is reported ACCEPTED.\n\nMEASURED AS ACCEPTED, both by the reviewer:\n\n [DeleteView(\"Scratch\"), SetNumberFormat { view: \"Scratch\" }]\n [RemoveCategory(\"Region\"), AddItem { category: \"Region\" }]\n\nThe second is the sharper one: it writes its error to the HOST's status line and\nnever tells the client anything at all. A connected browser sees a frame it\ncomposed reported accepted, with one of its commands having done nothing, and no\nchannel carrying the reason.\n\nWHY THIS IS NOT PART OF improvise-yqpd: closing the class needs effects to\nREPORT failure rather than narrate it -- roughly fifty of them -- which is a\ndifferent change from the snapshot-and-restore clause 4 landed. yqpd closed the\nrecord-addressed INSTANCE. That scope is stated in PR #109's body under \"what\nthis PR does NOT close\" rather than left implied.\n\nTHE SHAPE OF A FIX, not yet decided: an Effect currently returns nothing, so\nfailure has nowhere to go except status_msg. Either effects gain a fallible\nresult that apply_effects can turn into an abort, or the per-command check grows\nbeyond records_named to ask \"does this command's target still resolve\" for the\nview-, category- and item-addressed variants too. The first is larger and\ncloses the class; the second is narrower and closes the reachable cases.\n\nNote the interaction with gotcha 21: an abort is not a rollback, and tidy-up\ngoes at the HEAD of a batch where an abort cannot reach it. Any change here has\nto keep that property, because it is what stops a failed save stranding the user\nin a mode whose minibuffer hides the error.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T22:49:20Z","created_by":"Edward Langley","updated_at":"2026-09-21T22:49:20Z","labels":["adr-001","protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-hkbc","title":"Guard the position-free ModelCommand vocabulary, which 6nsd's answer silently depends on","description":"improvise-6nsd was closed as \"not a hole, conditionally\": a stale layout tier\ncannot misdirect a mutation BECAUSE no ModelCommand variant carries a row or\ncolumn index. Verified independently by two agents over all 24 variants.\n\nNothing guards it. A future positional variant reopens the hole SILENTLY, and\nlayout_version under-reports here by construction -- gotcha 35's expensive\ndirection, where under-reporting leaves a client wrong forever.\n\nThe guard: an exhaustive match over ModelCommand in improvise-protocol asserting\nevery variant names its target BY NAME (category, item, group, view, axis,\nformat, path) or by a position-free surrogate (RecordCell/RecordHash). Adding a\npositional variant then fails the build and forces the ADR conversation rather\nthan inheriting an answer that no longer holds.\n\nShape it like reduce.rs::records_named, which is already an exhaustive match\nwith an or-pattern for the variants that name no record -- same file, same\ndiscipline, and the two can be read together.\n\nNOT covered by this guard, and stated so nobody thinks it is: position-free ON\nTHE WIRE is not position-free END TO END. improvise-2wu3 is the live\ncounterexample -- web/app.js captures viewport (row, col) at paint time and\nresolves it against the current cache at commit. That is a client bug the\nprotocol's discipline makes possible to have, not one this test can catch.\n","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-21T21:59:19Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:59:19Z","labels":["protocol","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-k84e","title":"Clause 4 rollback of a frame containing LoadModel: the rebase-then-undo case is untested","description":"ADR-001 clause 4 landed in improvise-yqpd: Host::commands snapshots the document\nAND clones the whole Sessions before a frame, restoring both on failure.\n\nUNTESTED, and the agent that built it named this as the residual it would most\nwant reviewed: a failing frame that CONTAINS a document-replacing command.\n\nThe sequence: LoadModel -\u003e Applied::Rebased -\u003e every mirror reseeded and fresh\nSnapshots owed -\u003e then a later command in the same frame fails -\u003e undo_frame\nrestores the document and the Sessions clone.\n\nIt SHOULD be sound, because the Sessions clone restores ClientSession::generation\nalong with everything else, so the reseed is undone with it. But no fixture\nexercises it and \"should be sound\" is the phrasing that precedes every entry in\nthis repo's gotcha list.\n\nNote the fixture warning from gotcha 39 applies with force here: when testing a\ndocument swap, the incoming document's active view must have the SAME NAME as\nthe outgoing one, or apply_model broadcasts a SwitchView, the client folds it,\nand the stale cursor is read under a key nothing has written -- right by\naccident, with the bug invisible. Use a_document_shaped_differently_under_the_same_view_name.\n","notes":"PARTLY ANSWERED by PR #109's fix round (2026-09-21), scope narrowed.\n\nWHAT IS NOW DONE: the ghost active_view this bead's sibling finding named is FIXED, and a_rolled_back_load_leaves_no_ghost_view_and_no_false_status covers the rolled-back LoadModel. The fixture was renamed so it cannot pass vacuously -- with both documents named Default the ghost is indistinguishable from a correct value, and the test failed left: \"Loaded\", right: \"Default\" before the fix.\n\nChecked before renaming, and worth recording: gotcha 39 requires a SAME-NAME fixture for the stale-cursor case, but that is a DIFFERENT fixture (a_document_shaped_differently_under_the_same_view_name in project.rs). Neither user of host_over_a_loadable_document depends on the names matching, so gotcha 39's guard is untouched.\n\nWHAT REMAINS, and this bead should stay open scoped to it: the Applied::Rebased path proper -- a frame whose LoadModel SUCCEEDS, emits Snapshots, and is THEN rolled back by a later command. The Sessions clone restores ClientSession::generation so it should be sound, but the Snapshots were already computed and discarded, and nothing tests that.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T21:59:19Z","created_by":"Edward Langley","updated_at":"2026-09-21T22:49:21Z","labels":["adr-001","protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-lm5e","title":"[test] Debug-string tests verify effect names, not correctness","description":"## Summary\n\nUmbrella: many command tests assert `dbg.contains(\"EffectName\")` on debug-formatted effect lists. That checks type-name presence, not field values, correct application, or shadowing by later effects.\n\n## Steps to Reproduce\n\n1. Change an effect's fields to incorrect values while keeping the type name.\n2. `dbg.contains(\"AddCategory\")`-style tests still pass.\n\n## Acceptance Criteria\n\n- High-value tests assert structured effect fields / post-fold state, not only Debug substrings; remaining Debug checks documented as smoke-only.\n\n## Doctrine\n\n`doctrine(check_the_checker)`\n\n## Location\n\nMultiple files under `crates/improvise-command/src/command/cmd/`\n\n## PR / Attribution\n\nPR #100 F20 umbrella. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","notes":"GROOM 2026-09-21: one of FIVE beads that are the same defect -- improvise-lm5e (Debug-string tests verify effect names, not correctness), improvise-gjat (sequence-associativity test proves a tautology), improvise-ub4o (toggle-group-involution does not apply effects between calls), improvise-sfwy (records_mode_resolve_display asserts non-empty), improvise-a36c (count_aggregation asserts \u003e= 1.0 instead of the value).\n\nOne work unit. A sixth, improvise-jkxr, is already CLOSED and is the worked example of how to do these: inject the fault first, watch the OLD assertion pass, then strengthen it and watch it fail. In jkxr's case replacing a merge with a replace left the old assert!(!item_names.is_empty()) passing; the strengthened assert_eq! failed with left: [\"B\"], right: [\"A\", \"B\"]. Evidence in context/test-evidence/formula-and-io.md.\n\nDo NOT fix these by inspection. design-principles.md section 7 names this exact class -- 'A check that looks like the answer, answering a neighbouring question' -- and records that the FIRST repair of one of them was itself vacuous. The acceptance for each is a demonstrated fault injection, not a stronger-looking assertion.","status":"open","priority":2,"issue_type":"bug","owner":"box@local","created_at":"2026-09-21T20:49:06Z","created_by":"external-grok-bot","updated_at":"2026-09-21T21:27:46Z","external_ref":"gh-pr-100-F20-debug","labels":["adversarial-review","pr-100","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-gjat","title":"[test] sequence-associativity test proves a mathematical tautology","description":"## Summary\n\n`law_sequence_associativity` concatenates effect Vecs as `(a+b)+c` vs `a+(b+c)`. List concatenation is trivially associative — the test cannot fail regardless of command behavior (tautology).\n\n## Steps to Reproduce\n\n1. Note the test only compares Vec concatenations of execute results on the same ctx.\n2. It passes for any deterministic executes that return Vecs.\n\n## Acceptance Criteria\n\n- Replace with a property that can fail (ordered fold of effects on state), or remove as non-evidence.\n\n## Doctrine\n\n`doctrine(fold_tests_must_test_order_not_the_axiom)`\n\n## Location\n\n`crates/improvise-command/src/command/cmd/navigation.rs` ~L388-431\n\n## PR / Attribution\n\nPR #100 F16. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","notes":"GROOM 2026-09-21: one of FIVE beads that are the same defect -- improvise-lm5e (Debug-string tests verify effect names, not correctness), improvise-gjat (sequence-associativity test proves a tautology), improvise-ub4o (toggle-group-involution does not apply effects between calls), improvise-sfwy (records_mode_resolve_display asserts non-empty), improvise-a36c (count_aggregation asserts \u003e= 1.0 instead of the value).\n\nOne work unit. A sixth, improvise-jkxr, is already CLOSED and is the worked example of how to do these: inject the fault first, watch the OLD assertion pass, then strengthen it and watch it fail. In jkxr's case replacing a merge with a replace left the old assert!(!item_names.is_empty()) passing; the strengthened assert_eq! failed with left: [\"B\"], right: [\"A\", \"B\"]. Evidence in context/test-evidence/formula-and-io.md.\n\nDo NOT fix these by inspection. design-principles.md section 7 names this exact class -- 'A check that looks like the answer, answering a neighbouring question' -- and records that the FIRST repair of one of them was itself vacuous. The acceptance for each is a demonstrated fault injection, not a stronger-looking assertion.","status":"open","priority":2,"issue_type":"bug","owner":"box@local","created_at":"2026-09-21T20:49:05Z","created_by":"external-grok-bot","updated_at":"2026-09-21T21:27:46Z","external_ref":"gh-pr-100-F16-assoc","labels":["adversarial-review","pr-100","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-lm5e","title":"[test] Debug-string tests verify effect names, not correctness","description":"## Summary\n\nUmbrella: many command tests assert `dbg.contains(\"EffectName\")` on debug-formatted effect lists. That checks type-name presence, not field values, correct application, or shadowing by later effects.\n\n## Steps to Reproduce\n\n1. Change an effect's fields to incorrect values while keeping the type name.\n2. `dbg.contains(\"AddCategory\")`-style tests still pass.\n\n## Acceptance Criteria\n\n- High-value tests assert structured effect fields / post-fold state, not only Debug substrings; remaining Debug checks documented as smoke-only.\n\n## Doctrine\n\n`doctrine(check_the_checker)`\n\n## Location\n\nMultiple files under `crates/improvise-command/src/command/cmd/`\n\n## PR / Attribution\n\nPR #100 F20 umbrella. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","notes":"GROOM 2026-09-21: one of FIVE beads that are the same defect -- improvise-lm5e (Debug-string tests verify effect names, not correctness), improvise-gjat (sequence-associativity test proves a tautology), improvise-ub4o (toggle-group-involution does not apply effects between calls), improvise-sfwy (records_mode_resolve_display asserts non-empty), improvise-a36c (count_aggregation asserts \u003e= 1.0 instead of the value).\n\nOne work unit. A sixth, improvise-jkxr, is already CLOSED and is the worked example of how to do these: inject the fault first, watch the OLD assertion pass, then strengthen it and watch it fail. In jkxr's case replacing a merge with a replace left the old assert!(!item_names.is_empty()) passing; the strengthened assert_eq! failed with left: [\"B\"], right: [\"A\", \"B\"]. Evidence in context/test-evidence/formula-and-io.md.\n\nDo NOT fix these by inspection. design-principles.md section 7 names this exact class -- 'A check that looks like the answer, answering a neighbouring question' -- and records that the FIRST repair of one of them was itself vacuous. The acceptance for each is a demonstrated fault injection, not a stronger-looking assertion.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-cmd","owner":"box@local","created_at":"2026-09-21T20:49:06Z","created_by":"external-grok-bot","updated_at":"2026-09-23T01:25:32Z","started_at":"2026-09-23T01:25:32Z","lease_expires_at":"2026-09-23T01:30:32Z","heartbeat_at":"2026-09-23T01:25:32Z","external_ref":"gh-pr-100-F20-debug","labels":["adversarial-review","pr-100","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-gjat","title":"[test] sequence-associativity test proves a mathematical tautology","description":"## Summary\n\n`law_sequence_associativity` concatenates effect Vecs as `(a+b)+c` vs `a+(b+c)`. List concatenation is trivially associative — the test cannot fail regardless of command behavior (tautology).\n\n## Steps to Reproduce\n\n1. Note the test only compares Vec concatenations of execute results on the same ctx.\n2. It passes for any deterministic executes that return Vecs.\n\n## Acceptance Criteria\n\n- Replace with a property that can fail (ordered fold of effects on state), or remove as non-evidence.\n\n## Doctrine\n\n`doctrine(fold_tests_must_test_order_not_the_axiom)`\n\n## Location\n\n`crates/improvise-command/src/command/cmd/navigation.rs` ~L388-431\n\n## PR / Attribution\n\nPR #100 F16. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","notes":"GROOM 2026-09-21: one of FIVE beads that are the same defect -- improvise-lm5e (Debug-string tests verify effect names, not correctness), improvise-gjat (sequence-associativity test proves a tautology), improvise-ub4o (toggle-group-involution does not apply effects between calls), improvise-sfwy (records_mode_resolve_display asserts non-empty), improvise-a36c (count_aggregation asserts \u003e= 1.0 instead of the value).\n\nOne work unit. A sixth, improvise-jkxr, is already CLOSED and is the worked example of how to do these: inject the fault first, watch the OLD assertion pass, then strengthen it and watch it fail. In jkxr's case replacing a merge with a replace left the old assert!(!item_names.is_empty()) passing; the strengthened assert_eq! failed with left: [\"B\"], right: [\"A\", \"B\"]. Evidence in context/test-evidence/formula-and-io.md.\n\nDo NOT fix these by inspection. design-principles.md section 7 names this exact class -- 'A check that looks like the answer, answering a neighbouring question' -- and records that the FIRST repair of one of them was itself vacuous. The acceptance for each is a demonstrated fault injection, not a stronger-looking assertion.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-cmd","owner":"box@local","created_at":"2026-09-21T20:49:05Z","created_by":"external-grok-bot","updated_at":"2026-09-23T01:18:57Z","started_at":"2026-09-23T01:18:57Z","lease_expires_at":"2026-09-23T01:23:57Z","heartbeat_at":"2026-09-23T01:18:57Z","external_ref":"gh-pr-100-F16-assoc","labels":["adversarial-review","pr-100","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-ub4o","title":"[test] toggle-group-involution test doesn't apply effects between calls","description":"## Summary\n\n`law_toggle_group_involution` asserts two `cmd.execute(\u0026ctx)` debug outputs are equal without applying the first effects. `CmdContext` is borrowed/immutable — the test only proves deterministic pure execute, not involution (toggle twice = identity).\n\n## Steps to Reproduce\n\n1. Fault-inject `ToggleGroupAtCursor` to return different effects on odd/even calls without applying between.\n2. Test still passes (same unchanged ctx).\n\n## Acceptance Criteria\n\n- Test applies first effects (or folds state) before second execute; can fail when involution is broken.\n\n## Doctrine\n\n`doctrine(fold_tests_must_test_order_not_the_axiom)`\n\n## Location\n\n`crates/improvise-command/src/command/cmd/grid.rs` ~L26-36\n\n## PR / Attribution\n\nPR #100 F15. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","notes":"GROOM 2026-09-21: one of FIVE beads that are the same defect -- improvise-lm5e (Debug-string tests verify effect names, not correctness), improvise-gjat (sequence-associativity test proves a tautology), improvise-ub4o (toggle-group-involution does not apply effects between calls), improvise-sfwy (records_mode_resolve_display asserts non-empty), improvise-a36c (count_aggregation asserts \u003e= 1.0 instead of the value).\n\nOne work unit. A sixth, improvise-jkxr, is already CLOSED and is the worked example of how to do these: inject the fault first, watch the OLD assertion pass, then strengthen it and watch it fail. In jkxr's case replacing a merge with a replace left the old assert!(!item_names.is_empty()) passing; the strengthened assert_eq! failed with left: [\"B\"], right: [\"A\", \"B\"]. Evidence in context/test-evidence/formula-and-io.md.\n\nDo NOT fix these by inspection. design-principles.md section 7 names this exact class -- 'A check that looks like the answer, answering a neighbouring question' -- and records that the FIRST repair of one of them was itself vacuous. The acceptance for each is a demonstrated fault injection, not a stronger-looking assertion.","status":"open","priority":2,"issue_type":"bug","owner":"box@local","created_at":"2026-09-21T20:49:05Z","created_by":"external-grok-bot","updated_at":"2026-09-21T21:27:47Z","external_ref":"gh-pr-100-F15-involution","labels":["adversarial-review","pr-100","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-y01k","title":"ws-server silently drops ServerFrame/ClientFrame/closed-doc traffic","description":"## Summary\n\nws-server silently loses protocol traffic in three places: (1) `ServerFrame` serde failure dropped with `continue` in writer task; (2) unparseable ClientFrame text ignored with no log; (3) ClientFrame for a closed/unregistered document yields empty response — client gets nothing (no rejection).\n\n## Steps to Reproduce\n\n1. Force ServerFrame that fails `serde_json::to_string` — frame lost, no log.\n2. Send `{\"Unknown\": {}}` text — no response, no log.\n3. Send commands after document close/evict — empty response, client keeps composing.\n\n## Acceptance Criteria\n\n- Serialize/parse failures are logged (and ideally signaled to the peer); closed-doc frames produce an explicit rejection/error frame.\n\n## Location\n\n`crates/improvise-ws-server/src/transport.rs` ~L179-183, L240-248; `registry.rs` `handle` ~L193-197\n\n## PR / Attribution\n\nPR #100 F17+F18+F19 consolidated. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","status":"closed","priority":2,"issue_type":"bug","owner":"box@local","created_at":"2026-09-21T20:49:05Z","created_by":"external-grok-bot","updated_at":"2026-09-21T21:58:59Z","closed_at":"2026-09-21T21:58:59Z","close_reason":"Fixed in the 2026-09-21 wave, integrated into main at 1574 passed / 0 failed / 7 ignored. Playwright 42 passed / 0 failed / 4 skipped (WebKit, no OPFS) -- up from 37, so the count MOVED. All gates green.\n\nAll three drops logged.","external_ref":"gh-pr-100-F17-19-ws","labels":["adversarial-review","pr-100"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-r4nf","title":"serde_json failures in wasm exports are silent drops, not errors","description":"## Summary\n\nWasm exports `on_key_event`, `typed_cell`, `clicked` use `.ok()` on `serde_json::to_string()`. Serialization failure returns `None`, which JS treats as \"nothing to send\" (not editable / invalid click) — a valid operation is silently dropped.\n\n## Steps to Reproduce\n\n1. Inject a non-serializable value into the frame.\n2. Observe no error reaches the JS caller; operation vanishes.\n\n## Acceptance Criteria\n\n- Serde failures surface as errors to JS (not conflated with empty/None outcomes).\n\n## Location\n\n`crates/improvise-wasm-client/src/lib.rs` — `on_key_event` ~L54, `typed_cell` ~L97, `clicked` ~L178\n\n## PR / Attribution\n\nPR #100 F13. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","status":"closed","priority":2,"issue_type":"bug","owner":"box@local","created_at":"2026-09-21T20:49:04Z","created_by":"external-grok-bot","updated_at":"2026-09-21T21:59:00Z","closed_at":"2026-09-21T21:59:00Z","close_reason":"Fixed in the 2026-09-21 wave, integrated into main at 1574 passed / 0 failed / 7 ignored. Playwright 42 passed / 0 failed / 4 skipped (WebKit, no OPFS) -- up from 37, so the count MOVED. All gates green.\n\nResult\u003cOption\u003cString\u003e, JsError\u003e; Option still means 'nothing to send', a serde failure throws. JS 'if (frame)' unchanged everywhere.\n\nShape-grep confirmed no second site: serde_json::(to_string|from_str)...ok() across crates/ and src/ gives 3 hits, all in wasm-client/lib.rs, all named by this bead. Zero remain.","external_ref":"gh-pr-100-F13-serde","labels":["adversarial-review","pr-100"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-xg1z","title":"editing mode with missing buffer renders empty overlay instead of cell content","description":"## Summary\n\nIf `is_editing()` is true but no `\"edit\"` buffer exists, `render_model` sets `editing` to `Some(\"\")`. The UI shows an empty editor overlay over cell content.\n\n## Steps to Reproduce\n\n1. Fold `ChangeMode(Editing)` without preceding `SetBuffer{name:\"edit\",...}`.\n2. Call `render_model()` — empty overlay instead of cell content.\n\n## Acceptance Criteria\n\n- Missing edit buffer falls back to cell content (or refuses Editing mode loudly); never blank overlay over real content.\n\n## Location\n\n`crates/improvise-wasm-client/src/render.rs` — `render_model` ~L268-271\n\n## PR / Attribution\n\nPR #100 F14. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","status":"open","priority":2,"issue_type":"bug","owner":"box@local","created_at":"2026-09-21T20:49:04Z","created_by":"external-grok-bot","updated_at":"2026-09-21T20:49:04Z","external_ref":"gh-pr-100-F14-edit","labels":["adversarial-review","pr-100"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-sfwy","title":"[test] records_mode_resolve_display asserts non-empty instead of expected value","description":"## Summary\n\nTest `records_mode_resolve_display_returns_values` asserts only `!display.is_empty()` for Region when expected is `\"North\"`. Any non-empty garbage string passes.\n\n## Steps to Reproduce\n\n1. Fault-inject records display for Category to return `\"GARBAGE\"`.\n2. Test still passes.\n\n## Acceptance Criteria\n\n- Assert exact expected display value (`\"North\"` for the fixture).\n\n## Doctrine\n\n`doctrine(check_the_checker)`\n\n## Location\n\n`crates/improvise-core/src/view/layout.rs` — `records_mode_resolve_display_returns_values` ~L810\n\n## PR / Attribution\n\nPR #100 F11. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","notes":"GROOM 2026-09-21: one of FIVE beads that are the same defect -- improvise-lm5e (Debug-string tests verify effect names, not correctness), improvise-gjat (sequence-associativity test proves a tautology), improvise-ub4o (toggle-group-involution does not apply effects between calls), improvise-sfwy (records_mode_resolve_display asserts non-empty), improvise-a36c (count_aggregation asserts \u003e= 1.0 instead of the value).\n\nOne work unit. A sixth, improvise-jkxr, is already CLOSED and is the worked example of how to do these: inject the fault first, watch the OLD assertion pass, then strengthen it and watch it fail. In jkxr's case replacing a merge with a replace left the old assert!(!item_names.is_empty()) passing; the strengthened assert_eq! failed with left: [\"B\"], right: [\"A\", \"B\"]. Evidence in context/test-evidence/formula-and-io.md.\n\nDo NOT fix these by inspection. design-principles.md section 7 names this exact class -- 'A check that looks like the answer, answering a neighbouring question' -- and records that the FIRST repair of one of them was itself vacuous. The acceptance for each is a demonstrated fault injection, not a stronger-looking assertion.","status":"open","priority":2,"issue_type":"bug","owner":"box@local","created_at":"2026-09-21T20:49:03Z","created_by":"external-grok-bot","updated_at":"2026-09-21T21:27:47Z","external_ref":"gh-pr-100-F11-records","labels":["adversarial-review","pr-100","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-vyrx","title":"eval_bool_cached uses wildcard match on BinOp instead of exhaustive arms","description":"## Summary\n\n`eval_bool_cached` uses `_ =\u003e Err(\"type\".into())` for BinOp instead of listing arithmetic operators explicitly (unlike `eval_bool`). A new `BinOp` variant silently errors at runtime in the cached path while the non-cached path would fail to compile if exhaustive.\n\n## Steps to Reproduce\n\n1. Add `BinOp::Mod` to formula.\n2. Cached evaluator silently type-errors; non-cached fails at compile if arms are exhaustive.\n\n## Acceptance Criteria\n\n- `eval_bool_cached` BinOp match is exhaustive in the same style as `eval_bool`.\n\n## Location\n\n`crates/improvise-core/src/model/types.rs` — `eval_bool_cached` ~L700\n\n## PR / Attribution\n\nPR #100 F12. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","notes":"GROOM 2026-09-21: one of THREE beads that are the same defect -- improvise-492x (formula parser defaults unknown aggregates to Count and unknown comparisons to Ge), improvise-4lgj (the .improv pest walker's _ =\u003e {} arms silently skip unhandled grammar rules), improvise-vyrx (eval_bool_cached wildcards BinOp where eval_bool lists it).\n\nThey are one work unit: same fix shape, same doctrine, and splitting them across agents means three people re-deriving the same argument. design-principles.md section 2 states it outright -- 'Prefer exhaustive match over if let or _ =\u003e wildcards. When a new Axis variant or AppMode is added, non-exhaustive matches produce compile errors that guide you to every place that needs updating.' Each of these three is a place where that guidance was not followed and the failure is therefore SILENT rather than a compile error.\n\nNote the asymmetry that makes vyrx the sharpest: the cached and non-cached paths DISAGREE, so a new BinOp works in one and errors in the other.","status":"closed","priority":2,"issue_type":"bug","owner":"box@local","created_at":"2026-09-21T20:49:03Z","created_by":"external-grok-bot","updated_at":"2026-09-21T21:58:30Z","closed_at":"2026-09-21T21:58:30Z","close_reason":"Fixed in the 2026-09-21 wave, integrated into main at 1574 passed / 0 failed / 7 ignored, all gates green, every guard fault-injected.\n\neval_bool_cached's _ =\u003e Err(\"type\") replaced with the same explicit arm list eval_bool carries. Verified by INJECTING BinOp::Mod: the build now fails at four sites where it previously failed at three -- the fourth being eval_bool_cached, which used to compile silently.\n\nA shape-sweep then found a SECOND instance one level up: eval_bool and eval_bool_cached also wildcarded the outer Expr match, so a new Expr variant usable as a condition would have been a silent type error in BOTH paths. Both now list the six non-comparison variants; injecting Expr::And fails four sites where it previously failed two.\n\nThe rule the sweep produced, which is the generalisable part: a wildcard falling through to the GENERAL path is safe by construction; one falling through to a REFUSAL or a SPECIALISATION is not. That cleared three look-alikes (CellValue::as_f64, CategoryKind::of_name, and the new eval_agg arm) which were left deliberately.","external_ref":"gh-pr-100-F12-binop","labels":["adversarial-review","pr-100"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-a36c","title":"[test] count_aggregation assertion is \u003e= 1.0 instead of exact value","description":"## Summary\n\nTest `count_aggregation` asserts `val \u003e= 1.0` instead of exact `1.0` for `COUNT(Sales)` at `Region=East`. Any inflated count ≥ 1 still passes.\n\n## Steps to Reproduce\n\n1. Fault-inject `AggFunc::Count` to return `values.len() as f64 + 999.0`.\n2. Test still passes.\n\n## Acceptance Criteria\n\n- Assert exact expected count (1.0 for the fixture).\n\n## Doctrine\n\n`doctrine(check_the_checker)`\n\n## Location\n\n`crates/improvise-core/src/model/types.rs` — `count_aggregation` ~L1467\n\n## PR / Attribution\n\nPR #100 F10. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","notes":"GROOM 2026-09-21: one of FIVE beads that are the same defect -- improvise-lm5e (Debug-string tests verify effect names, not correctness), improvise-gjat (sequence-associativity test proves a tautology), improvise-ub4o (toggle-group-involution does not apply effects between calls), improvise-sfwy (records_mode_resolve_display asserts non-empty), improvise-a36c (count_aggregation asserts \u003e= 1.0 instead of the value).\n\nOne work unit. A sixth, improvise-jkxr, is already CLOSED and is the worked example of how to do these: inject the fault first, watch the OLD assertion pass, then strengthen it and watch it fail. In jkxr's case replacing a merge with a replace left the old assert!(!item_names.is_empty()) passing; the strengthened assert_eq! failed with left: [\"B\"], right: [\"A\", \"B\"]. Evidence in context/test-evidence/formula-and-io.md.\n\nDo NOT fix these by inspection. design-principles.md section 7 names this exact class -- 'A check that looks like the answer, answering a neighbouring question' -- and records that the FIRST repair of one of them was itself vacuous. The acceptance for each is a demonstrated fault injection, not a stronger-looking assertion.","status":"open","priority":2,"issue_type":"bug","owner":"box@local","created_at":"2026-09-21T20:49:02Z","created_by":"external-grok-bot","updated_at":"2026-09-21T21:27:48Z","external_ref":"gh-pr-100-F10-count","labels":["adversarial-review","pr-100","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-a36c","title":"[test] count_aggregation assertion is \u003e= 1.0 instead of exact value","description":"## Summary\n\nTest `count_aggregation` asserts `val \u003e= 1.0` instead of exact `1.0` for `COUNT(Sales)` at `Region=East`. Any inflated count ≥ 1 still passes.\n\n## Steps to Reproduce\n\n1. Fault-inject `AggFunc::Count` to return `values.len() as f64 + 999.0`.\n2. Test still passes.\n\n## Acceptance Criteria\n\n- Assert exact expected count (1.0 for the fixture).\n\n## Doctrine\n\n`doctrine(check_the_checker)`\n\n## Location\n\n`crates/improvise-core/src/model/types.rs` — `count_aggregation` ~L1467\n\n## PR / Attribution\n\nPR #100 F10. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","notes":"GROOM 2026-09-21: one of FIVE beads that are the same defect -- improvise-lm5e (Debug-string tests verify effect names, not correctness), improvise-gjat (sequence-associativity test proves a tautology), improvise-ub4o (toggle-group-involution does not apply effects between calls), improvise-sfwy (records_mode_resolve_display asserts non-empty), improvise-a36c (count_aggregation asserts \u003e= 1.0 instead of the value).\n\nOne work unit. A sixth, improvise-jkxr, is already CLOSED and is the worked example of how to do these: inject the fault first, watch the OLD assertion pass, then strengthen it and watch it fail. In jkxr's case replacing a merge with a replace left the old assert!(!item_names.is_empty()) passing; the strengthened assert_eq! failed with left: [\"B\"], right: [\"A\", \"B\"]. Evidence in context/test-evidence/formula-and-io.md.\n\nDo NOT fix these by inspection. design-principles.md section 7 names this exact class -- 'A check that looks like the answer, answering a neighbouring question' -- and records that the FIRST repair of one of them was itself vacuous. The acceptance for each is a demonstrated fault injection, not a stronger-looking assertion.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-core","owner":"box@local","created_at":"2026-09-21T20:49:02Z","created_by":"external-grok-bot","updated_at":"2026-09-23T01:18:33Z","started_at":"2026-09-23T01:18:33Z","lease_expires_at":"2026-09-23T01:23:33Z","heartbeat_at":"2026-09-23T01:18:33Z","external_ref":"gh-pr-100-F10-count","labels":["adversarial-review","pr-100","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-dhw5","title":"CSV analyzer misclassifies mostly-numeric columns with nulls as Label","description":"## Summary\n\n`analyze_records` `all_numeric` excludes `Value::Null`, so a column that is 99% numeric with one null becomes Label rather than Measure. Sparse numeric CSV columns lose numeric typing (numbers become stringified item names).\n\n## Steps to Reproduce\n\n1. Import CSV \"Revenue\" with 100 numeric rows and one null.\n2. Column classifies as Label; numeric values are not Measure cells.\n\n## Acceptance Criteria\n\n- Null/empty cells do not force Label when non-null values are uniformly numeric; sparse measures stay measures.\n\n## Location\n\n`crates/improvise-io/src/import/analyzer.rs` — `analyze_records` ~L124-135\n\n## PR / Attribution\n\nPR #100 F09. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","status":"closed","priority":2,"issue_type":"bug","owner":"box@local","created_at":"2026-09-21T20:49:02Z","created_by":"external-grok-bot","updated_at":"2026-09-21T21:26:10Z","closed_at":"2026-09-21T21:26:10Z","close_reason":"Duplicate of improvise-l1f5, the same defect found independently the same day: analyze_records' all_numeric excludes Value::Null, so a numeric column with one blank is classified Label instead of Measure and its numbers become stringified item names.\n\nKeeping l1f5 because it names the exact location (crates/improvise-io/src/import/analyzer.rs:129-132) and carries a concrete repro (Region,Revenue with East,10 and West,\u003cblank\u003e). This report's mechanism sentence is the clearer statement of WHY and has been copied onto l1f5.","external_ref":"gh-pr-100-F09-label","labels":["adversarial-review","pr-100"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-7gf8","title":"Render failure causes infinite retry loop (painted not updated)","description":"## Summary\n\nWhen `JSON.parse(client.render_model())` throws in `repaint`, the function returns early without updating `painted`. The `model.revision === painted` guard keeps the stale value, so every subsequent event retries the broken render — infinite retry loop / error spam.\n\n## Steps to Reproduce\n\n1. Inject permanently broken state where `render_model()` throws.\n2. Trigger `repaint` via keystrokes — repeated error logs every event.\n\n## Acceptance Criteria\n\n- Render failure updates a circuit-breaker / marks painted (or equivalent) so retries stop; user sees a loud failure once.\n\n## Location\n\n`web/app.js` ~L148-156\n\n## PR / Attribution\n\nPR #100 F07. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","status":"closed","priority":2,"issue_type":"bug","owner":"box@local","created_at":"2026-09-21T20:49:01Z","created_by":"external-grok-bot","updated_at":"2026-09-21T21:58:58Z","closed_at":"2026-09-21T21:58:58Z","close_reason":"Fixed in the 2026-09-21 wave, integrated into main at 1574 passed / 0 failed / 7 ignored. Playwright 42 passed / 0 failed / 4 skipped (WebKit, no OPFS) -- up from 37, so the count MOVED. All gates green.\n\nThe render breaker trips once -- permanent state, not a counter -- and routes through shell.fail.","external_ref":"gh-pr-100-F07-render","labels":["adversarial-review","pr-100"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-jcfs","title":"Date format detection ignores disambiguating values after row 10","description":"## Summary\n\n`detect_date_format` samples only the first 10 values. A disambiguating date (e.g. `25/12/2025` day-first) after row 10 is never seen; format choice is order-dependent and can pick `%m/%d/%Y` wrongly.\n\n## Steps to Reproduce\n\n1. CSV with 15 rows: rows 1–10 ambiguous `01/02/2025`; row 11+ `25/12/2025`.\n2. Import — misclassifies as month-first.\n\n## Acceptance Criteria\n\n- Detection considers enough rows (or streams until disambiguation) so order after the sample window cannot flip the format.\n\n## Notes\n\nDistinct from closed improvise-l8rh (American-first heuristic decision). This is the first-10 sampling window / order-dependence.\n\n## Location\n\n`crates/improvise-io/src/import/analyzer.rs` — `detect_date_format` ~L67-76\n\n## PR / Attribution\n\nPR #100 F08. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","status":"closed","priority":2,"issue_type":"bug","owner":"box@local","created_at":"2026-09-21T20:49:01Z","created_by":"external-grok-bot","updated_at":"2026-09-21T21:58:03Z","closed_at":"2026-09-21T21:58:03Z","close_reason":"Fixed in the 2026-09-21 wave, integrated into main at 1574 passed / 0 failed / 7 ignored, with fmt, clippy, --check-evidence and the 8-crate wasm gate green. Every guard was fault-injected and watched failing before being believed.\n\ntake(10) removed from detect_date_format; all() short-circuits so a non-fitting format still costs one parse.\n\nWHAT THIS COST, and it is the wave's best finding: an existing GREEN test had enshrined this bug as the specification. the_detected_date_format_does_not_depend_on_the_hash_seed (written for improvise-7yx) builds twelve ambiguous dates plus 25/12/2025 deliberately positioned THIRTEENTH -- outside the ten-sample window -- and asserted %m/%d/%Y. Its evidence entry even said the truncation 'is still a real limit'. So fixing this broke a green test, and the right response was to change the expectation. An agent that had run the suite, seen red, and restored take(10) would have reverted the fix and reported success.","external_ref":"gh-pr-100-F08-date","labels":["adversarial-review","pr-100"],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -105,25 +124,25 @@ {"_type":"issue","id":"improvise-7gip","title":"[bug] OpfsFs::open's dedicated-worker guard checks WorkerGlobalScope, which a SharedWorker satisfies","description":"MEASURED in all three engines at 1f28868.\n\nOpfsFs::open() guards with:\n\n .dyn_into::\u003cweb_sys::WorkerGlobalScope\u003e()\n .map_err(|_| anyhow!(\"OPFS sync access handles require a dedicated Web Worker\"))?\n\n`self instanceof WorkerGlobalScope` is TRUE in a SharedWorkerGlobalScope. So a\nshared worker sails past this check and fails much later as a raw TypeError deep\nin the wasm glue:\n\n [firefox] workerError \"TypeError: arg0.createSyncAccessHandle is not a\n function\" at opfs/pkg/opfs_contract.js:93\n\nThe message names the right requirement; the type cannot check it.\ndyn_into::\u003cweb_sys::DedicatedWorkerGlobalScope\u003e() would.\n\nThis is doctrine(an_invariant_is_made_unrepresentable_not_validated) HALF DONE:\na guard was reached for, and the type chosen is one level too wide, so the\ninvariant is validated-and-wrong rather than unrepresentable.\n\nopfs.rs is wasm32-only with no Rust tests (0t in the inventory), so there is no\ncargo test that can fail first. The instrument that CAN fail first is harness/ --\nthe probe above is exactly the reproduction, and harness/opfs-contract already\nboots the real bundle in a real worker.\n","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:40:31Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:58:57Z","closed_at":"2026-09-21T21:58:57Z","close_reason":"Fixed in the 2026-09-21 wave, integrated into main at 1574 passed / 0 failed / 7 ignored. Playwright 42 passed / 0 failed / 4 skipped (WebKit, no OPFS) -- up from 37, so the count MOVED. All gates green.\n\nGuard is now DedicatedWorkerGlobalScope.\n\nTHE ONE-LINER DOES NOT COMPILE: the type is web-sys feature-gated, so improvise-io/Cargo.toml needed a line too. That is why it looked free and was not.\n\nNOT VERIFIED: that a SharedWorker is actually refused -- the harness has no SharedWorker page. What is asserted is only that a DEDICATED worker still satisfies the narrow type, i.e. the fix did not break the shipped path. Stated explicitly in the evidence rather than implied. improvise-12ha owns the SharedWorker question.","labels":["browser","bug","opfs"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-7xyx","title":"FrameGroup::cat is dead in both front ends and its doc comment promises an echo nobody composes","description":"FrameGroup::cat is read by NEITHER front end -- dead in both. Its doc comment\nsays it is \"echoed back in ToggleGroup\", and resolve.rs has NO ToggleGroup arm\nat all, so the echo it promises cannot happen from a browser.\n\nBy design-principles.md section 6 this is the expensive class: a reason that\nlives only in a comment, where nothing checks it against the code. The comment\nis a promise about a client that does not exist.\n\nTwo outcomes are acceptable: compose ToggleGroup in resolve.rs and make the\ncomment true (z is on the silent list anyway, so this would close part of that),\nor delete the field and the comment together. What is not acceptable is leaving\na doc comment describing a protocol interaction no code performs.\n\nAlso noticed in the same pass: AxisFrame::cats is PARTIAL on both ends -- the\ncount is used, the category NAMES are drawn by neither, so the grid's corner\ncell is blank in both surfaces. Possibly intended; worth a decision either way.\n","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-21T20:39:58Z","created_by":"Edward Langley","updated_at":"2026-09-22T04:48:03Z","labels":["docs","protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-hs0u","title":"[bug] gg is implemented in the resolver and unreachable from any browser key","description":"DEMONSTRATED at 1f28868.\n\n PROBE 'g': browser = Upstream (silent)\n PROBE 'G': browser = Local[SetSelected { row: 1, col: 0 }, SetRowOffset(1)]\n\ngrid_move's GridMove::ToStart(true) arm EXISTS and is CORRECT. jump-first-row is\nbound only behind the g prefix (keymap.rs:639), and prefixes are unconditionally\nUpstream in resolve(). So a browser user can reach the last row and cannot get\nback to the first.\n\n0 and Home (jump-first-col) both work, so the asymmetry is ROW-ONLY and reads as\na bug rather than as an absence. The resolver arm is dead code in the browser\ntoday.\n\nSame root cause as the paste ticket: every prefix is Upstream, and\nHost::handle answers ClientFrame::Key with an explicit empty vec, so an upstream\nkey is a black hole rather than a slow path (gotcha 47). Fixing prefixes\nresolvably closes gg, yy and ZZ together.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:39:56Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:39:56Z","labels":["browser","bug","parity"],"dependencies":[{"issue_id":"improvise-hs0u","depends_on_id":"blocked-by:improvise-lut7","type":"blocks","created_at":"2026-09-21T20:40:37Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-hs0u","depends_on_id":"improvise-lut7","type":"related","created_at":"2026-09-21T21:31:57Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-0ygd","title":"[bug] Tab on the +measure column silently discards the commit that Enter accepts","description":"DEMONSTRATED at 1f28868.\n\n PROBE +measure at (0,4), extent GridExtent { rows: 1, cols: 5 }\n PROBE Tab on +measure, the LAST row:\n browser = Upstream (silent)\n tui = doc_changed true, status \"Added new record row\"\n\na_locally_committed_creating_column_agrees_with_the_host passes for\nCreateKind::Measure -- it presses ENTER. Press TAB on the very same cell and:\n\n - resolve.rs::advanced() opens with\n if matches!(advance, Advance::Right) \u0026\u0026 state.grid.records\n \u0026\u0026 row \u003e= row_max \u0026\u0026 col \u003e= col_max { return None; }\n - None propagates out of create_column -\u003e commit -\u003e composed -\u003e Upstream, so\n the WHOLE COMMIT is discarded, not just the advance. The typed measure name\n is lost silently.\n - CommitAndAdvance::execute (cmd/commit.rs) takes the\n at_bottom_right \u0026\u0026 ctx.is_records_mode() branch instead: runs AddRecordRow,\n moves to (row+1, 0) and re-enters the editor. That is the bulk-entry Tab\n contract (see the records-mode Tab memory: Tab always advances to the next\n useful place).\n\n+measure is ALWAYS the last column, so col \u003e= col_max holds for it by\nconstruction. This fires on every single-row records grid and on the last row of\nany records grid.\n\nThis sits inside the already-filed \"records mode is silent in the browser\" gap,\nand closing that gap subsumes it -- but carry the specific observation into that\nticket, because \"records mode is silent\" does not predict \"the same cell answers\nEnter and drops Tab\".\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:39:55Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:39:55Z","labels":["browser","bug","parity"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-0ygd","title":"[bug] Tab on the +measure column silently discards the commit that Enter accepts","description":"DEMONSTRATED at 1f28868.\n\n PROBE +measure at (0,4), extent GridExtent { rows: 1, cols: 5 }\n PROBE Tab on +measure, the LAST row:\n browser = Upstream (silent)\n tui = doc_changed true, status \"Added new record row\"\n\na_locally_committed_creating_column_agrees_with_the_host passes for\nCreateKind::Measure -- it presses ENTER. Press TAB on the very same cell and:\n\n - resolve.rs::advanced() opens with\n if matches!(advance, Advance::Right) \u0026\u0026 state.grid.records\n \u0026\u0026 row \u003e= row_max \u0026\u0026 col \u003e= col_max { return None; }\n - None propagates out of create_column -\u003e commit -\u003e composed -\u003e Upstream, so\n the WHOLE COMMIT is discarded, not just the advance. The typed measure name\n is lost silently.\n - CommitAndAdvance::execute (cmd/commit.rs) takes the\n at_bottom_right \u0026\u0026 ctx.is_records_mode() branch instead: runs AddRecordRow,\n moves to (row+1, 0) and re-enters the editor. That is the bulk-entry Tab\n contract (see the records-mode Tab memory: Tab always advances to the next\n useful place).\n\n+measure is ALWAYS the last column, so col \u003e= col_max holds for it by\nconstruction. This fires on every single-row records grid and on the last row of\nany records grid.\n\nThis sits inside the already-filed \"records mode is silent in the browser\" gap,\nand closing that gap subsumes it -- but carry the specific observation into that\nticket, because \"records mode is silent\" does not predict \"the same cell answers\nEnter and drops Tab\".\n","notes":"TUI HALF FIXED in PR #135 (merge 42ec30d). LEFT OPEN: the browser half is this\nticket's own subject and is blocked -- see below.\n\nIT REPRODUCED ONLY PARTLY, AND THE SPLIT MATTERS. The headline (\"silently\ndiscards the commit\") is BROWSER-ONLY. In the TUI, driving App::handle_key on a\none-record grid [Region, +cat, Revenue, +measure] with the cursor at (0,3),\n`i` C o s t Tab gives:\n measures=[\"Revenue\",\"Cost\"] \u003c- the commit LANDED\n sel=(1,0) status=\"Added new record row\"\n rows=2 cols=5 \u003c- a record row nobody asked for\nSo the TUI KEEPS the commit and ADVANCES WRONGLY, leaving the measure just created\nunreachable. Same root cause as the browser's discard, seen from the other end.\n\nTHE CAUSE IS A STALE INDEX -- not an abort, not an ordering. AdvanceDir::Right\nreads at_bottom_right off CursorState::col_count, the PRE-COMMIT column count\n(apply_effects rebuilds only after the batch, gotcha 23). A +measure commit\nINSERTS A COLUMN AT THE CURSOR'S OWN INDEX and pushes + one right, exactly as +cat\ndoes, so the cursor is no longer at the right edge. Enter survived ONLY BY\nACCIDENT: EnterAdvance aborts at bottom-right, and the SetSelected restore already\nat the head of the batch held the cursor.\n\nTHE MOST VALUABLE FINDING: context/code/command.md ARGUED THE BUG INTO EXISTENCE,\nand both clauses were false. Verified verbatim on main before the fix:\n \"The measure + shares none of this, and not by luck -- it is the last column,\n so there is no column to its right for an index to shift into, and both\n advances are already special-cased at the bottom-right edge.\"\nThe column that shifts is +measure ITSELF. And \"already special-cased at the\nbottom-right edge\" NAMES THE BUG, not a safeguard. Corrected in the PR with the\ncorrection recorded rather than quietly patched.\n\nTHE DISCRIMINATOR IS THE BUFFER, NOT THE COLUMN, and this decided the fix's shape:\n+measure is ALWAYS the rightmost column, so `cursor.col \u003e= col_max` in records\nmode names THAT ONE CELL AND NO OTHER -- the entire \"Tab at the bottom-right\nstarts the next record\" bulk-entry contract is reachable only by tabbing onto the\n+ with an EMPTY buffer. A guard keyed on the column alone fixes this ticket and\nSILENTLY DELETES BULK ENTRY. The injected rival demonstrates exactly that: dropping\nthe value-awareness passes the new test and fails\ntab_on_an_empty_new_measure_column_still_starts_a_record_row.\n\nWHY THE BROWSER HALF STAYS OPEN AND IS BLOCKED: resolve.rs::create_column early-\nreturns for CreateKind::Category, but CreateKind::Measure falls through to\nadvanced(), whose bottom-right None propagates out through commit -\u003e composed -\u003e\nUpstream, DISCARDING THE WHOLE COMMAND VECTOR -- strictly worse than the host's\nmisbehaviour. But that path is NOT REACHABLE at this head: improvise-bd9o put the\nrecords guard at the entrance of edit_or_drill and its CellId::Create arm declines\nwith not_yet(\"creating a column\"), so the editor never opens on a creating column\nand create_column is dead code. A browser regression test cannot be written before\nimprovise-x0xd's work -- THERE IS NO DOOR TO DRIVE IT THROUGH.\n\nUSER-VISIBLE CHANGE BEYOND THE TICKET'S LETTER, FLAGGED FOR ED: Enter on +measure\npreviously re-entered the editor there; it now behaves exactly like +cat, landing\non the created column in normal mode. That is a deliberate consequence of one\ntruthful predicate at one call site, and command.md already argued the two +\ncolumns are symmetric -- but it is a behaviour change and should be confirmed.\nAlso: +cat with an EMPTY buffer now falls through to the advance instead of\nstaying put (it creates no column, so the \"staying put lands on the inserted\ncolumn\" rationale does not apply). No test pinned the old behaviour either way.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:39:55Z","created_by":"Edward Langley","updated_at":"2026-09-22T22:51:09Z","labels":["browser","bug","parity"],"dependencies":[{"issue_id":"improvise-0ygd","depends_on_id":"improvise-x0xd","type":"blocks","created_at":"2026-09-22T22:51:22Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-wk18","title":"[bug] the advertised-key guard cannot see help.rs literal() lines, so 17 keys are unchecked","description":"DEMONSTRATED at 1f28868.\n\nevery_advertised_command_answers_in_its_own_mode iterates help::advertised(\u0026mode),\nwhich yields (command, args, caption) triples. help.rs ALSO emits 17 literal(..)\nlines naming a KEY STRING and no command, so the guard cannot see them.\nweb/app.js paintHelp renders them verbatim.\n\nOf the 17:\n gg, yy, ZZ -- all three are prefix keys, all three silent in the browser\n :wq :q :q! :import :add-cat :add-item :formula :add-view :set :help and the\n rest -- the whole ex-command reference page; the browser declines at the door,\n so it says something, but it renders a full page of commands it cannot run.\n\nSo the browser paints a help overlay in which, conservatively, an entire page\nplus three scattered lines are inert, and the test that exists to stop exactly\nthat cannot see any of it.\n\nThis is the cheapest of the parity findings and it PROTECTS THE OTHERS: it is\nthe mechanism this repo uses to stop black holes, and it looks away by\nconstruction from the two lines that the gg and paste tickets are about. Without\nit the next gg lands the same way and nothing notices.\n\nFix: either give literal() a command name so the guard can reach it, or add a\nsecond guard over the help pages.\n","notes":"BLOCKED AND RE-SCOPED 2026-09-21 by the keymap agent (PR #112), which took improvise-636d and deliberately did NOT half-do this one. Its reasoning and its proposed design, which is worth more than the ticket had:\n\nWHY IT COULD NOT BE DONE FROM THE KEYMAP GROUND:\n - the guard needs resolve() AND the `furnished` fixture, both private to resolve.rs's mod tests. Duplicating the fixture elsewhere is the disease this repo names repeatedly.\n - the HelpLine reshape both tickets call for breaks the struct patterns at improvise-wasm-client/src/render.rs:346 and src/ui/help.rs:72 -- NEITHER uses `..`, so the reshape is a compile error in a file another agent owns.\n\nTHE PROPOSED SHAPE, designed but not written: keep HelpLine FLAT and PUBLIC, and introduce an internal\n\n HelpRow {\n Keys { mode, command, args, description },\n Prefix { mode, prefix, then, .. },\n Ex { line, .. },\n Prose (HelpLine),\n }\n\nthat pages() RENDERS into HelpLine. One source, no renderer edit, and the guard walks the rows rather than the rendered lines.\n\nThat satisfies uln5's stated blocking cost -- \"carry command/mode/args onto the variant\" -- WITHOUT touching render.rs, which is what made it look expensive.\n\nIT ALSO SPLITS wk18's 17 literal() lines honestly rather than treating them as one lump:\n - gg / yy / ZZ are checkable via Keymaps::prefix_keymap\n - the 14 ex-commands are checkable against the registry from improvise-command's tests\n\nSo the two beads are one change to HelpRow plus two different guards, not one guard over a uniform list.\n\nNOTE THE GROUND THIS NEEDS: resolve.rs's test module, help.rs, and a test that can reach the registry. That is a different partition from the one this wave used, so schedule it with whoever owns resolve.rs rather than with the keymap.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:39:19Z","created_by":"Edward Langley","updated_at":"2026-09-22T02:27:15Z","labels":["bug","parity","testing"],"dependencies":[{"issue_id":"improvise-wk18","depends_on_id":"improvise-uln5","type":"related","created_at":"2026-09-22T02:27:15Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-lut7","title":"[bug] p is composed by the browser and can never do anything, so the name-level derivation lies","description":"DEMONSTRATED at 1f28868.\n\n PROBE yank 'y': browser = Upstream (silent), twice -- y is a Binding::Prefix\n PROBE yank: tui = yanked Some(Number(1200.0)), status \"Yanked\"\n PROBE p: browser = Ignored (silent)\n\ny is Binding::Prefix, and resolve() maps EVERY prefix to Upstream\nunconditionally. Command::SetYanked exists on the wire (reduce.rs:32, folded at\n:212) and NOTHING in resolve.rs composes it. So ViewState::yanked is permanently\nNone in a browser, and composed(\"paste\") returns Some(vec![]) -\u003e\nKeyOutcome::Ignored -\u003e silence.\n\nTHE REASON TO FILE THIS RATHER THAN SHRUG: paste appears on NEITHER the declined\nlist NOR the host-needed list. Every mechanical derivation of \"what the browser\ncan do\" -- including the_command_names_that_still_need_the_host_are_these --\nreports it as SUPPORTED. It is dead, and it is dead for a STATE reason that\nname-level derivations cannot see. That is this repo's most-cared-about failure\nmode: a green derivation over a broken thing.\n\nhelp.rs:546-554 advertises yy and p together as a copy/paste pair, so the browser\npaints a help page promising both.\n\nFix is one arm -- \"yank\" =\u003e Some(vec![Command::SetYanked(..)]), and the cache\nalready holds the value via cell_at_cursor().edit_text -- plus making prefixes\nresolvable. OR, if prefixes stay upstream, move p onto declined() so the\nderivation tells the truth. Either is acceptable; leaving the derivation lying\nis not.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:39:18Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:39:18Z","labels":["browser","bug","parity"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-7iww","title":"Cached circular formula publishes the last iteration as a valid number","description":"Report-only hunt at 70025e2. No implementation changes.\n\nLocation: crates/improvise-core/src/model/types.rs:549-571,624-641\n\nStore Revenue=10, add Revenue=Revenue+1. Direct Model::evaluate correctly returns an error. After recompute_formulas, evaluate_aggregated(OverHidden) returns Number(26). The cached evaluator seeds from raw data then iterates 16 times without convergence and leaves the last numeric result in formula_cache. This is the displayed path because default views hide virtual categories. On a workbook with an explicit stored measure target, a circular formula looks like a real calculated value. Separate from closed improvise-ubu, whose depth guard prevents recursive stack overflow; that guard does not run in the cached reference path. Mark nonconverging results as errors and use this direct/cached disagreement to drive a shared evaluator-policy refactor. Duplicate audit: circular, fixed, full descriptions; read improvise-ubu.\n\n## Steps to Reproduce\nStandalone Rust 2024 package with improvise and improvise-io path dependencies:\n```rust\nuse improvise_io::workbook::Workbook;\nfn revenue_book()-\u003eWorkbook {\n use improvise_io::model::cell::{CellKey,CellValue};\n let mut w=Workbook::new(\"T\");w.add_category(\"Region\").unwrap();w.add_item(\"Region\",\"East\").unwrap();w.add_item(\"_Measure\",\"Revenue\").unwrap();\n w.model.set_cell(CellKey::new(vec![(\"Region\".into(),\"East\".into()),(\"_Measure\".into(),\"Revenue\".into())]),CellValue::Number(10.0));w\n}\n#[test] fn circular_formula_not_a_plausible_cached_number() {\n use improvise_io::{formula::parse_formula,model::{Aggregation,cell::{CellKey,CellValue}}};\n let mut w=revenue_book();w.model.add_formula(parse_formula(\"Revenue = Revenue + 1\",\"_Measure\").unwrap());\n let key=CellKey::new(vec![(\"Region\".into(),\"East\".into()),(\"_Measure\".into(),\"Revenue\".into())]);\n assert!(matches!(w.model.evaluate(\u0026key),Some(CellValue::Error(_))));\n w.model.recompute_formulas(\u0026[\"_Index\".into(),\"_Dim\".into()]);\n let actual=w.model.evaluate_aggregated(\u0026key,Aggregation::OverHidden);\n assert!(matches!(actual,Some(CellValue::Error(_))),\"circular formula became {actual:?}\");\n}\n```\nRun cargo test circular_formula_not_a_plausible_cached_number -- --nocapture. Current source failed this test.\n\n## Acceptance Criteria\nThe focused regression passes without weakening the expected behavior; ordinary cases still agree, with both debug and release arithmetic considered.\n","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:37:40Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:58:29Z","closed_at":"2026-09-21T21:58:29Z","close_reason":"Fixed in the 2026-09-21 wave, integrated into main at 1574 passed / 0 failed / 7 ignored, all gates green, every guard fault-injected.\n\nrecompute_formulas stopped at MAX_EVAL_DEPTH and left the 16th iteration in formula_cache. It now records the keys that moved on each pass and, if the last pass still moved any, writes Error(\"circular\") over them. Both paths checked: evaluate was already correct; the cached evaluate_aggregated(OverHidden) was the broken one and is the DISPLAYED path, because default views put the virtual categories on Axis::None -- so a circular formula looked like a real calculated value.\n\nThe cost, and why it is right: the fixed point can only CONFIRM convergence with a pass that changes nothing, so a 16-deep chain is now reported circular -- the same bound evaluate_depth already enforces, so the two evaluators AGREE rather than diverge.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-3b2b","title":"Deleting a virtual category from the category panel disables records mode","description":"Report-only hunt at 70025e2. No source changes.\n\nLocation: crates/improvise-command/src/command/cmd/panel.rs:597-609; ui/effect.rs:295-302; core/src/workbook.rs:71\n\nThe panel deliberately shows _Index/_Dim/_Measure (improvise-vjms), but its delete command treats them as ordinary removable categories. Execute DeleteCategoryAtCursor at initial cursor 0 (_Index), then ToggleRecordsMode. Status says Records mode but layout.is_records_mode() is false: the new view has no _Index to set on Row. The invariant that virtual categories always exist is broken through a shipped user action. Protect required categories at a shared authoritative boundary, with both TUI and wire tests; merely hiding the delete key leaves the wire mutation unguarded. Duplicate checks: virtual, reserved, category; read improvise-vjms, which decides visibility, not removability.\n\n## Steps to Reproduce\nStandalone Rust 2024 package with improvise and improvise-io path dependencies:\n```rust\nuse improvise_io::{workbook::Workbook,persistence::{format_md,parse_md}};\nfn revenue_book()-\u003eWorkbook {\n use improvise_io::model::cell::{CellKey,CellValue};\n let mut w=Workbook::new(\"T\");w.add_category(\"Region\").unwrap();w.add_item(\"Region\",\"East\").unwrap();w.add_item(\"_Measure\",\"Revenue\").unwrap();\n w.model.set_cell(CellKey::new(vec![(\"Region\".into(),\"East\".into()),(\"_Measure\".into(),\"Revenue\".into())]),CellValue::Number(10.0));w\n}\n#[test] fn category_panel_cannot_delete_required_virtual() {\n use improvise::{ui::app::App,command::{cmd::{Cmd,panel::DeleteCategoryAtCursor},key::{KeyCode,KeyModifiers}}};\n let mut app=App::new(revenue_book(),None);\n app.view_state.cat_panel_cursor=0;\n let fx=DeleteCategoryAtCursor.execute(\u0026app.cmd_context(KeyCode::Char('d'),KeyModifiers::NONE));app.apply_effects(fx);\n let fx=improvise::command::cmd::grid::ToggleRecordsMode.execute(\u0026app.cmd_context(KeyCode::Char('r'),KeyModifiers::NONE));app.apply_effects(fx);\n assert!(app.layout.is_records_mode(),\"deleting _Index prevents records mode; status {}\",app.view_state.status_msg);\n}\n```\nRun cargo test category_panel_cannot_delete_required_virtual -- --nocapture. Reproduced failing against unchanged code.\n\n## Acceptance Criteria\nCorrect the described observable behavior; preserve ordinary cases with focused regressions and explicit edge-case policy.\n","notes":"CORE HALF CLOSED by PR #114 (wave/s-resolve-keys, head 614d818), guard placed at the MODEL rather than in the two Cmds.\n\nModel::remove_category now refuses a virtual category outright. That placement is the point and it is what this bead asked for: hiding the key in DeleteCategoryAtCursor would leave ModelCommand::RemoveCategory unguarded ON THE WIRE, so a connected client could still delete _Index/_Dim/_Measure and disable records mode for everyone.\n\nLanded alongside improvise-uej8's cursor fix, since both are the same keypress.\n\nKeep this bead open until the reproduction category_panel_cannot_delete_required_virtual is un-ignored and passing -- that is the acceptance, and it lives in tests/bug_hunt_reproductions.rs.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:35:50Z","created_by":"Edward Langley","updated_at":"2026-09-22T02:30:25Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-3b2b","title":"Deleting a virtual category from the category panel disables records mode","description":"Report-only hunt at 70025e2. No source changes.\n\nLocation: crates/improvise-command/src/command/cmd/panel.rs:597-609; ui/effect.rs:295-302; core/src/workbook.rs:71\n\nThe panel deliberately shows _Index/_Dim/_Measure (improvise-vjms), but its delete command treats them as ordinary removable categories. Execute DeleteCategoryAtCursor at initial cursor 0 (_Index), then ToggleRecordsMode. Status says Records mode but layout.is_records_mode() is false: the new view has no _Index to set on Row. The invariant that virtual categories always exist is broken through a shipped user action. Protect required categories at a shared authoritative boundary, with both TUI and wire tests; merely hiding the delete key leaves the wire mutation unguarded. Duplicate checks: virtual, reserved, category; read improvise-vjms, which decides visibility, not removability.\n\n## Steps to Reproduce\nStandalone Rust 2024 package with improvise and improvise-io path dependencies:\n```rust\nuse improvise_io::{workbook::Workbook,persistence::{format_md,parse_md}};\nfn revenue_book()-\u003eWorkbook {\n use improvise_io::model::cell::{CellKey,CellValue};\n let mut w=Workbook::new(\"T\");w.add_category(\"Region\").unwrap();w.add_item(\"Region\",\"East\").unwrap();w.add_item(\"_Measure\",\"Revenue\").unwrap();\n w.model.set_cell(CellKey::new(vec![(\"Region\".into(),\"East\".into()),(\"_Measure\".into(),\"Revenue\".into())]),CellValue::Number(10.0));w\n}\n#[test] fn category_panel_cannot_delete_required_virtual() {\n use improvise::{ui::app::App,command::{cmd::{Cmd,panel::DeleteCategoryAtCursor},key::{KeyCode,KeyModifiers}}};\n let mut app=App::new(revenue_book(),None);\n app.view_state.cat_panel_cursor=0;\n let fx=DeleteCategoryAtCursor.execute(\u0026app.cmd_context(KeyCode::Char('d'),KeyModifiers::NONE));app.apply_effects(fx);\n let fx=improvise::command::cmd::grid::ToggleRecordsMode.execute(\u0026app.cmd_context(KeyCode::Char('r'),KeyModifiers::NONE));app.apply_effects(fx);\n assert!(app.layout.is_records_mode(),\"deleting _Index prevents records mode; status {}\",app.view_state.status_msg);\n}\n```\nRun cargo test category_panel_cannot_delete_required_virtual -- --nocapture. Reproduced failing against unchanged code.\n\n## Acceptance Criteria\nCorrect the described observable behavior; preserve ordinary cases with focused regressions and explicit edge-case policy.\n","notes":"CORE HALF CLOSED by PR #114 (wave/s-resolve-keys, head 614d818), guard placed at the MODEL rather than in the two Cmds.\n\nModel::remove_category now refuses a virtual category outright. That placement is the point and it is what this bead asked for: hiding the key in DeleteCategoryAtCursor would leave ModelCommand::RemoveCategory unguarded ON THE WIRE, so a connected client could still delete _Index/_Dim/_Measure and disable records mode for everyone.\n\nLanded alongside improvise-uej8's cursor fix, since both are the same keypress.\n\nKeep this bead open until the reproduction category_panel_cannot_delete_required_virtual is un-ignored and passing -- that is the acceptance, and it lives in tests/bug_hunt_reproductions.rs.\nALREADY FIXED, same wave as improvise-uej8 (7a44616 / PR #114). Model::remove_category refuses _Index/_Dim/_Measure, guarded by model::types::model_tests::a_required_virtual_category_cannot_be_removed, passing at HEAD. Verified by the agent dispatched to uej8, which found this as uej8's second half. Stale-open.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:35:50Z","created_by":"Edward Langley","updated_at":"2026-09-22T06:39:21Z","closed_at":"2026-09-22T06:39:21Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-s08t","title":"Unknown names inside aggregate formulas silently select unrelated data","description":"Report-only hunt at 70025e2. No source changes.\n\nLocation: crates/improvise-core/src/model/types.rs:66-83\n\nRevenue=10 and Total=SUM(Typo), where Typo does not exist, yields Number(10) instead of a reference error. When find_item_category fails, eval_agg simply leaves partial unconstrained and aggregates every numeric measure matching the context. A typo produces a plausible wrong total. This is distinct from computed measures being bypassed: missing identifiers must fail before the raw-value fast path. Cover unknown refs across aggregate functions and cached/direct evaluation. Duplicate checks: reference, SUM, aggregate, formula, full existing descriptions.\n\n## Steps to Reproduce\nStandalone Rust 2024 package with improvise and improvise-io path dependencies:\n```rust\nuse improvise_io::{workbook::Workbook,persistence::{format_md,parse_md}};\nfn revenue_book()-\u003eWorkbook {\n use improvise_io::model::cell::{CellKey,CellValue};\n let mut w=Workbook::new(\"T\");w.add_category(\"Region\").unwrap();w.add_item(\"Region\",\"East\").unwrap();w.add_item(\"_Measure\",\"Revenue\").unwrap();\n w.model.set_cell(CellKey::new(vec![(\"Region\".into(),\"East\".into()),(\"_Measure\".into(),\"Revenue\".into())]),CellValue::Number(10.0));w\n}\n#[test] fn unknown_aggregate_reference_is_not_all_measures() {\n use improvise_io::{formula::parse_formula,model::cell::{CellKey,CellValue}};\n let mut w=revenue_book();w.model.add_formula(parse_formula(\"Total = SUM(Typo)\",\"_Measure\").unwrap());\n let key=CellKey::new(vec![(\"Region\".into(),\"East\".into()),(\"_Measure\".into(),\"Total\".into())]);\n assert!(matches!(w.model.evaluate(\u0026key),Some(CellValue::Error(_))),\"actual {:?}\",w.model.evaluate(\u0026key));\n}\n```\nRun cargo test unknown_aggregate_reference_is_not_all_measures -- --nocapture. Reproduced failing against unchanged code.\n\n## Acceptance Criteria\nCorrect the described observable behavior; preserve ordinary cases with focused regressions and explicit edge-case policy.\n","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:35:49Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:58:28Z","closed_at":"2026-09-21T21:58:28Z","close_reason":"Fixed in the 2026-09-21 wave, integrated into main at 1574 passed / 0 failed / 7 ignored, all gates green, every guard fault-injected.\n\neval_agg narrowed its partial key only 'if let Some(cat) = find_item_category(name)'; a failed lookup left the key UNCONSTRAINED, so SUM(Typo) aggregated every numeric measure matching the context and returned a plausible total. Now ok_or_else(|| format!(\"ref:{name}\"))? before the fast path.\n\nUNPREDICTED: an existing test was STANDING ON this bug to demonstrate a neighbouring property. eval_agg_over_no_rows_is_zero_only_where_an_identity_exists reached 'no rows' through Expr::Ref(\"Nonexistent\") and was the only suite failure when this was fixed. Not a vacuous assertion -- a CORRECT assertion whose fixture was the defect. Its vehicle is now a name that resolves and has no cells.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-s2w3","title":"SUM of a computed measure ignores its formula and returns zero","description":"Report-only hunt at 70025e2. No source changes.\n\nLocation: crates/improvise-core/src/model/types.rs:75-83\n\nWith stored Revenue=10 and Double=Revenue*2, A=SUM(Double) evaluates to 0 while B=SUM(Double+0) evaluates to 20. eval_agg special-cases Expr::Ref into raw DataStore lookup, bypassing formula evaluation entirely. The general expression branch uses per-record evaluation and sees the derived value. Test should cover Sum/Avg/Min/Max/Count as applicable, both direct and cached paths, and derived targets with existing raw data (which must not leak through). Duplicate checks: SUM, aggregate, formula, reference and full descriptions of 418 existing beads.\n\n## Steps to Reproduce\nStandalone Rust 2024 package with improvise and improvise-io path dependencies:\n```rust\nuse improvise_io::{workbook::Workbook,persistence::{format_md,parse_md}};\nfn revenue_book()-\u003eWorkbook {\n use improvise_io::model::cell::{CellKey,CellValue};\n let mut w=Workbook::new(\"T\");w.add_category(\"Region\").unwrap();w.add_item(\"Region\",\"East\").unwrap();w.add_item(\"_Measure\",\"Revenue\").unwrap();\n w.model.set_cell(CellKey::new(vec![(\"Region\".into(),\"East\".into()),(\"_Measure\".into(),\"Revenue\".into())]),CellValue::Number(10.0));w\n}\n#[test] fn sum_of_derived_measure_agrees_with_identity_expression() {\n use improvise_io::{formula::parse_formula,model::cell::CellKey};\n let mut w=revenue_book();\n for raw in [\"Double = Revenue * 2\",\"A = SUM(Double)\",\"B = SUM(Double + 0)\"] {w.model.add_formula(parse_formula(raw,\"_Measure\").unwrap());}\n let key=CellKey::new(vec![(\"Region\".into(),\"East\".into())]);\n let a=w.model.evaluate(\u0026key.clone().with(\"_Measure\",\"A\"));let b=w.model.evaluate(\u0026key.with(\"_Measure\",\"B\"));\n assert_eq!(a,b);\n}\n```\nRun cargo test sum_of_derived_measure_agrees_with_identity_expression -- --nocapture. Reproduced failing against unchanged code.\n\n## Acceptance Criteria\nCorrect the described observable behavior; preserve ordinary cases with focused regressions and explicit edge-case policy.\n","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:35:49Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:58:28Z","closed_at":"2026-09-21T21:58:28Z","close_reason":"Fixed in the 2026-09-21 wave, integrated into main at 1574 passed / 0 failed / 7 ignored, all gates green, every guard fault-injected.\n\nSame fast path, second false premise. core.md section 13 defends the specialisation as 'the fold specialised to a ref that names a record's own cell'; a formula target has no raw cell behind it (improvise-nrs), so the premise fails and the raw scan returns nothing. The fix asks model.formula_for(\u0026selected).is_some() -- the ONE HOME of 'is this cell derived', so no second derivation -- and routes a derived ref into the fold. The fast path is kept, not deleted.\n\ns08t and this are ONE bug filed as two: both are 'the Expr::Ref fast path's premise is unchecked', one for resolves-at-all and one for is-stored-rather-than-derived. Four lines apart, one match after the fix.\n\nAlso: formula_for's doc comment ALREADY called itself the one home of that question, and eval_agg was not using it. A grep-able class -- a doc comment claiming to be the single answer, against call sites that answer it themselves.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-cj4u","title":"New views pivot Label categories that were explicitly excluded from pivot axes","description":"Report-only hunt at 70025e2. Location: crates/improvise-core/src/workbook.rs:133-140; crates/improvise-core/src/view/types.rs:71-89\n\nAdd Label category Description: Default correctly uses Axis::None. create_view(Other) puts Description on Row. Workbook::create_view passes only category names to on_category_added, whose CategoryKind::of_name cannot distinguish Label from Regular. With existing row/column categories this can instead place a high-cardinality label on Page and filter the document to one description. Keep Label on None in each new view and consider passing real category kind across the seating boundary. This is independent of improvise-dy3j: no save/load is needed. Duplicate checks: label, view, current in-progress list.\n\n## Steps to Reproduce\nStandalone Cargo test with improvise and improvise-io path dependencies:\n```rust\nuse improvise_io::workbook::Workbook;\n#[test] fn label_stays_off_axes_in_new_view() {\n use improvise_io::view::Axis;\n let mut w=Workbook::new(\"test\");w.add_label_category(\"Description\").unwrap();\n assert_eq!(w.active_view().axis_of(\"Description\"),Axis::None);\n assert_eq!(w.create_view(\"Other\").axis_of(\"Description\"),Axis::None);\n}\n```\nObserved failing against unchanged source.\n\n## Acceptance Criteria\nThe reproduction passes and ordinary new-view creation still works. Add a control for the intentional cases described above.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:33:43Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:37:42Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-vw47","title":"Creating a view after deleting another silently resets a surviving view","description":"Report-only hunt at 70025e2. Location: crates/improvise-command/src/command/cmd/panel.rs:657; crates/improvise-protocol/src/resolve.rs:373; crates/improvise-core/src/workbook.rs:133\n\nCreate View 2 and View 3, customize View 3, delete View 2, then use the view panel new action. Both resolvers choose View {views.len()+1}, which is View 3 again. Workbook::create_view replaces that entry with defaults: its number format, axes, page selections and hidden/collapsed state are lost. The test executes the actual CreateAndSwitchView command and applies its effects; .4 becomes ,.0 and no view is added. Choose an unused name for user-created views in a shared helper. Preserve intentional replacement of _Records/_Drill; globally refusing create_view replacement would break those callers. Duplicate checks: create, View 3, view.\n\n## Steps to Reproduce\nStandalone Cargo test with improvise and improvise-io path dependencies:\n```rust\nuse improvise_io::workbook::Workbook;\n#[test] fn new_view_after_delete_does_not_overwrite_survivor() {\n use improvise::{ui::app::App,command::{cmd::{Cmd,panel::CreateAndSwitchView},key::{KeyCode,KeyModifiers}}};\n let mut w=Workbook::new(\"test\");w.create_view(\"View 2\");w.create_view(\"View 3\").number_format=\".4\".into();\n w.delete_view(\"View 2\").unwrap();\n let mut app=App::new(w,None);\n let effects=CreateAndSwitchView.execute(\u0026app.cmd_context(KeyCode::Char('n'),KeyModifiers::NONE));\n app.apply_effects(effects);\n assert_eq!(app.model_state.workbook.views[\"View 3\"].number_format,\".4\");\n assert_eq!(app.model_state.workbook.views.len(),3);\n}\n```\nObserved failing against unchanged source.\n\n## Acceptance Criteria\nThe reproduction passes and ordinary new-view creation still works. Add a control for the intentional cases described above.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:33:41Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:37:42Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-tg27","title":"[bug] Narrow window clips grid columns without usable horizontal scroll","description":"## Summary\nOn a narrow window the grid clips horizontally with no obvious horizontal scroll affordance, so rightmost columns are unreachable without widening the window.\n\n## Repro\n1. Open the web client with a demo that has several columns.\n2. Shrink the viewport width until columns overflow.\n3. Try to reach clipped columns (scrollbars / trackpad).\n\n## Expected\nOverflowing grid content is reachable via horizontal scroll (or another explicit navigation), consistent with `#grid { overflow: auto }` intent.\n\n## Actual\nContent appears clipped; horizontal overflow is not obviously scrollable in the smoke environment (may be a flex/min-width layout trap swallowing overflow).\n\n## Evidence\n- Browser smoke (external Grok Bot agent for Ed Langley), 2026-09-21 PT.\n- `web/style.css`: `#grid { overflow: auto; }` / main flex layout — suspected min-width:auto flex child clipping.\n- Closed `improvise-jq8l` was about `scrollIntoView` for selection, not narrow-window column clipping — different bug.\n\n## Notes\nFound by an external Grok Bot agent smoke/code hunt for Ed Langley (not Claude).\n","status":"open","priority":2,"issue_type":"bug","created_at":"2026-09-21T20:32:39Z","created_by":"box","updated_at":"2026-09-21T20:32:39Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-x0dc","title":"[bug] Esc from FORMULAS leaves formula_panel_open so panel chrome stays visible","description":"## Summary\nAfter opening the formulas panel with `F`, pressing Esc (or Tab→normal) returns mode to NORMAL but leaves formula-panel chrome visible because `formula_panel_open` stays true.\n\n## Repro\n1. Open web client with a document that has formulas.\n2. Press `F` — formulas panel opens and mode is FORMULAS.\n3. Press Esc (or the panel Tab binding that runs `enter-mode normal`).\n4. Mode indicator shows NORMAL; formulas sidebar/chrome may still be visible.\n\n## Expected\nLeaving the panel mode closes the panel (or Esc mirrors `F`'s toggle-shut: `SetPanelOpen { open: false }` + `ChangeMode(Normal)`), matching TUI intuition that Esc dismisses the panel.\n\n## Actual\n`PanelKeymap` binds Esc/Tab to `enter-mode normal` only. `entered()` emits `ChangeMode` and does not clear `formula_panel_open` / `view_panel_open`. Render still emits `formulas` rows while the flag is true (`render.rs` `formula_rows`), so `paintPanel` keeps the aside visible. By contrast, a second `F` correctly toggles shut (`SetPanelOpen false` + Normal) — see `a_focused_panel_toggles_shut` in `resolve.rs`.\n\n## Evidence\n- Browser smoke (external Grok Bot agent for Ed Langley), 2026-09-21 PT.\n- `crates/improvise-protocol/src/keymap.rs` `PanelKeymap::new`: Esc/Tab → `enter-mode normal`.\n- `crates/improvise-protocol/src/resolve.rs` `entered()` — ChangeMode only.\n- `crates/improvise-wasm-client/src/render.rs` `formula_rows` gated on `formula_panel_open`.\n- `web/app.js` `paintPanel` hides only when `rows.length === 0`.\n\n## Notes\nFound by an external Grok Bot agent smoke/code hunt for Ed Langley (not Claude). Category panel uses an explicit `category_panel_open` bool painted by the page; formula/view use empty-vec-as-closed, which makes this stickier.\n","status":"open","priority":2,"issue_type":"bug","created_at":"2026-09-21T20:32:39Z","created_by":"box","updated_at":"2026-09-21T20:32:39Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-cj4u","title":"New views pivot Label categories that were explicitly excluded from pivot axes","description":"Report-only hunt at 70025e2. Location: crates/improvise-core/src/workbook.rs:133-140; crates/improvise-core/src/view/types.rs:71-89\n\nAdd Label category Description: Default correctly uses Axis::None. create_view(Other) puts Description on Row. Workbook::create_view passes only category names to on_category_added, whose CategoryKind::of_name cannot distinguish Label from Regular. With existing row/column categories this can instead place a high-cardinality label on Page and filter the document to one description. Keep Label on None in each new view and consider passing real category kind across the seating boundary. This is independent of improvise-dy3j: no save/load is needed. Duplicate checks: label, view, current in-progress list.\n\n## Steps to Reproduce\nStandalone Cargo test with improvise and improvise-io path dependencies:\n```rust\nuse improvise_io::workbook::Workbook;\n#[test] fn label_stays_off_axes_in_new_view() {\n use improvise_io::view::Axis;\n let mut w=Workbook::new(\"test\");w.add_label_category(\"Description\").unwrap();\n assert_eq!(w.active_view().axis_of(\"Description\"),Axis::None);\n assert_eq!(w.create_view(\"Other\").axis_of(\"Description\"),Axis::None);\n}\n```\nObserved failing against unchanged source.\n\n## Acceptance Criteria\nThe reproduction passes and ordinary new-view creation still works. Add a control for the intentional cases described above.\n","notes":"FIXED in PR #129 (merge bf83294). Reproduced at HEAD: Description landed on\nAxis::Row in a view created one line after add_label_category.\n\nA SHARED THEME WITH improvise-dy3j, NOT A SHARED CAUSE, and the agent checked\nrather than assumed: CategoryKind::of_name reads Label as Regular BY DESIGN, and\nthat one fact produced two bugs at two different layers. dy3j is a file format\nwith nowhere to write the kind; cj4u is a function signature that dropped it.\nNEITHER FIX REPAIRS THE OTHER -- with dy3j alone create_view still pivots labels;\nwith cj4u alone a label still reloads as Regular. Two commits, deliberately.\n\nTHE FIX IS THE SIGNATURE: View::on_category_added now TAKES the CategoryKind, so\n`unseated = !kind.is_regular() || self.is_records_layout()`. Verified. That is\nwhat made the compiler name every call site (design-principles section 2).\n\nAND IT DELETED A DUPLICATE ANSWER. Both Workbook set_axis(name, Axis::None)\ncorrections are gone -- verified, the only Axis::None left in workbook.rs is in\nassertions and a doc comment. They were a second answer to \"where does this\ncategory go\", and ONLY ONE OF THE TWO CALLERS knew to write it: add_label_category\nfixed the views existing at that moment, while create_view -- which had only names\n-- pivoted the label onto Row, or onto Page on an already-pivoted document,\nfiltering everything to one description.\n\nTHE CONTROL TESTS CARRY AS MUCH WEIGHT AS THE FIX. An injected blanket\n`unseated = true` satisfies EVERY label assertion in the repository. What\ndiscriminates is asserting that a regular category still takes Row and that a\nlabel does not consume the slot the next one wants.\n\nRESIDUE DOCUMENTED RATHER THAN TIDIED: of_name survives, narrowed to callers\nholding no Category. holds_a_regular still asks BY NAME -- but of the categories\nalready SEATED on an axis, so a label a user deliberately moved onto Row still\ncounts as occupying it. Right answer, different route; a future View storing kinds\nshould collapse the two.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:33:43Z","created_by":"Edward Langley","updated_at":"2026-09-22T08:32:23Z","closed_at":"2026-09-22T08:32:23Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-vw47","title":"Creating a view after deleting another silently resets a surviving view","description":"Report-only hunt at 70025e2. Location: crates/improvise-command/src/command/cmd/panel.rs:657; crates/improvise-protocol/src/resolve.rs:373; crates/improvise-core/src/workbook.rs:133\n\nCreate View 2 and View 3, customize View 3, delete View 2, then use the view panel new action. Both resolvers choose View {views.len()+1}, which is View 3 again. Workbook::create_view replaces that entry with defaults: its number format, axes, page selections and hidden/collapsed state are lost. The test executes the actual CreateAndSwitchView command and applies its effects; .4 becomes ,.0 and no view is added. Choose an unused name for user-created views in a shared helper. Preserve intentional replacement of _Records/_Drill; globally refusing create_view replacement would break those callers. Duplicate checks: create, View 3, view.\n\n## Steps to Reproduce\nStandalone Cargo test with improvise and improvise-io path dependencies:\n```rust\nuse improvise_io::workbook::Workbook;\n#[test] fn new_view_after_delete_does_not_overwrite_survivor() {\n use improvise::{ui::app::App,command::{cmd::{Cmd,panel::CreateAndSwitchView},key::{KeyCode,KeyModifiers}}};\n let mut w=Workbook::new(\"test\");w.create_view(\"View 2\");w.create_view(\"View 3\").number_format=\".4\".into();\n w.delete_view(\"View 2\").unwrap();\n let mut app=App::new(w,None);\n let effects=CreateAndSwitchView.execute(\u0026app.cmd_context(KeyCode::Char('n'),KeyModifiers::NONE));\n app.apply_effects(effects);\n assert_eq!(app.model_state.workbook.views[\"View 3\"].number_format,\".4\");\n assert_eq!(app.model_state.workbook.views.len(),3);\n}\n```\nObserved failing against unchanged source.\n\n## Acceptance Criteria\nThe reproduction passes and ordinary new-view creation still works. Add a control for the intentional cases described above.\n","notes":"NOT FIXED in PR #129, deliberately, and the reason is worth keeping: THE NAME IS\nDERIVED AT TWO SITES.\n\n improvise-command/src/command/cmd/panel.rs::CreateAndSwitchView\n improvise-protocol/src/resolve.rs, arm \"create-and-switch-view\"\n\nThe agent held resolve.rs off its allowlist because another branch was in flight\non it, and refused to fix ONE site -- that would leave the TUI and the browser\nderiving different names, which is exactly the divergence design-principles\nsection 5 exists to prevent. It implemented NOTHING rather than half. Correct\ncall.\n\nREPRODUCED AT HEAD before that decision: View 3's \",.4\" reset to \",.2\" and no view\nwas added.\n\nNOW UNBLOCKED: resolve.rs is free as of PR #128 (merge 76d909e).\n\nTHE SHAPE, from the agent that read both sites: an UNUSED-NAME HELPER in\nimprovise-core called by both resolvers. NOT a change to Workbook::create_view --\nits production callers are effect::CreateView, parse.rs::build_view and the\n_Records/_Drill rebuilders, and all of them RELY ON ITS REPLACEMENT SEMANTICS.\nMaking it refuse or rename turns the symptom from \"a view was reset\" into \"the key\ndoes nothing\", which is a worse bug wearing a better error message.\n\nSo the helper answers \"what is the next unused name\" and the two resolvers each\ncall it; create_view keeps replacing, because that is what its other callers need.\nFIXED in PR #130 (merge 69c873d).\n\nTHE FIX: view::unused_view_name in improvise-core -- takes an iterator of \u0026str,\nstarts the search at the count, returns the first free \"View \u003cn\u003e\". Both resolvers\ncall it: cmd/panel.rs::CreateAndSwitchView and resolve.rs's\n\"create-and-switch-view\" arm. It takes NAMES rather than a \u0026Workbook because the\nbrowser holds only ViewListCache -- and the agent checked that\nproject::view_summaries maps workbook.views ONE-FOR-ONE including virtual views,\nso the two ends genuinely see the same list. Had they not, a shared helper would\nstill have diverged.\n\nTHE PREVIOUS AGENT'S ANALYSIS HELD, WITH ONE CORRECTION WORTH RECORDING. It named\n\"effect::CreateView, parse.rs::build_view, and the _Records/_Drill rebuilders\" as\nif those were three callers. VERIFIED BY THE ORCHESTRATOR: there are exactly TWO\nproduction callers of Workbook::create_view -- ui/effect.rs:310 and\npersistence/parse.rs:419, the latter guarded by contains_key so it cannot observe\nreplacement at all. The rebuilders are not callers; they reach it THROUGH\neffect::CreateView, emitting a fixed name on every entry with the comment\n\"Create (or replace)\".\n\nSo the substance was right -- replacement is load-bearing and create_view must not\nbe touched -- and the dependency runs through the one effect that ALSO serves the\nuser-facing path. THAT IS PRECISELY WHY THE NAME MUST BE FIXED AT THE RESOLVER:\nthe effect cannot tell its two callers apart.\n\nTHE FINDING THAT MATTERS MOST, AND IT IS ABOUT LOCKSTEP TESTS GENERALLY:\nboth_ends PASSED AGAINST THE BUG. Both resolvers computed format!(\"View {}\",\nlen + 1) and were wrong IDENTICALLY, so the two documents agreed perfectly. Only\nthe assertions after it -- the view count must rise, and the survivor's ,.4 must\nbe untouched -- caught it, failing left: 3 right: 4.\n\n\"The two front ends agree\" and \"the two front ends are right\" are different\nquestions, and a DUPLICATED DERIVATION makes them come apart in the direction\nwhere agreement is evidence of nothing. A lockstep test written as agreement-only\nwould have shipped green over this bug.\n\nAND THE BUG LIVED DIRECTLY UNDERNEATH A TEST THAT NAMED IT.\ncreate_and_switch_view_names_incrementally has asserted \"the name follows the\nworkbook\" since improvise-aaw -- but every fixture it uses leaves len + 1 FREE, so\nit could not separate \"the count\" from \"the first unused name\". It did earn its\nkeep here: the rival that starts the search at 1 fails it.\n\nNOT VERIFIED: no Playwright spec presses `n` in the view panel in a real browser.\nThat is the existing harness boundary, not something this change moved.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:33:41Z","created_by":"Edward Langley","updated_at":"2026-09-22T15:31:06Z","closed_at":"2026-09-22T15:31:06Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-tg27","title":"[bug] Narrow window clips grid columns without usable horizontal scroll","description":"## Summary\nOn a narrow window the grid clips horizontally with no obvious horizontal scroll affordance, so rightmost columns are unreachable without widening the window.\n\n## Repro\n1. Open the web client with a demo that has several columns.\n2. Shrink the viewport width until columns overflow.\n3. Try to reach clipped columns (scrollbars / trackpad).\n\n## Expected\nOverflowing grid content is reachable via horizontal scroll (or another explicit navigation), consistent with `#grid { overflow: auto }` intent.\n\n## Actual\nContent appears clipped; horizontal overflow is not obviously scrollable in the smoke environment (may be a flex/min-width layout trap swallowing overflow).\n\n## Evidence\n- Browser smoke (external Grok Bot agent for Ed Langley), 2026-09-21 PT.\n- `web/style.css`: `#grid { overflow: auto; }` / main flex layout — suspected min-width:auto flex child clipping.\n- Closed `improvise-jq8l` was about `scrollIntoView` for selection, not narrow-window column clipping — different bug.\n\n## Notes\nFound by an external Grok Bot agent smoke/code hunt for Ed Langley (not Claude).\n","notes":"FIXED in PR #138 (merge 327a76b).\n\nTHIS TICKET'S DIAGNOSIS WAS WRONG, and the correction is the useful part. It\nsuspected a flex min-width:auto trap swallowing #grid's overflow. Not that --\n#grid has overflow:auto, which already zeroes a flex item's automatic minimum.\nTHE APP BAR WAS WHAT COULD NOT SHRINK.\n\ndocumentElement.scrollWidth read 1011 AT EVERY WINDOW NARROWER THAN 1011px -- A\nFLOOR, NOT A SLOPE, which is the signature of a sum of flex:none reserves\n(#build-version 14ch + #doc-name 34ch + #view-name 16ch + #mode 21ch + a\nnon-shrinking #status). main was correctly window-width throughout. So THE PAGE\ngrew the scrollbar, carrying the bar's tail outside the window while the grid slid\noff to the left. Those reserves are improvise-n6t8's, added yesterday -- this is\nthat fix's cost, arriving.\n\nTHE FIX IS header{flex-wrap:wrap} OVER A @media BREAKPOINT, because a flex item\nwraps exactly when the line cannot hold it, so the desktop bar is untouched BY\nCONSTRUCTION rather than by a chosen number. Plus main{min-width:0} (the trap the\nticket asked about, where it actually applies), 40vw caps on the three side\npanels, and max-width caps so a reserve cannot exceed its own line.\n\nIT REJECTED body{overflow-x:hidden} EXPLICITLY, because it would make\nscrollWidth == clientWidth unconditionally and RENDER THE WHOLE SPEC VACUOUS --\nthe check-answering-its-neighbour shape. The bar has to genuinely fit.\n\nTWO THINGS IT GOT WRONG AND CORRECTED, which is the more valuable half:\n 1. It claimed the fix made #hint legible. IT DOES NOT, AND ITS SPEC ASSERTED IT\n AND PASSED -- the spec measured immediately after the grid painted, before\n #status had composed the transport's fact (186px); settled, the same width is\n 1px. Assertion removed; the spec now waits for #status before measuring.\n 2. It asserted no reflow at 1024 and it failed: #status composes the DOCUMENT'S\n NAME, so the reflow threshold is document-dependent -- the same 1024px window\n is one line under the name \"shot\" and two under \"narrow-tg27\". The threshold\n is now asserted nowhere; only that 1280 (where n6t8's stability spec runs) is\n above it.\n\nWHAT THE TICKET DID NOT PREDICT: THE KEYBOARD ALREADY REACHED EVERY CLIPPED\nCOLUMN. ArrowRight walks all six of the grouped fixture's columns at 520px where\nonly one is projected, so the ticket's own \"or another explicit navigation\" was\nalready satisfied -- what was broken is that the page offered a FALSE scroll\naffordance beside it. An assertion now pins the keyboard walk so a future\nover-fetch \"fix\" has to keep it passing.\n\nMERGE VERIFIED STRUCTURALLY, not by reading: after merging eefd992,\n`git diff eefd992 -- context/` is 198 insertions and ZERO DELETIONS (confirmed by\nthe orchestrator), and evidence entries rose 812 -\u003e 818 while Playwright stayed at\n68/4. Had the resolution eaten nxah's entries, that number would have stayed at\n812 while every other gate still passed.\n\nNOT TOUCHED: the ResizeObserver arithmetic. Over-announcing the extent so #grid\nhad real content to scroll was considered and declined -- a fixed over-fetch\nreveals a fixed number of extra columns and solves nothing general, while\nannouncing the whole grid defeats the windowed viewport, and it would have been\nthe one change into untested arithmetic.","status":"closed","priority":2,"issue_type":"bug","created_at":"2026-09-21T20:32:39Z","created_by":"box","updated_at":"2026-09-22T23:07:54Z","closed_at":"2026-09-22T23:07:54Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-x0dc","title":"[bug] Esc from FORMULAS leaves formula_panel_open so panel chrome stays visible","description":"## Summary\nAfter opening the formulas panel with `F`, pressing Esc (or Tab→normal) returns mode to NORMAL but leaves formula-panel chrome visible because `formula_panel_open` stays true.\n\n## Repro\n1. Open web client with a document that has formulas.\n2. Press `F` — formulas panel opens and mode is FORMULAS.\n3. Press Esc (or the panel Tab binding that runs `enter-mode normal`).\n4. Mode indicator shows NORMAL; formulas sidebar/chrome may still be visible.\n\n## Expected\nLeaving the panel mode closes the panel (or Esc mirrors `F`'s toggle-shut: `SetPanelOpen { open: false }` + `ChangeMode(Normal)`), matching TUI intuition that Esc dismisses the panel.\n\n## Actual\n`PanelKeymap` binds Esc/Tab to `enter-mode normal` only. `entered()` emits `ChangeMode` and does not clear `formula_panel_open` / `view_panel_open`. Render still emits `formulas` rows while the flag is true (`render.rs` `formula_rows`), so `paintPanel` keeps the aside visible. By contrast, a second `F` correctly toggles shut (`SetPanelOpen false` + Normal) — see `a_focused_panel_toggles_shut` in `resolve.rs`.\n\n## Evidence\n- Browser smoke (external Grok Bot agent for Ed Langley), 2026-09-21 PT.\n- `crates/improvise-protocol/src/keymap.rs` `PanelKeymap::new`: Esc/Tab → `enter-mode normal`.\n- `crates/improvise-protocol/src/resolve.rs` `entered()` — ChangeMode only.\n- `crates/improvise-wasm-client/src/render.rs` `formula_rows` gated on `formula_panel_open`.\n- `web/app.js` `paintPanel` hides only when `rows.length === 0`.\n\n## Notes\nFound by an external Grok Bot agent smoke/code hunt for Ed Langley (not Claude). Category panel uses an explicit `category_panel_open` bool painted by the page; formula/view use empty-vec-as-closed, which makes this stickier.\n","notes":"RECOMMEND DECLINING AS WRITTEN -- needs a user ruling, so left OPEN rather than closed.\n\nTHE STATE REPRODUCES: after F the mode is FormulaPanel with open=true; after Esc\nthe mode is Normal and open is STILL true. Driven through a real App, not inferred.\n\nBUT THE TUI IS DELIBERATE, AND THE LAUNCH STATE SETTLES IT: category_panel_open\nstarts TRUE while the mode is Normal. An open panel in Normal mode is the state\nTHE APP BOOTS IN. If that were the bug, the app would boot in the bug.\n\nThree corroborations, each independent:\n - F/C/V are while Ctrl-F/Ctrl-C/Ctrl-V are a SEPARATE\n . Two commands means two concepts.\n - Tab in Normal is , which is meaningless unless panels stay\n open while unfocused.\n - The panel hint spells as 'back', not 'close'.\n\nSo this ticket's premise -- 'matching TUI intuition that Esc dismisses the panel'\n-- is the one claim that does not hold. Open and focus are different things here.\n\nTHE GENUINE DEFECT ITS OWN EVIDENCE POINTS AT IS ALREADY FILED: improvise-df2w.\nrender.rs::formula_rows returns vec![] when the panel is closed, and\napp.js::paintPanel hides on rows.length === 0 -- so an OPEN BUT EMPTY formula or\nview panel is INVISIBLE IN THE BROWSER while the TUI draws '(no formulas -- press\nn to add)'. That is two answers to 'is this panel visible', which is\ndesign-principles section 5's own defect shape, and it is a real divergence rather\nthan a preference.\n\nWHAT THE USER NEEDS TO DECIDE: whether Esc should close a panel as well as\nunfocus it. That is a UX preference, not a correctness question, and it would\nchange the TUI's boot state to match. If the answer is no, close this and let\nimprovise-df2w carry the real bug.\nCORRECTION TO THE NOTE ABOVE: it was appended through a shell string containing\nbackticks, which the shell evaluated away, so the three corroborations lost the\ncommand names that were their whole content. The corrected list follows. (The\ntrap is the same one AGENTS.md records for gh: put the body in a FILE and pass it\nwith a file redirect, never inline.)\n\nThree corroborations that panel OPEN and panel FOCUS are different concepts here,\neach independent:\n\n 1. F / C / V are bound to \"toggle-panel-and-focus\", while Ctrl-F / Ctrl-C /\n Ctrl-V are bound to a SEPARATE \"toggle-panel-visibility\". Two commands\n means two concepts, deliberately.\n 2. Tab in Normal is \"cycle-panel-focus\", which is meaningless unless panels\n stay open while unfocused.\n 3. The panel hint spells \"enter-mode normal\" as \"back\", not \"close\".\n\nEverything else in the note above stands, in particular the launch-state\nargument: category_panel_open starts TRUE while the mode is Normal, so an open\npanel in Normal is the state the app boots in -- if that were the bug, the app\nwould boot in the bug.","status":"open","priority":2,"issue_type":"bug","created_at":"2026-09-21T20:32:39Z","created_by":"box","updated_at":"2026-09-22T06:39:38Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-9cma","title":"[bug] Invalid ?doc= yields blank UI and only generic watchdog text","description":"## Summary\nInvalid `?doc=` values (e.g. empty, `///`) produce a blank UI and only the generic handshake watchdog message — no specific error that the document name/path was rejected.\n\n## Repro\n1. Open `/improvise/?doc=` or `?doc=///`\n2. Observe blank UI; status eventually like `the in-page worker did not answer` (or similar watchdog text).\n\n## Expected\nA specific, actionable status: invalid document name / path rejected (and what is allowed).\n\n## Actual\nWorker start fails or never answers; UI collapses to the same generic watchdog path used for OPFS/browser blocks.\n\n## Evidence\n- Browser smoke (external Grok Bot agent for Ed Langley), 2026-09-21 PT.\n- `crates/improvise-worker-server/src/bindings.rs`: `PathBuf::from(document)` with little/no validation before `attach`.\n\n## Notes\nFound by an external Grok Bot agent smoke/code hunt for Ed Langley (not Claude). Related to but distinct from OPFS AccessHandle conflicts.\n","status":"closed","priority":2,"issue_type":"bug","created_at":"2026-09-21T20:32:38Z","created_by":"box","updated_at":"2026-09-21T21:58:55Z","closed_at":"2026-09-21T21:58:55Z","close_reason":"Fixed in the 2026-09-21 wave, integrated into main at 1574 passed / 0 failed / 7 ignored. Playwright 42 passed / 0 failed / 4 skipped (WebKit, no OPFS) -- up from 37, so the count MOVED. All gates green.\n\nCLOSED -- AND THE REPORT UNDERSTATED IT. ?doc=a/b/c.improv was not rejected at all: it SILENTLY OPENED c.improv, because entry_name takes file_name. Accepted-and-wrong, not blank. That is an ID-shaped non-ID problem -- diagnosis identical, repair different. boot::document_name now answers before storage is touched.","dependencies":[{"issue_id":"improvise-9cma","depends_on_id":"improvise-1wft","type":"blocks","created_at":"2026-09-21T21:31:51Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-iobc","title":"[bug] Bad/missing ?seed= creates empty doc with no loud error","description":"## Summary\nA bad or missing `?seed=` is silent: the worker creates an empty document and the UI never surfaces a loud, actionable error. CORS/404 appears only in the console.\n\n## Repro\n1. Open `/improvise/?doc=probe-missing.improv\u0026seed=https://example.com/missing.improv`\n2. Or `?seed=nope.improv` (404 relative to the page).\n3. Observe an empty document with normal shell status; no toast/status naming the seed failure.\n\n## Expected\nStatus (or a blocking notice) names that the seed could not be fetched/parsed (404, CORS, corrupt), and ideally does not pretend the empty doc was intentional without saying so.\n\n## Actual\n`web/worker.js` `seedBytes()` returns `undefined` on `!res.ok` or fetch throw; comment says \"A missing seed is not an error\"; `start(DOC, undefined)` creates empty model. User sees empty grid.\n\n## Evidence\n- Browser smoke (external Grok Bot agent for Ed Langley), 2026-09-21 PT.\n- `web/worker.js` lines ~13–22 (`seedBytes`).\n- `crates/improvise-worker-server/src/vfs.rs` / `bindings.rs`: open-with-optional-seed.\n\n## Notes\nFound by an external Grok Bot agent smoke/code hunt for Ed Langley (not Claude).\n","status":"closed","priority":2,"issue_type":"bug","created_at":"2026-09-21T20:32:38Z","created_by":"box","updated_at":"2026-09-21T21:58:56Z","closed_at":"2026-09-21T21:58:56Z","close_reason":"Fixed in the 2026-09-21 wave, integrated into main at 1574 passed / 0 failed / 7 ignored. Playwright 42 passed / 0 failed / 4 skipped (WebKit, no OPFS) -- up from 37, so the count MOVED. All gates green.\n\nvfs::Origin{Stored,Seeded,Created}. A seed error is a notice only when the document really was created without it, so a reload of an existing document says nothing.","dependencies":[{"issue_id":"improvise-iobc","depends_on_id":"improvise-1wft","type":"blocks","created_at":"2026-09-21T21:31:52Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-55v2","title":"Nonfinite CellValue numbers serialize into JSON that the wire cannot read","description":"Report-only bug hunt at HEAD 70025e2, 2026-09-21. No implementation changes.\n\nLocation: crates/improvise-core/src/model/cell.rs:65-70; crates/improvise-protocol/src/resolve.rs:805-813\n\nserde_json serializes CellValue::Number(infinity) as {\"Number\":null}, which cannot deserialize as an f64. The test uses Command::SetYanked; ModelCommand::SetCell contains the same CellValue. typed() accepts inf, NaN and overflowing numeric literals, so a browser can compose a valid Rust command that cannot cross its JSON transport. The websocket reader skips unparseable frames and the worker reports only a console error. Choose an explicit representation for supported nonfinite numbers or refuse them before sending, with an observable error. Duplicate checks: serialization, finite, NaN. improvise-jc95 is the separate CSV zero-substitution bug.\n\n## Steps to Reproduce\nUse a standalone Rust 2024 package with path dependencies on improvise and improvise-io, plus serde_json=1 and ratatui=0.30.\n```rust\nuse improvise_io::{workbook::Workbook,persistence::{format_md,parse_md},formula::AggFunc};\nuse improvise_io::import::csv_parser::parse_csv_bytes_with_warnings;\n#[test] fn nonfinite_cell_value_survives_wire() {\n use improvise::protocol::{Command,CellValue};\n let cmd=Command::SetYanked(Some(CellValue::Number(f64::INFINITY)));\n let json=serde_json::to_string(\u0026cmd).unwrap();\n assert!(serde_json::from_str::\u003cCommand\u003e(\u0026json).is_ok(),\"unreadable wire: {json}\");\n}\n```\nRun cargo test nonfinite_cell_value_survives_wire -- --nocapture. The current implementation failed this test.\n\n## Acceptance Criteria\nAddress the behavior above with a focused regression that fails before the fix and preserves ordinary input behavior.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:31:29Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:31:29Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-l1f5","title":"A blank numeric CSV field makes the entire measure a Label","description":"Report-only bug hunt at HEAD 70025e2, 2026-09-21. No implementation changes.\n\nLocation: crates/improvise-io/src/import/analyzer.rs:129-132\n\nCSV Region,Revenue with East,10 and West,\u003cblank\u003e yields a Revenue proposal of Label instead of Measure. parse_csv_field correctly maps blank to Null, but all_numeric tests every value including Null. Default import therefore stops importing all numeric Revenue cells; they become label coordinates instead. Infer from present values, define all-null behavior explicitly, and test both blank CSV and JSON null. Duplicate checks: blank, numeric, null, import. improvise-ych concerns missing CATEGORY values, a separate stage.\n\n## Steps to Reproduce\nUse a standalone Rust 2024 package with path dependencies on improvise and improvise-io, plus serde_json=1 and ratatui=0.30.\n```rust\nuse improvise_io::{workbook::Workbook,persistence::{format_md,parse_md},formula::AggFunc};\nuse improvise_io::import::csv_parser::parse_csv_bytes_with_warnings;\n#[test] fn blank_measure_still_infers_numeric() {\n use improvise_io::import::{wizard::ImportPipeline,analyzer::FieldKind};\n let (records,_)=parse_csv_bytes_with_warnings(b\"Region,Revenue\\nEast,10\\nWest,\\n\").unwrap();\n let p=ImportPipeline::new(serde_json::Value::Array(records));\n let proposal=p.proposals.iter().find(|p|p.field==\"Revenue\").unwrap();\n assert_eq!(proposal.kind,FieldKind::Measure);\n}\n```\nRun cargo test blank_measure_still_infers_numeric -- --nocapture. The current implementation failed this test.\n\n## Acceptance Criteria\nAddress the behavior above with a focused regression that fails before the fix and preserves ordinary input behavior.\n","notes":"Mechanism, carried over from improvise-dhw5 (closed as a duplicate, filed independently by an external Grok Bot review the same day): analyze_records' all_numeric EXCLUDES Value::Null, so a column that is 99% numeric with a single null is classified Label rather than Measure. parse_csv_field correctly maps blank to Null; the misclassification is entirely in the analyzer. Consequence beyond the proposal being wrong: the numbers then import as stringified item names.\n\nNote this interacts with gotcha 18 -- analyze_records was already changed once (improvise-7yx) to use first-seen order rather than a HashSet. Same function, different defect.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:31:27Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:58:03Z","closed_at":"2026-09-21T21:58:03Z","close_reason":"Fixed in the 2026-09-21 wave, integrated into main at 1574 passed / 0 failed / 7 ignored, with fmt, clippy, --check-evidence and the 8-crate wasm gate green. Every guard was fault-injected and watched failing before being believed.\n\nall_numeric AND all_string now run over the non-null values, with an !is_empty() guard so an all-null column does not satisfy a vacuous all() and falls through to Label. BOTH bindings fixed, not just the numeric one the ticket named: identical expression, identical defect, and a blank in a Region column made the whole category a Label.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-m4rv","title":"Loading a document invents a Default view the file does not contain, and saving writes it back","description":"Found by the UX audit. DEMONSTRATED; orchestrator re-verified.\n\nweb/demo.improv declares exactly ONE view, `Plan`. Parsing it produces TWO:\n\n $ cargo run -q --example pretty-print \u003c web/demo.improv | grep '^## View'\n ## View: Default \u003c- not in the file\n ## View: Plan\n\n $ grep -c '^## View' web/demo.improv\n 1\n\n`Default` comes out auto-laid-out (Product: row, Quarter: column, _Measure: page) because Workbook::new seeds a Default view and on_category_added assigns each parsed category into every existing view. examples/demo.improv is unaffected only because its view IS named Default.\n\n## Two consequences, the second worse\n1. THE SHIPPED DEMO'S VIEW PANEL SHOWS A PHANTOM ROW, AND IT IS INDEX 0. The panel cursor starts on `Default`, so the first V+Enter a visitor presses takes them OFF the curated view onto an auto-arrangement. harness/tests/contract.spec.js:71 already encodes this (toHaveCount(2), cursor on Default) as expected behaviour — so the test pins the bug.\n\n2. OPEN-AND-SAVE REWRITES THE USER'S DOCUMENT. A round trip with no edits adds the whole `## View: Default` block permanently. Demonstrated with `improvise cmd 'set-format ,.0'`.\n\n## Acceptance Criteria\n- parse_md drops a `Default` view that the parsed text never named and that holds no non-default configuration — the same class of rule as the virtual-view drop already in persistence.\n- Update contract.spec.js, which currently asserts the phantom.\n- A test: parse(format(x)) names exactly the views x named.\n\n## Adjacent, same experience, probably a different owner\nThe same round trip REORDERS the ## Data block — |Cold Brew| moves from the middle to the end. That is DataStore ordering (gotcha 18 territory) and lands in the same \"save produced a different file\" complaint. Worth checking whether it is the same insertion-order guarantee that gotcha 18 says persistence relies on.\n","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:30:19Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:58:14Z","closed_at":"2026-09-21T21:58:14Z","close_reason":"Fixed in the 2026-09-21 wave, integrated into main at 1574 passed / 0 failed / 7 ignored, all gates green, every guard fault-injected.\n\ndrop_unnamed_default drops Default only when the file named at least one view and none was Default. The name is lifted to view::DEFAULT_VIEW so parser and Workbook::new cannot drift.\n\nUNPREDICTED: the phantom was LOAD-BEARING in a test that had nothing to do with it. contract.spec.js's click test needs two view rows, and the invented view was silently supplying one. web/demo.improv gained a real second view (Season). Generalises: when removing something spurious, grep for tests that need N of something, not only tests that name the thing.","comments":[{"id":"a2b602bb-1f71-5d7b-9712-7e42113db440","issue_id":"improvise-m4rv","author":"Edward Langley","text":"Independently reproduced in this report-only hunt at 70025e2: create Custom, delete Default, parse_md(format_md(wb)); expected [Custom], actual [Default, Custom]. A failing standalone Rust regression and exact source location are retained in improvise-izx2. That ticket was filed at 20:27 before this concurrently-created report existed; consolidating here because this report adds shipped-demo and harness impact.","created_at":"2026-09-21T20:32:02Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} -{"_type":"issue","id":"improvise-q914","title":"improvise cmd advertises JSON it does not accept, and swallows every effect error at exit 0","description":"Found by the UX audit. Both halves DEMONSTRATED; orchestrator re-verified both.\n\n`improvise cmd` and `improvise script` are the scripting interface, and they are unusable as one for two independent reasons.\n\n## 1 — they advertise a language they do not accept\n cmd Run a JSON command headless (repeatable)\n Arguments: [JSON]... JSON command strings\n script Arguments: \u003cPATH\u003e Script file (one JSON command per line, # comments)\n\n $ improvise cmd '{\"cmd\":\"add-cat\",\"args\":[\"Region\"]}' -f m.improv\n Parse error: Unknown command: {\"cmd\":\"add-cat\",\"args\":[\"Region\"]}\n\nThe language is the `:` command line (add-cat Region, `.`-separated, \"quoted strings\"). src/main.rs is the ONLY place with the wrong words; README and AGENTS.md have it right. Verified: `improvise cmd --help` says JSON three times.\n\n## 2 — every error that goes through the status bar is swallowed, exit 0\nrun_headless_commands (src/main.rs:331) applies effects and never reads app.view_state.status_msg. Only PARSE errors reach stderr. Everything an Effect refuses is silent, exit 0, AND THE FILE IS SAVED ANYWAY:\n\n $ improvise cmd 'formula Product \"Bad = (((\"' -f n.improv ; echo $?\n 0 # no formula added, nothing said\n $ improvise cmd 'add-item Nonexistent Thing' -f n.improv ; echo $?\n 0 # workbook.add_item's Err went to status_msg\n $ improvise cmd 'show-item Region Nowhere' -f n.improv ; echo $?\n 0\n\nVerified independently: add-item against a nonexistent category exits 0 with no output.\n\nSilent no-ops with a success exit code are the worst possible contract for a scripting interface. Note this is also what makes `fit-measure` unusable headlessly — its whole deliverable, including the SEED THAT REPRODUCES THE FIT, goes to set_status.\n\n## Acceptance Criteria\n- Three clap doc strings: [JSON] -\u003e [COMMAND], \"JSON command\" -\u003e \"command line, as typed after `:`\".\n- After each batch in run_headless_commands: if status_msg is non-empty, eprintln! it and clear it.\n- Exit non-zero when an effect set abort_effects.\n- A test per arm — a refused effect must exit non-zero and say why.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:30:03Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:30:03Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-p8ls","title":"Nothing on screen tells a TUI user how to get help or how to quit","description":"Found by the UX audit. DEMONSTRATED via the help printout.\n\nhelp::advertised(AppMode::Normal) yields:\n\n NORMAL hjkl\u003c-^-\u003e:nav ai:edit R:records P:prune F:formulas C:categories\n V:views T:tiles [:prev-page ]:next-page \u003e:drill ::cmd\n\nNO `?`. NO F1. NO :q. NO Ctrl-S.\n\nThe welcome popup that DOES name ? and :q is drawn only when App::is_empty_model() — so `improvise mymodel.improv` shows it to nobody. A user who opens a document has no on-screen route to help and no on-screen route out.\n\nThis is the \"how do I quit vim\" problem with the escape hatch removed, and it is the highest-frequency finding in the audit: it affects every session that opens a file.\n\n## Acceptance Criteria\n- Add (\"enter-mode\", arg(\"help\"), \"help\") to the AppMode::Normal arm of advertised(). Costs ~8 columns and is DERIVED, so it cannot go stale.\n- :q is an ex-command rather than a binding, so it cannot come from advertised(); hint_tail(AppMode::Normal) can carry it — e.g. \"— :q to quit ? for help\".\n- The browser has the same gap and the same fix; see the #backend-notice finding, which wants the same sentence in the space it frees.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:30:02Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:30:02Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-z05h","title":"Every documented spelling of :formula fails, and the refusal names a command the user did not type","description":"Found by the UX audit. DEMONSTRATED; orchestrator re-verified.\n\nThe parser splits on whitespace (command/parse.rs::tokenize), so a formula containing spaces must be quoted. NOTHING IN THE PRODUCT SAYS SO, and all three places that teach :formula teach the unquoted form.\n\n Help page 1, Quick start step 4 : :formula Product Total = Widget + Gadget\n Help page 5, Model building : :formula \u003ccat\u003e \u003cName=expr\u003e\n draw.rs::draw_welcome : :formula \u003ccat\u003e \u003cexpr\u003e e.g. Profit = Revenue - Cost\n\n $ improvise cmd 'formula Product Total = Widget + Gadget' -f m.improv\n Parse error: add-formula requires 1-2 argument(s), got 6 [exit 1]\n $ improvise cmd 'formula Product \"Total = Widget + Gadget\"' -f m.improv\n works\n\n## Three UX bugs stacked\n1. The documented syntax is wrong, on the product's own quick-start page, for the headline feature.\n2. The refusal NAMES A COMMAND THE USER DID NOT TYPE — they typed `formula`, the message says `add-formula`. The alias resolves in CmdRegistry::resolve before the arity check in effect_cmds.rs:204.\n3. The refusal states the ARITY, not the SHAPE, so it is unactionable: \"requires 1-2 argument(s), got 6\" does not suggest quoting.\n\n## Why the help escaped the guard that would have caught it\nhelp.rs derives HelpLine::Keys rows from the keymap and DROPS a row whose command resolves to nothing, guarded by every_help_page_key_row_names_keys_that_exist. The ex-commands are HelpLine::Literal rows — the part that escaped the derivation. That is the whole unguarded surface and this is what was in it.\n\n## The non-broken path, worth keeping in mind\nThe formula panel (F, then n) takes free text `Name = expression` and works on both surfaces. The help is pointing new users at the one door that is jammed.\n\n## Acceptance Criteria\n- Quote the examples in all three places: :formula Product \"Total = Widget + Gadget\"\n- The welcome popup's `\u003cexpr\u003e` becomes `\u003cName=expr\u003e`; `\u003cexpr\u003e` alone is not accepted.\n- The refusal names the command as typed and states the SHAPE: formula: expected `formula [\u003ccategory\u003e] \"\u003cName\u003e = \u003cexpr\u003e\"` — got 6 words; quote the formula.\n- Check the four asciinema casts in docs/: if any shows the unquoted spelling it inherits this and needs re-recording.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:30:01Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:30:01Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-q914","title":"improvise cmd advertises JSON it does not accept, and swallows every effect error at exit 0","description":"Found by the UX audit. Both halves DEMONSTRATED; orchestrator re-verified both.\n\n`improvise cmd` and `improvise script` are the scripting interface, and they are unusable as one for two independent reasons.\n\n## 1 — they advertise a language they do not accept\n cmd Run a JSON command headless (repeatable)\n Arguments: [JSON]... JSON command strings\n script Arguments: \u003cPATH\u003e Script file (one JSON command per line, # comments)\n\n $ improvise cmd '{\"cmd\":\"add-cat\",\"args\":[\"Region\"]}' -f m.improv\n Parse error: Unknown command: {\"cmd\":\"add-cat\",\"args\":[\"Region\"]}\n\nThe language is the `:` command line (add-cat Region, `.`-separated, \"quoted strings\"). src/main.rs is the ONLY place with the wrong words; README and AGENTS.md have it right. Verified: `improvise cmd --help` says JSON three times.\n\n## 2 — every error that goes through the status bar is swallowed, exit 0\nrun_headless_commands (src/main.rs:331) applies effects and never reads app.view_state.status_msg. Only PARSE errors reach stderr. Everything an Effect refuses is silent, exit 0, AND THE FILE IS SAVED ANYWAY:\n\n $ improvise cmd 'formula Product \"Bad = (((\"' -f n.improv ; echo $?\n 0 # no formula added, nothing said\n $ improvise cmd 'add-item Nonexistent Thing' -f n.improv ; echo $?\n 0 # workbook.add_item's Err went to status_msg\n $ improvise cmd 'show-item Region Nowhere' -f n.improv ; echo $?\n 0\n\nVerified independently: add-item against a nonexistent category exits 0 with no output.\n\nSilent no-ops with a success exit code are the worst possible contract for a scripting interface. Note this is also what makes `fit-measure` unusable headlessly — its whole deliverable, including the SEED THAT REPRODUCES THE FIT, goes to set_status.\n\n## Acceptance Criteria\n- Three clap doc strings: [JSON] -\u003e [COMMAND], \"JSON command\" -\u003e \"command line, as typed after `:`\".\n- After each batch in run_headless_commands: if status_msg is non-empty, eprintln! it and clear it.\n- Exit non-zero when an effect set abort_effects.\n- A test per arm — a refused effect must exit non-zero and say why.\n","notes":"FIXED in PR #131 (merge 9d2bdd3), together with improvise-j6x8 -- they are ONE\nfix. Half 1 is three clap doc strings advertising JSON; half 2 is \"effect errors\nswallowed at exit 0\", which IS j6x8, measured more carefully.\n\n`improvise script` SHARES THE HOLE EXACTLY: run_headless_script literally\ndelegates to run_headless_commands, so it was the same function, fixed by the same\nchange, and asserted by a_script_reports_exactly_as_cmd_does.\n\nTHE STREAM DECISION CORRECTS MY OWN BRIEF, and the disproof is in-tree. I\nsuggested stdout for a status and stderr for a refusal. THAT CANNOT BE BUILT\nHONESTLY: ViewState::status_msg is a bare String with no severity, and BOTH\ncandidate classifiers are disproved by code already here --\n - \"a refusal aborted\" is FALSE: effect::AddItem writes its Err to status_msg and\n does NOT set abort_effects (verified by the orchestrator at effect.rs:103-129).\n That classifier would label a refusal a success.\n - \"a refusal is anything that spoke\" is FALSE: cmd/fit.rs:78 sets a status on the\n SUCCESS path, carrying the reproducing seed (verified; :86 is the Err arm).\n That classifier would label a deliverable a failure.\nPicking either is the adjacent-check defect design-principles section 7 names. So:\nSTDERR, UNDIVIDED.\n\nEXIT CODE IS NARROWER THAN THIS TICKET'S AC, DELIBERATELY: 1 on a parse error or\nan aborted batch (App::last_batch_aborted) -- both real flags -- and no further. A\nstatus-only refusal is now PRINTED but still exits 0, because nothing can classify\nit. README says so plainly: exit 0 means \"nothing aborted\", not \"everything\nworked\".\n\nSO THIS TICKET'S AC (\"exit non-zero when an effect is refused\") IS ONLY PARTLY\nMET, and that is recorded rather than glossed. Closing it properly needs a\nSEVERITY on ViewState::status_msg -- a wire change reaching the browser. Filed as\nimprovise-udt2, which names both disproved rivals so the next attempt does not\nrediscover them.\n\nNO --quiet FLAG: 2\u003e/dev/null already exists, and a second knob with nothing asking\nfor it leaves the default honest either way.\n\nALSO FIXED, from the trap I flagged: the no-op rewrite. The write-back gate\naccumulates model_state.dirty PER COMMAND rather than reading it once at the end,\nbecause effect::Save CLEARS the flag -- so `[add-item East, w other.improv]` would\notherwise look unchanged and silently drop the edit from the -f document.\na_run_that_changed_the_document_still_writes_it guards it.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:30:03Z","created_by":"Edward Langley","updated_at":"2026-09-22T15:38:57Z","closed_at":"2026-09-22T15:38:57Z","close_reason":"Closed","dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"improvise-p8ls","title":"Nothing on screen tells a TUI user how to get help or how to quit","description":"Found by the UX audit. DEMONSTRATED via the help printout.\n\nhelp::advertised(AppMode::Normal) yields:\n\n NORMAL hjkl\u003c-^-\u003e:nav ai:edit R:records P:prune F:formulas C:categories\n V:views T:tiles [:prev-page ]:next-page \u003e:drill ::cmd\n\nNO `?`. NO F1. NO :q. NO Ctrl-S.\n\nThe welcome popup that DOES name ? and :q is drawn only when App::is_empty_model() — so `improvise mymodel.improv` shows it to nobody. A user who opens a document has no on-screen route to help and no on-screen route out.\n\nThis is the \"how do I quit vim\" problem with the escape hatch removed, and it is the highest-frequency finding in the audit: it affects every session that opens a file.\n\n## Acceptance Criteria\n- Add (\"enter-mode\", arg(\"help\"), \"help\") to the AppMode::Normal arm of advertised(). Costs ~8 columns and is DERIVED, so it cannot go stale.\n- :q is an ex-command rather than a binding, so it cannot come from advertised(); hint_tail(AppMode::Normal) can carry it — e.g. \"— :q to quit ? for help\".\n- The browser has the same gap and the same fix; see the #backend-notice finding, which wants the same sentence in the space it frees.\n","notes":"CLOSED INVALID. The affordance exists and has existed since 2026-04-01.\n\nVERIFIED BY THE ORCHESTRATOR, not taken on report:\n src/draw.rs:274 let right = \" ?:help :q quit \";\n git log -S'?:help' -- src/draw.rs -\u003e da93145 \"refactor: introduce draw\n module\", a single commit, an ancestor of HEAD.\n\ndraw_title renders it into main_chunks[0] UNCONDITIONALLY, every mode, every\nframe, document loaded or not. The agent confirmed by rendering the real draw()\nthrough a ratatui TestBackend (precedent: bottom_bar_tests::bottom_row, already in\nsrc/draw.rs), giving row 0 as:\n improvise · Sales (sales.improv) ?:help :q quit\n\nSo the headline -- \"a user who opens a document has no on-screen route to help and\nno on-screen route out\" -- is false and has been false the whole time. No fixing\nSHA, because it was never broken.\n\nTHIS TICKET'S EVIDENCE IS ACCURATE AND ITS CONCLUSION DOES NOT FOLLOW.\nhint_line(Normal) really does omit ?, F1 and :q, and draw_welcome really is gated\non is_empty_model(). The author checked advertised() and the welcome popup and\nconcluded those were the only two places the affordance could live. THE TITLE BAR\nIS A THIRD PLACE AND IT IS THE ONE ALWAYS VISIBLE.\n\nGrepping the FACT rather than the function finds FOUR homes: draw.rs:274 (title\nbar, always on), draw.rs:519+522 (welcome array, empty-model only), help.rs\nliteral() lines (the 17 improvise-wk18 says are unguarded), and advertised()'s\nabsence of it. The audit walked two of the four -- the same \"named three\nlocations, there were four\" shape improvise-z05h hit a day earlier.\n\nTHE GENERALISABLE POINT: this ticket was demonstrated VIA THE HELP PRINTOUT -- a\nfunction's return value -- where the claim was about WHAT IS ON SCREEN. Those are\nadjacent questions (design-principles section 7). A printout of advertised()\ncannot see a string draw_title renders, and TestBackend is cheap, precedented in\nthat very file, and settles it in one run.\n\nTHE RESIDUAL IS REAL AND IS FILED SEPARATELY: the title bar drops the escape hatch\nwhen the document name is long, because fill_line gives the left segment\nunconditional priority and does not truncate.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:30:02Z","created_by":"Edward Langley","updated_at":"2026-09-22T22:59:22Z","closed_at":"2026-09-22T22:59:22Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-z05h","title":"Every documented spelling of :formula fails, and the refusal names a command the user did not type","description":"Found by the UX audit. DEMONSTRATED; orchestrator re-verified.\n\nThe parser splits on whitespace (command/parse.rs::tokenize), so a formula containing spaces must be quoted. NOTHING IN THE PRODUCT SAYS SO, and all three places that teach :formula teach the unquoted form.\n\n Help page 1, Quick start step 4 : :formula Product Total = Widget + Gadget\n Help page 5, Model building : :formula \u003ccat\u003e \u003cName=expr\u003e\n draw.rs::draw_welcome : :formula \u003ccat\u003e \u003cexpr\u003e e.g. Profit = Revenue - Cost\n\n $ improvise cmd 'formula Product Total = Widget + Gadget' -f m.improv\n Parse error: add-formula requires 1-2 argument(s), got 6 [exit 1]\n $ improvise cmd 'formula Product \"Total = Widget + Gadget\"' -f m.improv\n works\n\n## Three UX bugs stacked\n1. The documented syntax is wrong, on the product's own quick-start page, for the headline feature.\n2. The refusal NAMES A COMMAND THE USER DID NOT TYPE — they typed `formula`, the message says `add-formula`. The alias resolves in CmdRegistry::resolve before the arity check in effect_cmds.rs:204.\n3. The refusal states the ARITY, not the SHAPE, so it is unactionable: \"requires 1-2 argument(s), got 6\" does not suggest quoting.\n\n## Why the help escaped the guard that would have caught it\nhelp.rs derives HelpLine::Keys rows from the keymap and DROPS a row whose command resolves to nothing, guarded by every_help_page_key_row_names_keys_that_exist. The ex-commands are HelpLine::Literal rows — the part that escaped the derivation. That is the whole unguarded surface and this is what was in it.\n\n## The non-broken path, worth keeping in mind\nThe formula panel (F, then n) takes free text `Name = expression` and works on both surfaces. The help is pointing new users at the one door that is jammed.\n\n## Acceptance Criteria\n- Quote the examples in all three places: :formula Product \"Total = Widget + Gadget\"\n- The welcome popup's `\u003cexpr\u003e` becomes `\u003cName=expr\u003e`; `\u003cexpr\u003e` alone is not accepted.\n- The refusal names the command as typed and states the SHAPE: formula: expected `formula [\u003ccategory\u003e] \"\u003cName\u003e = \u003cexpr\u003e\"` — got 6 words; quote the formula.\n- Check the four asciinema casts in docs/: if any shows the unquoted spelling it inherits this and needs re-recording.\n","notes":"FIXED in PR #131 (merge 9d2bdd3).\n\nPR #126 HAD NOT CHANGED THE SYMPTOM -- checked before reproducing from this\nticket's text, as asked. improvise-55l5/#126 changed KeymapSet::dispatch -\u003e\nregistry.interactive; this message comes from registry.parse, the HEADLESS path,\nuntouched. Verified by reproducing at bf83294: still\n`add-formula requires 1-2 argument(s), got 6` verbatim.\n\nTHE FIX PUTS THE NAME WHERE THE TYPED WORD IS KNOWN. require_args drops its `word`\nparameter and CmdRegistry::parse prefixes \"{typed}: \", so the refusal names what\nthe user actually typed rather than what the validator was called about. That\nDELETES 41 NAME LITERALS from cmd/ -- the same argument gotcha 15 makes about\nregistry.rs, where every register* call reads the name off a prototype so a typo\nis impossible. `interactive` deliberately does NOT prefix, because #126 already\ndoes and both would double it.\n\nTHIS TICKET NAMED THREE LOCATIONS AND THERE WERE FOUR: help.rs carried the\nquick-start `:formula` line TWICE, on two different pages. Gotcha 48's \"a fact\nstated in more than one place is changed by a grep, not an edit\", live again.\n\nCASTS NEED NO RE-RECORDING -- checked rather than assumed: all four in docs/casts/\nuse the formula PANEL, not the `:formula` command line.\n\nREMAINING GAP, stated rather than hidden: draw.rs::draw_welcome is unguarded. Its\nlines are a local array inside a ratatui render fn, so no walk reaches them; they\nwere corrected by hand and the gap is recorded in context/code/cli.md section 6.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:30:01Z","created_by":"Edward Langley","updated_at":"2026-09-22T15:38:59Z","closed_at":"2026-09-22T15:38:59Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-5hfh","title":"Duplicate CSV headers silently overwrite earlier columns","description":"Report-only bug hunt at HEAD 70025e2, 2026-09-21. No implementation changes.\n\nLocation: crates/improvise-io/src/import/csv_parser.rs:50-69\n\nCSV A,A followed by 1,2 succeeds with a single A=2 field and no warnings. Both header positions map to the same JSON object key. Short rows can even overwrite an earlier real value with Null during padding. Reject duplicate names after trimming, or disambiguate explicitly with a warning. Duplicate checks: duplicate, CSV; improvise-k8i is specifically row-width mismatches, not duplicate header names.\n\n## Steps to Reproduce\nA standalone Rust 2024 test package depends on improvise-io by path. Imports:\n```rust\nuse improvise_io::{workbook::Workbook,persistence::{format_md,parse_md},formula::AggFunc};\nuse improvise_io::import::csv_parser::parse_csv_bytes_with_warnings;\n#[test] fn duplicate_csv_headers_not_silent() {\n let result=parse_csv_bytes_with_warnings(b\"A,A\\n1,2\\n\");\n assert!(result.is_err() || !result.unwrap().1.is_empty(),\"duplicate headers silently overwrite a field\");\n}\n```\nRun cargo test duplicate_csv_headers_not_silent. This test failed against the unchanged source.\n\n## Acceptance Criteria\nThe focused test passes after correction, with a control covering the unchanged ordinary case. Preserve the behavior described above without silent data loss.\n","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:27:44Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:58:02Z","closed_at":"2026-09-21T21:58:02Z","close_reason":"Fixed in the 2026-09-21 wave, integrated into main at 1574 passed / 0 failed / 7 ignored, with fmt, clippy, --check-evidence and the 8-crate wasm gate green. Every guard was fault-injected and watched failing before being believed.\n\ndisambiguate_headers renames later duplicates to A_2, A_3 ... choosing a suffix no name in the file claims, and warns per rename. Test asserts three fields with values 1,2,3 IN ORDER -- the observable outcome -- with the warning only as a companion.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-izx2","title":"Loading a saved workbook resurrects its deleted Default view","description":"Report-only bug hunt at HEAD 70025e2, 2026-09-21. No implementation changes.\n\nLocation: crates/improvise-io/src/persistence/parse.rs:399-425\n\nCreate Custom, delete Default, save and reopen. Expected only Custom; actual views are Default and Custom. build_workbook starts with Workbook::new, which inserts Default, then only adds/updates file views. Preserve the exact persisted set while retaining a sensible fallback for files with no view sections. Duplicate checks: Default, persist; improvise-azhb is about choosing the document default, not resurrecting a deleted view.\n\n## Steps to Reproduce\nA standalone Rust 2024 test package depends on improvise-io by path. Imports:\n```rust\nuse improvise_io::{workbook::Workbook,persistence::{format_md,parse_md},formula::AggFunc};\nuse improvise_io::import::csv_parser::parse_csv_bytes_with_warnings;\n#[test] fn deleted_default_stays_deleted() {\n let mut w=Workbook::new(\"test\"); w.create_view(\"Custom\"); w.delete_view(\"Default\").unwrap();\n let r=parse_md(\u0026format_md(\u0026w)).unwrap(); assert_eq!(r.views.keys().collect::\u003cVec\u003c_\u003e\u003e(),w.views.keys().collect::\u003cVec\u003c_\u003e\u003e());\n}\n```\nRun cargo test deleted_default_stays_deleted. This test failed against the unchanged source.\n\n## Acceptance Criteria\nThe focused test passes after correction, with a control covering the unchanged ordinary case. Preserve the behavior described above without silent data loss.\n","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:27:43Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:32:03Z","closed_at":"2026-09-21T20:32:03Z","close_reason":"Duplicate of concurrently filed improvise-m4rv; retain that richer report. Independent failing regression preserved here and cross-linked there.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-3yv3","title":"Saving reorders category items when grouped and ungrouped items are interleaved","description":"Report-only bug hunt at HEAD 70025e2, 2026-09-21. No implementation changes.\n\nLocation: crates/improvise-io/src/persistence/format.rs:156-185\n\nAdd grouped item first under G, then ungrouped second. Before save the insertion order is [first, second]; parse_md(format_md(wb)) returns [second, first]. The writer collects every ungrouped item into bare and emits that entire list before grouped. Grid axis ordering consumes item insertion order, so reopening rearranges rows/columns. Preserve interleaving when writing, with a populated roundtrip regression. Duplicate checks: group, order, reorder, roundtrip; existing prune/group-boundary issues concern rendering, not serialization.\n\n## Steps to Reproduce\nA standalone Rust 2024 test package depends on improvise-io by path. Imports:\n```rust\nuse improvise_io::{workbook::Workbook,persistence::{format_md,parse_md},formula::AggFunc};\nuse improvise_io::import::csv_parser::parse_csv_bytes_with_warnings;\n#[test] fn grouped_item_order_survives_save() {\n let mut w=Workbook::new(\"test\"); w.add_category(\"C\").unwrap();\n w.add_item_in_group(\"C\",\"first\",\"G\").unwrap(); w.add_item(\"C\",\"second\").unwrap();\n let r=parse_md(\u0026format_md(\u0026w)).unwrap();\n assert_eq!(r.model.category(\"C\").unwrap().ordered_item_names(),w.model.category(\"C\").unwrap().ordered_item_names());\n}\n```\nRun cargo test grouped_item_order_survives_save. This test failed against the unchanged source.\n\n## Acceptance Criteria\nThe focused test passes after correction, with a control covering the unchanged ordinary case. Preserve the behavior described above without silent data loss.\n","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:27:41Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:58:15Z","closed_at":"2026-09-21T21:58:15Z","close_reason":"Fixed in the 2026-09-21 wave, integrated into main at 1574 passed / 0 failed / 7 ignored, all gates green, every guard fault-injected.\n\nItems written in insertion order, a run of bare items sharing a line.\n\nNote the bead's own reproduction CANNOT discriminate: with one grouped and one ungrouped item, both the correct and incorrect writer emit one bare run followed by one grouped item. The landed test interleaves a, b, g1[G], c, g2[G] and asserts the emitted lines.","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -145,7 +164,7 @@ {"_type":"issue","id":"improvise-5ubs","title":"A cyclic-successor all() can silently omit a variant while its doc says it cannot","description":"Found by the adversarial review of PR #78, which probed it rather than reading the doc comment.\n\nAppMode::all() and Rejection::all() walk a total, cyclic after(). The claim in both doc comments is that a new variant cannot be omitted, because after() has no None arm so the only way to answer for a new variant is to NAME ANOTHER ONE.\n\nThat is half true and the half it gets wrong is the half that matters. ANSWERING after() FOR a new variant is forced by the compiler. POINTING AN EXISTING ARM AT IT IS NOT. A probe variant whose after() arm returns some existing variant, with nothing in the cycle pointing back to it, is simply never walked: all().len() came back 17, not 18, with the suite green.\n\nSo the consolidation replaced five grep-visible lists with one derivation whose totality is asserted in prose and checked by nothing. THE REVIEWER'S META-POINT IS THE REAL TICKET: five disagreeing AppMode lists were visible to grep; all() under-enumerating is visible to nothing. A consolidation owes a test that the surviving derivation is TOTAL, not merely that it agrees with what it replaced.\n\nWHAT IS STILL BETTER THAN BEFORE, so this is not a revert: the compiler now forces a human to look, and two of the five lists it replaced were ALREADY WRONG (both missing SavePrompt). This is a gap in a guarantee, not a regression.\n\nOPTIONS, roughly in order of cost:\n- A test that walks the cycle and asserts every variant is visited, driven by something independent of after() -- which needs an independent enumeration, and that is the problem restated.\n- A derive macro or a dependency (strum). Rejected during PR #78 only because adding a workspace dep mid-wave collides in Cargo.toml; on its own it is the honest answer and cheap.\n- Accept and document the residual precisely, replacing both doc comments, which currently overclaim.\n\nWHATEVER IS CHOSEN, FIX THE DOC COMMENTS FIRST: they presently say the omission is impossible, and a reader checking this property will believe them. An overclaiming comment is worse than none, because it stops the next person looking.","notes":"A FOURTH INSTANCE, inside the PR that found this ticket. The adversarial review of PR #78 added a fourteenth `pub struct` declaring `EffectKind::Model(ModelChange::LayoutGeometry)`, pointed at by nothing, and the whole suite stayed green.\n\nThe site is layout_geometry_effects_actually_change_document_config in ui/effect.rs. Its comment said \"a fourteenth declaring LayoutGeometry must come here and say so\". That is false for exactly the reason this ticket names: adding is compiler-forced only where an EXISTING arm must point at the new thing, and here nothing must. `assert_eq!(cases.len(), 13)` is a count, not a totality check -- it catches a case being deleted from the list, never one being absent from it.\n\nCorrected in #78 to say what it is rather than what it is not; the mechanism is unchanged and the cursor consolidation does not depend on it. Filed here rather than fixed because the honest fix is whatever 5ubs settles on, and a fourth hand-rolled attempt in a fourth place is the disease.\n\nWHAT MAKES THIS ONE WORTH RECORDING: it is the same defect as AppMode::all(), in the PR whose own consolidation work surfaced AppMode::all(). A list that is checked for LENGTH reads exactly like a list that is checked for COMPLETENESS, and both are written as an assert_eq over a number.\n","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T04:01:27Z","created_by":"claude-agent-protocol","updated_at":"2026-09-21T12:39:36Z","closed_at":"2026-09-21T12:39:36Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-349m","title":"format_md/parse_md is not idempotent when a .improv file has two Category sections with the same name","description":"format -\u003e parse -\u003e format differs, the two sides differing only in the order of '\u003e' group lines. DETERMINISTIC -- 8 failures in 8 runs on unmodified main (00f7678) -- so it is NOT gotcha 18's HashSet shape.\n\nCause: the file carries two '## Category: ||' sections with the SAME name. The second merges into the first and its group lines are appended in a position the second parse does not reproduce.\n\nReproducer, from the proptest's own minimal input:\n\n let file = \"v2025-04-09\\n# |\\\\a|\\n## View: a\\n_-: row\\ncollapsed: ||/A--\\n\\\n ## Category: ||\\n- |\\\\a\\\\a\\\\a|,|\\\\a\\\\a|,|\\\\a|,|\\\\a|\\n\u003e _---[|\\\\a|]\\n\u003e _[|\\\\a|]\\n\\\n ## Category: ||\\n- ||[||]\\n\u003e ||[||]\\n\";\n let m1 = parse_md(file).unwrap();\n let printed = format_md(\u0026m1);\n assert_eq!(printed, format_md(\u0026parse_md(\u0026printed).unwrap())); // fails\n\nWHY IT LOOKS FLAKY AND IS NOT: persistence::parse::grammar_prop_tests::generated_file_roundtrips goes red only when the grammar-walking generator happens to emit a duplicate section header, which is rare. It was observed once during PR #70's CI, the rerun passed, and the honest move was to take proptest's minimal input and try it deterministically rather than rerun and move on. That is what turned a 'flake' into a filed bug.\n\nFound by the improvise-pdvm/75ld agent on PR #70; not caused by that PR. bd search for idempotent, roundtrip, duplicate category, format_md and group order returned nothing, so it was not already filed.\n\nWorth deciding as part of the fix: is a duplicate '## Category:' header legal input at all? If it is not, the parser should refuse it and the generator should stop emitting it; if it is, the merge must produce a canonical group order. The round-trip property cannot hold without one of those answers.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T23:42:59Z","created_by":"orchestrator","updated_at":"2026-09-21T04:42:54Z","closed_at":"2026-09-21T04:42:54Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-v5pc","title":"restore the browser assertion that ? opens help","description":"36ade35 moved the shell suite to node and deleted harness/tests/widgets.spec.js, taking the Playwright test '? opens help, and its rows are the shared ones' with it. Nothing replaced it: no node test and no spec asserts that the help overlay is drawn, that its active tab is the shared module's page title, or that l pages it. The evidence entry in context/test-evidence/clients.md survived the deletion and went on promising the coverage for a whole release -- the improvise-5ray cost, now caught by --check-evidence, and the heading is demoted to prose rather than deleted so its two injections (dropping the paintHelp call; removing the mode != Help guard in help_rows) survive for whoever restores it. improvise-ktv1's acceptance -- a browser shows help -- is currently unasserted end to end.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T23:25:28Z","created_by":"claude-agent-scripts","updated_at":"2026-09-21T00:06:02Z","closed_at":"2026-09-21T00:06:02Z","close_reason":"Duplicate of improvise-78v6, already fixed and merged in PR #72. harness/tests/help.spec.js on main carries test(\"? opens help, and its rows carry the keys and the words, not empty frames\") plus a second test that reads an advertised key out of the DOM, presses it, and asserts the selection moved.\n\nWorth noting HOW this duplicate happened, because the filing agent did nothing wrong: it searched bd before filing, as AGENTS.md requires, and found nothing -- because the fix was in a sibling agent's UNMERGED PR at the time. A parallel wave makes in-flight work invisible to bd search. The cheap addition to the wave brief is to check open PRs, not only the tracker, before filing something a sibling unit might own.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-xfsb","title":"Agents should post to GitHub under their own identity, not the owner's","description":"Today every agent comment, review and PR goes out as @fiddlerwoaroof, because gh uses his credentials. The interim is a convention -- open each comment with a line saying a model wrote it (doctrine(an_agent_posting_to_github_says_so), AGENTS.md) -- and a convention is something an agent can forget. A separate identity makes attribution structural instead.\n\nTwo routes:\n\n1. GitHub App installed on the repo. Agents mint an installation token and export GH_TOKEN. Comments render as \u003cname\u003e[bot], the same shape as copilot-pull-request-reviewer[bot]. Scoped permissions, no seat consumed, revocable independently of the owner's account, and the bot suffix is not spoofable by a human account.\n\n2. Machine user with its own PAT as GH_TOKEN. Simpler to set up, consumes a seat on some plans, and is indistinguishable from a person at a glance -- which is most of what we are trying to fix.\n\nPrefer 1. Needs the user: create the App, grant contents+pull_requests+issues write, install it, and put the app id and private key where agents can reach them.\n\nWorth checking while doing it: whether the bot can be requested as a PR reviewer, which would let review agents post as reviews rather than as comments.\n\nOnce it lands, the attribution line becomes belt-and-braces rather than the mechanism, and the doctrine should be amended to say so rather than deleted -- a reader seeing an unattributed comment from the owner's account still needs to know it might be an agent.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T23:04:19Z","created_by":"orchestrator","updated_at":"2026-09-20T23:04:19Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-xfsb","title":"Agents should post to GitHub under their own identity, not the owner's","description":"Today every agent comment, review and PR goes out as @fiddlerwoaroof, because gh uses his credentials. The interim is a convention -- open each comment with a line saying a model wrote it (doctrine(an_agent_posting_to_github_says_so), AGENTS.md) -- and a convention is something an agent can forget. A separate identity makes attribution structural instead.\n\nTwo routes:\n\n1. GitHub App installed on the repo. Agents mint an installation token and export GH_TOKEN. Comments render as \u003cname\u003e[bot], the same shape as copilot-pull-request-reviewer[bot]. Scoped permissions, no seat consumed, revocable independently of the owner's account, and the bot suffix is not spoofable by a human account.\n\n2. Machine user with its own PAT as GH_TOKEN. Simpler to set up, consumes a seat on some plans, and is indistinguishable from a person at a glance -- which is most of what we are trying to fix.\n\nPrefer 1. Needs the user: create the App, grant contents+pull_requests+issues write, install it, and put the app id and private key where agents can reach them.\n\nWorth checking while doing it: whether the bot can be requested as a PR reviewer, which would let review agents post as reviews rather than as comments.\n\nOnce it lands, the attribution line becomes belt-and-braces rather than the mechanism, and the doctrine should be amended to say so rather than deleted -- a reader seeing an unattributed comment from the owner's account still needs to know it might be an agent.","notes":"DEFERRED by the user 2026-09-22. Recording the reasoning, because the status was\nset with none and the INTERIM IS LOAD-BEARING WHILE THIS SITS.\n\nWHY IT IS A FINE THING TO DEFER: route 1 needs the user to create a GitHub App,\ngrant it contents+pull_requests+issues write, install it, and put the app id and\nprivate key where agents can reach them. That is credential work only he can do,\nand nothing in the codebase is blocked on it.\n\nWHAT MUST STAY IN FORCE MEANWHILE, and this is the part that would be quietly\nlost: EVERY agent comment, review, PR body and issue still opens with a line\nnaming the model and that it is an agent posting in his session\n(doctrine(an_agent_posting_to_github_says_so), AGENTS.md). While this ticket is\ndeferred that convention is THE MECHANISM, not belt-and-braces -- an\nunattributed comment reads as the owner's own words to everyone who sees it.\nDeferring the structural fix therefore RAISES the cost of forgetting the\nconvention rather than lowering it.\n\nMeasured this session: the convention held across four merged PRs and every\nthread comment. It is not currently failing; it is simply the kind of thing that\nfails silently and only once someone notices.\n\nWHAT REOPENS IT:\n 1. The user creating the App -- the only real blocker.\n 2. Any instance of the convention actually being forgotten on a public\n artefact. One is enough: the failure mode is a human reading a model's\n words as his own, and it is not detectable after the fact by anything\n automated.\n 3. A second person gaining write access to the repo, which makes \"who said\n this\" ambiguous in a way the attribution line alone does not resolve.\n\nNOT A REASON TO REOPEN: more agents, or more PRs. Volume does not change the\nargument either way.\n\nNote the ticket's own closing paragraph still applies and should not be lost: when\nthis lands, the attribution doctrine is AMENDED rather than deleted, because a\nreader seeing an unattributed comment from the owner's account still needs to know\nit might be an agent.","status":"deferred","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T23:04:19Z","created_by":"orchestrator","updated_at":"2026-09-22T06:22:50Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-84wp","title":"Doctrine: a check that looks like the answer, answering a neighbouring question","description":"Ready-to-apply prose for `context/design-principles.md` §7. Filed rather than committed because PR #60 head `ec03f95` is byte-identical (tree `ec5d531...`) to a tree the reviewer fully tested, and a further commit would destroy that property and require a fresh harness run while `main` was moving several times an hour. The artefact is worth more preserved than rushed.\n\n---\n\n### A check that looks like the answer, answering a neighbouring question\n\nThe characteristic defect in this repo is not a wrong line; it is a check that is *adjacent* to the thing it names. Three instances landed in one campaign (PRs #59/#60), in three different layers, which is why it is worth stating as a shape rather than three gotchas:\n\n- **A vacuous assertion.** `set_panel_open_and_cursor` asserted `category_panel_open` was true after an effect set it true -- once the launch default supplied `true`, the effect could be replaced by a **no-op** and 1391 tests passed. It answered *is the flag true* where it meant *did the effect set it*.\n- **A vacuous repair of it.** The first fix folded `[open:false, open:true]` and asserted the final state -- which a reducer that folds nothing also satisfies. It answered *does the text mention the other direction* where it meant *does the writer move the flag both ways*. **Reading the diff is what failed**, and it failed because the vacuous version contained `open: false`.\n- **A clean auto-merge.** `git merge-tree` reporting no conflict answers *can git combine these texts*, where the question is *does the combination do what both sides meant*. Two PRs edited the same four files with no conflict and neither side CI-tested together.\n\nEach has the same remedy shape: **name the question you actually mean, then find an input that separates it from its neighbour.**\n\n- For an assertion: **assert the direction the default cannot supply.** Closing the panel discriminates; opening it proves nothing.\n- For a repair: inject a **one-directional** fault (`if open { set true }` -- can raise, never lower). A vacuous repair cannot see it, because a test that only asserts the `true` direction is satisfied by a writer that only ever writes `true`.\n- For a merge: **materialise it and run the gates**, and check that a count which should MOVE actually moved. Playwright going 16 -\u003e 17 is what showed the other side survived; \"no conflict\" and an unchanged total would both have been consistent with losing it.\n\nThis generalises the existing `doctrine(fold_tests_must_test_order_not_the_axiom)` (asserting `fold(prefix)+fold(rest) == fold(whole)` is the definition of a left fold, so it answers a neighbouring question too). What the campaign adds is that the pattern recurs in **test repairs** and in **merge hygiene**, not only in production code and not only in fold tests.\n\n---\n\nAcceptance: the section is in §7; `doctrine(fold_tests_must_test_order_not_the_axiom)` cross-references it or is subsumed by it. Worth considering a `kb.pl` doctrine clause, which is orchestrator-only.\n","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T21:38:23Z","created_by":"wave3-webui","updated_at":"2026-09-21T04:49:32Z","closed_at":"2026-09-21T04:49:32Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-qsmp","title":"App::cmd_context's panel-open propagator is untested: C cannot open a closed panel and nothing fails","description":"`App::cmd_context` (crates/improvise-command/src/ui/app.rs) propagates `self.view_state.category_panel_open` into the `CmdContext`. **Hardcode it to `true` and the entire workspace passes** — 1394 tests, nothing fails.\n\nThat matters because `registry.rs` reads it in three places for `toggle-panel-and-focus`s open-vs-close decision and the panel focus/cycle commands. With it stuck true, pressing `C` on a CLOSED category panel decides to *close* it rather than open it — the panel becomes unopenable by its own key — and no test notices.\n\n**Why it is invisible:** `cmd/mod.rs`s `test_helpers` sets `category_panel_open` explicitly on the context it builds, so no command test ever exercises the real propagator. The tests construct the value they then assert on.\n\n**This is PRE-EXISTING and was not caused by the launch-default ruling (PR #60).** That distinction was established by control rather than assumed: with the default inverted, this mutation fails *exactly the same two tests as inverting the default alone* and nothing more. So it was unguarded before the ruling too. The remedy is therefore \"a test at all\", not the precondition-stating fixture that fixed the two genuinely-disarmed tests (`set_panel_open_and_cursor`, `panel_commands_address_the_named_panel_only`).\n\n**The method that found it is worth more than the finding** and is written up in `context/test-evidence/clients.md` under \"How to know a launch default has stopped disarming things\": mutate every site that writes, propagates or reads the value; sites where nothing fails are suspects; then re-run each suspect with the default inverted to separate *vacuous* (fix the fixture) from *unguarded* (file it). Grep cannot do this — it finds sites that NAME the flag, not tests that merely start from a default-built state.\n\nAcceptance: a command-layer test that drives the real `App::cmd_context` rather than a hand-built context, such that `toggle-panel-and-focus` on a closed panel opens it; proved by hardcoding the propagator and watching it fail.\n\nSibling note: `improvise-wasm-client/src/render.rs` has the same propagator shape for `RenderModel`. Hardcoding THAT one true is improvise-wsaw reintroduced one layer up, and Rust sees nothing — only the Chromium harness catches it, at `contract.spec.js`s `toBeHidden`. Consequence worth keeping: a Rust-only run of the `test` CI job cannot see a reintroduced wsaw, which is the sharpest argument on file for the browser job being able to fail the build.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"claude-agent-cmd","owner":"el-github@elangley.org","created_at":"2026-09-20T21:23:53Z","created_by":"wave3-webui","updated_at":"2026-09-21T04:43:50Z","started_at":"2026-09-21T03:27:55Z","closed_at":"2026-09-21T04:43:50Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-5ray","title":"check-evidence ignores #### headings and every browser-test entry uses them","description":"Found by review of PR #65, 2026-09-20 (finding 4). Sibling of improvise-lyyc\nand improvise-r0qa, and the sharpest of the three because this one has already\nlet a false claim stand for a release.\n\nscripts/check-comment-runs.py --check-evidence collects headings with\n`s.startswith(\"### \")` and builds its `defined` set from rust_files(roots) only.\nTwo consequences compound:\n\n 1. A `####` heading is invisible to it — `\"### \"` does not match `\"#### \"`.\n 2. Even if it matched, `defined` holds only Rust `fn` names, so a JavaScript\n test name could never resolve.\n\ncontext/test-evidence/clients.md uses `#### \"quoted name\"` for every browser\ntest precisely BECAUSE of (2) — a `###` heading naming a Playwright test would\nbe reported as an orphan. So the convention that keeps the checker quiet is the\nsame convention that makes it blind.\n\nDEMONSTRATED: appending\n\n #### \"a test that does not exist anywhere\"\n\nto context/test-evidence/clients.md leaves `--check-evidence` exiting 0.\n\nWHAT IT COST: PR #61 deleted `a socket that opened and then dropped reads as\ndisconnected, and stays that way`. Its `####` heading stayed. The gate passed\nfor the whole release while the file promised a guard that was not there —\nexactly the \"dangling explanation\" design-principles §7 says the checker exists\nto prevent.\n\nFIX SHAPE: collect `###`/`####` alike, and extend `defined` with JS test names\n(`test(\"…\"` in harness/tests/*.spec.js and web/tests/*.test.js). Note the\nchecker's own --self-test must grow a case, or this fix is unguarded in exactly\nthe way the thing it fixes was.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"claude-agent-scripts","owner":"el-github@elangley.org","created_at":"2026-09-20T21:22:55Z","created_by":"wave3-jstests","updated_at":"2026-09-20T23:35:11Z","started_at":"2026-09-20T23:22:05Z","closed_at":"2026-09-20T23:35:11Z","close_reason":"fixed in PR #69, each proved by injection","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -176,6 +195,9 @@ {"_type":"issue","id":"improvise-kc7n","title":"improvise-ws-server never checks Origin, so any page a user visits can drive their session","description":"Found by Copilot on PR #21 round 5, against a reverse-proxy recipe whose IP allow-list had itself been added two rounds earlier to close a DIFFERENT hole. Verified independently by the orchestrator: `grep -rni origin crates/improvise-ws-server/src/` returns NOTHING. The server never inspects the Origin header and accepts every handshake.\n\nTHE HOLE: a WebSocket is EXEMPT FROM THE SAME-ORIGIN POLICY -- there is no CORS preflight on a handshake. So any hostile page that an allowed user visits can open a socket to the endpoint FROM THAT USER'S OWN ALLOWED ADDRESS, and read and edit every document the server holds. That is cross-site WebSocket hijacking.\n\nWHY NETWORK CONTROLS CANNOT SEE IT: an IP allow-list, a private network, loopback binding -- all answer \"may this CLIENT reach the endpoint\", and in this attack the answer is genuinely yes. The request really does come from an allowed client; it is simply being DRIVEN by a page the user did not intend to trust. Only Origin separates the two.\n\nLOOPBACK IS NOT EXEMPT, and this is the half a documentation-only fix cannot reach. In the local pairing case there is no proxy at all, so there is nothing in front to check Origin -- and loopback is not a boundary a browser enforces. While the server runs, ANY page open in that browser can reach ws://127.0.0.1:8787. So the local development path, which is the one the project actually recommends, is the least protected.\n\nDISTINCT FROM improvise-kpnr, and both are needed. kpnr is AUTHENTICATION: who is allowed to talk to this server at all. This is CSWSH: given that someone is allowed, what may put words in their mouth. An answer to either leaves the other open.\n\nTHE FIX IS CHEAP AND SERVER-SIDE: an allow-list of permitted origins checked at the handshake, defaulting to something safe for the local case (the page's own origin, and whatever --bind implies), with a flag to widen it. Refuse the upgrade rather than accepting and ignoring.\n\nTWO THINGS TO GET RIGHT:\n * A MISSING Origin header is not the same as a disallowed one. Non-browser clients (the repo's own tests, curl, a native client) send none, and the browser always does. Decide explicitly what absent means rather than letting the match arm decide by accident.\n * PR #21 has already documented Origin filtering in the PROXY as a required companion to the network control, phrased as \"in addition to, never instead of\". When this lands, the README should say the server does it too, and the proxy line becomes defence in depth rather than the only defence.\n\nTHE GENERAL LESSON, now doctrine(who_may_connect_is_two_questions): ask of any access control whether it constrains the CLIENT or what may DRIVE the client. Three consecutive rounds of that PR reasoned about the page's capabilities and none about the attacker's.\n\n## Steps to Reproduce\n\n1. Run `improvise-ws-server` on any document.\n2. From a browser that is allowed to reach it — the user's own machine, or any address an IP allow-list permits — visit an unrelated page that opens `new WebSocket(\"ws://127.0.0.1:8787\")`.\n3. The handshake succeeds. A WebSocket is exempt from the same-origin policy, so there is no CORS preflight and `grep -rni origin crates/improvise-ws-server/src/` returns nothing — the server never looks.\n4. That page can now read and edit every document the server holds, from the user's own allowed address.\n\nLoopback is not exempt and has no proxy in front of it, so the local development path is the least protected.\n","acceptance_criteria":"improvise-ws-server refuses a handshake whose Origin is not allow-listed, defaulting to something safe for the local case with a flag to widen it. A MISSING Origin is decided explicitly rather than by accident — non-browser clients (the repo's own tests, curl, a native client) send none and browsers always do. The README's proxy-side Origin filtering becomes defence in depth rather than the only defence.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T06:09:42Z","created_by":"Edward Langley","updated_at":"2026-09-21T04:33:43Z","closed_at":"2026-09-21T04:33:43Z","close_reason":"Closed","labels":["area:browser"],"dependencies":[{"issue_id":"improvise-kc7n","depends_on_id":"improvise-kpnr","type":"relates-to","created_at":"2026-09-20T06:09:49Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-mai9","title":"A status message written while a minibuffer is open is never drawn","description":"Found by WU-G while building improvise-lae (PR #27), and CONFIRMED THE HARD WAY in the same PR: Copilot then found a regression that this mechanism causes, which the agent had itself written down an hour earlier and not checked its own change against.\n\nTHE MECHANISM: src/draw.rs::draw_bottom_bar renders the minibuffer INSTEAD OF the status line. So a status_msg written while a minibuffer is open is never drawn at all -- not truncated, not overwritten, simply never rendered.\n\nWHO IS AFFECTED TODAY:\n * CategoryAdd / ItemAdd: a failed `commit-buffer category` is INVISIBLE. Worse, improvise-6t3 deliberately ordered that command's effects so the add effect is emitted LAST, specifically so its failure message would WIN over the command's optimistic status. It wins a race to a surface nobody can see.\n * A failed `:w \u003cpath\u003e` from CommandMode, which is the regression Copilot found on PR #27: the abort stops the batch before ReturnToNormalUnlessMoved and clear-buffer run, so the app stays in CommandMode with the stale command in the minibuffer and the new error hidden behind it.\n\nTHE WORKAROUND ALREADY SHIPPED, and it is a workaround: CommitSave emits [ChangeMode(Normal), SaveAs] so the error lands when the bottom row is the status bar again. That works for one command and does not generalise -- every other text-entry commit has the same hole.\n\nTWO CANDIDATE FIXES, and the choice is the ticket:\n * Draw both: give the minibuffer and the status line separate rows, or render the status into the minibuffer row when a message is pending. Cheap to describe, and it changes frame geometry, which is pinned by exact-frame characterization tests (see improvise-q0vb for what that costs).\n * Make it unrepresentable: a status message set while a minibuffer is open is a bug in the CALLER, so make the caller unable to do it -- e.g. commit paths always leave the mode before reporting. That is what CommitSave now does by hand, promoted to a rule.\n\nPREFER WHICHEVER MAKES THE FAILURE LOUD. The current state is the quiet kind: the code sets a message, the test asserts the message was set, and the user never sees it. A test asserting `app.status_msg == \"...\"` passes in every one of these cases, which is why this survived improvise-6t3's careful effect ordering.\n\n## Steps to Reproduce\n\n1. Enter a text-entry mode that opens a minibuffer — `CategoryAdd` (`N`) is the clearest.\n2. Commit something that fails, so the command sets a status message.\n3. Nothing appears: `src/draw.rs::draw_bottom_bar` renders the minibuffer INSTEAD OF the status line, so the message is never drawn at all — not truncated, not overwritten.\n4. A test asserting `app.status_msg == \"...\"` passes, which is why this survived improvise-6t3's careful effect ordering.\n\nSecond path, found on PR #27: a failed `:w /bad/path` aborts before `ReturnToNormalUnlessMoved`, leaving the app in `CommandMode` with the stale command drawn and the error hidden behind it.\n","acceptance_criteria":"A status message set while a minibuffer is open reaches the user. Either the two share the bottom row, or a commit path is structurally unable to report while its minibuffer is open (which is what CommitSave now does by hand, promoted to a rule). Whichever is chosen, the failure becomes LOUD: a test asserting app.status_msg was set must no longer pass while the user sees nothing.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-render-frame","owner":"el-github@elangley.org","created_at":"2026-09-20T06:04:20Z","created_by":"Edward Langley","updated_at":"2026-09-21T03:46:31Z","started_at":"2026-09-21T03:39:56Z","closed_at":"2026-09-21T03:46:31Z","close_reason":"Fixed on PR #77 (fix/render-frame-wave, head 8f3a976). Each guard proved by injecting the fault it names; GROUPED_FRAME and harness/tests/grouped.spec.js re-pinned deliberately.","labels":["area:tui"],"dependencies":[{"issue_id":"improvise-mai9","depends_on_id":"improvise-sihr","type":"relates-to","created_at":"2026-09-20T06:12:22Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-kpnr","title":"improvise-ws-server has no authentication, and the documented sharing path now exposes it","description":"Found by Copilot on PR #21 (improvise-o8fa, the reverse-proxy documentation), and it is a finding ABOUT the documentation that PR introduced rather than about code.\n\nimprovise-ws-server has NO AUTHENTICATION BY DESIGN. crates/improvise-ws-server/src/transport.rs says it outright: anyone who can open a socket to the port can rewrite every document it serves. The only control today is that it binds loopback.\n\nTHE README NOW TELLS USERS TO PUT A PUBLIC TLS PROXY IN FRONT OF IT (improvise-o8fa, per the user's ruling that the reverse proxy is the supported path to reaching a TUI-hosted document from an https origin). That instruction REMOVES THE ONLY CONTROL. TLS is confidentiality, not access control, and in a copy-pasteable recipe the two read alike.\n\nSo proxy-side auth is now LOAD-BEARING DOCUMENTATION rather than a server feature. PR #21 mitigated it -- the warning leads the section, the Caddyfile carries basic_auth marked not-optional, the --bind note now separates what loopback buys from what it does not, and the tunnel line says cloudflared/ngrok print public URLs and authenticate nothing -- but mitigation in prose is not the same as a property of the system.\n\nTHE DECISION TO MAKE, and it is a real fork rather than an oversight to correct:\n * The server grows a shared secret or token, checked at the websocket handshake. Small, and it makes the documented path safe by default rather than safe if followed.\n * Or \"auth is the proxy's job\" is the PERMANENT answer, stated in the server's own docs as a design position rather than left as an absence. Defensible -- it is what most single-binary dev servers do -- but then the README recipe is the security boundary and should say so in those words.\n\nDO NOT SPLIT THE DIFFERENCE. A token that is optional and off by default is the worst of both: it reads as a feature and defends nothing.\n\nNOTE THE GENERALISABLE PART, worth keeping whichever way this goes: DOCUMENTATION THAT INSTRUCTS AN ACTION CARRIES THAT ACTION'S CONSEQUENCES. The PR #21 section was scrupulous about one axis (build complexity, the cost of a second binary to a one-binary tool) and blind on another (exposure), and the --bind paragraph actively REASSURED the reader -- \"not reachable from anywhere else\", true of the port and false of the deployment. Neither the ticket, nor gotcha 48, nor the agent's own /code-review pass caught it; a reviewer reading the server's source did.\n\n## Rationale\n\nimprovise-ws-server has no authentication by design and `transport.rs` says so outright: anyone who can open a socket to the port can rewrite every document it serves. The only control was loopback binding — and improvise-o8fa's README now instructs users to put a public TLS proxy in front of it, which REMOVES exactly that control. TLS is confidentiality, not access control, and in a copy-pasteable recipe the two read alike.\n\nSo proxy-side auth is now load-bearing documentation rather than a property of the system. PR #21 mitigated it in prose; mitigation in prose is not the same as a mechanism.\n\n## Alternatives Considered\n\n**A shared secret or token checked at the handshake.** Small, and it makes the documented path safe by default rather than safe-if-followed. Note a URL query parameter is the one credential `new WebSocket(url)` can carry — the API sets no headers.\n\n**\"Auth is the proxy's job\" as a stated permanent position**, written into the server's own docs as a design decision rather than left as an absence. Defensible — it is what most single-binary dev servers do — but then the README recipe IS the security boundary and should say so in those words.\n\n**Rejected: a token that is optional and off by default.** The worst of both: it reads as a feature and defends nothing.\n","status":"open","priority":2,"issue_type":"decision","owner":"el-github@elangley.org","created_at":"2026-09-20T05:45:28Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:45:40Z","labels":["area:browser"],"dependencies":[{"issue_id":"improvise-kpnr","depends_on_id":"improvise-kc7n","type":"relates-to","created_at":"2026-09-20T06:09:49Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-rucf.3","title":"Projection freshness for a source an external writer can change","description":"Child of improvise-rucf (ADR 003, live-backed models). Filed and DEFERRED 2026-09-19 alongside its parent.\n\nADR 001 governs concurrent EDITS. A live-backed model is read-only, so it has none and the basis check does not apply to it -- the guarantee is not voided, it is scoped to documents improvise owns, which is what it always meant.\n\nWHAT REMAINS IS A DIFFERENT AND SMALLER QUESTION, and this bead is it: an EXTERNAL WRITER changes what clients SEE. So a live-backed projection needs a freshness story -- polled, triggered, or explicitly stale-until-refreshed. That is projection freshness, not conflict resolution, and conflating the two is the trap.\n\nNOTE WHAT CHANGES ABOUT THE PROJECTION ARCHITECTURE: today's diff (ui/project.rs::grid_updates) compares a desired state derived from the App against each client's mirror. With a live source, \"the desired state\" involves latency -- a HashMap lookup becomes a query -- so the VIEWPORT-BOUNDED PROJECTION STOPS BEING AN OPTIMISATION AND BECOMES LOAD-BEARING. improvise-i5tk (narrow the TUI's GridCache to its viewport, benchmark first) is the related bead and its answer may be forced by this one.\n\nSHARED WITH THE SQLITE SOURCE CHILD: the RecordHash -\u003e record index becomes a cache needing invalidation rather than a structure maintained alongside writes. One invalidation story, not two.\n\nEXPLICITLY OUT OF SCOPE: anything resembling a lease, a lock, or a reservation. Those are ADR 001 layer 2 and they are about edits.\n","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T05:31:33Z","created_by":"Edward Langley","updated_at":"2026-09-22T06:24:21Z","dependencies":[{"issue_id":"improvise-rucf.3","depends_on_id":"improvise-rucf","type":"parent-child","created_at":"2026-09-20T05:31:33Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-rucf.2","title":"SQLite source implementation: aggregate pushdown over a user-supplied query, native-only and feature-gated","description":"Child of improvise-rucf (ADR 003, live-backed models). Filed and DEFERRED 2026-09-19 alongside its parent.\n\nThe SQLite implementation of the source trait: aggregate pushdown against a user-supplied query.\n\nWHICH ROWS: a user-supplied SELECT, with a bare table name as sugar over it. A query is strictly more useful (joins, filters, computed columns) and no harder to consume -- a result set is a result set.\n\nTYPE MAPPING, and it is BETTER INPUT THAN CSV rather than merely different: a SQLite column carries a declared type, where import/analyzer.rs::detect_date_format currently guesses a date by pattern-matching text. INTEGER/REAL are measure candidates, TEXT a category candidate, BLOB skip. NULL is ABSENCE, not the string \"null\" (improvise-ych). Dates may be TEXT, INTEGER (epoch) or REAL (julian) -- DETECT, DO NOT ASSUME. SQLite is dynamically typed per value, so both the declared-type and the sampled path are real and the classifier should take typed input where it has it.\n\nWHERE THE BOUNDARY GOES: forbidden(improvise_core, io). The source TRAIT belongs in improvise-core; this implementation belongs in improvise-io, FEATURE-GATED AND NATIVE-ONLY. libsqlite3-sys does not build for wasm32-unknown-unknown under this repo's nix cc-wrapper, and improvise-io is in the wasm bundle, so the wasm CI job must keep passing untouched. VERIFY THE GATE, do not assume it -- the improvise-9ix agent established the wasm failure is the nix wrapper injecting -fzero-call-used-regs rather than proof that bundled SQLite cannot target wasm, so the gating protects the browser bundle rather than working around an impossibility.\n\nNO WRITES, and not as a runtime refusal: per ADR 003 writes are not in the trait a query source implements, and a source projecting no CellId::Record makes a client write unconstructible.\n\nINTERNING AND IDENTITY: DataStore interns names and ADR 002 hashes the coordinate tuple. Both still work -- coordinates come from the query -- but the RecordHash -\u003e record index that resolves a CellId becomes A CACHE WITH AN INVALIDATION STORY rather than a structure maintained alongside writes. That invalidation is shared with the freshness child; do not solve it twice.\n","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T05:31:32Z","created_by":"Edward Langley","updated_at":"2026-09-22T06:24:21Z","dependencies":[{"issue_id":"improvise-rucf.2","depends_on_id":"improvise-rucf","type":"parent-child","created_at":"2026-09-20T05:31:32Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-rucf.1","title":"The .improv source specification: a live-backed document is a saved query plus a pivot configuration","description":"Child of improvise-rucf (ADR 003, live-backed models). Filed and DEFERRED 2026-09-19 alongside its parent; the direction is accepted but not a priority.\n\nA live-backed document splits into OUR configuration and THEIR data, and .improv does not currently distinguish them. Today a file holds categories, views, formulas AND a ## Data section. A live-backed model's categories and cell values belong to the source; the views, formulas and axis assignments belong to improvise.\n\nSo the format grows a SOURCE SPECIFICATION where a live model would otherwise have data: a connection, a query, and the column-to-field mapping. Categories become DERIVED (SELECT DISTINCT) rather than declared.\n\nWHY THIS IS THE FIRST CHILD: persistence/improv.pest is the single source of truth (design-principles 3) and the parser is a tree-walker over it, so the grammar changes before the walker. The grammar-walking test generator reads the grammar AST via pest_meta, so a new production needs generator coverage or the round-trip proptests silently stop describing the format.\n\nTHE FRAMING THAT MAKES THIS FEEL RIGHT RATHER THAN LIKE A COMPLICATION, from the ADR: a live-backed .improv is A SAVED QUERY PLUS A PIVOT CONFIGURATION -- which is exactly what a saved report is in any BI tool. The document stops being \"my data\" and becomes \"my question\", portable between databases in a way a data file never is.\n\nOPEN: whether a document may be PARTLY live (some categories declared, some derived) or whether live and owned are exclusive per document. Exclusive is almost certainly right and much cheaper; say so deliberately rather than leaving it implied.\n","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T05:31:31Z","created_by":"Edward Langley","updated_at":"2026-09-22T06:24:20Z","dependencies":[{"issue_id":"improvise-rucf.1","depends_on_id":"improvise-rucf","type":"parent-child","created_at":"2026-09-20T05:31:31Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-p1ym","title":"Project the page-axis item lists so [ and ] work in a browser","description":"The one key pair an independent smoke test found inert that the shared keymap did NOT fix, and it is a genuine projection gap rather than a resolution gap.\n\nGridCache::page_coords carries (category, SELECTED ITEM). PageNext/PagePrev need THE ITEM LIST TO CYCLE THROUGH, which nothing projects. So [ and ] resolve to a command name the client holds but cannot compose an argument for, and they reach Host::handle empty vec -- doctrine(an_upstream_key_is_a_black_hole), so they do nothing at all.\n\nCONFIRMED BY SMOKE TEST 2026-09-20 against the live page: \"[ / ] did not cycle Quarter (never saw Q2 ~17,100)\". The demo model puts Quarter on the page axis specifically so paging is a thing a visitor would try.\n\nTHE AGENT WAS TOLD NOT TO FORCE IT and did not -- a truthful \"these four keys work, [ / ] needs X\" is worth more than five half-working keys.\n\nSCOPE: extend page_coords (or add a sibling) to carry the item list per page-axis category. Note gotcha 27 -- \"what data is visible\" is View::filtering_cats() (Page UNION Filter) while \"what the user can page\" is categories_on(Axis::Page). This needs the SECOND one; projecting the first would let a client try to cycle a Filter axis, which by design it must not.\n\nWhile you are there: check whether the item list should be the EFFECTIVE items (hidden items excluded) rather than all of them, since [ and ] in the TUI cycle what the user can actually select.","acceptance_criteria":"GridCache carries the item list per page-axis category, so PageNext/PagePrev compose client-side and [ / ] cycle Quarter on the live page. It projects categories_on(Axis::Page), NOT filtering_cats() — projecting the latter would let a client try to cycle a Filter axis, which by design it must not (gotcha 27). Decide and state whether the list is the EFFECTIVE items (hidden excluded), since that is what the TUI cycles. Verified against the demo, which puts Quarter on the page axis precisely because paging is a thing a visitor tries.","status":"closed","priority":2,"issue_type":"task","assignee":"wave2-b-projection","owner":"el-github@elangley.org","created_at":"2026-09-20T04:38:45Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:05:59Z","started_at":"2026-09-20T18:00:04Z","closed_at":"2026-09-20T18:05:59Z","close_reason":"Closed","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-o8fa","title":"improvise-ws-server speaks ws:// only, so the deployed page cannot reach it","description":"Found by opening the deployed page (2026-09-20). The guidance the page and the README carried was WRONG in a way that sends someone down a dead end.\n\nFROM AN https ORIGIN A ws:// ADDRESS IS BLOCKED AS MIXED CONTENT, so the deployed GitHub Pages site needs wss://. improvise-ws-server has NO TLS AT ALL -- no rustls, no native-tls, nothing in the crate or its dependencies -- so wss:// is not obtainable from it. The text said \"note that from an https origin a ws:// address is blocked\", which correctly named the symptom and then offered an address that can never work.\n\nTEXT CORRECTED ALREADY: both web/index.html and README.md now say the address must be wss:// from the deployed page, that the server speaks plain ws:// only, and that the ws:// form works when the page is served over http://localhost. This ticket is the missing CAPABILITY.\n\nTHREE WAYS TO PROVIDE IT, and they are not equivalent:\n * TERMINATE TLS IN FRONT (nginx, caddy, cloudflared, ngrok). Zero code, works today, and is what the corrected text points at. But it means the documented path to sharing a TUI-hosted document involves a second piece of software, which is a poor answer for a tool whose selling point is that it is one binary.\n * NATIVE wss:// IN improvise-ws-server -- rustls plus a cert. Real work, and it raises the certificate question: a self-signed cert makes the browser refuse the socket with no usable error, so this is only pleasant with a real cert or an explicitly trusted local CA (mkcert).\n * DECIDE THE DEPLOYED PAGE DOES NOT PAIR WITH A LOCAL SERVER at all, and say so. Defensible: the standalone worker deployment is the point of the Pages site, and ws:// pairing is a LOCAL development convenience that works fine from the local page. Cheapest, and it makes the docs shorter rather than longer.\n\nThe third is probably right for now and the first is the escape hatch; the second only earns its place if sharing a TUI-hosted document to a remote browser becomes a real use case. Do not implement the second by default.","status":"closed","priority":2,"issue_type":"feature","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T03:53:54Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:22:10Z","started_at":"2026-09-20T05:32:35Z","closed_at":"2026-09-20T06:22:10Z","close_reason":"Closed by PR #21 (WU-I). DOCUMENTATION ONLY, per the user's ruling; native wss:// DECLINED.\n\nTHE DECLINE'S SUBSTANCE IS THE CERTIFICATE, NOT rustls. A self-signed cert makes the browser refuse the socket with no usable error, because a WebSocket handshake has no interstitial -- there is no place to click through. A real cert or mkcert is a second piece of software too, so the choice is two-either-way, with one option additionally shipping a TLS stack and a cert-loading surface in a pivot-table binary.\n\nWHAT LANDED: a worked Caddy example, the nginx difference that bites (proxy_http_version 1.1 plus the Upgrade/Connection headers), the honest cost (a second binary, a poor answer for a one-binary tool), and the framing that the standalone worker deployment is the point of the Pages site while ws:// pairing works fine from a local http://localhost page.\n\nTHIS BEAD COST SEVEN COPILOT ROUNDS AND NINE FINDINGS, EVERY ONE IN PROSE. The sequence is the value and is recorded in kb.pl:\n * the first recipe exposed an unauthenticated world-writable server -- documenting a proxy documents an exposure, because TLS termination REMOVES the loopback binding that was the only control;\n * the \"fix\" put Caddy basic_auth in front of a socket the page opens as new WebSocket(url), an API that can set no headers over a handshake that shows no prompt -- BROKEN reads as protection, which is worse than absent;\n * an ngrok --cidr-allow recipe was written without being run (deprecated-but-accepted, so the reviewer's own \"fails at startup\" was also wrong);\n * \"the browser cannot send a credential on a WebSocket handshake\" over-generalised the governing fact and contradicted the Cloudflare Access bullet three paragraphs up, which works BECAUSE the handshake carries a cookie;\n * an IP allow-list cannot see a cross-site socket at all, which is improvise-kc7n;\n * an absolute loopback claim denied a browser mitigation that exists but is mid-rollout;\n * and \"the auth above\" called an allow-list authentication in a section whose opening sentence is that the server has none.\n\nThe governing fact was ALREADY IN THE SAME FILE at every step, as the basis of this very decline. The lesson the repo did not have: a reviewed paragraph is not evidence.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-955n","title":"[bug] Host::commands applies a batch partially before rejecting","description":"Found by Copilot on PR #13 (the retro review of the unreviewed batch), DECLINED there with reasons on the thread, and filed rather than patched.\n\nADR 001 is emphatic that a stale batch is never applied, NOT EVEN PARTIALLY -- and improvise-ondj went to the trouble of a TYPE (Checked) to make the unchecked path unconstructible. But Checked is PER-COMMAND: Checked::against answers for one Command against the App AS IT STANDS. A ClientFrame::Commands is a SEQUENCE of them, and Host::commands applies in order and abandons at the first rejection. So a frame carrying SwitchView followed by a stale Command::Model has already applied the SwitchView.\n\nSO ADR 001 GUARANTEE IS CURRENTLY TRUE OF A SINGLE COMMAND AND FALSE OF A BATCH.\n\nA SECOND-ORDER EFFECT NOBODY HAD WRITTEN DOWN, found while declining it: Worker::on_frame skips persist() when ANY frame in the answer is Rejected. So a batch that DID apply a SwitchView before being refused leaves Workbook::active_view moved in memory and NOT WRITTEN BACK -- the document on disk and the document in the worker disagree, silently.\n\nWHY IT WAS DECLINED RATHER THAN PATCHED: closing it means the basis check moving from the COMMAND to the BATCH -- every command answered before any is applied. That is an ADR 001 design change with a real question inside it: WHAT DOES A BATCH BASIS MEAN WHEN ITS LATER COMMANDS WERE COMPOSED AGAINST ITS EARLIER ONES EFFECTS? A client resolving one keypress produces SetSelected plus offsets computed from the selection it just set. Answer that before writing code.\n\nREACHABILITY IS BOUNDED TODAY: no client in this workspace composes a batch containing more than one basis-carrying Command::Model -- the batches improvise-bpq2 produces are view commands, which carry no basis. Do this before the second tranche of client-side resolution creates a reason to batch two model commands.\n\nSupersedes the earlier ticket filed from bpq2 if there is one -- dedupe.","status":"closed","priority":2,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T03:35:27Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:29:48Z","started_at":"2026-09-20T05:32:29Z","closed_at":"2026-09-20T07:29:48Z","close_reason":"Closed by PR #26 (WU-C). improvise-08wb is closed as a duplicate of this one.\n\nADR 001 said a stale batch is never applied, NOT EVEN PARTIALLY, and improvise-ondj went to the trouble of a type (Checked) to make the unchecked path unconstructible -- but Checked is PER-COMMAND and a ClientFrame::Commands is a sequence of them, so Host::commands applied in order and abandoned at the first rejection. The guarantee was true of a single command and false of a batch.\n\nCheckedBatch::against now answers for EVERY command before any is applied.\n\nTHE DESIGN QUESTION THE TICKET DEMANDED WAS ANSWERED BY THE USER, not by the code, and it is larger than the ticket imagined. The ticket asked \"what does a batch's basis mean when its later commands were composed against its earlier ones' effects\". The user's ruling: \"a sequence of commands sent to the server should assume that it's played in order from start to end and that each command sees the state after the previous command runs\", as \"a single transaction from a given base\". Four clauses -- one basis per frame; checked once before anything; applied in order with data dependence EXPECTED; all or nothing. Clauses 1-3 landed here; clause 4 (rollback) is improvise-yqpd.\n\nAN EARLIER \"at most one document mutation per frame\" RULE WAS SHIPPED AND THEN REJECTED BY THE USER as forbidding the capability. It was wrong twice over: it forbids data-dependent chains, and arity is not a property of a variant anyway -- a future Command::RunScript is one variant performing unboundedly many mutations.\n\nTHE FINDING THAT MATTERED MOST came from the independent adversarial review that substituted for Copilot (quota exhausted), and it was a REGRESSION THIS WORK INTRODUCED: hoisting the basis check to the frame hoisted ALL THREE TIERS. The document and layout tiers are legitimately frame-level facts; THE RECORD TIER IS NOT AND CANNOT BE, because which records are at stake is a property of the command. So every mutation after the first had its records checked against nothing -- a silent lost update, confirmed by executing a real Host: client A's stale [SetCell(North), SetCell(South)] clobbered client B's write to South with no rejection, where the base commit refused it. Fixed: every mutation answers for its own records against the FRAME'S basis, with an exemption stated over a property -- a record that neither resolves nor is claimed is one the frame is creating. The reviewer attacked that exemption at all four corners and could not break it: the predicate is a conjunction and the client controls only one conjunct.\n\nTHE SECOND-ORDER WORKER EFFECT the ticket named is closed twice over; its test fails only when BOTH old behaviours are restored.\n\nA CAPABILITY NOBODY HAD NAMED, now guarded: a frame may write THE SAME RECORD TWICE -- two measures on one record IS one record -- and that works only because every check happens before any apply. Moving the checks into the loop breaks it, which is what stops someone closing clause 4 by taking that trade unnoticed.","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -190,6 +212,7 @@ {"_type":"issue","id":"improvise-zv4m","title":"improvise-protocol should own the websocket framing, so both ends stop mirroring it","description":"Raised by improvise-cr3 (2026-09-19) as the thing to push back on, and filed rather than fixed there.\n\nTODAY: improvise-wasm-client/src/socket.rs declares ClientFrame/ServerFrame, which MIRROR improvise-ws-server/src/wire.rs ClientMsg/ServerMsg field for field. The client cannot depend on the server crate -- tokio, tungstenite, Box\u003cdyn Storage\u003e, all native -- so the duplication was the only way to speak the protocol from wasm.\n\nWHAT HOLDS THE LINE MEANWHILE: improvise-ws-server is a DEV-dependency of the client (the same argument the crate already makes for improvise-command), and the_socket_framing_matches_the_server round-trips every variant THROUGH THE SERVERS OWN TYPES in both directions; a_frame_named_the_other_envelopes_way_does_not_parse is its check-the-checker. So a drift is caught -- but only by a test whose job is to notice, rather than by a shared definition that cannot drift.\n\nTHE FIX: move the framing into improvise-protocol, which both ends already depend on and which is the crate whose charter is exactly this -- \"the wire vocabulary and the portable session slice\". Then the mirror, the dev-dependency and both compatibility tests all delete.\n\nNOTE THE THIRD ENVELOPE while you are in here: improvise-wasm-client/src/wire.rs has ClientMessage/ServerMessage framing the WORKER deployment (improvise-tm6). Three envelopes for two deployments is worth resolving in one pass -- decide whether the worker and the socket want the same vocabulary before moving either.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T00:37:29Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:37:50Z","closed_at":"2026-09-20T00:37:50Z","close_reason":"Duplicate of improvise-xks6 ('One home for the client/server frame envelopes'), filed minutes apart from the improvise-cr3 hand-back without spotting the existing ticket. Closing this one; xks6 is the survivor and its description should absorb two facts this one carried:\n\n * WHAT HOLDS THE LINE MEANWHILE: improvise-ws-server is a DEV-dependency of improvise-wasm-client, and the_socket_framing_matches_the_server round-trips every variant THROUGH THE SERVERS OWN TYPES in both directions, with a_frame_named_the_other_envelopes_way_does_not_parse as its check-the-checker. The drift is caught by a test whose job is to notice, rather than prevented by a shared definition.\n * THERE ARE THREE ENVELOPES, NOT TWO: improvise-wasm-client/src/wire.rs frames the WORKER deployment (improvise-tm6) separately again. Decide whether the worker and the socket want the same vocabulary before moving either, or the move produces two homes instead of one.\n\n(See also memory agent-issue-drift-pattern -- search before filing.)","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-8xhy","title":"[bug] A fit over a categorical feature emits an unevaluable formula","description":"Found during improvise-7na (2026-09-19), verified by probe, and pinned as a characterization test rather than fixed.\n\ngp_tree::data::preprocess one-hots a categorical column into a feature named Region=North. That is an ENCODING ARTEFACT, not an item of the document, so the emitted formula Expr::Ref fails Model::find_item_category and EVERY CELL of the fitted measure evaluates to Error(\"ref:Region=North\"). col.missing and the col.year / col.month date sub-features are in exactly the same position.\n\nSo \"fit a tree and get a working formula\" holds ONLY for a fit over numeric measures.\n\nTHE TEST THAT SHOULD HAVE CAUGHT IT WAS LYING: improvise_evaluation_matches_gp_tree_eval cannot see this, because its improvise_eval helper FABRICATES a _Measure item for every feature name, including outlook=rainy. It asserts the emitter and gp_tree::eval agree, which they do -- against a model that could not exist. Worth remembering as a shape: a helper that builds the world the code wants is not a test of whether that world exists.\n\nPinned as a_split_on_a_one_hot_feature_does_not_evaluate -- characterization, flip it when fixed.\n\nTHREE REMEDIES, pick deliberately: restrict fits to numeric features (smallest, and honest about what works); materialise one-hot columns as real _Measure items (makes the formula evaluable but pollutes the document with encoding artefacts); or teach the emitter improvise WHERE vocabulary so a categorical split becomes a real predicate over the category (largest, and the only one that produces a formula a human would have written). The third is probably right and should be costed before the first is shipped as permanent.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T00:35:05Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:45:21Z","closed_at":"2026-09-20T00:45:21Z","close_reason":"Fixed as REMEDY 1, explicitly labelled interim. fitted_formula now takes the \u0026Model the formula is to be stored in and refuses a tree whose splits it cannot resolve, listing them. formula_text is untouched -- it is the tree's own text and there is nothing else it could write. Nothing outside the crate called fitted_formula.\n\nWHY THE EMITTER AND NOT THE DATASET LAYER: dropping categorical columns before the fit is smaller, but it throws away real signal (permutation importance, and any future remedy-3 emitter) and makes the crate's halves disagree -- one-hot encoding is gp-tree's whole job. The defect is that the EMITTER spells a formula the document cannot evaluate.\n\nWHY THE CHECK ASKS THE DOCUMENT, NOT THE NAME'S SHAPE: refusing names containing '=' or matching .missing would be a syntactic proxy and a wrong one, because a measure LITERALLY NAMED Region=North is a legal document and must be accepted. The two are indistinguishable as strings; only the document can tell them apart. So it calls Model::find_item_category -- the exact precondition Expr::Ref has -- which covers .missing, the date parts, and any future sub-feature naming scheme with no second copy of preprocess's expansion rules to drift from. Both directions pinned.\n\nTHE LYING TEST IS KEPT, NOT DELETED. Quoting and threshold routing are real things improvise_evaluation_matches_gp_tree_eval checks, and outlook=rainy earns its place there as an awkward NAME TO SPELL. What changed is that it and its helper now state their scope -- a test of arithmetic and quoting against a STIPULATED model, unable to see whether such a model could exist. The property it could not state is asserted separately against a real document: a_formula_the_emitter_accepted_evaluates_everywhere -- if fitted_formula says Ok, no cell of the target is a ref: error. That test and the refusal test are each other's check; without the accepting case the refusal could pass by refusing everything. The characterization is flipped to a_split_on_a_one_hot_feature_is_refused.\n\nREMEDY 3 IS STILL RIGHT AND IS NOT THIS TICKET -- successor filed. It is much larger than it looked: improvise has NO SCALAR COORDINATE PREDICATE (Filter gates a formula or an aggregate, it is not an Expr), it would still leave col.missing and the date parts uncovered, and it must first answer what such a predicate means where context.get(cat) is None.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-shyt","title":"[bug] Records-mode row order is not stable across processes","description":"Found during improvise-4s8 (2026-09-19). A real latent bug in improvise-core, out of that ticket scope, worked around there by sorting.\n\nDataStore::matching_cells returns cells in HashSet iteration order, so RecordRow::group -- and therefore RECORDS-MODE ROW ORDER IN THE LIVE UI -- is not stable across processes. The same document opened twice can list its records in different orders.\n\nWHY IT MATTERS BEYOND TIDINESS:\n * gotcha 18 says IndexMap insertion order is load-bearing for persistence, and DataStore::cells is IndexMap precisely so \"records mode can display rows in the order they were entered\" (its own doc comment). The SECONDARY INDEX walk defeats that.\n * f32 accumulation is order-dependent, so anything summing over matching_cells is reproducible only within one process. improvise-4s8 had to sort before building a feature matrix or a SEEDED FIT would not reproduce across runs -- see the known-answer vectors in gp-tree, which exist because that class of drift is silent.\n * A user watching rows reorder between sessions has no way to tell whether the document changed.\n\nFIX: make matching_cells (or its callers that care) yield in the primary IndexMap order rather than the index HashSet order. Note the index exists for O(1) LOOKUP, not for iteration, so the fix is probably to intersect the index result back against the insertion-ordered primary rather than to change the index.\n\nTEST IT THE WAY THE INSTABILITY ACTUALLY SHOWS: a single-process test cannot distinguish \"sorted\" from \"hashed that way today\". Build the same document twice with different insertion histories that produce the same set, or assert directly against the IndexMap order.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T00:35:04Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:45:20Z","closed_at":"2026-09-20T00:45:20Z","close_reason":"Fixed. A private DataStore::matching_positions replaces the duplicated prelude in matching_values and matching_cells: the secondary index still NARROWS the candidates -- that is what it is for -- but each survivor is resolved to its position in the primary IndexMap with get_index_of, positions are sorted, results read back with get_index. The index does lookup, the primary map does ordering.\n\nTHE BLAST RADIUS WAS LARGER THAN FILED -- five callers, and the one nobody knew about is worse than the headline:\n * view/layout.rs::group_records -- records-mode row order in the live UI (the filed case).\n * command/cmd/grid.rs::DrillIntoCell -- THE DRILL SNAPSHOT froze its record list in hash order while drill_state.pending_edits are keyed by ROW INDEX (gotcha 1). So the same drill opened in a second process keyed staged edits to DIFFERENT RECORDS: silent misdirected writes, not a cosmetic reorder. Never filed; inherits the fix.\n * eval_agg, evaluate and aggregate_raw fold f64 over the result, and f64 addition is not associative, so a formula's value could differ in the last bits between two openings of the same document. Min/Max/Count are order-free, and so is the text-unanimity rule (gotcha 7).\n * improvise-gp dataset.rs::visible_records was already sorting as a workaround (improvise-4s8). The sort is KEPT -- removing it would change the fit's row order and gp-tree's known-answer vectors are bit-level -- but its comments claimed the sort is what buys stability, which this fix makes false, so they now say it is a choice of CANONICAL order: a fit's rows should be a property of the data, not of the typing history.\n\nMEASURED, release, 18,000 cells, 2000 iterations: worst case +20% on matching_values (a sort plus one hash lookup per candidate); matching_cells is 7% FASTER, because sorted positions resolve CellKeys with better locality. Nothing became O(store) -- the alternatives (an ordered index value type, or scanning the primary) would have moved the cost onto every set or onto every query.\n\nTHE TEST SHAPE IS THE POINT: a single-process test cannot tell 'sorted' from 'hashed that way today on this machine'. So the order is compared against iter_cells -- the primary map's own order -- rather than a literal, and the_same_cells_entered_in_opposite_orders_list_in_opposite_orders builds the same logical store by two different histories and requires each to list its own.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-rucf","title":"Read arbitrary SQLite databases as a data source","description":"SUPERSEDED BY context/adr/003-live-backed-models.md, and rewritten 2026-09-19 to match it. The original text framed this as an open choice between import-once and live-backed, and said \"READ-ONLY for now\" as though that were a scoping convenience. ADR 003 decided both, and in the opposite direction from what this ticket assumed.\n\nWHAT THE ADR DECIDED, and this ticket now means:\n\n * LIVE-BACKED, not import-once. Import-once was explicitly REJECTED as the only answer -- it makes the model a snapshot, so source changes are invisible until re-import and the data must fit in memory. The model is a view over the source and queries go to it as the user pivots.\n * READ-ONLY IS A FEATURE, not a limitation accepted reluctantly. Pointing a pivot tool at a production database and being unable to write to it is the point. No write-through, no overlay, no reconciliation.\n * THEREFORE: Model becomes a FACADE OVER A DATA SOURCE, of which today's in-RAM DataStore is one implementation and a SQL query engine is another. That is a change to what Model IS, not to where its bytes come from.\n\nSTATUS: NOT A PRIORITY, per the ADR's own header. Deferred 2026-09-19 during the whole-backlog campaign, by user ruling, together with improvise-qddo and improvise-mjy7. Nothing here outranks the browser work. It is written down so the option stays open, because several decisions made recently could foreclose it by accident.\n\nTHE THREE THINGS THAT MAKE IT TRACTABLE, from the ADR, which should shape the design rather than be discovered late:\n 1. Aggregation is ALREADY the interface and it is ALREADY SQL. apply_agg computes Sum/Avg/Min/Max/Count over a matching cell set; those are exactly SQL's aggregates. GridLayout asks for a VALUE, not for the rows behind it. The pushdown is not a translation layer to be invented.\n 2. Formula results are ALREADY separate from stored data. Evaluation is fixed-point and derived values live in Model::formula_cache, not in DataStore. Raw data is queried; derived data stays in RAM. Formulas do not become SQL.\n 3. Clients are ALREADY insulated. A client sees CellDisplays and never learns where a value came from, so live-backing and the browser deployment compose rather than compete. A browser never needs SQLite at all.\n\nTHE CONSEQUENCE NOBODY ASKED FOR, and the reason this is three tickets rather than one: a live-backed document splits into OUR configuration and THEIR data, and .improv does not distinguish them. The format grows a SOURCE SPECIFICATION where a live model would otherwise have a ## Data section, and categories become derived (SELECT DISTINCT) rather than declared. improv.pest is the single source of truth, so the grammar comes first.\n\nCHILDREN (all filed and deferred alongside this one):\n * the .improv source specification and the grammar change\n * the SQLite source implementation, native-only and feature-gated\n * projection freshness for a source an external writer can change\n\nWHAT ADR 001 DOES AND DOES NOT OWE THIS: a read-only model has no concurrent EDITS, so the basis check simply does not apply -- the guarantee is scoped to documents improvise owns, which is what it always meant. What remains is a different and smaller question, which is the freshness child.\n\nTHE MACHINERY TO EXPRESS READ-ONLY ALREADY EXISTS, built for something else: ADR 002 makes SetCell take a CellId::Record and nothing else, so a source that projects no Record ids makes a write UNCONSTRUCTIBLE by the client -- no runtime check, no error path, no read-only dialog. One design consequence for the source trait follows: writes must NOT be in the trait a query source implements, or a read-only source has to fail at runtime for a method it structurally cannot serve.\n\nTHE DEPENDENCY CONSTRAINT THAT CARRIES OVER FROM improvise-9ix: improvise-io is in the wasm bundle (the wasm job in .github/workflows/ci.yml gates it) and libsqlite3-sys does not build for wasm32-unknown-unknown under this repo's nix cc-wrapper. Native-only and feature-gated, with the wasm job passing untouched. Verify the gate rather than assuming it.\n\nWHAT THIS IS NOT: improvise-9ix, which proposed SQLite as improvise's OWN PERSISTENCE FORMAT and was declined on measurements (22% larger than .improv, 4.7x gzipped, a second implementation of the pest grammar). Reading someone else's database has no round-trip obligation and no grammar duplication.\n","notes":"\n--- SUPERSEDED IN FRAMING by ADR 003 (user, 2026-09-19) ---\nThe user chose LIVE-BACKED over import-once, and the goal is stated as: have\nTHE OPTION of essentially using improvise as a DB CLIENT. NOT A PRIORITY --\nnothing here outranks improvise-1ey. Re-prioritised P2 -\u003e P3.\n\nSo this ticket read-only import framing is not the target shape. See\ncontext/adr/003-live-backed-models.md. The import-once seam it described\n(csv_parser -\u003e Vec\u003cValue\u003e -\u003e analyze_records) is still accurate and still\nuseful, but as ONE implementation of a source rather than as the answer.\n\nWHAT SURVIVES UNCHANGED and is worth keeping from this ticket: the type-mapping\nnotes (SQLite columns carry declared types, which is BETTER input than CSV\nstring sampling -- detect_date_format currently guesses a date by pattern-\nmatching text; dates may be TEXT, INTEGER epoch or REAL julian, so detect\nrather than assume; NULL is ABSENCE not the string \"null\", per improvise-ych),\nand the wasm constraint (improvise-io is in the bundle, libsqlite3-sys does not\nbuild for wasm32 under this repo nix cc-wrapper, so any SQLite dependency is\nfeature-gated and native-only).","status":"open","priority":2,"issue_type":"epic","owner":"el-github@elangley.org","created_at":"2026-09-20T00:14:11Z","created_by":"Edward Langley","updated_at":"2026-09-22T06:24:20Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-l4qx","title":"[bug] A text cell is invisible whenever any category is on the None axis","description":"Found during improvise-q08 (2026-09-19), PRE-EXISTING, and it is the NORMAL case rather than an edge one.\n\nModel::evaluate_aggregated takes the aggregating path when none_cats is non-empty, and that path does filter_map(as_f64) -- so a CellValue::Text aggregates to NOTHING and never becomes a CellDisplay. _Index and _Dim sit on Axis::None in a default view, so none_cats is non-empty almost always.\n\nWHY IT MATTERS: .improv has pipe-quoted text cells as a FIRST-CLASS FEATURE -- the format doc gives Region=West, Measure=Revenue = |pending| as an example -- and they do not render. A user can write a text value, save it, load it and not see it.\n\nThe q08 agent hit this while building a fixture, worked around it there, and noted it in the round-trip test comment. Costing a test iteration is how it surfaced; nothing in the suite covers it.\n\nWrite the failing test first: a text cell in a default view (so _Index/_Dim are on None) must render its text. Then decide what aggregation of text should MEAN -- filter_map(as_f64) silently dropping it is clearly wrong, but \"show the single value when there is exactly one, and something explicit when there are several\" is a product decision, not an obvious fix. Note gotcha 7: apply_agg and eval_agg already differ on empty input, so this family has precedent for being decided rather than defaulted.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T23:46:09Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:24:05Z","closed_at":"2026-09-20T00:24:05Z","close_reason":"FIXED. aggregate_cell_values in model/types.rs, shared by evaluate_aggregated and aggregate_raw (which had byte-identical bodies precisely so they could not drift -- improvise-kdp).\n\nTHE RULE IS UNANIMITY, and the ORDER is load-bearing:\n empty -\u003e None (absent, unchanged -- follows apply_agg)\n ALL NUMERIC -\u003e apply_agg -\u003e Number (unchanged; checked FIRST)\n all values equal -\u003e that value\n otherwise -\u003e Error(\"mixed\")\n\nReducing several copies of one value is the identity, so that reduction has an answer even when the values are not numbers -- which covers the case the ticket is actually about. Two different words, or a word among numbers, have no reduction, and gotcha 7 already has precedent for saying so out loud (eval_agg returns Err(\"empty\") for AVG/MIN/MAX over nothing rather than inventing a value). So this is consistent with BOTH existing rules: emptiness follows apply_agg (absent, not zero), undefined-reduction follows eval_agg (explicit error, not silence).\n\nTHE ORDERING IS THE WHOLE DIFFERENCE from the ticket own first suggestion. \"Show the single value when exactly one matches\" taken literally BREAKS Count, whose entire point is to not be the value. The all-numeric branch is tested first, so Count of a lone number is still 1. Proven by injection: swapping the branch order failed exactly one test, a_lone_number_still_counts_as_one_rather_than_passing_through, which is why that guard exists.\n\nREJECTED: \"show the first\" (depends on DataStore hash iteration order, so the displayed value would be arbitrary AND look authoritative while hiding the others -- worse than the blank); \"show a count\" (changes the type of the answer; a count rendered under a Sum measure is indistinguishable from real data); and making the display path differ from the formula path.\n\nBEHAVIOUR CHANGE TO KNOW: a formula ref over MIXED values now errors \"mixed\" instead of \"ref:name\". Still an error, but a truer one, and it no longer silently sums the numbers while dropping the text. Text-valued refs still error exactly as before. Fixed-point convergence untouched.\n\nFailing test first: a_text_cell_renders_in_a_default_view, which also asserts !none_cats.is_empty() to confirm the premise that a DEFAULT view really does put _Index/_Dim on None -- the normal path, not an edge one. 1024 tests pass; three faults injected, all fired.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-vjms","title":"Decide whether a category panel shows the virtual categories","description":"Found by improvise-35e (2026-09-19). A GENUINE BEHAVIOUR DIVERGENCE, which is why that ticket stopped rather than migrating the widget.\n\nThe TUI category panel builds from cat_tree::build_cat_tree, which walks Model::category_names() and therefore draws _Index, _Dim and _Measure. CategoryRow/category_rows carry only regular_category_names(), and CategoryTreeCache own doc says \"Only the categories a user may see -- the client does not know the _-prefixed virtual-category convention\".\n\nSo migrating the category panel to the cache would SILENTLY DROP THREE ROWS from the TUI panel. That is a behaviour change, not a relocation, so doctrine(characterization_survives_the_move) says stop and ask.\n\nTHE DECISION IS improvise-edp: either the browser panel grows the virtual categories, or the TUI panel loses them. Note gotcha 7 -- _Measure items are explicit items UNION formula targets, so a client showing _Measure needs measure_item_names semantics, not a raw item list; that is part of the cost of the first option.\n\nWhichever way it goes, the widget then also needs a tree flattener over CategoryTreeCache -- today build_cat_tree takes \u0026Model.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T23:40:37Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:06:54Z","closed_at":"2026-09-20T00:06:54Z","close_reason":"DECIDED (user, 2026-09-19): the TUI keeps the virtual categories AND the browser gets them too. CategoryTreeCache grows to carry _Index, _Dim and _Measure, so both panels render the same thing and the cache stays the single source for what a category panel shows.\n\nWHAT THIS COSTS, and it is the part to get right: the client now has to be told about the _-prefixed convention, which the caches doc currently says it does not know. In particular _MEASURE ITEMS ARE DERIVED, not a raw list -- gotcha 7: they are the explicit items UNION the formula targets, and add_formula does NOT add items to _Measure. So category_tree_of must ship membership computed through measure_item_names()/effective_item_names(), never category(\"_Measure\").items, or the panel will silently omit every formula target. That is the same trap improvise-ete was closed for proposing.\n\nConsequences to carry into improvise-edp:\n * CategoryTreeCache and its doc comment change -- the \"the client does not know the _-prefixed virtual-category convention\" line is now false and must go.\n * build_cat_tree can then migrate off \u0026Model, which is what unblocks the category panel and therefore edp.\n * A characterization test should pin that the cache carries all seven rows for a default workbook (4 regular + 3 virtual) and that _Measure includes a formula target that is not an explicit item -- one fixture that would fail under the raw-list implementation.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-pauh","title":"Make the beads flake pin actually bind in agent worktrees","description":"Two mitigations, BOTH wanted (user, 2026-09-19). Manual direnv allow is not sufficient on its own -- see the scaling problem below.\n\nTHE PROBLEM. flake.nix pins beads to github:gastownhall/beads/v1.3.0 precisely to control bd version. A git worktree does NOT load that pin: .envrc is unallowed in a fresh worktree, so a process there falls through to ~/.nix-profile/bin/bd. Verified 2026-09-19 with a scrubbed DIRENV env: \"Found RC allowed 0\". Today the profile bd is ALSO 1.3.0, so nothing breaks -- which is exactly why a careless test passes and why this is worth fixing before it bites.\n\nTHE FAILURE MODE IS SILENT. See the bd-1-0-2-dev-embedded-dolt-dependencies memory: bd VERSION SKEW SILENTLY EMPTIES THE DEPENDENCY GRAPH in .beads/issues.jsonl. No error; the loss is invisible until someone asks what blocks what. The flake pin exists to prevent this and an unallowed worktree bypasses it.\n\nWHY MANUAL ALLOW DOES NOT SUFFICE: agent worktrees are created FRESH PER DISPATCH. Allowing the ones that exist right now does not help the next one, so any manual pass is stale the moment another agent starts. Confirmed today -- allow entries were created at 16:35 and a worktree from earlier still reported allowed 0.\n\nMITIGATION 1 -- make the allow automatic. direnv allow (or an equivalent: a whitelist.prefix entry in ~/.config/direnv/direnv.toml covering the worktree root, which would cover every future worktree in one stroke and is probably the better answer) at worktree creation. Note whitelist.prefix is currently [] per direnv status.\n\nMITIGATION 2 -- assert the version rather than trust it. Have .claude/hooks/session-start.sh compare bd version against the flake pin and FAIL LOUDLY on a mismatch. doctrine(force_the_failure) applies: the whole point is that the skew is silent, so the check must be the thing that is not. Read the pin from flake.nix rather than hard-coding it, or the assertion rots the first time the pin moves.\n\nReading with a mismatched bd is safe; it is MUTATING commands that corrupt. If a cheap distinction is available, guard those specifically.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T23:39:38Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:30:40Z","closed_at":"2026-09-20T00:30:40Z","close_reason":"Half 2 landed: section 0 of .claude/hooks/session-start.sh asserts bd matches the flake pin, reading the pinned version OUT OF flake.nix so there is no second copy to rot on the next bump. Fails loudly (exit 2 + banner) on mismatch, on an unparseable pin, AND on an unreadable bd version -- an assertion that cannot answer is not a pass. Placed BEFORE the CLAUDE_CODE_REMOTE gate, because the local worktree is where the problem lives; the cloud path re-checks after installing bd and still completes tracker sync and cargo warm. It blocks nothing -- reads are safe, and the banner names the mutating commands specifically.\n\nDemonstrated both ways rather than asserted: a stub reporting 1.2.4 fires the banner; a stub reporting 1.3.0 is silent; an unparseable version fires; a flake.nix pinning a bare commit fires the cannot-read-the-pin arm. With the real binaries BOTH of today candidates pass (the flake store path and ~/.nix-profile), which is exactly the ticket point -- today pass proves nothing.\n\nHALF 1 IS THE USER TO MAKE, and is reported rather than done: create ~/.config/direnv/direnv.toml (it does not exist today) with [whitelist] prefix = [\"\u003crepo root\u003e\"]. One prefix covers the main checkout and every future worktree since worktrees live under .claude/worktrees/ inside it. Verified without touching the user file by pointing DIRENV_CONFIG at a scratch copy: same worktree went from not-allowed to allowed. There is NO repo-side alternative by design -- direnv refuses to let a repository authorize its own .envrc, and that refusal IS the security model; a .claude/settings.json env block prepending the flake store path was considered and rejected because it hard-codes a machine-specific nix hash that rots on every bump and is less trustworthy than the pin it claims to enforce.\n\nCORRECTION THIS TICKET SURFACED: the evidence in its original description read \"Found RC allowed 0\" as the broken state. It is the opposite -- direnv prints the AllowStatus enum where 0=Allowed, 1=NotAllowed. The conclusion still holds, on better evidence: this agent worktree (created after the users allow batch) reports allowed 1 and its allow file does not exist on disk.","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -290,6 +313,23 @@ {"_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-q36k","title":"jq is not in flake.nix, so a clean nix develop cannot run the pr-reviews suite","description":"Found by the improvise-f9qf agent while making scripts/checkers.sh run every\ncheck_the_checker by default.\n\nscripts/pr-reviews needs jq, and JQ IS NOT IN flake.nix. It resolves only via the\ndeveloper's own ~/.nix-profile, so A CLEAN `nix develop` WOULD FAIL\n`checkers.sh pr-reviews`. Nobody has hit it because every machine that has run it\nso far happened to have jq installed independently.\n\nThis is the same class as the bug improvise-f9qf just closed: an environment\ndifference that is invisible on the machine that has the thing, and fatal on the\none that does not. checkers.sh now scrubs the environment to an allowlist, which\nmakes the dev shell closer to CI -- and makes THIS gap reachable, because the\nallowlist keeps PATH but the flake does not put jq on it.\n\nTwo smaller siblings worth checking in the same pass, since they are the same\nquestion:\n - kb-test.sh fails SIXTEEN CASES CONFUSINGLY when tpl (trealla) is absent,\n rather than saying \"no trealla\". Pre-existing, and newly VISIBLE because\n checkers.sh runs everything by default rather than the suite you named.\n - What else does a scripts/ suite shell out to that the flake does not provide?\n The answer is a grep, and the fix is either a flake input or an explicit\n \"this suite needs X\" refusal with a distinct exit code -- checkers.sh already\n uses exit 3 for \"no usable bash\", so the precedent exists.\n\nPREFER THE EXPLICIT REFUSAL over silently skipping: a suite that quietly does not\nrun is how a green gate stops meaning anything, which is the failure improvise-f9qf\nwas filed about.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T23:16:24Z","created_by":"Edward Langley","updated_at":"2026-09-22T23:16:24Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-8mgz","title":"Row headers scroll out of the grid: not sticky, and the offsets cannot be computed in CSS","description":"Noticed by the improvise-tg27 agent while probing narrow widths, and correctly\nleft out of scope there.\n\n#grid is horizontally scrolled by scrollIntoView({inline:\"nearest\"}), and the\nrow-header columns are NOT sticky -- so at a narrow width the coordinate labels\nscroll off the screen and the user is looking at numbers with nothing naming the\nrows they belong to.\n\nPRE-EXISTING; improvise-tg27 did not introduce it and did not widen it. That fix\nstopped the PAGE scrolling horizontally (the app bar's reserves were the cause);\nthis is the GRID's own horizontal scroll, which is legitimate and remains.\n\nWHY IT IS NOT A CSS ONE-LINER: position:sticky on the row-header cells is the\nobvious move, and the grid's header structure is multi-level -- AxisFrame carries\n`cats` outermost-first with label TUPLES (gotcha 44), so there are N row-header\ncolumns, not one, and each needs its own `left` offset computed from the widths of\nthose to its left. The width rule has one home, improvise_protocol::header_level_widths,\nand CSS cannot read it.\n\nThat is the same shape as the sticky COLUMN headers improvise-tg27's agent\ndeclined for the same reason: getting the offsets wrong is SILENT -- the cell\nhides BEHIND the header while getBoundingClientRect still reports it in view, so\nthe existing scroll assertions stay green.\n\nSO THE TEST HAS TO ASSERT OCCLUSION, not position: that the row-header cell is the\ntopmost element at its own coordinates (elementFromPoint), not merely that its\nrect is inside the viewport. Without that, a wrong offset ships green.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T23:07:55Z","created_by":"Edward Langley","updated_at":"2026-09-22T23:07:55Z","labels":["browser"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-rx8g","title":"#hint is illegible at phone width: 1px at 520, and fixing it reopens n6t8's zero-basis argument","description":"Found and MEASURED by the improvise-tg27 agent, which first claimed its fix made\nthe hint legible, found its own spec had asserted that and PASSED, and corrected\nboth.\n\n#hint is flex:1 1 0 -- the only elastic child of the app bar (improvise-n6t8) --\nso after the header wraps it gets whatever the line leaves. Measured, settled\n(i.e. after #status composes the transport fact, not immediately after paint):\n\n 360px -\u003e 26px 520px -\u003e 1px 600px -\u003e 81px\n 700px -\u003e 317px 768px -\u003e 385px 900px -\u003e 67px 1024px -\u003e 6px\n\nNON-MONOTONIC, because it is the remainder after #view-name + #mode + #status take\ntheir reserves, and #status composes the DOCUMENT'S NAME, so the numbers are\ndocument-dependent too.\n\nWHY THIS IS NOT A ONE-LINE FIX, and the reason it is filed rather than patched:\ngiving #hint a min-width so it forces its own line DIRECTLY CONTRADICTS\nimprovise-n6t8's argument for flex:1 1 0 -- \"zero basis, absorbs the remainder,\ncontributes NOTHING TO OVERFLOW\". That basis is what stops the hint widening the\ndocument, which is exactly what improvise-tg27 then had to fix at the header\nlevel. So this reopens n6t8's reasoning and needs it argued again, not worked\naround.\n\nWORTH CONSIDERING INSTEAD OF A min-width: the hint is REFERENCE, not state (n6t8's\nown framing), so the honest answer at phone width may be to DROP it rather than\nsqueeze it -- a visible nothing beats an illegible something, and the affordance\nit carries is duplicated in the help overlay. That is a product call.\n\nMeasurements are in context/test-evidence/clients.md. Do not re-derive them\nimmediately after paint: the spec that did read 186px where the settled value is\n1px.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T23:07:55Z","created_by":"Edward Langley","updated_at":"2026-09-22T23:07:55Z","labels":["browser"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-t14d","title":"The TUI title bar drops '?:help :q quit' when the document name is long: fill_line gives the left segment priority","description":"Found by the improvise-p8ls agent while proving that ticket invalid. The\naffordance it was filed about DOES exist -- and it is the first thing sacrificed\nwhen the title bar overflows, which happens at ordinary widths.\n\nsrc/draw.rs:172:\n fn fill_line(left: String, right: \u0026str, width: u16) -\u003e String {\n let pad = \" \".repeat((width as usize).saturating_sub(left.len() + right.len()));\n format!(\"{left}{pad}{right}\")\n }\nVerified by the orchestrator. On overflow it simply concatenates, and ratatui\nclips the RIGHT -- which is where \" ?:help :q quit \" lives.\n\nMEASURED against the real draw() through a TestBackend:\n w= 36 | improvise · Sales (sales.improv)| affordance GONE\n w= 44 ... ?:help| half gone\n w= 52 ... ?:help :q qui| truncated\n w= 60 intact\n\nBUT WIDTH IS NOT THE REAL VARIABLE -- TITLE LENGTH IS, and it is unbounded:\n w=100 | improvise · Quarterly Regional Sales Analysis\n (quarterly-regional-sales-2026.improv) ?:help :q|\nAn ordinary document name plus filename destroys the escape hatch AT 100 COLUMNS,\nin Normal, RecordsNormal and TileSelect.\n\nSO THE PRIORITY IS BACKWARDS: the left segment (decoration) has unconditional\npriority over the escape hatch (the thing a stuck user needs). That is the bug.\n\nTWO SMALLER FAULTS IN THE SAME THREE LINES:\n 1. left.len() is BYTES, and the title contains \"·\" (2 bytes in UTF-8), so the\n padding is always one column short and a non-ASCII document name shifts the\n right segment further left. Note src/ui/text.rs::truncate now exists as the\n one display-width-safe truncator (improvise-8l0c) -- fill_line predates it\n and does not use it.\n 2. There is no truncation strategy at all, so the failure is a MID-TOKEN clip\n (\"?:\") rather than a graceful drop of the whole segment.\n\nNOT A DUPLICATE of improvise-tg27, which is the WEB client's grid columns and\nweb/style.css flex/overflow. Different front end, different file, different\nmechanism. bd search \"narrow\" and \"title bar\" found nothing covering this.\n\nWHEN FIXING: give the right segment priority, truncate the left with\nsrc/ui/text.rs::truncate (display cells, never bytes), and test through\nTestBackend at the widths above -- a test at 80 columns with a short fixture name\ncannot see this.","status":"in_progress","priority":3,"issue_type":"bug","assignee":"agent-tui","owner":"el-github@elangley.org","created_at":"2026-09-22T22:59:23Z","created_by":"Edward Langley","updated_at":"2026-09-23T01:18:16Z","started_at":"2026-09-23T01:18:16Z","lease_expires_at":"2026-09-23T01:23:16Z","heartbeat_at":"2026-09-23T01:18:16Z","labels":["tui"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-56qu","title":"site/ is a tracked build artifact that silently lags context/, and nothing compares them","description":"Found by the improvise-xgra agent (PR #133) by accident: it ran\nscripts/build-doc-site.mjs with no argument, which defaults to the TRACKED\nsite/docs, and the regeneration produced 26 MODIFIED FILES plus THREE PAGES THAT\nDO NOT EXIST IN THE TREE AT ALL:\n\n adr/004-faces-and-themes\n code/cli\n test-evidence/cli\n\nAll three are documents added TODAY (ADR 004 in PR #122/#132, the CLI docs in PR\n#131). So the committed site/ lags context/ by at least a day, and NOTHING IN CI\nCOMPARES THEM. The build job assembles into its OWN output directory, so the\ntracked copy is never checked against the source it was generated from.\n\nTWO SEPARATE PROBLEMS, and they want different answers:\n\n1. site/ IS A TRACKED BUILD ARTIFACT THAT SILENTLY LAGS. Either CI regenerates\n and compares (failing when they differ), or site/ stops being tracked, or it is\n explicitly declared a periodic snapshot with the staleness accepted. The third\n is a real option -- improvise-oujb's agent noted site/docs has been touched by\n two commits ever -- but it should be a DECISION rather than the current\n accident, because a reader cannot tell a snapshot from a stale artifact.\n\n2. RUNNING THE DOC-SITE BUILD DIRTIES THE TREE, which is a trap for any agent\n that runs it to verify the SECTIONS gate. The xgra agent reverted its 26 files;\n the next one may not notice. Note the gate itself is worth keeping: running\n build-doc-site.mjs exits 0 and prints no \"neither published nor a named\n omission\", which is a cheap POSITIVE check that a touched context/ file is\n published -- not only a trap to avoid.\n\nRELATED, ALREADY KNOWN: improvise-oujb's agent reported that site/ is partially\ntracked (27 files under site/docs/) and is NOT in .gitignore, so following the\nREADME's own instructions dirties the tree. Same root, reported twice by two\nagents on different days, which is itself the argument for deciding it.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T21:04:54Z","created_by":"Edward Langley","updated_at":"2026-09-22T21:04:54Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-1jay","title":"context/code/protocol.md:470 says no client composes CellId::Create; gcvb closed that and bd9o closed the route","description":"Reported by the improvise-bd9o agent (PR #128) and deliberately not fixed there --\nnot its file, and stale independently of that branch.\n\ncontext/code/protocol.md:470 says \"No client composes it yet\" about\nCellId::Create, and cites improvise-gcvb as an open gap. That predates gcvb\nlanding: a client DOES compose it, in commit.\n\nAnd improvise-bd9o has since changed the picture again from the other side: it\ntook away the DOOR. edit_or_drill now declines on a records grid before reaching\nthe CellId arms at all, so CellId::Create is no longer reachable from a key --\nwhich is why improvise-x0xd exists (create_column is now explicitly dead rather\nthan accidentally so).\n\nSO ONE SENTENCE IS OWED, AND IT HAS TWO CLAUSES: gcvb closed the compose gap in\ncommit, and bd9o has since closed the key route. Writing only the first would\nleave a reader believing a key reaches it.\n\nThis is the same class as the two prose passages Codex caught on PR #128 -- a\nfact stated in more than one place, where the place that moved was not the place\nsomeone was editing. Cheap to fix, and worth doing while the two commits that\nfalsified it are still fresh enough to cite.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T08:16:51Z","created_by":"Edward Langley","updated_at":"2026-09-22T08:16:51Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-x0xd","title":"The browser records-mode creating columns are explicitly dead and could be live","description":"improvise-gcvb built `create_column` — the one records-mode commit a thin client\ncan compose, since a `CellId::Create` commits the typed NAME as one existing\nModelCommand (AddItem into _Measure, or AddCategory), reads no record_col and\nwrites no cell. It has never been reachable.\n\nThe only door into RecordsEditing is `edit-or-drill`, whose `CellId::Create` arm\nreturned None; `o` is declined at its `add-record-row` step. So `commit`'s\n`CellId::Create` branch was dead on arrival — an accident of two unrelated arms\nrather than a decision.\n\nimprovise-bd9o made that deadness EXPLICIT rather than closing it: the records\nguard now sits at the entrance of `edit_or_drill`, so every records column\ndeclines, and the `CellId::Create` arm declines too (\"creating a column is not\navailable in the browser yet\") instead of returning None. That was the ticket's\nown stated acceptance criterion and the right scope for a P1 about silence.\n\nWHAT THIS TICKET IS: open the editor on `CellId::Create` while still refusing\nthe coordinate and measure columns. That means the entrance guard becomes a\nper-column decision again, which is exactly the shape bd9o argued against for\nthe silence case — so it needs its own evidence rather than a revert.\n\nTwo things to answer first, both already argued in gcvb and clients.md:\n\n - The +cat half MUST NOT ADVANCE. An inserted column renumbers every index to\n its right and the advance was computed from the pre-commit layout (gotcha\n 23), so a client that advanced anyway lands one column off, silently.\n `create_column` already does this; the test does not exist end to end.\n - AddCategory can be refused by the 12-category limit and sets abort_effects,\n so the mode change goes FIRST, where an abort cannot reach it (gotcha 21).\n Also already in `create_column`.\n\nSo the code is written and argued; what is missing is a door and the evidence\nthat the door is safe. Worth doing because it is the ONE thing a browser could\ndo in records mode and currently cannot.\n\nACCEPTANCE CRITERIA\n\n - Pressing `i`/`a` on a records `+` or `+cat` column opens the editor in the\n browser, and Enter commits through the existing `create_column`.\n - Every OTHER records column still declines out loud (the bd9o property must\n not regress) — assert it by walking all columns, as\n `the_tile_bar_route_into_records_mode_does_not_strand_the_user` does.\n - An end-to-end test through a real Host proves +cat does not advance, by\n injecting the advancing rival and watching it land one column off.\n","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T07:57:32Z","created_by":"Edward Langley","updated_at":"2026-09-22T07:57:39Z","dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"improvise-vgp4","title":"A refusal written while a minibuffer is open may never be drawn: draw_bottom_bar paints one or the other","description":"Found by the improvise-55l5 agent (PR #126), which wrote the message and stopped\nat the point where drawing it is a different question.\n\nimprovise-55l5 made a bound key that cannot construct its command SAY SO, via\nset_status. The class that reaches a user is \"no cell at cursor\" from six\nregistrations that read ctx.cell_key(), and the worst case is commit-cell-edit:\nthe user is in Editing, has typed a value, presses Enter, and the batch is\ndiscarded.\n\nTHE MESSAGE IS NOW WRITTEN AND MAY NOT BE DRAWN. gotcha 21 records that\ndraw_bottom_bar renders the MINIBUFFER INSTEAD OF the status line, which is\nexactly the rendering trap that made an aborted batch appear to do nothing at\nall. A refusal from commit-cell-edit leaves the user in Editing -- a minibuffer\nmode -- so the status it just wrote is plausibly invisible.\n\nTHIS IS THE SAME SHAPE AS improvise-j6x8 one layer over: a refusal that is\ncorrect, tested at the effect, and discarded by the surface that was supposed to\nshow it. Compare gotcha 21's own remedy -- tidy-up goes at the HEAD of a batch\nwhere an abort cannot reach it -- and note that remedy is about ORDERING within a\nbatch, not about which of two things draw_bottom_bar draws.\n\nTO ANSWER: does draw_bottom_bar draw a status while a minibuffer is open, and if\nnot, what should it do when the status is a REFUSAL of the very thing the\nminibuffer is for. A refusal the user cannot see is not a refusal\n(doctrine(a_refusal_the_headless_path_discards_is_not_a_refusal), the TUI twin).\n\nReproduce before fixing: the agent did not chase this and it may already hold --\ngotcha 21's own fix put mode restoration at the head of several batches.","status":"in_progress","priority":3,"issue_type":"bug","assignee":"agent-tui","owner":"el-github@elangley.org","created_at":"2026-09-22T07:40:56Z","created_by":"Edward Langley","updated_at":"2026-09-23T01:34:40Z","started_at":"2026-09-23T01:34:40Z","lease_expires_at":"2026-09-23T01:39:40Z","heartbeat_at":"2026-09-23T01:34:40Z","labels":["tui"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-b8ng","title":"view_frame cannot tell an appended row from a renumbered one, so every append resets the cursor","description":"Found by the improvise-o9uq agent (PR #126) while fixing the cursor placement.\nFiled rather than fixed: it is the SECOND cause of o9uq and is not the one that\nmade o destructive.\n\nview_frame hashes entries.len() AND every entry, so it cannot distinguish \"rows\nwere appended\" from \"rows were renumbered\". Gotcha 24 invalidates the cursor by\nOBSERVING the frame, which is deliberately total over EffectKind::Model(_) and is\nthe reason there is no hand-kept table of geometry effects -- so every append\nthrows the cursor to the origin, although nothing above the new row moved.\n\nWHY IT IS NOT URGENT NOW: improvise-o9uq closed the destructive path by having\nadd-record-row place its own cursor AFTER the SetCell, so the invalidation is\nimmediately overwritten by a correct placement. The remaining cost is any OTHER\nappend-shaped change whose command does not place a cursor.\n\nWHY IT IS DELICATE, and the reason this is a ticket rather than a patch: the\nobservation's totality is the property gotcha 24 was rewritten to get. A frame\nhash that can tell append from renumber is a SMARTER observation, and a smarter\nobservation is one more thing that can be subtly wrong in a direction nothing\ntests -- gotcha 24 already records that doing only half of the invalidation is\nSTRICTLY WORSE THAN DOING NOTHING, because the diff then believes every client\nholds origin-positioned content. Do not optimise this without a test that\ndistinguishes the two cases by construction.\n\nNote the adjacent gap already filed: improvise-9c9o (a geometry effect\nimmediately after a mid-batch SwitchView skips the check entirely).","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T07:40:55Z","created_by":"Edward Langley","updated_at":"2026-09-22T07:40:55Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-n8vh","title":"The category and view panels never truncate, so a long name overwrites the panel border","description":"The category and view panels never truncate a row, so a name longer than the\npanel overwrites its right border.\n\nFound while fixing improvise-8l0c, which was the *wrong* truncation in\n`src/ui/formula_panel.rs`. These two are a different bug: no truncation at all.\n\n`src/ui/category_panel.rs::render_item` writes `format!(\"{indicator} {name} ({item_count})\")`\nstraight through `Buffer::set_string`, and `src/ui/view_panel.rs::render_item`\ndoes the same with `format!(\"{prefix}{}\", summary.name)`. `set_string` clips at\nthe *buffer* edge, not at the panel's `inner` rect, so a long category or view\nname draws over the block's right border character and, in a narrow terminal,\nruns to the screen edge. No panic.\n\n8l0c left the tool in place: `src/ui/text.rs::truncate` is the shared\ndisplay-width-safe truncator and is what these two want, with the axis tag /\naxis summary suppressed rather than pushed off. Both files already measure the\ntag's offset in cells since 8l0c, so the arithmetic is there.\n\nEvidence to write: `context/test-evidence/tui.md` already carries the\nobservation under the 8l0c section, keyed by\n`ui::category_panel::tests::the_axis_tag_sits_beside_a_multibyte_category_name`.\nA regression test should assert the border column still holds the border glyph.\n\nFiled by Claude (Opus 5), an agent in Edward Langley's session.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T06:28:47Z","created_by":"Edward Langley","updated_at":"2026-09-22T06:28:47Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-dw33","title":"Truncate the build stamp to 8 characters -- but only when it is a SHA, never a tag","description":"USER 2026-09-22: \"the build number in the header should be truncated to like 8\ndigits, `ee0f57d227e8d9601a7353b23704de78df9a75a8` is way too much text\"\n\nWHERE IT IS: web/index.html:43 carries\n`\u003cspan title=\"Build this page was assembled from\" id=\"build-version\"\u003eunstamped\u003c/span\u003e`,\nand web/assemble.sh:88 substitutes the token into it, gated by\nscripts/assemble-stamp.sh against the pattern `id=\"build-version\"\u003e${token}\u003c`.\n\n8 IS ALSO THE REPO'S OWN CONVENTION: scripts/pr-reviews prints `.commit.oid[0:8]`,\nand the orchestrator's prose uses 8 throughout. So this is consistency, not taste.\n\nTHE TRAP, AND IT IS THE WHOLE TICKET: THE TOKEN IS NOT ALWAYS A SHA. Pages passes\n$GITHUB_SHA; a RELEASE passes the tag (improvise-oujb). Truncating blindly turns\n`v0.1.0-rc2` into `v0.1.0-r`, which is not a shorter version -- it is a WRONG one,\nand it is wrong in the one build a user is most likely to quote in a bug report.\n\nSo truncate ONLY when the token is a 40-hex SHA. That test already exists in this\nrepo: scripts/build-doc-site.mjs:21 is\n`const ref = /^[0-9a-f]{40}$/.test(token) ? token : \"main\";`\n-- SHARE the rule rather than writing a third copy of it, or at minimum point at\nthat line so the two cannot drift. Two answers to \"is this token a SHA\" is the\ndisease design-principles section 5 names.\n\nKEEP THE FULL VALUE REACHABLE. The 40-char SHA renders today only because CSS\n`text-overflow` clamps it, and the #121 agent measured that selecting the text\nstill copies the WHOLE SHA. Truncating the text loses that unless the full value\ngoes somewhere -- the `title` attribute already exists and is the obvious home,\nor a `data-` attribute. A build id you cannot copy in full is a build id that\ncannot be pasted into an issue.\n\nTHE GATE MUST MATCH WHAT IS DISPLAYED. assemble.sh:88 passes `${token}` as the\nexpected pattern; if the displayed text is truncated, that argument becomes the\ntruncated form. Getting it wrong REFUSES THE ASSEMBLY rather than shipping an\nunstamped page, so it fails safe -- but it fails loudly during a release, so get\nit right rather than relying on that. scripts/assemble-stamp.sh verifies each of\nits five pairs independently (improvise-x4wv), so a wrong pattern names itself.\n\nTEST IT AT BOTH SHAPES. scripts/assemble-stamp-test.sh is the check_the_checker\nand already runs 9 checks; a tag token and a SHA token are two cases and only one\nof them is the interesting one. Inject a missed substitution at each shape.\n\nSEQUENCING: branch ui/page-menu is in flight. It should not touch\nweb/index.html:43 or web/assemble.sh, but confirm before starting.","notes":"CLOSED BY USER RULING 2026-09-22, as already answered by PR #121.\n\nThe user proposed the CSS form -- \"We could also just do a max-width there, plus\nellipsis text-truncation in css\" -- and on being shown that it is already live,\nruled: leave it.\n\nWHAT IS ALREADY ON MAIN (web/style.css:92, landed with improvise-oujb / PR #121):\n #build-version { flex: none; max-width: 14ch; overflow: hidden;\n text-overflow: ellipsis; white-space: nowrap; ... }\nwith a comment stating the property that makes it the right choice: A TAG FITS\nWHOLE, and a 40-char SHA truncates to a prefix long enough to name a build while\nselecting the text still copies the whole thing. Measured by the #121 agent in\nChromium: tag renders whole (scrollWidth 69 = clientWidth 69), SHA renders 13\nchars + ellipsis, header does not overflow at either.\n\nWHY THIS IS THE BETTER ANSWER AND NOT JUST THE CHEAPER ONE: clamping by WIDTH\ncannot mangle a tag, where truncating by CHARACTER COUNT turns v0.1.0-rc2 into\nv0.1.0-r -- a wrong version rather than a short one, in exactly the build a user\nwould quote. The CSS form sidesteps the whole SHA-vs-tag test this ticket was\nfiled to handle.\n\nTHE COST, ACCEPTED AND RECORDED so it is not rediscovered as a defect: the shown\nprefix is VIEWPORT-DEPENDENT, so two people may quote different prefixes of one\nSHA, and neither matches the 8-character form scripts/pr-reviews and the rest of\nthis repo's tooling uses. If that ever bites -- a bug report quoting a prefix\nnobody can match -- the fix is build-side truncation gated on\n/^[0-9a-f]{40}$/ (the test already exists at scripts/build-doc-site.mjs:21),\nwith the gate pattern in web/assemble.sh:88 changed to match the displayed form.\nReopen rather than re-derive.\n\nNOTE the user was most likely looking at a Pages build predating the #121 merge;\nthe deploy for it went out with the push of ee0f57d.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T06:15:12Z","created_by":"Edward Langley","updated_at":"2026-09-22T06:16:24Z","closed_at":"2026-09-22T06:16:24Z","close_reason":"Closed","labels":["browser"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-l2b8","title":"ADR 005: exact decimal arithmetic for formulas, on Scheme numeric-tower promotion rules","description":"USER 2026-09-22: \"Formulas should default to decimal math (ideally following\nScheme's numeric tower rules for promotion).\"\n\nSEPARATE FROM THE SYMPTOM THAT PROMPTED IT. The \"useless numbers\" the user saw\nare a FORMATTING defect (improvise-e0ex): number_format defaults to \",.0\", zero\ndecimal places, so a 0.66 ratio draws as \"1\". Exact arithmetic would not change\nthat by one character. This ticket is the numerics change on its own merits, and\nthose merits are real -- which is why it is filed rather than folded in.\n\nWHY IT IS WORTH DOING INDEPENDENTLY:\n - A spreadsheet that answers 0.1 + 0.2 with 0.30000000000000004 is wrong in the\n way users notice and never forgive. CellValue::Number(f64) does.\n - gotcha 18 records a REAL BUG this would have prevented: f64 addition is not\n associative, so SUM/AVG drifted in the last bits when DataStore iteration\n order changed. Min/Max/Count were unaffected because they are order-free.\n EXACT RATIONALS ARE ASSOCIATIVE -- the whole class disappears rather than\n being fixed by pinning iteration order.\n - gotcha 17's FLOAT_EQ_EPSILON (1e-10, shared by =, != and the div/0 guard in\n both evaluators) exists BECAUSE of binary floats. Exact arithmetic makes\n equality exact and the epsilon unnecessary -- six uses, one constant, gone.\n\nWHAT THE TOWER MEANS HERE. Scheme's rule: exact integer -\u003e exact rational -\u003e\ninexact real, with operations preserving exactness where they can, and `/` on two\nexacts yielding an exact rational rather than a float. So Profit / Revenue is an\nexact ratio, rounded only at display. Decide explicitly what makes a value\nINEXACT and whether improvise has any such operation today (POW with a\nnon-integer exponent is the obvious candidate; there are no transcendentals).\n\nSEVEN THINGS TO ANSWER BEFORE WRITING CODE:\n 1. WHAT IS THE STORED TYPE? CellValue::Number(f64) is in improvise-core and\n reaches everything. A new arm, a replaced arm, or a wrapper -- each has a\n different blast radius, and CellValue is Serialize/Deserialize and crosses\n the wire.\n 2. DENOMINATOR GROWTH. Summing thousands of exact rationals with unrelated\n denominators is how exact arithmetic becomes slow and memory-hungry. Say\n what bounds it. Measure at the 18,000-cell and 90,000-cell sizes gotcha 24\n already uses, so the numbers are comparable.\n 3. THE WASM BUNDLE. improvise-core is a browser-bundle crate. A bignum\n dependency is bundle weight on every page load; check with `cargo tree\n --edges normal` per crate (the default output includes DEV edges and reads\n like a leak -- see the Wasm section of repo-map).\n 4. .improv ROUND-TRIP. The format stores numbers as TEXT, so exact decimals\n round-trip BETTER than f64 does today. That is a point in favour, but the\n grammar and the round-trip tests must be re-read: \"1/3\" and \"0.333...\" are\n not the same value and the format has to say which it holds.\n 5. gp-tree IS NOT IN SCOPE. It fits over f32 feature matrices and its\n tests/scoring_known_answers.rs pins f32 BIT PATTERNS plus an RNG-call-order\n witness (gotcha 37c). It must keep binary floats; the seam is\n improvise-gp::dataset, and what crosses it needs stating.\n 6. WHAT DOES A THIN CLIENT SEE? CellDisplay carries text today, so a browser\n may be unaffected -- confirm rather than assume, because if a number crosses\n as a number, its type is a protocol change.\n 7. MIGRATION. Every existing .improv must load unchanged. Exactness is a\n property the current format does not record, so decide what an existing\n decimal literal BECOMES on load, and whether that is a behaviour change to\n existing documents.\n\nTHIS IS AN ADR, NOT A TICKET, and should be one before any code -- the type\nchoice reaches improvise-core, the wire, the grammar and the browser bundle at\nonce. Note ADR 004 is being drafted for the face system, so this is 005.\n\nDO NOT START BY REPLACING f64. Start by writing down what exactness means in a\npivot table, where most cells are user-entered decimals and most operations are\nSUM.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T06:11:47Z","created_by":"Edward Langley","updated_at":"2026-09-22T06:11:47Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-wiew","title":"[ and ] should probably be in the global keymap: decide whether 'global' means every mode or every grid mode","description":"USER 2026-09-22: \"Also [/] probably should be in the global keymap.\"\n\nNote the \"probably\" -- judgement is invited, and there is a real question below.\n\nWHERE THEY ARE NOW, verified: keymap.rs:615-616 binds ']' -\u003e page-next and\n'[' -\u003e page-prev on NORMAL. global_keymap() (:392) currently holds '?', ':' and\nthe panel toggles.\n\nMECHANICALLY THIS IS SAFE AND CHEAP. Both are unmodified Char keys bound as\nBinding::Cmd, so they satisfy the_global_binds_only_unmodified_char_keys, the\nguard PR #112 added. And TEXT ENTRY IS NOT AFFECTED: the lookup order is\nexact(key,mods) -\u003e Char(NONE) -\u003e AnyChar -\u003e Any -\u003e parent, so a text-entry mode's\nAnyChar shadows the global BEFORE the fallthrough is reached. Typing '[' into a\ncategory name still types '['. That shadowing is already covered by #112's tests.\n\nNO EXACT CONFLICT EXISTS. The Help overlay pages with Right / l / n, not with\nbrackets, so nothing is shadowed by accident.\n\nTHE ACTUAL QUESTION, WHICH THE TICKET MUST ANSWER RATHER THAN ASSUME: is the\nglobal \"every mode\", or \"every mode whose subject is the grid\"? Falling through\nmeans [ and ] would change the DOCUMENT'S PAGE AXIS while the user is reading the\nHELP OVERLAY -- a real mutation, invisible because the overlay is covering the\ngrid it changed. The same question applies to TileSelect and the three panels,\nthough those are at least about the document.\n\nThree defensible answers:\n a. Global, unshadowed -- paging is always available, and the help overlay\n changing the page underneath is harmless because it is visible on exit.\n b. Global, shadowed to a no-op in Help -- costs a wart, but says plainly that\n an overlay is not a place to mutate the document.\n c. Not global; add it to the specific maps that want it (RecordsNormal already\n inherits Normal, so the real gain is the panels and TileSelect).\n\nPick one, say why in the PR body, and expect the user to have an opinion --\nthis is the kind of thing that is cheap now and annoying to reverse once muscle\nmemory forms.\n\nWHATEVER IS CHOSEN, the browser must follow: improvise-protocol/src/keymap.rs is\nthe shared table both front ends look up, so this is one change, not two -- but\ncheck resolve.rs still composes page-next/page-prev from whatever modes newly\nreach them, and that lockstep still agrees. improvise-p1ym closed the projection\ngap that used to make [ and ] inert in a browser, so they do work there now.","notes":"IMPLEMENTED in PR #123 (merge 7470b61), to the user's ruling of an INTERMEDIATE\nkeymap rather than any of the three options this ticket listed.\n\ndocument_keymap sits between global and Normal / the three panels / TileSelect.\nRecordsNormal reaches it THROUGH Normal. Help, ImportWizard and the text-entry\nmodes take global directly.\n\nTHE ARGUMENT IS NEW RATHER THAN THE GLOBAL'S AGAIN, and it is better than the\none I wrote on this ticket. I framed the question as \"is the document the subject\nright now\", which is a judgement. The agent found a STRUCTURAL line: every key\nthe global binds is a UI action, whereas page-next resolves to SetPageSelection,\nwhich is EffectKind::Model(LayoutGeometry) -- THE FIRST SHARED KEYS THAT MUTATE.\nUnder the help overlay that is an invisible change to the grid it is covering.\nSo the split is not taste; it is \"may a fallthrough key mutate the document\".\n\nThe three asks were met: the name is the guard (document_keymap, not\ncommon/shared, because nothing in the code stops a mode parenting it);\nRecordsNormal inherits ONCE, asserted on CHAIN IDENTITY (exactly four deep)\nrather than behaviour; and the layer gets its OWN Char-only guard, owed for the\nidentical fallthrough reason as the global's.\n\nTHE TEST-SHAPE LESSON: parenting RecordsNormal to the layer directly is a fault\nthe BEHAVIOURAL test does not catch -- it passes, because the key still works.\nOnly records_normal_inherits_the_document_layer_once_through_normal, which\nasserts the chain, sees it. A duplicated inheritance edge is invisible to any\ntest that only presses the key.\n\nprefix_keymaps_are_flat is about the sub-keymaps a PREFIX opens and is unaffected\nby depth. resolve.rs dispatches on command NAME, so the browser followed with no\nsecond table and no resolve.rs change was needed. lockstep.rs unchanged.\nIt did not grow: one binding moved, one layer added.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T06:07:52Z","created_by":"Edward Langley","updated_at":"2026-09-22T06:41:47Z","closed_at":"2026-09-22T06:41:47Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-771e","title":"The browser tile bar says 'Pag': short_label is a TUI column-width device, not a browser one","description":"Spotted by the orchestrator in PR #120's screenshots: the browser tile bar renders\n\"_Measure Col\", \"Product Row\", \"Quarter Pag\".\n\n\"Pag\" is not a CSS clip and not a bug in #120. Axis::short_label() (improvise-core\n/src/view/axis.rs) returns THREE CHARACTERS on purpose -- repo-map gotcha 26 says\nit is \"three columns for the tile bar\", so the TUI's tile bar aligns in a\nfixed-width terminal. Note `None` is \".\" there and is deliberately NOT a\ntruncation.\n\nIn a browser there is no column-alignment constraint, so the abbreviation buys\nnothing and reads to a user as a rendering defect. Axis::plain_label() already\nreturns \"Row\"/\"Col\"/\"Page\"/\"None\".\n\nTHE FIX IS NOT TO CHANGE short_label(). It is shared with the TUI, where the\nthree-column width is load-bearing. Pick the other EXISTING method on the browser\nside. That is not a second derivation -- both are already on Axis and the decision\nstays in the shared layer; only the choice of which vocabulary a front end uses is\nlocal, which is exactly the shape design-principles section 5 blesses for\nAxisColor -\u003e ratatui Color.\n\nCHECK BEFORE CHANGING: tests/grid_frame_agreement.rs asserts the two front ends\ndraw the same grid FRAME. Confirm it does not also pin the tile bar's label\nvocabulary, or this will fail for a reason that is not a bug.","notes":"FIXED in PR #123 (merge 7470b61). The browser tile bar asks plain_label();\nshort_label() now has exactly one caller, src/ui/tile_bar.rs.\n\nTHE FIND THAT MATTERS MORE THAN THE FIX, and it corrects this ticket's own\nguidance. I told the agent to check tests/grid_frame_agreement.rs before\nchanging the label, on the theory that it might pin the tile bar's vocabulary.\nIT DOES NOT -- it does not mention the tile bar at all.\n\nBut harness/tests/drill.spec.js DID: it asserted the tile text exactly, twice,\nwith \"Quarter Pag\" AS THE EXPECTED VALUE, plus \"Quarter ·\" after a cycle. A\nPlaywright spec in a directory this ticket never names, asserting the bug as its\nexpectation. Verified fixed on the merged branch: it now expects \"Quarter Page\".\n\nSO THE RULE IS: A RENDERED STRING IS PINNED WHEREVER ANYTHING READS THE SCREEN.\nThe search is for the STRING, not for the function that produces it -- I pointed\nat the function and named the wrong file. A now-false comment in\ncontract.spec.js naming short_label was fixed in the same PR.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T06:07:11Z","created_by":"Edward Langley","updated_at":"2026-09-22T06:41:46Z","closed_at":"2026-09-22T06:41:46Z","close_reason":"Closed","labels":["browser"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-ycxi","title":"The release preflight's dist plan arm is unchecked by its check_the_checker","description":"Proposed by the improvise-gx5d agent (PR #119) and DECLINED IN THAT PR on stated\ngrounds, which is why it is a bead rather than a fix.\n\nscripts/release-preflight.sh runs `dist plan --tag=\u003ctag\u003e` as a SECOND OPINION after\nits own textual tag-vs-version check, and matches on the refusal text. That arm can\nbe rewritten so it NEVER MATCHES and scripts/release-preflight-test.sh stays green,\nbecause every input the test feeds the script has ALREADY been refused by the\ntextual check -- so control never reaches the dist arm under test.\n\nWHY IT WAS DECLINED RATHER THAN FIXED, and the reasoning is sound: the arm is\nOPTIONAL BY DESIGN. An absent dist must not read as a pass, and a locally-installed\ndist whose version differs from dist-workspace.toml's cargo-dist-version stops\nbefore planning, which the script deliberately tolerates. Pinning the arm in a test\nwould mean installing a dist matching cargo-dist-version -- the exact mismatch the\nscript exists to tolerate.\n\nSO THE VALUE HERE IS NOT COVERAGE, IT IS THAT THE SILENCE IS RECORDED. It is stated\nin context/code/tooling.md so nobody reads the check_the_checker's green as evidence\nabout that arm. This bead exists so the statement does not become the permanent\nanswer by default.\n\nWHAT WOULD ACTUALLY CLOSE IT, if anyone takes it up: feed the dist arm an input the\ntextual check ACCEPTS but dist refuses. That requires a tree whose root version\nmatches the tag while dist still objects -- e.g. a dist-workspace.toml naming a\nmember that does not exist. That is a fixture, not an installed dist, and it may be\ncheap. Worth ten minutes before accepting the decline as permanent.\n\nDO NOT close this by deleting the dist arm. It is the thing that actually refused in\nCI, and running it is worth more than any restatement of its rules; the textual\ncheck is the one that catches the failure the script was written for, but it is not\nthe authority.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T05:40:52Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:40:52Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-htl6","title":"view-forward is bound in no keymap, in either front end","description":"Found by the improvise-cfly agent while adding the browser's view-back arm, and\nNOT predicted by that ticket. Verified by the orchestrator: grep of\nKeymaps::default_keymaps() finds `\u003c` bound to view-back and NOTHING bound to\nview-forward.\n\nSo the FORWARD half of the view history has never had a key, in either front end:\n\n - TUI: reachable only as `:view-forward` through the command line.\n - BROWSER: not reachable at all -- declined() refuses to enter CommandMode at\n the door (improvise-y11g is the ruling that this must change).\n\nTHIS IS A TUI GAP FIRST, which is the surprising part. The browser was the thing\nunder investigation and the browser is the second victim, not the first.\n\nBoth ends COMPOSE the command correctly since improvise-cfly / PR #117; only the\nbinding is missing. So this is one keymap line plus its consequences, not a\nfeature.\n\nWHAT IT NEEDS:\n - a key. `\u003e` is taken by drill-into-cell, so this needs a choice rather than\n the obvious symmetry -- say what you picked and why.\n - a help.rs hint, or `every_hint_key_resolves_in_its_mode` and\n `only_quit_has_no_hint` will have something to say.\n - it WILL fail resolve::tests::the_forward_step_mirrors_its_cmd_and_no_key_reaches_it,\n which PR #117 added precisely so this gap cannot be closed silently. That\n test asserts the arm exists AND that no keymap binds view-forward, with\n view-back's presence as the control so it is not vacuous over a misspelling.\n Deleting the absence assertion is the correct move when adding the binding --\n coming there and saying so is the point.\n\nWHY IT SURVIVED: pressing `\u003c` twice is the only way to observe the forward stack,\nso nothing a user does by accident reaches it, and until PR #115 the stack was\nnot maintained for any client at all (gotcha 38's correction).","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T05:21:26Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:21:26Z","labels":["browser","tui"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-z24n","title":"Ctrl-Q should not be claimed by the browser at all: force-quit resolves Ignored, not Upstream","description":"USER RULING 2026-09-22: \"What should Ctrl-Q do in a browser tab? this shortcut\nonly really makes sense in an application context.\"\n\nSo: the browser must NOT claim it. `force-quit` should stop resolving to\nKeyOutcome::Upstream and resolve to Ignored -- no frame composed, nothing sent.\n\nWHAT IS ACTUALLY WRONG TODAY is narrower and stranger than \"a black hole\", and the\ndifference is the reason this is worth a ticket rather than a one-liner. web/app.js\n(~:599) reads:\n\n if (!e.ctrlKey \u0026\u0026 !e.altKey) e.preventDefault();\n\nwith the comment \"Ctrl/Alt combinations are left alone: they are the browser's.\"\nSo the page ALREADY declines to consume Ctrl chords. But `client.on_key_event` is\ncalled FIRST and force-quit resolves to Upstream, so the page composes and sends a\nClientFrame::Key that Host::handle answers with an empty vec (gotcha 47) -- AND the\nbrowser also acts on Ctrl-Q, which on Linux Firefox and Chrome QUITS THE BROWSER.\n\nSo the present behaviour is: send a useless frame, and let the browser quit. The\nfix aligns the resolver with the policy app.js already states rather than inventing\none -- which is why the answer is Ignored and NOT \"decline with a status message\":\na message would imply improvise had claimed the key, and it deliberately has not.\n\nCHECK BEFORE ASSUMING THE ABOVE: I read the handler and did not run it. Confirm by\ninjection that Ctrl-Q currently produces a frame, rather than trusting this note --\nthat is the whole discipline this repo runs on.\n\nDO IT IN THE SAME COMMIT: remove `force-quit` from\nthe_command_names_that_still_need_the_host_are_these, because gotcha 47 says closing\na gap has to come to that list and say so. This one closes by DECLINING rather than\nby composing, which the list should be able to express -- if it cannot, that is the\nfinding.\n\nRELATED: improvise-uln5 (help page 5 advertises keys a browser cannot resolve). If\nCtrl-Q is genuinely not a browser feature, the browser's help should not list it,\nand that is uln5's business rather than this ticket's -- but note it there.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T05:13:58Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:13:58Z","labels":["browser"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-dsn2","title":"Browser back/forward buttons and real URLs: deferred, not foreclosed","description":"USER RULING 2026-09-22: \"I want to ignore browser history for now, without\nforeclosing the question: GitHub Pages is going to make actual client-side\nnavigation annoying.\" Clarified on the same day: \"Only actual browser history,\n\u003c/\u003e is a core feature but I don't much care about how it interacts with the\nback/forward buttons at the moment.\"\n\nSO THE SPLIT IS EXPLICIT, and the two halves must not be confused again:\n\n IN SCOPE AND SHIPPING -- the IN-APP view history. `\u003c` and `\u003e` over\n ViewState::view_back_stack, a CORE FEATURE per the user. PR #115 landed the\n mechanism (Command::ViewBack/ViewForward/ForgetView, per-client stacks,\n ForgetView pruning a deleted view from every client's history); improvise-cfly\n / PR #117 gives the browser a key that reaches it. Nothing here is deferred.\n\n DEFERRED, NOT FORECLOSED -- binding any of that to the BROWSER's own history:\n history.pushState, real URLs per document or per view, and the back/forward\n buttons doing anything at all.\n\nWHY DEFERRED, in the user's own reasoning: GitHub Pages is the deployment target\nand has NO server-side rewrite. A client-side router therefore needs either hash\nrouting or the 404.html-copies-index.html trick, and a deep link to a document or\na view is awkward under both. The cost lands on the deployment, not on the code,\nwhich is exactly the kind of cost that is cheap to defer and expensive to\ndiscover late.\n\nWHAT IT WOULD TOUCH IF TAKEN UP, recorded so the shape is not re-derived:\nweb/app.js (the only file that knows about the page), web/assemble.sh and the\npages-deploy job in .github/workflows/ci.yml. It does NOT touch the protocol:\nCommand::SwitchView, ViewBack and ViewForward already exist, so this is a\nquestion about the SHELL, not the vocabulary -- which is the main reason it is\nsafe to leave open.\n\nONE THING TO CHECK BEFORE BUILDING IT, because it decides the design: a URL that\nnames a view is a URL that names ONE CLIENT'S UI state, and gotcha 29 says the\nmodel is shared and the UI is not. A shared link that moves the recipient's view\nis fine; one that moves the SENDER's is the bug. Decide which a URL means before\nwriting a router.\n\nDO NOT close this as done when \u003c/\u003e ships. They are different features and the\ntracker should not let the second stand in for the first.","status":"blocked","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T05:12:00Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:12:04Z","labels":["browser"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-swzk","title":"A failed :view corrupts the TUI's own view history","description":"Found by the improvise-h3y7 completeness audit.\n\neffect::SwitchView (ui/effect.rs:336) pushes the current view onto view_back_stack\nBEFORE calling App::switch_to_view, which is guarded (ui/app.rs:565) and writes\nactive_view only if the view exists.\n\nSo `:view NoSuchView` leaves the user where they were WITH THEIR OWN CURRENT VIEW\nPUSHED ONTO THEIR OWN BACK STACK. `\u003c` then \"goes back\" to where they already are, and\none real history frame is buried under it.\n\nTHE WIRE PATH IS ALREADY CORRECT and this is not a regression of it: reduce_view's\n`switched` has no guard (the client holds no Workbook, so it cannot check), but\nSessions::apply_switch detects the refusal and relays the exact inverse\n([ViewBack, ForgetView], improvise-6c6q). Only the HOST'S OWN KEY PATH is unguarded.\nTUI-only, small, real.\n\nFIX: move the push after the switch, or make it conditional on the switch having\nhappened. Prefer whichever makes the ordering unrepresentable-otherwise rather than\nconventional.\n\nDEMONSTRATE WITH A FAILING TEST FIRST (AGENTS.md): switch to a real view, then to a\nnonexistent one, then `\u003c`, and assert you land on the real one. Note the test must use\na real second view -- a bystander sitting on the view it would land on anyway cannot\nsee this, which is the vacuity review demonstrated twice on PR #48.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T04:47:55Z","created_by":"Edward Langley","updated_at":"2026-09-22T04:47:55Z","labels":["tui"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-4n4i","title":"effect::HelpPageSet is unclamped where its wire mirror clamps","description":"Found by the improvise-h3y7 completeness audit (direction B: vocabulary -\u003e effects).\n\neffect::HelpPageSet(n) (ui/effect.rs:1174) writes `help_page = self.0` with NO bound.\nreduce_view's arm clamps: `HelpPage::Set(n) =\u003e (*n).min(HELP_PAGE_COUNT - 1)`.\n\nreduce.rs's own comment above that arm says \"these mirror HelpPageNext/HelpPagePrev,\nwhich saturate. The two used to disagree\" -- Next/Prev were fixed and Set WAS LEFT\nBEHIND, so the comment is now false of the arm it sits above. A stale correction: the\nsame failure mode as gotcha 38's prose, and the reason this is filed rather than\nquietly patched.\n\nLATENT, NOT LIVE, and the ticket should say so rather than overstate it:\neffect::help_page_set has exactly one call site (cmd/effect_cmds.rs:404) passing the\nliteral 0, and no client composes HelpPage::Set (resolve.rs has only Next/Prev arms).\nSo it is an asymmetry waiting for its first real caller.\n\nFIX: clamp the effect, and correct the comment in the same commit -- the comment is\nthe thing that made this survive.\n\nTHE REGRESSION GUARD: a test that the two agree for n \u003e HELP_PAGE_COUNT. Nothing tests\nthis in EITHER direction today, which is why a one-sided fix would look complete.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T04:47:54Z","created_by":"Edward Langley","updated_at":"2026-09-22T04:47:54Z","labels":["protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -297,8 +337,8 @@ {"_type":"issue","id":"improvise-ozxr","title":"The browser's page bar is display-only; clicking an entry cannot cycle it","description":"PR #113 (improvise-p7lh) made the page bar VISIBLE. It is display-only.\n\nIn the TUI, `[` and `]` cycle the page selection and the page bar is where a\npaged category is both shown and navigated. In the browser the bar now paints,\nbut clicking an entry does nothing -- cycling needs a `click_page` on the\nwasm-bindgen surface (`improvise-wasm-client/src/lib.rs`), which was outside\nthat agent's ground.\n\nWORTH KNOWING BEFORE SCOPING IT: `[` and `]` ALREADY WORKED in the browser\nbefore #113. The feature was never missing -- the FEEDBACK was. That is a worse\nfailure than a dead key, because the user sees the document change and cannot\nattribute it. So this bead is the mouse affordance for something the keyboard\nalready does, not new capability.\n\nPattern to follow: the other five surfaces got delegated handlers in\nimprovise-b8ot, with operands carried on the node as data attributes and read\nback by a pure `cellAction`/`indexAction` in `web/shell.js`. A page entry should\ndo the same -- `data-index` on the entry, a pure `pageAction`, one handler on\nthe `#pages` root. Do NOT derive the index from DOM position.\n\nNote gotcha 27: Page is the NAVIGABLE axis and Filter is not, and `PageAxis::cycle`\nis where that question is already answered -- ask it rather than respelling it.\n\nRelated: improvise-z336 (a page selection is never cleared, so cycling a\ncategory's axis hides a filter and cycling back restores it) is the other half\nof the page-axis story and is core-side, not browser-side.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T02:28:49Z","created_by":"Edward Langley","updated_at":"2026-09-22T02:28:49Z","labels":["browser","ux"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-nwgk","title":"Model::remove_item returns unit, so a wire RemoveItem naming nothing still reports success","description":"Left open by improvise-g3no, deliberately, with the reasoning in\ncontext/code/core.md section 9.\n\ndoctrine(a_status_line_composed_beside_a_mutation_lies_when_the_mutation_no_ops)\ngeneralises to: a mutation whose failure is reportable must RETURN whether it\nacted, because a unit-returning mutation makes the lie unavoidable rather than\naccidental. g3no removed the REACHABLE lie by restoring the formula sweep to\nModel::remove_item -- from either panel the item name comes off the tree, so it\nexists and the delete now really happens. What is left is narrow: a wire\nModelCommand::RemoveItem naming an item the document does not have is still\nanswered with an unconditional past-tense \"Deleted item ...\".\n\nWhy it was not done in that PR: closing it means threading an answer through\nWorkbook::remove_item and effect::RemoveItem, and ui/effect.rs was another\nagent's ground in that wave. It is a two-file change, not a design question.\n\nThe browser half is separately blocked and is the more interesting part:\nSetStatus is ViewOnly and never crosses the wire, so a host-side refusal cannot\nreach a client at all, and nothing in the projection stream says which _Measure\nrows are derived (FormulaCache is Vec\u003cString\u003e of Formula::raw, and recovering a\ntarget from that would be a second home for the unquoting gotcha 34 says has\nexactly one). A browser that is to tell the truth about a refused delete needs\neither a ServerFrame::Rejected arm or an enriched projection.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T02:27:40Z","created_by":"Edward Langley","updated_at":"2026-09-22T02:27:40Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-25bf","title":"The category panel's Space cycles the axis silently while the tile route announces it","description":"MEASURED at 523d571: cycle-axis-at-cursor from the category panel emits no\nSetStatus at all, where the tile-bar route for the same operation emits\n\"Product -\u003e None\".\n\nFour Space presses moved Product's axis through None/Row/Col/Page with #status\nfrozen the whole time on a stale \"Filter: Product = Espresso\" (see\nimprovise-z336 for the staleness itself).\n\nSo there are two derivations of \"cycle this category's axis\" and only one of\nthem tells the user. design-principles section 5 is about exactly this: a\nderivation done twice will diverge, and the divergence is invisible until\nsomeone compares them side by side.\n\nWorth noting Space IS advertised in the CATEGORIES hint as \"Space:cycle-axis\",\nso this is not a black hole -- the key works. It is the feedback that is missing,\nwhich makes it a smaller problem than improvise-koh6 and a different one.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T00:22:25Z","created_by":"Edward Langley","updated_at":"2026-09-22T00:22:25Z","labels":["browser","ux"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-ipqu","title":"The FitOutcome::warnings order stated in two places is checked in none","description":"Found by the adversarial reviewer of PR #111 at head 20f04c83.\n\ncrates/improvise-gp/src/fit.rs:317-318\n\n data.warnings.splice(0..0, table.warnings);\n data.warnings.splice(0..0, notices);\n\nyields notices -\u003e table -\u003e preprocess. BOTH the FitOutcome::warnings doc comment\n(\"the budget's notices, then the feature table's, then preprocess's\") AND\ngp.md section 14 assert that order, and FitOutcome's status line joins them\nverdict-first, so the order is USER-FACING.\n\nMEASURED: swapping the two statements passes all 73 improvise-gp tests.\n\nThe sharp part: PR #111's third commit (20f04c8) is a DOCS-ONLY commit whose\nentire subject is this ordering. So the repository now states the rule in two\nplaces and checks it in none.\n\nThis is the exact shape gotcha 43 records and closed -- \"held up only by the\norder of two statements\", fixed there by\na_published_basis_follows_the_grid_updates_it_describes, which was proved by\ninjecting the swap. Same repair applies here.\n\nWHAT IS NEEDED: one test asserting a notice precedes a table warning when BOTH\nare present. No current fixture produces both, which is why nothing caught it --\na budget that drops a column and a feature table that also warns. That fixture is\nthe work.\n\nSee also doctrine(fold_tests_must_test_order_not_the_axiom): asserting the\nconcatenation contains both is vacuous; the assertion has to be about ORDER.\n\nNOT a correctness bug, not a regression, and it touches neither improvise-pv3g's\nnor improvise-e8no's acceptance criteria -- hence P3 and not a merge blocker.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T23:29:51Z","created_by":"Edward Langley","updated_at":"2026-09-21T23:29:51Z","labels":["gp","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-nzeu","title":"A deserialized TargetModel can still carry a non-finite affine map","description":"Left open deliberately by PR #111 (improvise-pv3g), which made a non-finite\naffine map unrepresentable at the point it is DERIVED but not at the point it is\nDESERIALIZED.\n\n`TargetModel::Regress { mean: f32, std: f32 }` in `crates/gp-tree/src/data.rs`\nstill carries a raw pair. So a hand-edited or corrupted JSON model can\nreintroduce a non-finite map on `fit_csv --predict`, which is exactly the\npoisoned state improvise-pv3g made unconstructible everywhere else.\n\nWhat pv3g DID close: `fit_csv --save` can no longer WRITE one, because\n`standardize` refuses before a model is produced. So the hole is narrower than\nit was and is inbound-only.\n\nTHE FIX SHAPE, and it matters which one: `#[serde(try_from = \"...\")]` on\n`Scaling`, so deserialization goes through the same one constructor that already\nrefuses a non-finite mean or std. NOT a check at the use site -- a use-site check\nis the validated form of an invariant that pv3g just spent its whole change\nmaking unrepresentable, and adding one back would leave two answers to \"is this\nmap usable\" with nothing keeping them in agreement.\n\nNote this reaches `Scaling`'s constructor by a path that IS meaningful for its\notherwise-unreachable `mean` check: `doctrine(an_unreachable_half_of_a_guard_must_say_so)`\nrecords that no `standardize` path can produce a non-finite mean, so the\nconstructor's mean arm is a contract of the type only. A deserializer is\nprecisely the caller that makes that contract load-bearing, so closing this bead\nalso gives that half a reachable path and a test.\n\nReachability today: requires a hand-edited or corrupted model file. Low, which is\nwhy it was not widened into pv3g's scope.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T23:05:32Z","created_by":"Edward Langley","updated_at":"2026-09-21T23:05:32Z","labels":["gp"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-ipqu","title":"The FitOutcome::warnings order stated in two places is checked in none","description":"Found by the adversarial reviewer of PR #111 at head 20f04c83.\n\ncrates/improvise-gp/src/fit.rs:317-318\n\n data.warnings.splice(0..0, table.warnings);\n data.warnings.splice(0..0, notices);\n\nyields notices -\u003e table -\u003e preprocess. BOTH the FitOutcome::warnings doc comment\n(\"the budget's notices, then the feature table's, then preprocess's\") AND\ngp.md section 14 assert that order, and FitOutcome's status line joins them\nverdict-first, so the order is USER-FACING.\n\nMEASURED: swapping the two statements passes all 73 improvise-gp tests.\n\nThe sharp part: PR #111's third commit (20f04c8) is a DOCS-ONLY commit whose\nentire subject is this ordering. So the repository now states the rule in two\nplaces and checks it in none.\n\nThis is the exact shape gotcha 43 records and closed -- \"held up only by the\norder of two statements\", fixed there by\na_published_basis_follows_the_grid_updates_it_describes, which was proved by\ninjecting the swap. Same repair applies here.\n\nWHAT IS NEEDED: one test asserting a notice precedes a table warning when BOTH\nare present. No current fixture produces both, which is why nothing caught it --\na budget that drops a column and a feature table that also warns. That fixture is\nthe work.\n\nSee also doctrine(fold_tests_must_test_order_not_the_axiom): asserting the\nconcatenation contains both is vacuous; the assertion has to be about ORDER.\n\nNOT a correctness bug, not a regression, and it touches neither improvise-pv3g's\nnor improvise-e8no's acceptance criteria -- hence P3 and not a merge blocker.\n","status":"in_progress","priority":3,"issue_type":"bug","assignee":"agent-core","owner":"el-github@elangley.org","created_at":"2026-09-21T23:29:51Z","created_by":"Edward Langley","updated_at":"2026-09-23T01:25:50Z","started_at":"2026-09-23T01:25:50Z","lease_expires_at":"2026-09-23T01:30:50Z","heartbeat_at":"2026-09-23T01:25:50Z","labels":["gp","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-nzeu","title":"A deserialized TargetModel can still carry a non-finite affine map","description":"Left open deliberately by PR #111 (improvise-pv3g), which made a non-finite\naffine map unrepresentable at the point it is DERIVED but not at the point it is\nDESERIALIZED.\n\n`TargetModel::Regress { mean: f32, std: f32 }` in `crates/gp-tree/src/data.rs`\nstill carries a raw pair. So a hand-edited or corrupted JSON model can\nreintroduce a non-finite map on `fit_csv --predict`, which is exactly the\npoisoned state improvise-pv3g made unconstructible everywhere else.\n\nWhat pv3g DID close: `fit_csv --save` can no longer WRITE one, because\n`standardize` refuses before a model is produced. So the hole is narrower than\nit was and is inbound-only.\n\nTHE FIX SHAPE, and it matters which one: `#[serde(try_from = \"...\")]` on\n`Scaling`, so deserialization goes through the same one constructor that already\nrefuses a non-finite mean or std. NOT a check at the use site -- a use-site check\nis the validated form of an invariant that pv3g just spent its whole change\nmaking unrepresentable, and adding one back would leave two answers to \"is this\nmap usable\" with nothing keeping them in agreement.\n\nNote this reaches `Scaling`'s constructor by a path that IS meaningful for its\notherwise-unreachable `mean` check: `doctrine(an_unreachable_half_of_a_guard_must_say_so)`\nrecords that no `standardize` path can produce a non-finite mean, so the\nconstructor's mean arm is a contract of the type only. A deserializer is\nprecisely the caller that makes that contract load-bearing, so closing this bead\nalso gives that half a reachable path and a test.\n\nReachability today: requires a hand-edited or corrupted model file. Low, which is\nwhy it was not widened into pv3g's scope.\n","status":"in_progress","priority":3,"issue_type":"bug","assignee":"agent-core","owner":"el-github@elangley.org","created_at":"2026-09-21T23:05:32Z","created_by":"Edward Langley","updated_at":"2026-09-23T01:30:35Z","started_at":"2026-09-23T01:30:35Z","lease_expires_at":"2026-09-23T01:35:35Z","heartbeat_at":"2026-09-23T01:30:35Z","labels":["gp"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-7k7v","title":"check-evidence cannot see a misfiled or body-severed evidence entry","description":"Found by a wave agent reviewing its own diff, NOT by the checker.\n\nscripts/check-comment-runs.py --check-evidence validates that every\ntest-name-shaped heading in context/test-evidence/ names a test that RUNS, and\nthat no test is named twice. It never asks whether the entry sits in the FILE it\nbelongs to, or whether a heading still has the body that was written for it.\n\nObserved: moving two new entries into protocol.md silently dragged the prune\nsection's fault table out of core.md along with them, and --check-evidence\nSTAYED GREEN. A misfiled or body-severed entry is invisible to it. Caught only\nby reading the diff -- which design-principles section 7 already records as\nhaving failed once, in the set_panel_open_and_cursor case.\n\nA second instance in the same session: wide_cell_clamps_without_overflow's\nevidence had been filed in core.md by hand, while improvise-yae4 touches\nviewport.rs and render.rs and does not touch improvise-core at all.\n\nSo the blind spot is structural rather than a slip, and it has two shapes:\n - an entry in the wrong crate's file\n - a heading whose body has been severed from it\n\nWHAT A FIX WOULD NEED TO DECIDE FIRST, because it is not obvious: there is no\nmechanical mapping from a test to the evidence file it belongs in. cargo's\n--list gives a module path, and the file naming is by AREA (core, protocol,\nclients, tooling, command, gp, formula-and-io, tui), which is a human judgement\nin the general case. A checker that guessed would produce false failures, which\nis worse than the current silence.\n\nA cheaper partial that avoids that problem entirely: assert every heading has a\nNON-EMPTY body before the next heading. That catches the severed-body shape\nwithout needing to know where an entry belongs, and it is the shape that lost\nreal content here.\n\nSee context/code/tooling.md for why the names come from cargo rather than from\nthe source (improvise-rm4s), which is the reasoning any change here must respect.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T22:47:55Z","created_by":"Edward Langley","updated_at":"2026-09-21T22:47:55Z","labels":["testing","tooling"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-9xsp","title":"The grid frame's prune composition is asserted at both ends and not across the seam","description":"PR #106 closed the narrower half: AxisFrame::group_of's GroupEnd rule now has\ntwo tests in improvise-protocol's render.rs, over HAND-BUILT frames. Injecting\n`FrameEntry::GroupEnd =\u003e None` fails both. Before that, the same injection left\nall 133 improvise-protocol lib tests passing.\n\nWHAT IS STILL NOT PINNED, and it is the seam rather than the contract: that\n`prune_axis_items -\u003e axis_frame -\u003e group_of` actually PRODUCES those frames. The\ntwo new tests assert group_of behaves correctly on frames someone hand-built;\nnothing asserts the real pipeline emits frames of that shape from a pruned\nlayout. So the property reaches the protocol by construction through project.rs\nand is asserted only at each end.\n\nThat is the shape gotcha 44 and design-principles section 5 exist to prevent --\na derivation living in two places with only one of them asserted.\n\nTHE FIXTURE, measured by hand and passing today (frame dump is in PR #106's\nbody), so this is a test to land rather than a bug to find:\n\n Month: Jan[Q1], Feb[Q1], Mar[Q2], Sep with Mar unfunded, prune_empty on\n assert grid_of(\u0026app, vp).row_frame.group_of(2) == None\n\n plus the collapsed fixture asserting the frame carries\n Group(FrameGroup { name: \"Q1\", collapsed: true })\n\nThe collapsed assertion matters on its own: a header arriving WITHOUT the flag\nis present and inert -- the user sees a group they cannot expand, which is\nimprovise-lhi4 one layer out.\n\nLives in improvise-command/src/ui/project.rs, which was outside the wave agent's\nground; that is the only reason it was not landed with #106.\n","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-21T22:47:53Z","created_by":"Edward Langley","updated_at":"2026-09-21T22:47:53Z","labels":["protocol","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-yss5","title":"extract_date_component silently drops a row whose date does not match the detected format","description":"Noticed while fixing improvise-jcfs, NOT reproduced -- so the first job is a\nfailing test, per AGENTS.md.\n\nextract_date_component silently returns None for a value that does not parse\nunder the detected format. The consequence: a row with one odd date vanishes\nfrom the derived _Year/_Month category with no warning at all.\n\nThis is the same shape as the three defects improvise-2cw9/jc95/5hfh closed --\na value the code cannot interpret becomes an absence rather than a report. The\nimport path now has warning machinery (csv_parser and wizard both emit), and the\nhouse wording is lowercase with single-quoted names and counts, e.g.\n`column 'E' missing from 2 row(s)`.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T21:59:44Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:59:44Z","labels":["import"],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -323,7 +363,7 @@ {"_type":"issue","id":"improvise-swre","title":"grid_of recomputes an unchanged grid every frame; memoise on the tiers that already exist","description":"Split out of improvise-jmjz on the adversarial review of PR #92, which closed jmjz on its own acceptance criterion (fuse the passes) and argued this is the separate, larger win.\n\ngrid_of recomputes the whole grid from scratch EVERY FRAME, even when nothing moved. After jmjz that is exactly one pass rather than three — 95ms at 18,000 cells, 1.32s at 90,000 — but the common case during scrolling, panel toggling and cursor movement is that the grid has not changed at all.\n\n## Why this is cheap to do here\nThe invalidation signal ALREADY EXISTS and is maintained for other reasons:\n- document_generation, layout_version, record_versions (gotcha 40, ADR 001's three tiers)\n- improvise-mcq4's view_shapes / view_frame observation\n\nA GridBody memo keyed on those takes an unchanged grid to roughly zero.\n\n## Why the remaining pass cannot be narrowed instead\nRuled out twice over, and the review measured it: totals sum EVERY row including ones a viewport scrolled past (gotcha 44), and widths are content-adaptive over every row. The benchmark's windowing ratio stayed 1.2-1.4x precisely because the surviving pass is the one a viewport cannot narrow. improvise-i5tk's decline stands.\n\n## Acceptance Criteria\n- A memo on the projected grid body keyed on the existing invalidation tiers; no NEW tier invented, because a second description of \"has the document changed\" is the disease (see the reasoning behind gotcha 39 and 45, where the trigger is the counter moving rather than a table of command variants).\n- Benchmarked: an unchanged grid across frames should approach zero, and the first frame must not regress.\n- The negative arm matters as much as the positive: a test that a grid which DID change is recomputed. A memo that never invalidates is fast and wrong, and it is the failure mode nothing will notice.\n\n## Second-order, much bigger, not this ticket\n~5us per cell at 18k is dominated by evaluate_aggregated doing a matching_values set intersection per cell (gotcha 18). An incremental aggregate index is the deeper fix. 90,000 cells at 1.32s remains unusable; that case is labelled pathological and memoisation helps it most.\n","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-21T11:30:15Z","created_by":"Edward Langley","updated_at":"2026-09-21T11:30:15Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-4dpj","title":"The RecordsNormal-to-Normal arm of settle_normal_mode has zero coverage, and it is live","description":"Two gaps in settle_normal_mode's coverage, both live code with zero tests. The second was raised by ChatGPT Codex on PR #91 and is recorded here because the behaviour turned out CORRECT and only the guard is missing.\n\n## Gap 1 — the RecordsNormal-to-Normal arm (found by injection)\nReplacing settle_normal_mode's guard with a one-way version — matching only AppMode::Normal, dropping the RecordsNormal arm — leaves THE ENTIRE WORKSPACE SUITE PASSING.\n\nThe existing crossing test drives the LAYOUT both ways, but both branches end with `enter-mode normal`, so the mode handed to the settle is AppMode::Normal in BOTH directions. It discriminates \"settles at all\" and \"settles against the ending layout\", but not \"settles from either mode\".\n\nThe arm is live, proved reachable in production:\n\n records mode -\u003e apply_effects([SetAxis { \"_Index\", Axis::None }])\n layout.is_records_mode() == false, mode was RecordsNormal\n -\u003e settle yields AppMode::Normal\n\nOne extra case in the existing loop — seed the mode as RecordsNormal, cross OUT — closes it, and the one-way injection must then fail by name.\n\n## Gap 2 — the SwitchView path, raised by Codex and VERIFIED CORRECT\nCodex reviewed PR #91 at c186909 (round 1, the per-effect resolution) and pointed out that SwitchViewAtCursor emits [SwitchView(target), ChangeMode(Normal)]: open the view panel over a records view, select a pivot view, and round 1 would leave RecordsNormal over the pivot grid, with the inverse leaving Normal over records.\n\nRound 2 (a10e95e) moved the settle to ONE call after rebuild_layout at the end of apply_effects, so it resolves against the POST-batch layout. Verified on main at ce895ff with a throwaway probe driving a real `:` line that crosses records-ness within one batch:\n\n start_records=true -\u003e ended_records=false mode=Normal\n start_records=false -\u003e ended_records=true mode=RecordsNormal\n\nCorrect in both directions. So the behaviour Codex named is fixed.\n\nTHE GUARD IS STILL MISSING. Codex asked specifically for \"a regression that switches between records and pivot views through the view panel\", and no such test exists. The path it names is different from the one the crossing test drives (toggle-records-mode, not SwitchView), and records-ness is auto-detected from the axes (gotcha 1), so a view switch can change it.\n\n## Acceptance Criteria\n- The gap-1 case added to the existing loop; the one-way-guard injection fails by name.\n- A regression driving SwitchViewAtCursor over the view panel, records view to pivot view AND back, asserting the mode matches the grid the batch ends on.\n- Both must fail if the settle is moved back before rebuild_layout.\n","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T11:29:18Z","created_by":"Edward Langley","updated_at":"2026-09-21T18:04:27Z","closed_at":"2026-09-21T18:04:27Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-26dm","title":"The fourth length-not-completeness list: cases.len() == 13 in ui/effect.rs","description":"improvise-5ubs is closed for the two CYCLIC all() functions, by reading serde's\nderive-generated variant list out of its \"unknown variant ... expected one of\"\nmessage and diffing it against the names the cycle reached. That closes the\nAppMode and Rejection halves and the mechanism generalises to any serde enum.\n\nIt does NOT reach the FOURTH instance 5ubs records in its notes:\nlayout_geometry_effects_actually_change_document_config in\ncrates/improvise-command/src/ui/effect.rs, whose assert_eq!(cases.len(), 13) is\nover a hand-built list of EFFECTS, not of enum variants. serde has nothing to\nsay about it: an effect is a struct implementing a trait, so there is no derived\nenumeration to diff against.\n\nThe general statement stands and is the reason this is worth its own ticket:\ndoctrine(a_list_checked_for_length_reads_like_a_list_checked_for_completeness).\nA count catches a case being DELETED from the list and never one being ABSENT\nfrom it, and the two are written identically as an assert_eq over a number. The\nadversarial review of PR #78 added a fourteenth pub struct declaring\nEffectKind::Model(ModelChange::LayoutGeometry), pointed at by nothing, and the\nwhole suite stayed green.\n\nWhat would close it: an enumeration of the effects the compiler maintains. The\ncandidates are a proc-macro over the impl blocks, or inventory-style\nregistration, or reading the Debug names off a derived list the way\nevery_model_command_names_the_effect_of_the_same_name already does for the\nModelCommand mapping -- that last one is the closest existing precedent in this\nrepo and is worth looking at first.\n\nNot fixed in wave I because ui/effect.rs was another wave's ground that round,\nand because a fourth hand-rolled attempt in a fourth place is the disease 5ubs\nnames.\n","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T11:09:56Z","created_by":"Edward Langley","updated_at":"2026-09-21T18:04:28Z","closed_at":"2026-09-21T18:04:28Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-e8no","title":"A wide numeric category now refuses a fit that used to ignore it","description":"improvise-zv8t forces every document category to `Forced::Categorical`, which is\nright (a `Year` category with items 2024/2025 is a coordinate, not a number).\nThe cost: a WIDE numeric category one-hots past `MAX_FEATURES` and refuses the\nwhole fit, where before it encoded as one numeric feature and a fit that never\nsplit on it succeeded.\n\nMeasured on 100 records with a 100-item numeric `Year` beside a `Cost` that\npredicts `Score` on its own:\n- pre-zv8t: fit lands on `IF(Cost \u003c 48.015...)`; `Year` never appears.\n- post-zv8t: preflight refuses at 101 features.\n\nPinned by `a_wide_numeric_category_now_refuses_a_fit_that_used_to_ignore_it`\nin crates/improvise-gp/src/fit.rs, and argued in context/code/gp.md section 11.\n\nThe workaround the refusal names works: mark the category a Label and the\npre-zv8t feature set is restored exactly. What is lost is the case where the\nuser wants neither -- a wide numeric category left alone while the fit uses the\nmeasures.\n\n## Steps to Reproduce\n\nBuild a model with a Regular category `Year` holding 100 numeric items\n(1900..1999), a Label category for record identity, and two measures `Cost` and\n`Score` where Score = 4000 if Cost \u003e 50 else 1000. Put `Year` on Axis::Row and\n_Measure on Axis::Page. Run `fit_measure(.., \"Score\", ..)`.\n\nPre-zv8t it lands on `IF(Cost \u003c 48.015..)`. Post-zv8t it is refused with\n\"would encode 101 features from 100 records\". The fixture is\n`wide_category_beside_a_good_measure()` in crates/improvise-gp/src/fit.rs.\n\n## Acceptance Criteria\n\nThe honest fix is a budget that can decline A COLUMN rather than the whole\ntable: `encoded_width` (and the exact check beside it) would drop the widest\ncategory and report it as a `Dataset::warnings` notice -- gotcha 37(a), it comes\nback as DATA and nothing prints. That keeps the zv8t decision intact (a category\nis still never inferred numeric) and changes only what the budget does when it\ncannot afford one.\n\nDo NOT close this by making the forcing conditional on cardinality: that is\ninference by another name, and makes the ENCODING depend on a budget rather than\non what the document says.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T10:55:32Z","created_by":"Edward Langley","updated_at":"2026-09-21T10:55:39Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-e8no","title":"A wide numeric category now refuses a fit that used to ignore it","description":"improvise-zv8t forces every document category to `Forced::Categorical`, which is\nright (a `Year` category with items 2024/2025 is a coordinate, not a number).\nThe cost: a WIDE numeric category one-hots past `MAX_FEATURES` and refuses the\nwhole fit, where before it encoded as one numeric feature and a fit that never\nsplit on it succeeded.\n\nMeasured on 100 records with a 100-item numeric `Year` beside a `Cost` that\npredicts `Score` on its own:\n- pre-zv8t: fit lands on `IF(Cost \u003c 48.015...)`; `Year` never appears.\n- post-zv8t: preflight refuses at 101 features.\n\nPinned by `a_wide_numeric_category_now_refuses_a_fit_that_used_to_ignore_it`\nin crates/improvise-gp/src/fit.rs, and argued in context/code/gp.md section 11.\n\nThe workaround the refusal names works: mark the category a Label and the\npre-zv8t feature set is restored exactly. What is lost is the case where the\nuser wants neither -- a wide numeric category left alone while the fit uses the\nmeasures.\n\n## Steps to Reproduce\n\nBuild a model with a Regular category `Year` holding 100 numeric items\n(1900..1999), a Label category for record identity, and two measures `Cost` and\n`Score` where Score = 4000 if Cost \u003e 50 else 1000. Put `Year` on Axis::Row and\n_Measure on Axis::Page. Run `fit_measure(.., \"Score\", ..)`.\n\nPre-zv8t it lands on `IF(Cost \u003c 48.015..)`. Post-zv8t it is refused with\n\"would encode 101 features from 100 records\". The fixture is\n`wide_category_beside_a_good_measure()` in crates/improvise-gp/src/fit.rs.\n\n## Acceptance Criteria\n\nThe honest fix is a budget that can decline A COLUMN rather than the whole\ntable: `encoded_width` (and the exact check beside it) would drop the widest\ncategory and report it as a `Dataset::warnings` notice -- gotcha 37(a), it comes\nback as DATA and nothing prints. That keeps the zv8t decision intact (a category\nis still never inferred numeric) and changes only what the budget does when it\ncannot afford one.\n\nDo NOT close this by making the forcing conditional on cardinality: that is\ninference by another name, and makes the ENCODING depend on a budget rather than\non what the document says.\n","notes":"STALE-OPEN. Its acceptance -- a budget that DROPS a column rather than refusing the table -- is implemented and passing. Verified by the orchestrator by running them: improvise-gp fit::tests::a_wide_numeric_category_is_dropped_rather_than_refusing_the_fit (crates/improvise-gp/src/fit.rs:735) and the_budget_drops_the_widest_category_and_keeps_the_narrow_one both pass at 1ae2d2e, 1 passed each.\n\nFound by the improvise-k844 agent, which noticed because THIS TICKET'S BODY HAD BEEN COPIED INTO k844's DESCRIPTION -- so it read e8no's text while working k844's title, and checked whether e8no was done. The tracker defect is what surfaced the stale-open.\n\nEighth stale-open found today. See doctrine(a_ready_ticket_may_already_be_fixed).","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T10:55:32Z","created_by":"Edward Langley","updated_at":"2026-09-22T23:11:20Z","closed_at":"2026-09-22T23:11:20Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-sxsf","title":"cargo test -- --list cannot name the binary, so two crates could share a full test path","description":"MEASURED 2026-09-21 (PR #88). Re-verified latent: 1444 names, zero duplicate full paths, zero duplicate (crate, path) pairs.\n\nTHE PROPOSED FIX IS DECLINED ON TWO INDEPENDENT GROUNDS, and the second is the interesting one:\n\n1. COST: 45.2s against 2.4s for the one workspace call (11 members, warm) — 18x. And --check-evidence needs the --ignored twin, so the honest figure is 22 cargo invocations.\n\n2. IT DOES NOT CLOSE THE CLASS IT NAMES. A package is not a binary. The improvise crate alone has FIVE integration test binaries beside its lib, and an integration test's top-level fn lists with NO MODULE PATH AT ALL — 60 of 1444 names are bare. So '-p improvise' prefixes all six identically and tests/a.rs::foo still cannot be told from tests/b.rs::foo.\n\nAny real fix is per-TARGET (--lib, --test \u003cname\u003e), not per-package. Whoever takes this should start there.\n\nWorth noting how this was found: the agent set out to time the per-package fix and discovered it does not work while reading the target list. The cost measurement was the excuse for reading it; the correctness finding was the payoff — an argument for measuring declines rather than asserting them.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T04:49:16Z","created_by":"Edward Langley","updated_at":"2026-09-21T05:23:48Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-8shu","title":"Constrain the .improv grammar generator's strategy; improvise-io's lib binary is the run's outlier","description":"Split out of improvise-8ec3, whose second half (stop pinning one machine's number) is done. This is the first half, left open.\n\nimprovise-io's lib binary is an order of magnitude above every other binary in the run on every machine it has been measured on: 1.40-1.45s over three runs here (vs a ~0.06s median), 3.70-5.24s on the machine that filed 8ec3. The ratio is the portable signal; the seconds are not.\n\nIts slow half is the grammar-walking generator in persistence/parse.rs plus the .improv roundtrip proptests at the default 256 cases. A grammar walk with no size bound generates deeply nested documents whose cost is not uniform across cases, so a few cases dominate.\n\ndoctrine(proptest_cases) rules out the cheap fix: constrain the strategy, do NOT lower the count below 256.\n\nAcceptance: improvise-io's lib binary is no longer an order of magnitude above the run's median, with the case count still 256 and the properties still covering groups, formulas, views, hidden items and pipe-quoting edges. Report the before/after ratio, not seconds.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-21T04:14:01Z","created_by":"Edward Langley","updated_at":"2026-09-21T05:15:42Z","closed_at":"2026-09-21T05:15:42Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-9v0j","title":"view_frame and view_shape are separated by example, not by property","description":"RE-SCOPED 2026-09-21. The ticket proposed a property test asserting that view_frame and view_shape agree — roughly \"every reframe is also a reshape\". THAT PROPERTY IS FALSE and the test would have failed. Measured by the Wave I agent and confirmed by the adversarial review of PR #92, with a prune_empty view of two rows and a ClearCell on North's only cell, driven through sessions.apply_caught_up:\n\n rows 2 -\u003e 1, layout_version 0 -\u003e 0, cursor_epoch 0 -\u003e 1\n\nA genuine reframe with no reshape. That is improvise-mcq4 working as designed: a cell write changes DATA, not the view's CONFIGURATION, and view_shape hashes configuration. Writing the proposed test would have been wrong.\n\nSo the original acceptance criterion is withdrawn. What remains is the real question the ticket was reaching for:\n\n## Acceptance Criteria (replaced)\n- State, in context/code/command.md, the PROPERTY that actually separates the two: view_shape answers \"has this view's configuration changed\" and view_frame answers \"does this view draw differently\". A data write moves the second and not the first, by design.\n- If a property test is wanted, it must be the true one — e.g. a configuration change moves BOTH, and a pure data write moves only the frame — with the direction asserted rather than the conjunction.\n- The consequence for ADR 001's middle tier is filed separately as improvise-6nsd (P4), with the severity note: a client names a cell by RecordHash, a coordinate surrogate, so a renumbered row does not misaddress a write, and cursor_epoch did move so every client is told.\n\nNote the file ownership that blocked the original attempt: view_frame, view_shape, view_frames and view_shapes are all private in crates/improvise-command/src/ui/app.rs.\n","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-21T04:01:34Z","created_by":"claude-agent-protocol","updated_at":"2026-09-21T17:09:51Z","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -374,9 +414,6 @@ {"_type":"issue","id":"improvise-z05r","title":"CommitExport does not bail on an empty path, and ExportCsv does not abort the batch","description":"Found by WU-G on PR #27 while fixing the save arms, and left deliberately: these are the same two defects one file over, at lower severity, and fixing them in that PR would have widened a save-focused change into an export-focused one.\n\nTWO ARMS, both mirroring what was just fixed for save:\n 1. CommitExport does not bail on an empty path, where CommitBuffer's three kinds all do (gotcha 20: buffer -\u003e trim -\u003e bail-on-empty -\u003e dirty -\u003e status is the shared spine).\n 2. ExportCsv does not set abort_effects on failure, where Save and (as of PR #27) SaveAs both do. So a `Binding::Sequence` or a `:` line composing an export with anything after it continues past a failed write.\n\nWHY IT IS LOWER SEVERITY THAN THE SAVE ARMS, and this is the honest reason it was deferred rather than a claim that it does not matter: an export failure discards nothing. improvise-8tk was a P1 because ZZ quit and the model was gone. Here the model is untouched and the user has simply not got the CSV they asked for -- so the cost is a missing file and a message, not lost work.\n\nNOTE THE SHAPE, because it is the third instance in this area: improvise-8tk fixed Save and not SaveAs; PR #27 found SaveAs; and this is the export arm of the same rule. doctrine(one_arm_of_a_symmetric_rule_is_evidence_about_that_arm_only) exists for exactly this, and the general fix may be better than the third patch -- consider whether \"an effect that writes a file and fails aborts the batch\" can be made structural rather than remembered three times.\n\nALSO CHECK, while in there: whether the export error is visible at all, given that ExportPrompt is a minibuffer mode and a status message written while a minibuffer is open is never drawn (see the sibling bead on draw_bottom_bar).\n\n## Steps to Reproduce\n\n1. `CommitExport` with an empty path: it does not bail, where `CommitBuffer`'s three kinds all do (gotcha 20's shared spine is buffer → trim → bail-on-empty → dirty → status).\n2. Compose an export with anything after it — a `Binding::Sequence`, or a `:` line on ` . ` — and make the write fail. `ExportCsv` does not set `abort_effects`, so the chain continues past the failure, where `Save` and (since PR #27) `SaveAs` both stop it.\n\nLower severity than the save arms because an export failure discards nothing: the model is untouched and the user simply has not got their CSV.\n","acceptance_criteria":"CommitExport bails on an empty path like the other three commit kinds, and ExportCsv aborts the batch on a failed write like Save and SaveAs. Prefer the structural fix over the third patch: this is the third instance of the same asymmetry, so consider whether 'an effect that writes a file and fails aborts the batch' can be made impossible to forget rather than remembered three times.","status":"closed","priority":3,"issue_type":"bug","assignee":"claude-agent-cmd","owner":"el-github@elangley.org","created_at":"2026-09-20T06:04:21Z","created_by":"Edward Langley","updated_at":"2026-09-21T04:43:49Z","started_at":"2026-09-21T03:27:54Z","closed_at":"2026-09-21T04:43:49Z","labels":["area:tui"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-44ad","title":"ColWidths is redundant under the label fold, and the test that would change is gotcha 33(c)'s evidence","description":"Found while closing improvise-hkt6 (PR #20, WU-K), tried, and DELIBERATELY REVERTED rather than landed -- filed so it is a decision rather than a rediscovery.\n\nhkt6(b) obliged grid_updates to diff `totals` against the LABEL-FOLDED cache rather than against the mirror (see gotcha 33). The symmetric move for `col_widths` looks obviously right and is not free: it BREAKS `a_client_on_a_socket_is_seeded_and_gets_its_edit_back`.\n\nWHY, and it is a real property rather than a brittle assertion: folding the labels RE-FITS the widths (gotcha 33(c) -- every arm that touches labels or widths re-fits, because a renderer zips widths with labels and a length mismatch indexes off the end). So under a fold-relative diff the ColWidths that a reframe currently carries becomes REDUNDANT and is suppressed. The client is correct either way; what changes is whether the reframe still SENDS the widths.\n\nTHE REASON IT WAS NOT SPENT IN PR #20: that assertion is gotcha 33(c)'s standing evidence -- it is the test that catches \"a late ColWidths put the mismatch straight back\". Rewriting it inside a PR whose subject was three totality gaps would have destroyed the evidence for a rule while fixing something else, which is exactly the shape improvise-q0vb was declined for on PR #12. Two right changes, different costs.\n\nTHE DECISION TO MAKE:\n * Leave widths diffed against the mirror. Costs one redundant ColWidths per reframe -- the same direction Dirty::of is deliberately sloppy in (gotcha 35: over-report pays one redundant Projection, under-report leaves a client wrong forever). Defensible and arguably correct on that principle alone.\n * Or move widths to the fold and REPLACE the socket assertion with one that still pins 33(c)'s property -- that a client never holds widths of a different length from its labels -- rather than one that pins the message count. That is the better test either way, and writing it is most of the work.\n\nPREFER THE SECOND ONLY IF the replacement test is written first and shown to fail against a deliberately late ColWidths. Otherwise prefer the first and say so in gotcha 33, so the asymmetry between totals and widths is STATED rather than discovered by the next person who notices it.\n\n## Rationale\n\nimprovise-hkt6 obliged `grid_updates` to diff `totals` against the LABEL-FOLDED cache rather than the mirror, or a rename that leaves the number equal sends no `Totals` and the client blanks its own. The symmetric move for `col_widths` looks obviously right and is not free: it breaks `a_client_on_a_socket_is_seeded_and_gets_its_edit_back`.\n\nThat is a real property, not a brittle assertion. Folding the labels RE-FITS the widths (gotcha 33(c)), so under a fold-relative diff the `ColWidths` a reframe currently carries becomes redundant and is suppressed. The client is correct either way; what changes is whether the reframe still SENDS them.\n\n## Alternatives Considered\n\n**Leave widths diffed against the mirror.** Costs one redundant `ColWidths` per reframe — the same direction `Dirty::of` is deliberately sloppy in (gotcha 35: over-report pays one redundant Projection, under-report leaves a client wrong forever). Defensible on that principle alone, and then the asymmetry between totals and widths should be STATED in gotcha 33 rather than rediscovered.\n\n**Move widths to the fold and replace the socket assertion** with one that still pins 33(c)'s property — that a client never holds widths of a different length from its labels — rather than one that pins the message count. That is the better test either way, and writing it is most of the work.\n\n**Not acceptable: move widths to the fold and simply update the failing assertion.** It is gotcha 33(c)'s standing evidence, and rewriting it inside a change it is meant to guard destroys the evidence for that change — the improvise-q0vb / PR #12 shape.\n","status":"closed","priority":3,"issue_type":"decision","owner":"el-github@elangley.org","created_at":"2026-09-20T05:54:56Z","created_by":"Edward Langley","updated_at":"2026-09-21T12:39:38Z","closed_at":"2026-09-21T12:39:38Z","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_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":"closed","priority":3,"issue_type":"task","assignee":"claude-agent-protocol","owner":"el-github@elangley.org","created_at":"2026-09-20T05:45:29Z","created_by":"Edward Langley","updated_at":"2026-09-21T03:47:03Z","started_at":"2026-09-21T03:28:20Z","closed_at":"2026-09-21T03:47:03Z","close_reason":"Closed","labels":["area:shared-frontend"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-rucf.3","title":"Projection freshness for a source an external writer can change","description":"Child of improvise-rucf (ADR 003, live-backed models). Filed and DEFERRED 2026-09-19 alongside its parent.\n\nADR 001 governs concurrent EDITS. A live-backed model is read-only, so it has none and the basis check does not apply to it -- the guarantee is not voided, it is scoped to documents improvise owns, which is what it always meant.\n\nWHAT REMAINS IS A DIFFERENT AND SMALLER QUESTION, and this bead is it: an EXTERNAL WRITER changes what clients SEE. So a live-backed projection needs a freshness story -- polled, triggered, or explicitly stale-until-refreshed. That is projection freshness, not conflict resolution, and conflating the two is the trap.\n\nNOTE WHAT CHANGES ABOUT THE PROJECTION ARCHITECTURE: today's diff (ui/project.rs::grid_updates) compares a desired state derived from the App against each client's mirror. With a live source, \"the desired state\" involves latency -- a HashMap lookup becomes a query -- so the VIEWPORT-BOUNDED PROJECTION STOPS BEING AN OPTIMISATION AND BECOMES LOAD-BEARING. improvise-i5tk (narrow the TUI's GridCache to its viewport, benchmark first) is the related bead and its answer may be forced by this one.\n\nSHARED WITH THE SQLITE SOURCE CHILD: the RecordHash -\u003e record index becomes a cache needing invalidation rather than a structure maintained alongside writes. One invalidation story, not two.\n\nEXPLICITLY OUT OF SCOPE: anything resembling a lease, a lock, or a reservation. Those are ADR 001 layer 2 and they are about edits.\n","status":"deferred","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T05:31:33Z","created_by":"Edward Langley","updated_at":"2026-09-20T05:31:35Z","dependencies":[{"issue_id":"improvise-rucf.3","depends_on_id":"improvise-rucf","type":"parent-child","created_at":"2026-09-20T05:31:33Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-rucf.2","title":"SQLite source implementation: aggregate pushdown over a user-supplied query, native-only and feature-gated","description":"Child of improvise-rucf (ADR 003, live-backed models). Filed and DEFERRED 2026-09-19 alongside its parent.\n\nThe SQLite implementation of the source trait: aggregate pushdown against a user-supplied query.\n\nWHICH ROWS: a user-supplied SELECT, with a bare table name as sugar over it. A query is strictly more useful (joins, filters, computed columns) and no harder to consume -- a result set is a result set.\n\nTYPE MAPPING, and it is BETTER INPUT THAN CSV rather than merely different: a SQLite column carries a declared type, where import/analyzer.rs::detect_date_format currently guesses a date by pattern-matching text. INTEGER/REAL are measure candidates, TEXT a category candidate, BLOB skip. NULL is ABSENCE, not the string \"null\" (improvise-ych). Dates may be TEXT, INTEGER (epoch) or REAL (julian) -- DETECT, DO NOT ASSUME. SQLite is dynamically typed per value, so both the declared-type and the sampled path are real and the classifier should take typed input where it has it.\n\nWHERE THE BOUNDARY GOES: forbidden(improvise_core, io). The source TRAIT belongs in improvise-core; this implementation belongs in improvise-io, FEATURE-GATED AND NATIVE-ONLY. libsqlite3-sys does not build for wasm32-unknown-unknown under this repo's nix cc-wrapper, and improvise-io is in the wasm bundle, so the wasm CI job must keep passing untouched. VERIFY THE GATE, do not assume it -- the improvise-9ix agent established the wasm failure is the nix wrapper injecting -fzero-call-used-regs rather than proof that bundled SQLite cannot target wasm, so the gating protects the browser bundle rather than working around an impossibility.\n\nNO WRITES, and not as a runtime refusal: per ADR 003 writes are not in the trait a query source implements, and a source projecting no CellId::Record makes a client write unconstructible.\n\nINTERNING AND IDENTITY: DataStore interns names and ADR 002 hashes the coordinate tuple. Both still work -- coordinates come from the query -- but the RecordHash -\u003e record index that resolves a CellId becomes A CACHE WITH AN INVALIDATION STORY rather than a structure maintained alongside writes. That invalidation is shared with the freshness child; do not solve it twice.\n","status":"deferred","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T05:31:32Z","created_by":"Edward Langley","updated_at":"2026-09-20T05:31:34Z","dependencies":[{"issue_id":"improvise-rucf.2","depends_on_id":"improvise-rucf","type":"parent-child","created_at":"2026-09-20T05:31:32Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-rucf.1","title":"The .improv source specification: a live-backed document is a saved query plus a pivot configuration","description":"Child of improvise-rucf (ADR 003, live-backed models). Filed and DEFERRED 2026-09-19 alongside its parent; the direction is accepted but not a priority.\n\nA live-backed document splits into OUR configuration and THEIR data, and .improv does not currently distinguish them. Today a file holds categories, views, formulas AND a ## Data section. A live-backed model's categories and cell values belong to the source; the views, formulas and axis assignments belong to improvise.\n\nSo the format grows a SOURCE SPECIFICATION where a live model would otherwise have data: a connection, a query, and the column-to-field mapping. Categories become DERIVED (SELECT DISTINCT) rather than declared.\n\nWHY THIS IS THE FIRST CHILD: persistence/improv.pest is the single source of truth (design-principles 3) and the parser is a tree-walker over it, so the grammar changes before the walker. The grammar-walking test generator reads the grammar AST via pest_meta, so a new production needs generator coverage or the round-trip proptests silently stop describing the format.\n\nTHE FRAMING THAT MAKES THIS FEEL RIGHT RATHER THAN LIKE A COMPLICATION, from the ADR: a live-backed .improv is A SAVED QUERY PLUS A PIVOT CONFIGURATION -- which is exactly what a saved report is in any BI tool. The document stops being \"my data\" and becomes \"my question\", portable between databases in a way a data file never is.\n\nOPEN: whether a document may be PARTLY live (some categories declared, some derived) or whether live and owned are exclusive per document. Exclusive is almost certainly right and much cheaper; say so deliberately rather than leaving it implied.\n","status":"deferred","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T05:31:31Z","created_by":"Edward Langley","updated_at":"2026-09-20T05:31:33Z","dependencies":[{"issue_id":"improvise-rucf.1","depends_on_id":"improvise-rucf","type":"parent-child","created_at":"2026-09-20T05:31:31Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-ktv1","title":"Move the help content into the shared layer so a browser can show help","description":"? and F1 now enter AppMode::Help in the browser -- the shared keymap delivered that for free -- BUT THE BROWSER HAS NO HELP OVERLAY, so the mode changes and nothing appears. src/ui/help.rs is ~600 lines of ratatui and nothing shared carries its content.\n\nTHE INTERIM, already shipped: the page draws AppMode::hint_text() in a #hint element, so each mode states its own keys. That is genuinely useful and is not a placeholder to rip out -- but it is one line, not five pages.\n\nWHY IT WAS DONE THAT WAY RATHER THAN SPECIAL-CASING: the alternative was to make the client refuse \"enter-mode help\", which is exactly the hand-written exception the keymap consolidation had just deleted. Declining to re-add one was right.\n\nTHE REAL FIX is the same shape as every other consolidation this week (design-principles 5): the help CONTENT is data -- pages of (key, description) -- and it belongs in improvise-protocol where both front ends read it. The TUI keeps its ratatui LAYOUT; the browser writes its own DOM; neither restates what the keys are.\n\nNOTE THE OPPORTUNITY: help content that lives beside the keymap can be DERIVED from it rather than written twice, which would close a drift nobody currently checks -- there is no test that the help text and the bindings agree, and doctrine(binding_dump_diff) exists because bindings have almost no direct tests. A help page generated from Keymaps::default_keymaps() cannot go stale.","acceptance_criteria":"A browser shows help. The content is data — pages of (key, description) — in improvise-protocol, with the TUI keeping its ratatui layout and the browser writing its own DOM; neither restates what the keys are. PREFER DERIVING IT FROM Keymaps::default_keymaps(), which closes a drift nothing currently checks: there is no test that the help text and the bindings agree. The AppMode::hint_text() interim in the browser is NOT a placeholder to rip out — it is one line per mode and genuinely useful; say what survives.","status":"closed","priority":3,"issue_type":"task","assignee":"wave2-h-frontend","owner":"el-github@elangley.org","created_at":"2026-09-20T04:38:45Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:18:06Z","started_at":"2026-09-20T17:53:40Z","closed_at":"2026-09-20T18:18:06Z","close_reason":"Closed","labels":["area:shared-frontend"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-e24v","title":"Second tranche of client-side key resolution: ~15 bindings needing no enrichment","description":"improvise-bpq2 closed the cursor half and DELIBERATELY STOPPED rather than half-doing a second tranche. These need NOTHING added to the protocol -- the operands are already in ViewState or a widget cache -- so they are the cheapest remaining keys, and each one currently does NOTHING in a browser (Host::handle answers ClientFrame::Key with an empty vec).\n\n * enter-mode \u003cx\u003e (: ? F1 C F V Esc Tab in most modes) -\u003e ChangeMode\n * toggle-panel-visibility / toggle-panel-and-focus -\u003e SetPanelOpen + ChangeMode\n * cycle-panel-focus\n * help-page-next / help-page-prev\n * transpose, toggle-prune-empty, view-back\n * switch-view-at-cursor / delete-view-at-cursor / create-and-switch-view (ViewListCache is flat)\n * commit-buffer category|formula|item (all operands in ViewState)\n * commit-cell-edit (ADR 002 CellId + the buffer)\n * clear-cell / paste (CellId + ViewState::yanked)\n * set-axis-for-tile / cycle-axis-for-tile\n\nALSO IN REACH, and bpq2 listed them under \"needs enrichment\" before establishing they do not: filter-to-item (Enter), open-item-add-at-cursor (a/o), delete-category-at-cursor (d), cycle-axis-at-cursor (Space) -- CatTreeEntry already carries the name and the axis. Each is a distinct Command shape, which is why they were left.\n\nMETHOD IS SETTLED, follow it: mirror each Cmd LINE FOR LINE including where siblings disagree, and add a lockstep test that drives the client and the real reduce_key over the same keypresses and compares ViewState. Do not tidy sibling commands into one rule -- that makes the client a second derivation, which is what lockstep testing exists to rule out.\n\nAnd update the_normal_mode_keys_that_still_need_the_host_are_these as you go: it is a named list precisely so closing one has to come there and say so.","acceptance_criteria":"Each listed binding resolves client-side with a lockstep test driving the client and the real reduce_key over the same keypresses. Mirror each Cmd LINE FOR LINE including where siblings disagree — Move and MoveTileCursor emit even when they clamp, MovePanelCursor returns nothing when the cursor does not move — and do NOT tidy them into one rule, which is what makes the client a second derivation. the_command_names_that_still_need_the_host_are_these shrinks by exactly the bindings closed, and closing one has to come to that list and say so. Note improvise-fdt6: commit-buffer formula is on this list and is also why a browser cannot add a formula at all.","status":"closed","priority":3,"issue_type":"task","assignee":"wave2-h-frontend","owner":"el-github@elangley.org","created_at":"2026-09-20T02:36:43Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:05:09Z","started_at":"2026-09-20T17:53:38Z","closed_at":"2026-09-20T18:05:09Z","close_reason":"Closed","labels":["area:shared-frontend"],"dependencies":[{"issue_id":"improvise-e24v","depends_on_id":"improvise-fdt6","type":"relates-to","created_at":"2026-09-20T07:55:25Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-s17o","title":"reduce_full and apply_command's view-slice arm have no production caller","description":"Found while fixing improvise-ondj (2026-09-20), and it is the reason that defect could sit unnoticed.\n\nui/reduce.rs module doc says: \"the one place a command is applied to the authoritative state\" and \"Every deployment mode -- the native TUI, a websocket server, a browser worker -- reaches the model through the functions here.\"\n\nTHAT IS ASPIRATIONAL. reduce_full has NO PRODUCTION CALLER ANYWHERE IN THE WORKSPACE. Every host reaches the model through Sessions::apply or reduce_key. Nothing exercised reduce_full Command::Model arm, so nothing could notice it skipped the basis check -- a door advertised by documentation and walked by nobody is exactly the shape that hides a defect until the next host arrives.\n\nSECOND HALF, same shape: apply_command other =\u003e reduce_view(\u0026mut app.view_state, other) folds a wire VIEW command into the AUTHORITATIVE App own slice. Nothing in production does that -- Sessions::apply folds view commands into the CLIENT MIRROR instead. So view_slice_commands_land_in_the_authoritative_view_state is testing a path no host walks, and passing tells you nothing about any deployment.\n\nTHE DECISION: are these a host API worth keeping, or test-only scaffolding wearing a module doc? If the former, HAVE A HOST USE ONE -- improvise-9x6 (the browser worker) is the natural candidate and is being built now, so this is decidable immediately rather than in principle. If the latter, delete them and correct the module doc, which is currently making a promise the code does not keep.\n\nDo not just delete the test: decide what the function is for first.","acceptance_criteria":"reduce_full and apply_command's view-slice arm either have a production caller or are deleted with the module doc corrected. The module doc currently promises 'every deployment mode reaches the model through the functions here', which is false and is what let improvise-ondj's defect sit unnoticed. NOTE: the post-wave review found Sessions::apply has ALSO lost its production caller (Host::command now calls apply_checked), and reduce.rs:48 still advertises it as 'the door that does it for you' — same question, same file, decide them together.","status":"closed","priority":3,"issue_type":"task","assignee":"wave2-n-cleanups","owner":"el-github@elangley.org","created_at":"2026-09-20T01:54:30Z","created_by":"Edward Langley","updated_at":"2026-09-21T05:15:41Z","started_at":"2026-09-20T17:57:47Z","closed_at":"2026-09-21T05:15:41Z","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -389,7 +426,6 @@ {"_type":"issue","id":"improvise-2yc2","title":"DataStore::live_records walks a HashMap -- decide its order before writing the caller","description":"The next instance of improvise-shyt, found while fixing it, and deliberately LEFT ALONE rather than guessed at.\n\nDataStore::live_records walks the records: HashMap, so it yields a different order in every process -- the same defect that made records-mode rows reorder between sessions, misdirected drill staged edits, and drifted SUM/AVG in the last f64 bits.\n\nIt differs from shyt in one way that matters: IT HAS NO CALLERS YET. So this is not a bug report, it is \"decide the order deliberately before something depends on it\". The ADR 002 surrogate path will be the caller -- the RecordHash -\u003e RecordEntry index is what live_records walks -- and whoever writes it should pick an order on purpose rather than inherit a hash walk and then discover it, which is how shyt happened.\n\nTHE ANSWER SHYT LANDED ON, for reference: the index narrows, the primary IndexMap orders (matching_positions resolves each candidate with get_index_of, sorts, reads back with get_index). Measured at 18,000 cells that cost at worst +20% on one path and was 7% FASTER on the other. Whether that is the right answer here depends on what the surrogate path wants; a hash-keyed index may legitimately want no order at all, in which case say so and return something whose type admits it.","status":"closed","priority":3,"issue_type":"task","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T00:45:22Z","created_by":"Edward Langley","updated_at":"2026-09-20T05:54:43Z","started_at":"2026-09-20T05:32:37Z","closed_at":"2026-09-20T05:54:43Z","close_reason":"Closed by PR #20 (WU-K). DECIDED: live_records answers in the STORE'S OWN ORDER -- it walks the primary IndexMap's cells and dedupes by RecordHash, so a record sits where its FIRST cell sits. That is the order matching_positions already gives (gotcha 18), projected onto records, rather than a second answer to \"what order is this store in\".\n\nDerived on each call rather than memoised on RecordEntry, because a stored position is a copy and the tombstone a deleted record leaves would keep it while its cells moved to the end. That case is now tested (a_recreated_record_takes_its_new_place_not_its_old_one) -- and it was found because Copilot noticed the doc comment ASSERTED the derive-don't-memoise choice mattered for delete-and-recreate while nothing tested it; a memoising implementation passed all three other tests.\n\nTHE TICKET'S ALTERNATIVE WAS REJECTED WITH A REASON WORTH KEEPING: \"a hash-keyed index may want no order at all -- return something whose type admits it\" cannot be built. Every iterator has an order, and a type that merely DISCLAIMS one still hands the caller hash order. Disclaiming is not making unrepresentable.\n\nCost moves O(records) -\u003e O(cells). With no caller to benchmark, correctness is the defensible default and the doc says what a hot-path caller should bring.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-odbc","title":"[bug] bank-info.improv in the repo root does not parse","description":"Incidental finding from improvise-cr3 (2026-09-19), outside that ticket scope, unreproduced beyond the one load.\n\n Parse error at line 293, column 3: - JORDAN MARTINEZ (expected pipe_quoted)\n\nTWO READINGS, and they want different actions:\n * The file is STALE -- written before a grammar change -- in which case delete it or regenerate it, and the finding is that a sample file in the repo root is not exercised by anything.\n * The GRAMMAR HAS A GAP -- some name shape a writer emits that the reader rejects. That is the serious reading, because save/load round-trip is a property test (persistence proptests, parse(format(parse(generate())))) and a gap that survives it is a gap the GENERATOR does not reach.\n\nDecide which by checking whether improvise can WRITE that line today. If it can, the round-trip property is incomplete and the generator is the thing to fix, not the file.","status":"closed","priority":3,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T00:37:30Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:28:58Z","started_at":"2026-09-20T05:32:40Z","closed_at":"2026-09-20T06:28:58Z","close_reason":"Closed by PR #22 (WU-L). THE TICKET'S PREMISE WAS FALSE, AND TESTING IT RATHER THAN ASSUMING EITHER READING IS WHAT MADE THIS CHEAP.\n\nThe ticket offered two readings and hoped for the serious one: that the grammar had a gap some writer could emit, which would mean the round-trip property is incomplete and the GENERATOR is the thing to fix. It said to decide by checking whether improvise can WRITE the offending line. IT CANNOT. item_list's name is `pipe_quoted | bare_name`, bare_name is [A-Za-z_][A-Za-z0-9_-]* with no space, and quote_name mirrors it character for character -- so improvise writes |JORDAN MARTINEZ|. Reader and writer agree, the round-trip property is COMPLETE, and the grammar-walking generator needs no change.\n\nThe file was stale BY HAND: c48a5cd migrated it to v2025-04-09 by editing in place, pipe-quoting the Date items and not the Entity ones, and every line of its ## Data section names a category `Measure` where the file declares `_Measure`. Unloadable since April. Nothing loaded it.\n\nDELETED RATHER THAN REGENERATED, on the user's ruling 2026-09-19, together with foo.improv (the same class, one file over, with no version line at all). There is no generator for either -- they are hand-written fake data -- so regenerating meant inventing a canonical form and checking in 1200 lines whose only property is that they parse, which the grammar proptests already establish over a far wider input space.\n\nTHE EVIDENCE KEPT IS WORTH MORE THAN THE FILE:\n * an_item_name_the_item_line_cannot_carry_round_trips -- the deleted file's own names through format_md/parse_md.\n * a_bare_item_name_with_a_space_is_rejected -- the reader really refuses the unquoted line, with the quoted form parsing as a precondition. Without this the first test is consistent with BOTH spellings being accepted.\n * tests/shipped_documents_load.rs -- a DIRECTORY WALK over the two directories .gitignore un-ignores, not a list of names, because a list is a second place to remember and the forgotten file is the one nobody added to it. Fault injected and failed. Then HARDENED: it matched Path::extension() == \"improv\", which of x.improv.gz is \"gz\", so a gzipped shipped document would have been skipped -- the very hole the test exists to close, put back inside the fix for it.\n\nTHE FINDING, which is the opposite of what the ticket expected: a checked-in document in a format the repo parses is exercised by NOTHING unless something loads it, and grammar proptests cannot see it because they GENERATE their inputs.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-qddo","title":"Make DataStore an implementation of a source trait, not the implementation","description":"REWRITTEN 2026-09-19 to match context/adr/003-live-backed-models.md, and DEFERRED by user ruling in the same session, together with improvise-rucf and improvise-mjy7. The ADR calls this \"worth doing first regardless of the eventual shape\"; the user's ruling is that the whole direction waits, so it waits as a unit rather than landing a seam with nothing on the other side of it.\n\nTHE WORK IS UNCHANGED: introduce a source trait in improvise-core and make the current in-RAM DataStore AN implementation of it rather than THE implementation. No behaviour change -- doctrine(characterization_survives_the_move) applies and the existing tests must carry across unchanged in meaning.\n\nTODAY Model holds a DataStore directly and a Workbook loads ENTIRE; there is no partial-load path anywhere in the tree (verified while declining improvise-9ix). So \"where cell values come from\" is not a seam, it is an assumption spread across GridLayout, evaluation and persistence.\n\nWHAT ADR 003 ADDS TO THE ORIGINAL FRAMING, and it is the part worth re-reading before starting:\n\n * AGGREGATION IS THE INTERFACE, AND IT IS ALREADY SQL. apply_agg computes Sum/Avg/Min/Max/Count over a matching cell set -- exactly SQL's aggregates -- and GridLayout asks for a VALUE, not for the rows behind it. Shape the trait around \"give me the aggregate for this coordinate set\", never around \"give me the cells\": shaping it around cells forecloses pushdown, which is the entire point of the eventual live source.\n\n * WRITES MUST NOT BE IN THE TRAIT. This is new since the original text and it is a real constraint. If writing were in the trait a query source implements, a read-only source would have to FAIL AT RUNTIME for a method it structurally cannot serve. Keep reads and aggregates in the trait every source implements; let writing be a capability only the owned in-RAM implementation has. Read-only then becomes UNREPRESENTABLE rather than enforced -- the same discipline as CellId (ADR 002) and doctrine(an_invariant_is_made_unrepresentable_not_validated).\n\n * FORMULAS DO NOT MOVE. Evaluation is fixed-point and derived values live in Model::formula_cache, not in DataStore, so the split is clean: raw data is queried, derived data stays in RAM.\n\nCONSTRAINT: forbidden(improvise_core, io). The TRAIT belongs in improvise-core; any IO-touching implementation belongs in improvise-io. Do not let the trait signature drag IO types into core; if it needs Result, that is a boundary decision worth arguing rather than defaulting (AGENTS.md keeps Option/Result at the edges).\n\nWHY IT IS STILL WORTH DOING ON ITS OWN WHEN IT IS UN-DEFERRED: it is the seam every later option needs -- live-backing, a test double, or the import-once path ADR 003 rejected as the ONLY answer but did not forbid as A path; it makes \"the model is fully in RAM\" an explicit property of one implementation rather than an invisible assumption of the whole crate; and done as a pure refactor it is cheap and reviewable, where doing it AS PART OF adding a SQLite source mixes a large refactor with a large feature and makes both unreviewable.\n","notes":"\n--- READ-ONLY DECIDED (user, 2026-09-19), and it shapes the trait ---\nA live-backed model is READ-ONLY, and not reluctantly: for many use cases\nediting capabilities would be a DOWNSIDE. Pointing a pivot tool at a production\ndatabase and being unable to write to it is a feature.\n\nSO DO NOT PUT WRITES IN THE TRAIT. If writing were a trait method, a read-only\nsource would have to fail at RUNTIME for something it structurally cannot\nserve. Keep reads -- and crucially AGGREGATES, see the note above about shaping\nit around \"give me the aggregate for this coordinate set\" -- in the trait every\nsource implements, and let writing be a capability only the owned in-RAM\nimplementation has. Then read-only is UNREPRESENTABLE rather than enforced,\nwhich is the same discipline ADR 002 CellId uses and the same design-principles\n3 rule.\n\nRELATED CONVERGENCE WORTH KNOWING: ADR 002 already makes SetCell take a\nCellId::Record and nothing else, so a live-backed model that projects no Record\nids makes a client-side write unconstructible with no runtime check and no\n\"this model is read-only\" dialog. The settability distinction built for\naggregates carries read-only sources for free -- so the client half of this may\nalready be done.","status":"deferred","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T00:16:29Z","created_by":"Edward Langley","updated_at":"2026-09-20T05:31:47Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-rucf","title":"Read arbitrary SQLite databases as a data source","description":"SUPERSEDED BY context/adr/003-live-backed-models.md, and rewritten 2026-09-19 to match it. The original text framed this as an open choice between import-once and live-backed, and said \"READ-ONLY for now\" as though that were a scoping convenience. ADR 003 decided both, and in the opposite direction from what this ticket assumed.\n\nWHAT THE ADR DECIDED, and this ticket now means:\n\n * LIVE-BACKED, not import-once. Import-once was explicitly REJECTED as the only answer -- it makes the model a snapshot, so source changes are invisible until re-import and the data must fit in memory. The model is a view over the source and queries go to it as the user pivots.\n * READ-ONLY IS A FEATURE, not a limitation accepted reluctantly. Pointing a pivot tool at a production database and being unable to write to it is the point. No write-through, no overlay, no reconciliation.\n * THEREFORE: Model becomes a FACADE OVER A DATA SOURCE, of which today's in-RAM DataStore is one implementation and a SQL query engine is another. That is a change to what Model IS, not to where its bytes come from.\n\nSTATUS: NOT A PRIORITY, per the ADR's own header. Deferred 2026-09-19 during the whole-backlog campaign, by user ruling, together with improvise-qddo and improvise-mjy7. Nothing here outranks the browser work. It is written down so the option stays open, because several decisions made recently could foreclose it by accident.\n\nTHE THREE THINGS THAT MAKE IT TRACTABLE, from the ADR, which should shape the design rather than be discovered late:\n 1. Aggregation is ALREADY the interface and it is ALREADY SQL. apply_agg computes Sum/Avg/Min/Max/Count over a matching cell set; those are exactly SQL's aggregates. GridLayout asks for a VALUE, not for the rows behind it. The pushdown is not a translation layer to be invented.\n 2. Formula results are ALREADY separate from stored data. Evaluation is fixed-point and derived values live in Model::formula_cache, not in DataStore. Raw data is queried; derived data stays in RAM. Formulas do not become SQL.\n 3. Clients are ALREADY insulated. A client sees CellDisplays and never learns where a value came from, so live-backing and the browser deployment compose rather than compete. A browser never needs SQLite at all.\n\nTHE CONSEQUENCE NOBODY ASKED FOR, and the reason this is three tickets rather than one: a live-backed document splits into OUR configuration and THEIR data, and .improv does not distinguish them. The format grows a SOURCE SPECIFICATION where a live model would otherwise have a ## Data section, and categories become derived (SELECT DISTINCT) rather than declared. improv.pest is the single source of truth, so the grammar comes first.\n\nCHILDREN (all filed and deferred alongside this one):\n * the .improv source specification and the grammar change\n * the SQLite source implementation, native-only and feature-gated\n * projection freshness for a source an external writer can change\n\nWHAT ADR 001 DOES AND DOES NOT OWE THIS: a read-only model has no concurrent EDITS, so the basis check simply does not apply -- the guarantee is scoped to documents improvise owns, which is what it always meant. What remains is a different and smaller question, which is the freshness child.\n\nTHE MACHINERY TO EXPRESS READ-ONLY ALREADY EXISTS, built for something else: ADR 002 makes SetCell take a CellId::Record and nothing else, so a source that projects no Record ids makes a write UNCONSTRUCTIBLE by the client -- no runtime check, no error path, no read-only dialog. One design consequence for the source trait follows: writes must NOT be in the trait a query source implements, or a read-only source has to fail at runtime for a method it structurally cannot serve.\n\nTHE DEPENDENCY CONSTRAINT THAT CARRIES OVER FROM improvise-9ix: improvise-io is in the wasm bundle (the wasm job in .github/workflows/ci.yml gates it) and libsqlite3-sys does not build for wasm32-unknown-unknown under this repo's nix cc-wrapper. Native-only and feature-gated, with the wasm job passing untouched. Verify the gate rather than assuming it.\n\nWHAT THIS IS NOT: improvise-9ix, which proposed SQLite as improvise's OWN PERSISTENCE FORMAT and was declined on measurements (22% larger than .improv, 4.7x gzipped, a second implementation of the pest grammar). Reading someone else's database has no round-trip obligation and no grammar duplication.\n","notes":"\n--- SUPERSEDED IN FRAMING by ADR 003 (user, 2026-09-19) ---\nThe user chose LIVE-BACKED over import-once, and the goal is stated as: have\nTHE OPTION of essentially using improvise as a DB CLIENT. NOT A PRIORITY --\nnothing here outranks improvise-1ey. Re-prioritised P2 -\u003e P3.\n\nSo this ticket read-only import framing is not the target shape. See\ncontext/adr/003-live-backed-models.md. The import-once seam it described\n(csv_parser -\u003e Vec\u003cValue\u003e -\u003e analyze_records) is still accurate and still\nuseful, but as ONE implementation of a source rather than as the answer.\n\nWHAT SURVIVES UNCHANGED and is worth keeping from this ticket: the type-mapping\nnotes (SQLite columns carry declared types, which is BETTER input than CSV\nstring sampling -- detect_date_format currently guesses a date by pattern-\nmatching text; dates may be TEXT, INTEGER epoch or REAL julian, so detect\nrather than assume; NULL is ABSENCE not the string \"null\", per improvise-ych),\nand the wasm constraint (improvise-io is in the bundle, libsqlite3-sys does not\nbuild for wasm32 under this repo nix cc-wrapper, so any SQLite dependency is\nfeature-gated and native-only).","status":"deferred","priority":3,"issue_type":"epic","owner":"el-github@elangley.org","created_at":"2026-09-20T00:14:11Z","created_by":"Edward Langley","updated_at":"2026-09-20T05:31:18Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-i9lt","title":"View::axis_of panics for a category the view has not seen","description":"Noticed during improvise-35e (2026-09-19), reported not fixed per the mention-do-not-fix rule for pre-existing issues.\n\nView::axis_of(cat) panics for a category the view has never seen, and View::tileable_cats admits exactly such categories -- it filters on try_axis_of(c).is_none_or(...). So the pairing is a live panic waiting for the right view state.\n\nThe old TileBar called axis_of over that list. The migrated widget cannot panic there any more, because tile_entries uses try_axis_of(...).unwrap_or(Axis::None) -- so the reachable path is gone and no failing test was written, since the old path no longer exists to fail.\n\nWorth a guard anyway: either make axis_of total (returning Axis::None like tile_entries does) or make it private and force callers through try_axis_of. A panicking accessor over a list built to include the panicking case is a trap for the next caller, and the next caller will not have the migration to protect them.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T23:40:38Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:25:57Z","closed_at":"2026-09-20T00:25:57Z","close_reason":"The reachable path is gone. improvise-edp migrated CategoryContent to take (\u0026CategoryTreeCache, \u0026HashSet\u003cString\u003e) instead of \u0026View, and the cache is built with try_axis_of(...).unwrap_or(Axis::None) -- so the widget that called view.axis_of(name) over a list that admits unseen categories no longer exists. Same resolution as the TileBar path in improvise-35e.\n\nView::axis_of itself is unchanged and still panics for a category the view has not seen. Left deliberately: it is a pre-existing API sharpness with no remaining caller that can reach it, and the mention-do-not-fix rule applies. If it acquires a new caller, make it total (returning Axis::None like the cache does) or make it private and force callers through try_axis_of -- a panicking accessor over a list built to include the panicking case is a trap for whoever arrives next.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-j31x","title":"Server hosts several documents: a registry above Sessions","description":"DEFERRED by the user (2026-09-19) -- recorded so nothing forecloses it, not to be built now.\n\nimprovise is a multi-document app and the server should be able to host several separate models. The shape:\n server -\u003e many documents\n document -\u003e one App + one Sessions + many ClientSessions\nThat is NOT one App per session (what improvise-q08 ticket text says, written before the session model landed) and NOT one App total.\n\nNOTHING THAT HAS LANDED NEEDS TO CHANGE. Sessions does not own the App -- connect(\u0026mut self, app: \u0026App, ..), apply(\u0026mut self, app: \u0026mut App, ..) and project_all(\u0026mut self, app: \u0026App, ..) all take it as a parameter. So Sessions is already document-agnostic and a Sessions instance is implicitly bound to one document generation sequence, which is right: document_generation lives on the App. Multi-document is a REGISTRY ABOVE Sessions mapping a document id to its (App, Sessions) pair.\n\nWHY IT MATTERS BEYOND TIDINESS: sessions editing independent models are trivially non-conflicting, so ADR 001 concurrency design scopes per document FOR FREE -- the basis version tiers live on the App and each document has its own. A server that shared one App across documents would throw that away.\n\nWHAT TO AVOID IN THE MEANTIME: improvise-q08 must not hard-code a single App into a static or a lazily-initialised singleton. The document should be a PARAMETER even while there is only one, and a connection should be attachable to a document even if the MVP answers that from a CLI argument naming one .improv file.\n\nScope when picked up: the registry, document lifecycle (open/close/last-session-leaves), and how a client names the document it wants. Auth and access control are explicitly NOT in scope here -- file them separately if wanted.","status":"closed","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-19T23:32:31Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:33:15Z","closed_at":"2026-09-20T00:33:15Z","close_reason":"Registry landed in crates/improvise-ws-server/src/registry.rs. 1051 tests pass.\n\nIT CAUGHT A REAL BUG BY VERIFYING THE SEAM INSTEAD OF TRUSTING IT. My brief and the q08 notes both said nothing below the registry needs to change. Nearly true -- but Sessions::next_id is a PER-Sessions counter (confirmed: project.rs holds next_id on Sessions, not globally), so EVERY documents first client is SessionId(0), and transport.rs keyed its outs: HashMap\u003cSessionId, _\u003e on it. A registry that kept that key would have delivered document Bs frames to document As client the moment two documents each had one client.\n\nThe server-wide address is the pair, ClientId { document, session }. Registry::route(document, Vec\u003cAddressed\u003e) is the ONLY place a SessionId acquires a document -- the one it came out of, since that is the only one in scope there -- so cross-document delivery of a projection, a SwitchView broadcast (gotcha 36) or an Applied::Rebased fan-out (gotcha 39) is UNREPRESENTABLE rather than checked. The test names the hazard: an_edit_is_projected_only_to_the_edited_documents_clients asserts on_alpha.session == on_beta.session FIRST.\n\nLIFECYCLE, and the non-obvious half. Open at STARTUP from the files named on the command line -- open-on-request was rejected because deciding which paths a client may open IS access control, which this ticket put out of scope; serving only what was named leaves the question unanswered rather than answered badly. NOT closed when the last session leaves: eviction loses unsaved edits, and worse, document_generation and record_versions start at ZERO in a fresh App (gotcha 40), so a client reconnecting with a STALE basis would have it accepted as current -- a hole in the exact mechanism ADR 001 is building, bought for a memory saving nobody asked for. Registry::is_idle is the hook a future policy asks from; nothing evicts.\n\nA client names its document in the URL PATH (ws://host:port/quarterly), which leaves wire.rs COMPLETELY unchanged so the wasm-client frames keep working; / resolves to the only document when there is exactly one, and with two is ConnectError::Ambiguous rather than a coin toss. A refusal is a websocket CLOSE FRAME carrying the reason -- no ServerMsg::Error variant invented, because that is a connection fact and wire.rs only grows variants the mutations demand.\n\nEvery required claim has a counter-test beside it so it cannot pass vacuously -- e.g. the broadcast-isolation fixture gives BOTH documents a view named Second, so betas silence is the routing and not a name it would have rejected anyway.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-2wi8","title":"ADR-001 e: a forced overwrite is visible to the client it overwrote","description":"ADR 001 layer 1, step e. Depends on step c.\n\nWithout this, force is silent data loss with a confirmation step in front of it. The overwritten client edit was already committed, so this is a NOTIFICATION rather than a conflict -- but it must exist, or \"someone else won\" is indistinguishable from \"my edit never happened\".\n\nDesign note: the notification is about the loser OWN prior edit, so it is per-client and therefore UI -- the model is shared, the UI is not. Do not broadcast it as document state.","acceptance_criteria":"A client whose committed edit was overwritten by a force is TOLD. Without this, force is silent data loss with a confirmation step in front of it — 'someone else won' must be distinguishable from 'my edit never happened'. It is a NOTIFICATION rather than a conflict, because the overwritten edit was already committed. Per-client and therefore UI: do not broadcast it as document state (model-shared-ui-not-shared).","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-19T23:27:34Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:56:22Z","labels":["area:protocol"],"dependencies":[{"issue_id":"improvise-2wi8","depends_on_id":"improvise-3al1","type":"blocks","created_at":"2026-09-19T23:27:47Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} @@ -463,7 +499,7 @@ {"_type":"issue","id":"improvise-0zf","title":"Bug: WHERE inside aggregate parens broken by greedy identifier tokenizer","description":"The tokenizer treats multi-word identifiers greedily — 'Revenue WHERE' becomes a single token. This means SUM(Revenue WHERE Region=East) doesn't parse correctly. The WHERE keyword is consumed as part of the identifier. Top-level WHERE (outside parens) works because split_where handles it before tokenization. Fix: either stop allowing spaces in identifiers inside parens, or detect WHERE as a keyword break point in the tokenizer.","status":"closed","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T06:06:15Z","created_by":"Edward Langley","updated_at":"2026-04-09T06:38:08Z","closed_at":"2026-04-09T06:38:08Z","close_reason":"Fixed tokenizer to break multi-word identifiers at keywords (WHERE, SUM, AVG, MIN, MAX, COUNT, IF). Two-pronged: (1) break when current identifier IS a keyword, (2) break when next word IS a keyword. SUM(Revenue WHERE Region=East) now parses correctly.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-w82","title":"Test audit: persistence/mod.rs at 82% coverage","description":"persistence/mod.rs is at 82% - just above the floor. Missing coverage for edge cases: empty models, special characters in names, view state persistence details, and formula preservation corner cases.","status":"closed","priority":3,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T05:04:46Z","created_by":"Edward Langley","updated_at":"2026-04-09T06:15:10Z","closed_at":"2026-04-09T06:15:10Z","close_reason":"Added 16 tests covering save/load roundtrip (plain + gzip), autosave_path, export_csv, collapsed groups, page-without-selection, none axis, number format, text values, multiple views, and full feature roundtrip. Coverage from 82% to 97%.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-kq6","title":"Test audit: formula/parser.rs at 76% coverage","description":"formula/parser.rs is at 76% line coverage, just under the 80% target. Missing coverage for MIN, MAX, COUNT aggregate functions, complex nested expressions, and error/edge case paths.","status":"closed","priority":3,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T05:04:44Z","created_by":"Edward Langley","updated_at":"2026-04-09T06:10:12Z","closed_at":"2026-04-09T06:10:12Z","close_reason":"Added 19 tests covering MIN/MAX/COUNT aggregates, comparison operators, power/unary/mul/div, WHERE with quotes, error paths, multi-word identifiers, aggregate-name-as-ref edge case. Coverage from 76% to 89%. Found bug: WHERE inside aggregate parens broken by greedy tokenizer (improvise-0zf).","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-3tj","title":"3.3 Tag v0.1.0 release","description":"Create git tag v0.1.0, push it, verify cargo dist workflow produces release artifacts. Update README prebuilt binaries link to point at actual release.","acceptance_criteria":"Tag v0.1.0 exists and is pushed, the cargo-dist workflow has produced release artifacts for Linux x86_64 and macOS (Intel + Apple Silicon), and README's prebuilt-binaries link points at the actual release rather than a placeholder. NEEDS THE USER: tagging and pushing a release is theirs to authorise, and it is ordered after improvise-l36.","notes":"STATE CHECKED 2026-09-20 at 52507a6 (wave 2, WU-P), and one premise of this\nticket is already false.\n\nThe README's prebuilt-binaries link is NOT a placeholder. `v0.1.0-rc1` and\n`v0.1.0-rc2` both exist as prereleases and `v0.1.0-rc2` carries real cargo-dist\nartifacts: improvise-{aarch64,x86_64}-apple-darwin.tar.xz,\nimprovise-{aarch64,x86_64}-unknown-linux-gnu.tar.xz, each with a .sha256, plus\nimprovise-installer.sh, sha256.sum, dist-manifest.json and source.tar.gz. So\ncargo-dist demonstrably works on this repository, and the four targets in\ndist-workspace.toml are the four that were built.\n\nWhat remains is therefore narrower than the ticket reads: there is no `v0.1.0`\ntag, only the two rc tags, and the rcs are marked prerelease so the releases\npage has no \"Latest\". README was updated in wave 2 to say exactly that rather\nthan to imply a stable release exists.\n\nMECHANISM: `.github/workflows/release.yml` fires on a pushed version tag and\nruns dist itself. Nothing needs to be built by hand; the tag is the trigger.\n\nBEFORE TAGGING: ten manifests say `0.1.0-rc2` (and `crates/gp-tree` says\n`0.1.0`). A `v0.1.0` tag on manifests that say `0.1.0-rc2` makes dist announce\na version nothing in the tree claims, so the bump comes first, in the same\ncommit, and gp-tree's disagreement is resolved with it.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-09T04:08:02Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:01:39Z","labels":["area:release","needs:user"],"dependencies":[{"issue_id":"improvise-3tj","depends_on_id":"improvise-11a","type":"blocks","created_at":"2026-04-08T21:09:30Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-3tj","depends_on_id":"improvise-l36","type":"blocks","created_at":"2026-04-08T21:09:30Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"improvise-3tj","title":"3.3 Tag v0.1.0 release","description":"Create git tag v0.1.0, push it, verify cargo dist workflow produces release artifacts. Update README prebuilt binaries link to point at actual release.","acceptance_criteria":"Tag v0.1.0 exists and is pushed, the cargo-dist workflow has produced release artifacts for Linux x86_64 and macOS (Intel + Apple Silicon), and README's prebuilt-binaries link points at the actual release rather than a placeholder. NEEDS THE USER: tagging and pushing a release is theirs to authorise, and it is ordered after improvise-l36.","notes":"STATE CHECKED 2026-09-20 at 52507a6 (wave 2, WU-P), and one premise of this\nticket is already false.\n\nThe README's prebuilt-binaries link is NOT a placeholder. `v0.1.0-rc1` and\n`v0.1.0-rc2` both exist as prereleases and `v0.1.0-rc2` carries real cargo-dist\nartifacts: improvise-{aarch64,x86_64}-apple-darwin.tar.xz,\nimprovise-{aarch64,x86_64}-unknown-linux-gnu.tar.xz, each with a .sha256, plus\nimprovise-installer.sh, sha256.sum, dist-manifest.json and source.tar.gz. So\ncargo-dist demonstrably works on this repository, and the four targets in\ndist-workspace.toml are the four that were built.\n\nWhat remains is therefore narrower than the ticket reads: there is no `v0.1.0`\ntag, only the two rc tags, and the rcs are marked prerelease so the releases\npage has no \"Latest\". README was updated in wave 2 to say exactly that rather\nthan to imply a stable release exists.\n\nMECHANISM: `.github/workflows/release.yml` fires on a pushed version tag and\nruns dist itself. Nothing needs to be built by hand; the tag is the trigger.\n\nBEFORE TAGGING: ten manifests say `0.1.0-rc2` (and `crates/gp-tree` says\n`0.1.0`). A `v0.1.0` tag on manifests that say `0.1.0-rc2` makes dist announce\na version nothing in the tree claims, so the bump comes first, in the same\ncommit, and gp-tree's disagreement is resolved with it.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-09T04:08:02Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:01:39Z","labels":["area:release","needs:user"],"dependencies":[{"issue_id":"improvise-3tj","depends_on_id":"improvise-11a","type":"blocks","created_at":"2026-04-08T21:09:30Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-3tj","depends_on_id":"improvise-gx5d","type":"blocks","created_at":"2026-09-22T05:26:55Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-3tj","depends_on_id":"improvise-l36","type":"blocks","created_at":"2026-04-08T21:09:30Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":3,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"improvise-l36","title":"3.2 Publish to crates.io","description":"After cargo publish --dry-run is clean and user confirms, run cargo publish. Verify crate appears at crates.io/crates/improvise and cargo install improvise works.","acceptance_criteria":"improvise is on crates.io and 'cargo install improvise' works from a clean machine. NEEDS THE USER: crates.io credentials, and their confirmation after 'cargo publish --dry-run' is clean. Note the workspace has grown to nine crates since this was filed — decide which are published and in what order, since a dependent cannot publish before its dependency, and improvise-io/protocol/command are all now real crates rather than internal modules.","notes":"DRY RUN ACTUALLY RUN, 2026-09-20 at 52507a6 (wave 2, WU-P). It is NOT clean,\nand the blocker is mechanical rather than a judgement call:\n\n error: failed to verify manifest at Cargo.toml\n all dependencies must have a version requirement specified when publishing.\n dependency `gp-tree` does not specify a version\n\ncargo stops at the FIRST offender, and gp-tree is not the only one. Every\ninter-crate dependency in this workspace is `{ path = \"...\" }` with no\n`version`, which crates.io forbids for normal dependencies:\n\n Cargo.toml (root) gp-tree, improvise-core, improvise-formula,\n improvise-io, improvise-command\n improvise-core improvise-formula\n improvise-formula (none)\n improvise-protocol improvise-core\n improvise-io improvise-core, improvise-formula\n improvise-gp gp-tree, improvise-core, improvise-formula\n improvise-command improvise-core, improvise-protocol,\n improvise-formula, improvise-io, improvise-gp\n\nDev-dependencies are exempt — cargo strips them on publish — so the root's\n`improvise-wasm-client`/`improvise-protocol` dev deps and improvise-wasm-\nclient's dev dep on the unpublishable improvise-ws-server are fine as they are.\n\nWHICH CRATES PUBLISH. `improvise-ws-server` and `improvise-worker-server` both\ncarry `publish = false` already, so the set is eight, and a dependent cannot go\nbefore its dependency. The only order that works:\n\n 1. improvise-formula (no intra-workspace deps)\n 2. improvise-core\n 3. gp-tree (no intra-workspace deps; can go any time before 4)\n 4. improvise-gp\n 5. improvise-protocol\n 6. improvise-io\n 7. improvise-command\n 8. improvise (the binary)\n\ncrates.io needs a minute or two to index each one before the next can resolve\nit, so this is eight sequential `cargo publish` calls, not one.\n\nTWO VERSION FACTS THE USER MUST DECIDE. `gp-tree` is at `0.1.0` while every\nother crate is at `0.1.0-rc2`; and a `-rc2` version requirement in a published\nmanifest pins consumers to a prerelease. If the intent is to publish 0.1.0\n(which improvise-3tj's tag implies), the version bump across ten manifests\nshould happen BEFORE the version requirements are written, or they will be\nwritten twice.\n\nNAME AVAILABILITY CHECKED: crates.io returns \"crate `improvise` does not exist\",\nso the name is free. Not checked: the other seven names.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:58Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:01:38Z","labels":["area:release","needs:user"],"dependencies":[{"issue_id":"improvise-l36","depends_on_id":"improvise-11a","type":"blocks","created_at":"2026-04-08T21:09:29Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-l36","depends_on_id":"improvise-2fr","type":"blocks","created_at":"2026-04-08T21:09:28Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":2,"comment_count":0} {"_type":"issue","id":"improvise-11a","title":"3.1 Configure cargo dist","description":"Run cargo dist init targeting x86_64-unknown-linux-gnu, aarch64-apple-darwin, x86_64-apple-darwin. Skip Windows and musl. Commit generated .github/workflows/release.yml and Cargo.toml additions. Test with v0.1.0-rc1 tag, verify builds, delete rc tag.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:55Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-11T07:49:57Z","close_reason":"Closed","dependencies":[{"issue_id":"improvise-11a","depends_on_id":"improvise-abz","type":"blocks","created_at":"2026-04-08T21:09:28Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":3,"comment_count":0} {"_type":"issue","id":"improvise-0s6","title":"Phase 3: Distribution","description":"Configure cargo dist, publish to crates.io, tag v0.1.0 release. Produces prebuilt binaries for Linux x86_64 and macOS (Intel + Apple Silicon).","acceptance_criteria":"Closes when improvise-l36 and improvise-3tj close. Both need the user; nothing here is agent work. improvise-11a (cargo dist) is already closed.","notes":"CHECKED, wave 2 WU-P: STAYS OPEN, correctly — both children need the user and\nneither was touched. Nothing was published and nothing was tagged.\n\nWhat this pass DID do is make them ready, and one of them is not as ready as\nit looked. `cargo publish --dry-run` was actually run and FAILS: every\ninter-crate dependency in the workspace is `{ path = ... }` with no `version`,\nwhich crates.io forbids. cargo reports only the first offender (gp-tree), so\nthe dry run understates it — the full list, the publish order for the eight\npublishable crates, and the gp-tree/rc2 version disagreement are on\nimprovise-l36.\n\nimprovise-3tj's premise is already partly false and its note now says so: the\nREADME prebuilt-binaries link is not a placeholder, v0.1.0-rc2 carries real\ncargo-dist artifacts for all four targets, and what is actually missing is the\nv0.1.0 tag and the manifest bump that must precede it.","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-09T04:05:51Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:05:07Z","labels":["area:release","needs:user"],"dependencies":[{"issue_id":"improvise-0s6","depends_on_id":"improvise-11a","type":"blocks","created_at":"2026-04-08T23:37:39Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-0s6","depends_on_id":"improvise-3tj","type":"blocks","created_at":"2026-04-08T23:37:41Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-0s6","depends_on_id":"improvise-l36","type":"blocks","created_at":"2026-04-08T23:37:40Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":3,"dependent_count":0,"comment_count":0} diff --git a/context/code/cli.md b/context/code/cli.md index 061d3480..e1c20ca3 100644 --- a/context/code/cli.md +++ b/context/code/cli.md @@ -115,3 +115,43 @@ What it still cannot see, and should be closed if the welcome text moves again: `draw.rs::draw_welcome` builds its lines as a local array inside a ratatui render function, so nothing can walk them. Its `:formula` line was corrected by hand. + +## 7 — a script's command is built against the cursor it will run against + +`improvise cmd` and `improvise script` used to resolve every command through +`CmdRegistry::parse` — the **pure** arm, which builds `CursorState::default()`: +row 0, col 0, and a grid of zero rows and zero columns. The interactive arm, +which builds `CursorState::from_ctx(ctx)`, was never reached from a script. + +So every `Move` (`move-selection`, `scroll-rows`, `page-scroll`, the four +`jump-*`) and `enter-advance` snapshotted a zeroed cursor at **parse** time, +clamped to `(0, 0)`, and emitted `SetSelected(0, 0)`. That is worse than a +no-op: it *resets* the cursor. Nothing could position it, so every +cursor-reading command — `clear-cell`, `yank`, `paste`, `cycle-axis-at-cursor`, +`hide-selected-row-item`, `edit-or-drill`, `drill-into-cell`, +`toggle-group-under-cursor`, the three `delete-*-at-cursor`, +`commit-and-advance` — was pinned to the origin (improvise-6uva). It was +introduced with `CursorState` and the two-arm registry in April and predates the +browser work by five months. + +**A registry with two construction arms silently demotes every caller of the +weaker arm, and nothing type-checks which arm a caller deserves.** That is the +shape to remember, and it has a second instance in the same fix: the +`register_pure` entries had an interactive arm that refused **empty** args while +their pure arm accepted them, so `:w` (save to the current path) parsed one way +and refused the other. Both arms are now `parse`, which is what "needs no +interactive context" was supposed to mean; +`a_pure_commands_two_arms_agree_on_no_arguments` is the guard. + +The repair is `parse::segments` plus `parse::parse_segment_interactive`, and the +loop re-parses **per segment**, because command *n* must see command *n-1*'s +cursor — which means construction cannot be hoisted out of the loop. Line +atomicity is preserved by keeping the existing whole-line `parse_line` as a +**syntax gate** before anything in the line runs: a line that parsed before +still parses, and a line that did not still does not, so the only behavioural +change is which cursor the commands see. + +The TUI's `:` line had the same defect and is fixed the same way, with one +limitation that is not new: `ExecuteCommand` returns one effect batch, so the +segments of `:a . b` all see the pre-batch cursor, exactly as a +`Binding::Sequence` does (gotchas 21 and 23). diff --git a/context/code/command.md b/context/code/command.md index 4a3ed7ad..02de2469 100644 --- a/context/code/command.md +++ b/context/code/command.md @@ -692,6 +692,42 @@ does, so the branch ends with an explicit `SetSelected` back to where it was — which states the intent instead of inheriting it from an absence, and still sits after `AddCategory`'s abort so a refused category leaves the batch unchanged. +### `SwitchView` — the history records a move that happened (improvise-swzk) + +`switch_to_view` is guarded: it writes `active_view` only if the document has the +view. `SwitchView` used to push the leaving frame onto `view_back_stack` **before** +calling it, under the prediction `previous != self.0` — which answers *did the user +ask for a different name*, where the history means *did this client go somewhere*. +Those two questions differ on exactly one input, the name the document has not got, +and there `:view NoSuchView` buried a real frame under a frame naming the view the +user was already on. `<` then "went back" to where it already was, and the frame +behind it needed a second `<` nobody knew to press. + +The wire path was never wrong here and is not the model for the fix: `reduce_view` +**cannot** check — the client holds no `Workbook` — so `apply_switch` relays the +exact inverse, `[ViewBack, ForgetView]` (improvise-6c6q, and see "A refused switch +is undone, not overwritten"). The host holds the document, so it does not need an +inverse; it needs to *look*. `apply` now clones `previous`, calls `switch_to_view`, +and pushes only if `active_view` actually differs afterwards. + +That is `ReturnToNormalUnlessMoved`'s rule one effect over, and it is the same +answer `cursor_epoch` gives instead of matching on `CycleAxis` and +`document_generation` gives instead of matching on `LoadModel` (gotchas 24, 39, 45): +**observe the state that moved, never predict it from the request.** It also +subsumes the case the prediction was originally written for — switching to the view +you are already on is a `switch_to_view` that changes nothing — so the special case +is gone rather than joined by a second one. A guard spelled here as +`workbook.views.contains_key(&self.0)` would have been correct today and a second +copy of `switch_to_view`'s own condition, free to disagree with it the day that +condition grows a clause. + +Not fixed, and reachable only by construction: `ViewBack` and `ViewForward` call +`switch_to_view` just as unguardedly, pushing the current view onto the opposite +stack before the switch they cannot be sure of. They are safe **because +`ViewState::forget_view` purges both stacks when a view is deleted**, so a popped +frame always names a live view — a guard living one level away from the hazard, +which is the shape that keeps returning here. + ### `ReturnToNormalUnlessMoved` — observation, not prediction (improvise-toc) Return to `Normal` unless something earlier in this batch already moved the mode diff --git a/context/code/tui.md b/context/code/tui.md index bfa20efb..68c830f5 100644 --- a/context/code/tui.md +++ b/context/code/tui.md @@ -154,6 +154,72 @@ composes both: the prompt and buffer on the left, any pending `status_msg` right-aligned. The alternatives and why they lost are in `context/test-evidence/tui.md`. +### `fill_line` — which segment yields is a per-call-site decision + +Three rows in the TUI are one left segment and one right segment sharing a line: +the title bar, `minibuffer_line` and `draw_status`. `fill_line` composes them, +and it owes two things that it did not originally do — it must measure in +**display cells** (it padded with `left.len()`, bytes, and the title's own `·` +is two of them, so every document's right segment sat one cell in from its edge) +and it must **truncate**, because concatenating and letting ratatui clip clips +the *right*. + +What clipping the right costs depends on the row, which is why `Keeps` is a +parameter and not a rule: + +- **The title bar keeps the right.** Its left segment is decoration whose length + is unbounded — a document name plus a filename — and its right segment is the + fixed `" ?:help :q quit "`, the only thing on screen telling a stuck user how + to get out. Unconditional left priority made the escape hatch the first thing + sacrificed, at 100 columns with an ordinary document name (improvise-t14d). +- **Both bottom-bar rows keep the left**, which is what they already did. There + the left is the user's own typing plus the cursor (`minibuffer_line`) or the + status message (`draw_status`), and the right is a view badge or the message's + own margin. Right priority there would let a long message eat the text being + edited — the same mistake in the other direction. + +So the rule is not "the right wins" but "the affordance wins over the +decoration", and which is which is local. Truncation goes through +`ui::text::truncate` (improvise-8l0c), so an overflow is a `…` rather than a +mid-token clip of `?:`. + +### One key table on screen, and it is the one derived from the keymap + +The TUI stacks the tile bar (row 3) directly above the status bar (row 4), and +the import wizard's overlay sits above the status bar too, so a mode can put +**two** key tables on screen at once. When it does, they disagree — different +notation, different content, and in one case a flat contradiction. + +The rule is not "the overlay wins" or "the status bar wins". It is **keep +whichever table is derived from the thing it describes** (improvise-rr8n): + +- **Tile select: the status bar wins.** `hint_line(&mode)` is read off the + keymap, so it cannot go stale, and it is a strict superset of what the tile + bar's literal carried — `Space`, `h`/`l` and `Tab` were only ever on the + derived line. The tile bar's own hints are therefore **deleted**, both of + them, and `TileBar::render` now advertises no keys at all. The one it drew in + Normal was false: it named `Ctrl+↑↓←→` as "to move tiles" while those four + keys are bound to `enter-tile-select`, which moves nothing. +- **The import wizard: the overlay wins.** `hint_line(&AppMode::ImportWizard)` + is derived too, but from the *wrong input* — the wizard's real keys are + resolved per step inside `HandleWizardKey`, and one static line off the mode's + keymap is wrong on four of its eight steps, including telling the user `Esc` + cancels on `Done` where `Esc` goes back. So `draw_status` suppresses its hint + fallback for that one mode and lets the overlay's per-step hints stand alone. + +Two things worth keeping from how this survived. `every_hint_key_resolves_in_its_mode` +checks `AppMode::hint_text()` against the keymaps and **cannot see a string +literal in a widget**, which is the same blind spot improvise-wk18 names for +`help.rs`. And the widget's own characterization test renders at 80 columns, +where the hint does not fit and is never drawn — so the test that pins this +widget's output was structurally incapable of showing either hint. A fixture +width is part of what a test can see. + +The longer-term shape the ticket names is still open: the wizard's per-step keys +belong in `advertised()` keyed by step, which would make them derived like +everything else and let the status bar carry them correctly instead of being +switched off. + ### The caches are built here, per frame `project::formulas_of`, `category_tree_of`, `view_list_of`, `tile_bar_of`, @@ -272,3 +338,44 @@ draw one cache and nothing else. Two facts worth keeping: the selected tile still fits in the available width, and draws `‹`/`›` overflow indicators. That is the file's only piece of real arithmetic and it is local geometry, not a shared derivation. + +## `,` walks the view history forward (improvise-htl6) + +`view-forward` was bound in **no** keymap, in either front end, from the day it +was written. The TUI reached it as `:view-forward` and a browser could not reach +it at all, because `declined()` refuses to enter `CommandMode` at the door. It +survived because pressing `<` twice is the only way to observe the forward stack +in the first place, and until PR #115 no client maintained one. + +**The key is `,`, and `>` is why the obvious answer is wrong.** `<` is +`view-back` and `>` is `drill-into-cell`, so the pair that *looks* symmetric is +not one — `>` moves within the grid, `<` moves between views. Binding the +forward half to anything that reads as `>`'s partner would deepen exactly the +confusion that hid the gap. `,` is the unshifted twin of `<`, so both halves of +the history sit on one physical key, and it carries no direction-in-the-grid +meaning to collide with. + +**It is deliberately not a `Ctrl-` key.** `Ctrl-R`, the universal "forward +through a history" spelling, is browser reload; `Ctrl-S` and `Ctrl-W` are +already bound here and `Ctrl-S` is already a filed bug for precisely that reason +(improvise-clso, "Ctrl-S saves and also opens the browser's own save dialog"). +The keymap is **shared** between the two front ends, so a binding chosen for the +terminal is a binding the browser must also live with, and adding a second +instance of an open bug is not a thing to do by accident. + +Nothing else moved. `help.rs` keys its lines off the command *name* and looks +the key up in the keymap, so the overlay and the hint line follow the binding; +`every_bound_command_name_is_registered` already covers the new entry. The +absence assertion PR #117 put in +`resolve::tests::the_forward_step_mirrors_its_cmd_and_no_key_reaches_it` was +**inverted rather than deleted** — the test is renamed `…_and_one_key_reaches_it` +and now asserts the binding exists, with `view-back` kept as the control so it +cannot pass against a table that binds everything or nothing. Coming there and +saying so was the point of that assertion, and an inverted guard keeps the +property under watch where deleting it would have let the key quietly go away +again. + +`,` is not advertised in `AppMode::Normal`'s hint line, and neither is `<`: the +hint is width-constrained and the history lives on help page 5 under "View +history" with both halves. That is the #149 lesson — a hint that does not fit is +never drawn, so the test pinning the widget cannot see it. diff --git a/context/repo-map.md b/context/repo-map.md index 9014a8e2..220d9403 100644 --- a/context/repo-map.md +++ b/context/repo-map.md @@ -194,11 +194,11 @@ Region=West, Measure=Revenue = |pending| # pipe-quoted text 26. **Axis presentation lives on `Axis`, colors resolve in `draw.rs`.** Four vocabularies hang off the enum in `view/axis.rs` — `plain_label()` ("Row"/"Col"/"Page"/"None"), `glyph()` (which `Display` appends, giving "Row ↕"), `short_label()` (three columns for the tile bar; `None` is "·", *not* a truncation), and `initial()` (one letter for the view panel; `None` is empty). Plus `color() -> AxisColor` and `next()` for the Row→Column→Page→None rotation. `AxisColor` is a semantic palette: `improvise-core` must not name a ratatui type, so `draw.rs::axis_fg` does the one mapping to a terminal color, exactly as `minibuf_fg` does for `MinibufColor` (improvise-rml). The tile bar and category panel each carried their own copy of the color table before, with nothing keeping them in agreement. Not covered here: `persistence/format.rs` maps `Axis` to the on-disk keywords ("row"/"column"/"page"), which is a format concern and stays in the io layer. -27. **`Axis::Filter` constrains without being navigable.** Drilling fixes its coordinates on `Filter`, not `Page` (improvise-dz8): both restrict which records are visible, but only `Page` is something the user may move or page through. The split shows up as two different questions, and picking the wrong one is the trap: **"what data is visible" is `View::filtering_cats()`** (Page ∪ Filter — used by `GridLayout` and by `add-record-row` when it builds a key from the current constraints), while **"what can the user act on" is `categories_on(Axis::Page)`** (used by `[`/`]` page cycling) or `View::tileable_cats()` (used by all three tile-select sites). `Axis::next()` maps `Filter` to itself and no navigable axis rotates into it, so tile select cannot hand one out. The `.improv` grammar accepts `filter` and round-trips a selection with it — a Filter axis only ever occurs in the never-persisted `_Drill` view, but the alternative was writing it out as some other axis. +27. **`Axis::Filter` constrains without being navigable.** Drilling fixes its coordinates on `Filter`, not `Page` (improvise-dz8): both restrict which records are visible, but only `Page` is something the user may move or page through. The split shows up as two different questions, and picking the wrong one is the trap: **"what data is visible" is `View::filtering_cats()`** (Page ∪ Filter — used by `GridLayout` and by `add-record-row` when it builds a key from the current constraints), while **"what can the user act on" is `categories_on(Axis::Page)`** (used by `[`/`]` page cycling) or `View::tileable_cats()` (used by all three tile-select sites). `Axis::next()` maps `Filter` to itself and no navigable axis rotates into it, so tile select cannot hand one out. The `.improv` grammar accepts `filter` and round-trips a selection with it — a Filter axis only ever occurs in the never-persisted `_Drill` view, but the alternative was writing it out as some other axis. **What a virtual view must *be* is model-side and has one home** (improvise-gd28): `view::{records_spine, records_steps, drill_steps}` return `Vec` — pure data naming no `Effect`, no `App` and no front end — and `cmd/grid.rs::steps_into_active_view` is the whole of *how the TUI gets there*, twenty lines a browser answers its own way. Before the split, the drill's filter placement and the records spine were spelled out twice in `cmd/grid.rs` with nothing keeping them in agreement. `DrillState` deliberately did **not** move: it holds an `Rc` and its `pending_edits` are uncommitted writes, so gotcha 30's session-local tag already places it. Reasoning: `context/code/core.md` §11. 28. **The browser backend is sync on purpose, with an async seam.** `OpfsFs` (`improvise-io/src/opfs.rs`, wasm32 only) implements the same synchronous `Storage` trait as `PhysicalFs`, because `FileSystemFileHandle.createSyncAccessHandle()` yields a handle whose read/write/truncate/flush **are** blocking — available only inside a dedicated Web Worker, which is where improvise-9x6 puts the app. So `Storage` never became async. What is still async splits out into four explicit methods the worker awaits from its message loop: `open()`, `attach(path)` (must precede any read/write of that document — acquiring a handle is async even though using it is not), `refresh()` (repopulates the index `list` answers from), and `commit_deletes()`. Consequence: **`delete` is visible immediately but lands in OPFS at the end of the batch** — it drops the name from the index and queues a `removeEntry`. Handles are exclusive per file, so `attach` closes any handle it replaces and `detach` releases the lock. `storage_contract` in `storage.rs` is the behaviour every backend owes; it runs against both host backends and is the written-down spec `OpfsFs` must satisfy in a browser, where the harness cannot reach. **And the premise is dedicated-worker-only, which is narrower than this paragraph used to read.** `createSyncAccessHandle` is spec'd `[Exposed=DedicatedWorker]`, and measured in a module `SharedWorker` on Chromium and Firefox it is **absent** — not rejected, not flagged, not transient: `getDirectory()` and `getFileHandle()` both succeed and the method is simply not on the object (improvise-12ha). So `OpfsFs` cannot be hosted by a `SharedWorker` as written, and "a browser worker" in this paragraph means a *dedicated* one everywhere it appears. `OpfsFs::open`'s own guard does not enforce that — it `dyn_into`s `WorkerGlobalScope`, which a `SharedWorkerGlobalScope` **satisfies**, so a shared worker sails past a check whose message names the right requirement and fails later as a raw `TypeError` deep in the glue (improvise-7gip). The consequence for multi-tab is that the machinery everyone reaches for first is the storage seam, not `Sessions`: `Sessions`, the `Addressed` filter and the one `frame` pair are all ready, and none of that helps, because the no is entirely on the handle. -29. **"Which view is active" is stored twice, and the copies are now allowed to differ.** `Workbook::active_view` is the document's **default** — what a freshly connected client opens, and what `.improv` writes as `Initial View`. `ViewState::active_view` is **authoritative for that client**, and it is what keys `ViewState::cursors`. The second copy exists because `reduce_view` runs on a client that holds no `Workbook` (improvise-cqi); the *split* is improvise-jytm answering improvise-g0f, on the user's ruling that **the model is shared and the UI is not**. Two clients may sit on different views of one document. Three things follow. **`App::resolve_view` is the one answer** to what a view name names in this document, falling back to the default so a slice naming a deleted view cannot panic in `rebuild_layout`; `view()`, `view_mut()`, `view_name()`, `project::grid_in` and `ClientSession::view_in` all go through it, and a second copy of that fallback was the first thing review found. **The guard was replaced, not deleted** — `the_two_active_view_names_never_diverge` became `the_documents_default_view_outlives_this_clients_switches` plus `a_fresh_session_opens_the_documents_default_view`, because a deliberate split with no guard is how one silently regresses into the other. And **a deleted view is the case the split creates**: `DeleteView` can take away the view another client is on, so `Sessions::adopt_deleted_views` moves that mirror to the default and tells it, and `effect::DeleteView` does the same for the host — observed ("does this mirror still name a view the document has"), never predicted from the command variant. **Testing any of it needs a bystander that can be moved**: both audience tests first sat theirs on the default, where a wrongly-addressed relay folds to a no-op, and review demonstrated the vacuity by injecting `ToAll` twice. Two gaps remain, both filed: a view-scoped `ModelCommand` names no view, so a connected client's `SetAxis` lands on the host's view (improvise-lltt — deferred on a *condition*, that no shipped client emits `SwitchView`, while the browser already emits `TransposeAxes`), and `Initial View` no longer follows the TUI user (improvise-azhb). Follow mode is deliberately not foreclosed. +29. **"Which view is active" is stored twice, and the copies are now allowed to differ.** `Workbook::active_view` is the document's **default** — what a freshly connected client opens, and what `.improv` writes as `Initial View`. `ViewState::active_view` is **authoritative for that client**, and it is what keys `ViewState::cursors`. The second copy exists because `reduce_view` runs on a client that holds no `Workbook` (improvise-cqi); the *split* is improvise-jytm answering improvise-g0f, on the user's ruling that **the model is shared and the UI is not**. Two clients may sit on different views of one document. Three things follow. **`App::resolve_view` is the one answer** to what a view name names in this document, falling back to the default so a slice naming a deleted view cannot panic in `rebuild_layout`; `view()`, `view_mut()`, `view_name()`, `project::grid_in` and `ClientSession::view_in` all go through it, and a second copy of that fallback was the first thing review found. **The guard was replaced, not deleted** — `the_two_active_view_names_never_diverge` became `the_documents_default_view_outlives_this_clients_switches` plus `a_fresh_session_opens_the_documents_default_view`, because a deliberate split with no guard is how one silently regresses into the other. And **a deleted view is the case the split creates**: `DeleteView` can take away the view another client is on, so `Sessions::adopt_deleted_views` moves that mirror to the default and tells it, and `effect::DeleteView` does the same for the host — observed ("does this mirror still name a view the document has"), never predicted from the command variant. **Testing any of it needs a bystander that can be moved**: both audience tests first sat theirs on the default, where a wrongly-addressed relay folds to a no-op, and review demonstrated the vacuity by injecting `ToAll` twice. One gap remains: `Initial View` no longer follows the TUI user (improvise-azhb). The other — a view-scoped `ModelCommand` naming no view — is **closed** (improvise-lltt), and this paragraph described it as an open gap for a day after: every view-scoped variant carries `view: String` and `model_effects` maps it to `ViewTarget::Named`, so a connected client's `SetAxis` lands on the view it names. `ViewTarget::Active` resolves through `App::view_mut()` and is therefore the *host's* view — but **the wire never constructs one**, which is what closed it. Follow mode is deliberately not foreclosed. 30. **Two tests gate `ViewState` membership.** `ViewState` is the *portable* session slice — the one thing serialized into the Snapshot a newly-connected client gets (improvise-q08). A field may live there only if it is **portable** (plain data from the allowlisted crates, no `Rc`/`Arc`) and **snapshot-safe** (a fresh client inheriting the value is correct). `wizard` fails both — `ImportWizard` is an `improvise-io` type, and a half-open dialog is not something to inherit — and `drill_state` fails both — it holds an `Rc`, and `pending_edits` are uncommitted writes belonging to whoever staged them. Both live on `App` under the **session-local** residue tag (improvise-h0b). The `ViewState: Send` static assertion in `app.rs`'s tests is the compile-time proxy for portability: `Rc` is not `Send`, so a re-added shared pointer fails the build (the `Deserialize` derive from gotcha 25 now rejects it too, serde's `rc` feature being off). `Send` does *not* catch the origin-crate or snapshot-safety halves — check those by hand. @@ -221,7 +221,7 @@ Region=West, Measure=Revenue = |pending| # pipe-quoted text 39. **`Applied` is an enum because a document swap is not a diff.** `Sessions::apply_checked` returns `Applied::Diff { dirty, broadcast }`, `Applied::Rebased { snapshots }` or — since ADR 001 layer 1 — `Applied::Rejected { reason }`, and the enum *is* the mechanism: a host that wants the `Dirty` must match, and matching names the case where a `Snapshot` is owed. The server's mirrors self-heal — `ClientSession::project` reseeds on the `document_generation` bump — but `ViewState::cursors` lives on the **client**, keyed by the outgoing document's view names (gotcha 24), and only a fresh `Snapshot` replaces it. `Command::Model` put the three `WholeWorkbook` mutations on the wire (gotcha 38), so the host that must send the snapshots is no longer the host that asked. `apply_model` therefore calls `rebase` itself rather than instructing the host to, exactly as `apply_switch` adopts the accepted view itself. **The trigger is the generation moving, not the command variant**: a `LoadModel` that cannot read its path leaves the document alone, and reseeding on the variant would cost a client its cursors for a *failed* load — `a_load_that_fails_is_not_a_rebase` guards that arm. A `rebase: bool` beside `dirty` was rejected, because the host that forgets to call `rebase` is the host that forgets to read the flag. The **third** case is a *normal outcome*, not an error, and it is in the enum for the same reason the other two are: a host that wants the `Dirty` must match, and matching names it. `ServerMsg::Rejected` is the matching frame, addressed to **the asker alone** — nothing happened, so nobody else is owed anything — and the client keeps it as a `Rejection` rather than a sentence, because force needs to know *which* record and *which* kind (gotcha 43). **Fixture warning.** When testing a document swap, the incoming document's active view must have the **same name** as the outgoing one. A rename makes `apply_model` broadcast a `SwitchView`; the client folds it, and the stale cursor is then read under a key nothing has written — right *by accident*, with the bug invisible. `other_document()` renames, and a first version of this test passed because of it; `a_document_shaped_differently_under_the_same_view_name()` keeps the name and changes only the shape, so there is nothing to hide behind. -40. **A batch's basis is three tiers, and only two of them can be read off the effect.** `App` carries `document_generation` (bumped by `install_workbook`), `layout_version` (per `ModelChange::LayoutGeometry` effect) and `record_versions` (per *record*) — ADR 001's stale-basis check, and shared document state, so never on `ViewState` (gotcha 30). **The unit is the record**: `record_of(key)` is the coordinate tuple minus `_Measure` (`record_row_identity`), because renaming a category coordinate in wide records mode moves every measure cell in the row (gotcha 1) — a cell-level unit is violated by one legal edit, so the unit must be at least as coarse as the coarsest legal write. Both tiers are read in `apply_effects` off the same `EffectKind` the `Dirty` is: one classification, two readers, no second table to drift from gotcha 35's. `layout_version` bumps **inside** the loop for the reason `Dirty` accumulates there — an aborted batch must not claim effects it never ran (gotcha 21). `record_versions` **cannot**, because an `EffectKind` says *that* a batch writes cells, never *which*; so the records are **observed**, via a per-record XOR fingerprint of the raw data taken when the batch declares a `CellData` effect and diffed after it. Three consequences. A write landing the value already there **bumps nothing**, which is correct — no basis was invalidated. A `LayoutGeometry` effect that drops cells **will** bump their records when it shares a batch with a `CellData` one: an over-bump costing at most an unnecessary rejection, the same direction `Dirty::of` is deliberately sloppy in. And only *raw* data is fingerprinted — formula results live in `Model::formula_cache`, and a record whose derived value moved because someone edited elsewhere has not itself been written. The diff collects into a **set**: chaining `before.keys()` with `after.keys()` counts any rewritten record twice, a bug that survived one test run. +40. **A batch's basis is three tiers, and only two of them can be read off the effect.** `App` carries `document_generation` (bumped by `install_workbook`), `layout_version` (per *view shape*) and `record_versions` (per *record*) — ADR 001's stale-basis check, and shared document state, so never on `ViewState` (gotcha 30). **The unit is the record**: `record_of(key)` is the coordinate tuple minus `_Measure` (`record_row_identity`), because renaming a category coordinate in wide records mode moves every measure cell in the row (gotcha 1) — a cell-level unit is violated by one legal edit, so the unit must be at least as coarse as the coarsest legal write. **Only the `Dirty` is read off the `EffectKind`; both lower tiers are *observed* around the loop** — the source says so at `apply_effects` and this paragraph said the opposite until improvise-ilvl's audit. The `EffectKind` decides only *whether to look*: `touches_model` arms the shape snapshot, `writes_cells` the record fingerprints. `record_versions` **must** be observed, because an `EffectKind` says *that* a batch writes cells, never *which*; `layout_version` is observed for a better reason than necessity — `observe_reshape` diffs `view_shapes` before and after and bumps only where a view's shape actually moved (improvise-pdvm/nynk), so it is a **fact about the document** rather than a claim about the batch. Two consequences the declared form did not have. An **idempotent** `set-axis` bumps nothing, and batching two real changes into one line or two gives the same `2`. And the gotcha 21 argument is subsumed rather than restated: an aborted batch moved no shape, so there is nothing to not-claim — which is why observing once **after** the loop is correct where accumulating `Dirty` inside it is. Note the arming is deliberately wider than the tier: a `CellData`-only batch that prunes a row out of a view **does** bump `layout_version`, because the shape moved. Three further consequences. A write landing the value already there **bumps nothing**, which is correct — no basis was invalidated. A `LayoutGeometry` effect that drops cells **will** bump their records when it shares a batch with a `CellData` one: an over-bump costing at most an unnecessary rejection, the same direction `Dirty::of` is deliberately sloppy in. And only *raw* data is fingerprinted — formula results live in `Model::formula_cache`, and a record whose derived value moved because someone edited elsewhere has not itself been written. The diff collects into a **set**: chaining `before.keys()` with `after.keys()` counts any rewritten record twice, a bug that survived one test run. 41. **`Storage` is not a VFS, and that closes the SQLite question.** The trait is whole-document `read`/`write` bytes (gotcha 2, `invariant(storage_is_two_ops)`) — no offsets, seeking, handles or locking — so an `sqlite3_vfs`, which needs `xRead(offset,len)`/`xWrite`/`xTruncate`/`xLock`, has nothing to bind to. SQLite *can* still sit behind it, as an in-memory database serialized to a blob (`rusqlite` `serialize`/`deserialize_read_exact`, proven with a probe before declining — declining on a capability that actually exists would have been the wrong decline). It is worth nothing: measured on 18,000 cells the SQLite container is **22% larger** than `.improv`, **4.7× larger gzipped**, ~**160×** larger at the floor (24 KB against ~150 bytes), and still rewritten whole on every save, so the one dimension SQLite normally wins — incremental page writes — is unavailable. improvise-9ix was declined on that evidence. Two things generalise past SQLite. **A second serialization format is a second implementation of the grammar**, obliged to track `improv.pest` forever; and **its own round-trip test cannot detect drift from `.improv`**, because it never compares the two — that missing cross-format equality test is what made the ticket look cheap. If a structured-query story is ever wanted it is a one-way `export_sqlite` beside `export_csv`, native-only and feature-gated: `libsqlite3-sys`' C build fails under this repo's nix cc-wrapper for `wasm32-unknown-unknown`, which would break the `wasm` CI gate the day it landed. @@ -237,6 +237,8 @@ Region=West, Measure=Revenue = |pending| # pipe-quoted text 44. **The TUI grid draws from its cache, and since improvise-anr9 the cache frames it too.** `GridWidget` holds no `&Model` and no `&View`: cell text, aggregate-ness (`CellId::Aggregate`, ADR 002), column widths, the page bar, the totals strip and now the **frame** all come off `GridCache`. The TUI still builds that cache over the **whole** grid (offsets zero), so a cache position is an absolute position — the browser's `Viewport` is a real window, the TUI's is not, and the two clients therefore use it differently; column totals **must** be projected rather than client-derived, because the sum runs over every row *including the ones a viewport scrolled past*. The frame is an `AxisFrame` per axis — `cats` (the categories, outermost first) plus `entries: Vec` interleaving `Group(FrameGroup)` headers with `Item(Vec)` label *tuples*, exactly as `GridLayout::row_items` interleaves them. That replaced a `Vec` built by `GridLayout::row_label`, which slash-joined the tuple — the exact rendering `two_row_categories_produce_cross_product_labels` says must not appear — so the browser drew `Food/Alice` where the TUI drew two header columns, with no groups and no collapse indicators. It is **one** type and not a `labels` plus a `levels` beside it: two answers to "what are the labels" is the disease (improvise-edp), and this is the second cure, which cost a *type* change rather than an addition. Three things to know before touching it. **`leaf_labels()` is not "the labels"** — it is the innermost label, what a one-line summary calls a row by; a draw loop wants `header_labels(first_rendered)`, `groups()` and `shares_ancestry(sel)`, each of which is one pass, because `item`/`label`/`shows_level`/`group_of` scan `entries` from the start and asking them per position is quadratic in the frame *and grows with the TUI's absolute scroll offset* (found three times on anr9; the per-position forms survive for one-off lookups and say so). **The width rule has one home**, `improvise_protocol::header_level_widths` — widest label per level, plus a gap, clamped — reached by `AxisFrame::header_widths` and by `ui::viewport::compute_row_header_width`, which borrows level tuples off a `GridLayout` rather than building a frame on the keypress path. **A `cats` change re-identifies every position**: a cell's identity is `cats` zipped with its tuple, so `invalidate_relabelled` blanks the whole axis when the categories move, not merely the positions whose tuples differ, and blanks the **totals** on a recategorization of *either* axis — a column total is a sum over the rows, so re-identifying the rows leaves a number answering coordinates the grid no longer has (gotcha 33(b): the rule lives in `apply`, not in a producer's memory). What is still **not** on the cache is `record_col`, and that is correct: it keys pending drill edits and is session-local (gotcha 1). `body_from(idx)` takes an argument only because the two front ends scroll differently — a browser's frame is already windowed and passes `0`, the TUI's covers the whole grid and passes its row offset — not because the rule differs. `group_of` answers group **membership**: the nearest preceding header *unless* an `AxisEntry::GroupEnd` closed the run first (improvise-gd83, landed in PR #77). It previously answered "nearest preceding header" unconditionally, which gave an ungrouped item the preceding group. `GroupEnd` draws nothing and exists only so membership is not inherited. Both of the gaps this sentence used to list are **closed**, and it named them as open for a day after: `group_runs` coalesces by **header identity** (`std::ptr::eq`), not by name, so two runs under two headers equal as values are two runs (improvise-h0iv); and `AxisFrame::first` carries **no** `serde(default)` — its doc comment says why, since zero is a claim and not an absence (improvise-1wl0). `doctrine(a_stale_list_of_what_is_missing_is_worse_than_no_list)` is about exactly this paragraph, twice now. And `project::grid_of` evaluates every cell per frame where the widget evaluated only the visible ones — a widening of an existing whole-grid cost. Since improvise-jytm `grid_in` may additionally **derive a `GridLayout`** for a session on a view the host is not on; the host's layout is reused where the names agree, which is the common case and the only one that can carry a drill snapshot. + **`RecordHash` is document-scoped; `AggregateHash` is layout-scoped and cannot be otherwise** (improvise-gd28). A `RecordHash` falls back to `DataStore`'s maintained index and so resolves against the **document**, which is why a connected client's `SetCell` works from any view. An aggregate has no existence outside the layout that derives it, so there is no index to fall back to and none can be built — `App::resolve_aggregate` therefore answers only for the **host's** active view, which since improvise-jytm is not the client's. **That, and not ADR 001 layer 2, is what blocks browser drill.** The ticket's blocking argument was right about the *commit* (`ApplyAndClearDrill` is addressed to one session, gotcha 38) and wrong about the *entry*: entering a drill needs no commit, and `CreateView`/`SetAxis`/`SetPageSelection` are already unaddressed `ModelCommand`s that name their view. The gap is one level down from where it was looked for — **the mutation and its operand have different scopes**: the mutation configures the shared `_Drill` and is addressed to nobody, while the operand exists only inside one session's layout. So the fix is not a new mechanism but the `view: String` convention every other view-scoped variant already follows, resolved against a layout derived for that name (improvise-obtu). Two things travel with it: `improvise-fdk1` is **not** needed, because `edit_or_drill` declines records-mode editing out loud before reaching a cell, so a browser cannot edit inside a drill grid at all; and closing it makes improvise-t219 live, `_Drill` being one shared view in `Workbook::views` that two clients drilling would contend for — the same masking relationship improvise-6uva has with improvise-up8g. Guarded by `an_aggregate_a_session_was_shown_does_not_resolve_against_the_hosts_view`, whose **control** is the load-bearing half — the same surrogate resolves once the host is moved onto that view, so a blanket `None` cannot pass. + 45. **One session loop, two transports — and the browser saves itself.** `Host` is `App` + `Sessions` + `handle(id, ClientFrame) -> Vec` and lives in `improvise_command::ui::host`, **not** in `improvise-ws-server`, which re-exports it. It moved for improvise-9x6: the worker hosts the same authoritative `App` over a `MessagePort` and cannot depend on tokio, and a second copy would have been exactly the mirror improvise-xks6 had just deleted for the frames — a drifting `Applied`/`Broadcast` match is invisible until a client sees it. `registry` stayed on the server, because a worker hosts **one** document. What the worker adds is only what a `MessagePort` differs in: **one client by construction** (the `Addressed` filter is honoured anyway, against a future `SharedWorker`), and **a handshake with no close** — `ClientFrame::Viewport` must still come first, but a pre-handshake frame is *dropped* rather than closing a connection that does not exist. **Saving is a frame, and dirtiness is read off the classification the effect already carries.** `effect::Save` is `ViewOnly` (gotcha 3) — correctly, since it writes *this host's* `file_path` through *this host's* `Storage` — so the derivation that builds `ModelCommand` from the model effects cannot name it, and for a while that meant no client on any transport could ask its host to save. `ClientFrame::Save` closes it (improvise-368h): a **request** carrying no path, because a client cannot see the host's storage, answered `ServerFrame::Rejected(NoDocumentPath | SaveFailed)` to the asker alone and **silently on success** — nothing moved, so no widget is dirty. It is a frame and not a `Command` for one concrete reason: as a `Command` it put a runtime-fallible operation inside a batch `ClientFrame::Commands` promises is all-or-nothing, so `[Save, SetCell]` reported a failed write and then applied the edit. It also fails the test every `Command` passes — it mutates no shared state and no client's slice, so `reduce_view` has nothing to fold and `Checked` has no basis to answer for. **And `MarkDirty` is gone.** It was a separate effect the *command* layer composed beside each mutation — reasonable on the key path, unreachable from anywhere else, because improvise-kmgs derived `ModelCommand` from the effects **one at a time** and so put `SetCell` on the wire without its companion (improvise-gv9r): a derivation correct per element and wrong for a set, whose symptom was that "save if dirty" never fired. `App::apply_effects` now raises the flag for any `EffectKind::Model(_)`, all three `ModelChange` levels included, which makes omitting it unrepresentable rather than validated. It over-approximates in the same direction `layout_version` does — a `LoadModel` that cannot read its path leaves the flag raised, costing one redundant write — and `Save` clears it in loop order, so a batch that edits and then saves ends clean. The worker's persist trigger is therefore the **flag**, not a table of command variants: an observation rather than a second description of which commands change the document, for the reason `cursor_epoch` beats matching on `CycleAxis` (gotcha 24) and `document_generation` beats matching on `LoadModel` (gotcha 39). It is also strictly tighter — a refused batch raises nothing, so the "was anything rejected" guard went with it. A browser still never *sends* `ClientFrame::Save`, and that is the answer rather than a gap: there is no file the user owns and no Ctrl-S semantics, the document *is* the OPFS entry, so the worker persists on its own behalf through the one existing `Save` effect. The cost that remains: `Storage` is whole-document (gotcha 41), so OPFS rewrites the file per accepted model command. @@ -251,7 +253,7 @@ Region=West, Measure=Revenue = |pending| # pipe-quoted text 47. **Keys become commands in the browser, and the server must not know the keymap.** The user's ruling on improvise-bpq2, following from "the model is shared, the UI is not" — and it buys two clients different bindings over one document. What made it urgent is **not** latency. `a_key_resolved_on_the_host_moves_state_no_client_can_hear` shows nothing in the projection stream carries the host's own `ViewState`, so host-side resolution **diverges**; and `Host::handle` answers `ClientFrame::Key` with an explicit empty vec, so **an upstream key is a black hole, not a slow path** — every `KeyOutcome::Upstream` names a binding that does nothing at all in a browser. That test still holds and was not flipped: bpq2 closes the divergence by *avoiding* it. **The method is the ticket's** — enumerate the **bindings**, not the commands (`doctrine(binding_dump_diff)`) — and where a client cannot compose the `Command`, **enrich the projection** rather than moving resolution back. - Three things that cost. **`GridExtent` on `GridCache`** — the whole grid, of which the labels are one window — because a viewport-indexed cache cannot tell "the last visible row" from "the last row". It is deliberately **not framed by the labels** (`fit_to_labels` leaves it alone) and its batch position is not load-bearing the way `RowFrame`-before-`Cells` is, since it addresses no position; the producer reads `GridLayout::row_count()`, and reading it off the projected ranges is **invisible in the TUI** (which builds at offsets zero, gotcha 44) and is exactly the browser bug. **The window is the client's own fact**, recorded by `Client::set_viewport` from what it announced — *not derivable*, since the row frame's own length is the window **clipped to the grid** and so differs precisely at the bottom edge where the scroll arithmetic matters. `Client::viewport` (associated, no recording) still exists and is the one that quietly turns cursor keys off; `web/app.js` does call `set_viewport`. **`ClientFrame::Commands(Vec)`** replaces the singleton, because one cursor key is `SetSelected` plus the offsets `viewport_effects` returns and a host that folds the selection but not the offset projects the wrong window. It is **answered whole, then applied whole** (improvise-955n): `CheckedBatch::against` checks the frame before anything runs, so there is no longer a moment at which part of a frame has landed and the rest has been refused, and `Applied::Rejected` is unreachable through `Host::commands` — still matched, because a host that wants the `Dirty` must name every case (gotcha 39). The contract is four clauses and **clause 4 is not delivered**: one basis per frame, checked once, applied in order with each command seeing the last one's state, and all-or-nothing. The fourth is open because `App::apply_effects` aborts without undoing (gotcha 21 — an abort is not a rollback), so `[RemoveItem("North"), SetCell(the North record)]` lands the removal, resolves the write to nothing, and reports the frame accepted having applied half of itself — partial *and* silent. It is unreachable from any client in this workspace, the only multi-command producer being `keys::commit`; **reachability is a reason not to rush a fix and not a reason not to notice one** — the lost update `CheckedBatch` fixed had the same reachability argument and none of the documentation, which is how it shipped. + Three things that cost. **`GridExtent` on `GridCache`** — the whole grid, of which the labels are one window — because a viewport-indexed cache cannot tell "the last visible row" from "the last row". It is deliberately **not framed by the labels** (`fit_to_labels` leaves it alone) and its batch position is not load-bearing the way `RowFrame`-before-`Cells` is, since it addresses no position; the producer reads `GridLayout::row_count()`, and reading it off the projected ranges is **invisible in the TUI** (which builds at offsets zero, gotcha 44) and is exactly the browser bug. **The window is the client's own fact**, recorded by `Client::set_viewport` from what it announced — *not derivable*, since the row frame's own length is the window **clipped to the grid** and so differs precisely at the bottom edge where the scroll arithmetic matters. `Client::viewport` (associated, no recording) still exists and is the one that quietly turns cursor keys off; `web/app.js` does call `set_viewport`. **`ClientFrame::Commands(Vec)`** replaces the singleton, because one cursor key is `SetSelected` plus the offsets `viewport_effects` returns and a host that folds the selection but not the offset projects the wrong window. It is **answered whole, then applied whole** (improvise-955n): `CheckedBatch::against` checks the frame before anything runs, so there is no longer a moment at which part of a frame has landed and the rest has been refused, and `Applied::Rejected` is unreachable through `Host::commands` — still matched, because a host that wants the `Dirty` must name every case (gotcha 39). The contract is four clauses and **all four are now delivered** — clause 4 by improvise-yqpd on 2026-09-21, whose mechanism is `App::snapshot`/`restore` plus a `Sessions` clone taken in `Host::mutating_frame` and put back by `undo_frame`, deliberately *not* inside `apply_effects`, which runs on every keypress. **This paragraph said it was undelivered for a day after it landed**, and an orchestrator brief repeated that from here, so read the clause list as a claim to check. A delivered clause 4 is also what makes the CLIENT's rollback well-defined (improvise-nxah): a refusal means the host applied none of the frame and restored every mirror, so a client keeping its optimistic fold is the only thing left out of step. What rolls back there is `ViewState`, never a cache — a `Command::Model` is a no-op in `reduce_view`, so the optimistic fold never reached one. The old text, kept because the hazard it names is still the right shape to fear: one basis per frame, checked once, applied in order with each command seeing the last one's state, and all-or-nothing. was that the fourth was open because `App::apply_effects` aborts without undoing (gotcha 21 — an abort is not a rollback), so `[RemoveItem("North"), SetCell(the North record)]` would land the removal, resolve the write to nothing, and report the frame accepted having applied half of itself — partial *and* silent. That is what `yqpd` closed, and it closed it **outside** `apply_effects` for the reason the old text implies: the abort lives on the keypress path and must stay cheap. The old note that it was *unreachable* from any client here, the only multi-command producer being `keys::commit`, still reads correctly as a caution — **reachability is a reason not to rush a fix and not a reason not to notice one**, the lost update `CheckedBatch` fixed having had the same reachability argument and none of the documentation, which is how it shipped. Two corrections to the ticket's own gap list, both worth more than the code. **Panel cursor movement was never a gap**: improvise-edp had already made `viewmodel::flatten_cat_tree` the one home both ends call, so it is the *shared* derivation, not a second one — worth re-checking the "known gaps" in other old tickets against what has landed since. And the "unmodified characters only" rule was **mis-scoped, not wrong**: it was about `KeyPattern::AnyChar`, which is bound only in the text-entry modes, so `Ctrl-d` in Normal has an exact binding and mirroring it is a faithful subset. Each resolver mirrors its `Cmd` **line for line, including where siblings disagree** — `Move` and `MoveTileCursor` emit even when they clamp, `MovePanelCursor` returns nothing when the cursor does not move. Tidying that into one rule would make the client a second derivation, which is what `tests/lockstep.rs` exists to rule out. What still needs the host is a **named list** in `keys.rs` and in `the_normal_mode_keys_that_still_need_the_host_are_these`, so closing one has to come there and say so. diff --git a/context/test-evidence/cli.md b/context/test-evidence/cli.md index 0e965a1b..bf5f7818 100644 --- a/context/test-evidence/cli.md +++ b/context/test-evidence/cli.md @@ -135,3 +135,106 @@ spawned child at a stable path. Runs `add-category Region` to create the document. It asserts the file exists afterwards rather than trusting the seed, because every test that follows would otherwise misreport as "the command did nothing" when the seed was what failed. + +### a_script_can_move_the_cursor_and_the_next_command_sees_it + +`improvise cmd` and `improvise script` resolved every command through +`CmdRegistry::parse` — the **pure** arm, which builds `CursorState::default()` +(row 0, col 0, zero rows, zero columns). The interactive arm, which builds +`CursorState::from_ctx(ctx)`, was never reached from a script, so every `Move` +snapshotted a zeroed cursor at *parse* time, clamped to `(0, 0)` and emitted +`SetSelected(0, 0)` — worse than a no-op, since it **resets** the cursor +(improvise-6uva). The reasoning is `context/code/cli.md` section 7. + +Reproduced at `a9cad15` before the fix, on a seeded document with `Region` on +row carrying `North, South, East`: `move-selection 2 0 . hide-selected-row-item` +and a bare `hide-selected-row-item` both wrote `hidden: Region/North`. After the +fix the first writes `hidden: Region/East`. + +**Why the test has two halves.** Asserting only that the moved script hides +`East` cannot separate "the move worked" from "the origin happens to be East". +The second document asserts the origin is `North`, so the first assertion is +discriminating, and its message says what breaks if that ever changes — three +identical replays of a fixture whose moves are silent no-ops prove nothing, +which is how this survived five months. + +Two faults were injected, one per layer, and both fail this test: + +- **`parse_segment_interactive` calls `ctx.registry.parse` instead of + `.interactive`** — the bug itself, one level down. + `left: "hidden: Region/North" right: "hidden: Region/East"`. +- **`run_headless_commands` builds the segment up front with `parse_line`** + rather than per segment against a live `CmdContext` — the composition half. + Same failure. The unit test in `parse.rs` passes under this second one, which + is why both tests exist. + +### a_script_cursor_move_survives_the_line_that_made_it + +The same property across two `-f` arguments rather than one dot-separated line, +because `improvise cmd` takes several and `improvise script` is one per line: +the cursor is `App` state and a line boundary is not a reset. + +### a_segment_is_built_against_the_cursor_it_will_run_against + +The unit test for the layer that owns the bug. It seats a `CmdContext` at +`(1, 0)` in a two-by-two grid and asserts `move-selection 0 1` executes to +`SetSelected(1, 1)`. + +Its second half is what keeps it honest: it builds the *same* command through +`registry.parse` and asserts that one gives `SetSelected(0, 0)`. If the pure arm +ever learns the cursor, the first assertion stops discriminating and this half +fails first, saying so. + +### a_pure_commands_two_arms_agree_on_no_arguments + +A second instance of the same shape, found by the first fix rather than +predicted by the ticket. `CmdRegistry::register_pure` built an interactive arm +that refused **empty** args while its own pure arm accepted them, so `w` with no +path — save to the current document — parsed one way and refused the other. Once +a script re-parsed through the interactive arm, `improvise cmd 'w'` and `:w` +both broke; the suite caught it as +`a_failed_colon_write_lands_in_normal_where_its_error_is_drawn` reporting +`":w — w: this command requires arguments"`. + +Both arms are now `parse`, which is what "needs no interactive context" was +supposed to mean. Restoring the empty-args guard fails this test on the first +`expect`: the interactive arm refuses a bare `w` the pure arm accepts. + +### segments_drops_comments_and_blanks + +`segments` is the split half of `parse_line_with`, extracted so the headless +loop can re-parse one segment at a time. It answers the same three cases the +whole-line parser did — a `#` comment, a `//` comment and a blank line are no +commands — plus the empty segment in `a . . b`. + +### an_unknown_segment_is_an_error_not_a_silent_skip + +`parse_segment_interactive` returns `Ok(None)` for a segment with no word, which +is a shape an unknown *word* must not be able to reach: a silently skipped +command in a script is the partial-and-silent failure this repo keeps finding. +The error also names the word, matching what `CmdRegistry::parse` does. + +### an_explicit_coordinate_still_beats_the_cursor + +Found by the ChatGPT Codex review of PR #151 at head `64bcb82`, and correct: the +first version of improvise-6uva's fix introduced a regression it was not looking +for. Four registry entries — `clear-cell`, `yank`, `paste`, `drill-into-cell` — +were registered with `register`, and their **interactive** arms took `|_args, +ctx|`: they discarded the arguments and used `ctx.cell_key()`. That was harmless +while a script only ever reached the *pure* arm, which reads the coordinates. The +moment the headless loop re-parsed interactively, `clear-cell Region/East` passed +the syntax gate and then cleared whatever the cursor was sitting on — the origin, +`Region/North`. + +So improvise-6uva's own lesson had a **third** instance in its own fix: two +construction arms that mean different things demote whichever caller reaches the +weaker one, and here the "weaker" one was the arm that had the context. The four +arms now go through `key_from_args_or_cursor`: explicit coordinates win, the +cursor is the fallback, which is also what the keymap path wants since a binding +carries no arguments. + +The fixture writes three cells and clears the one the cursor is **not** on, so +the assertion separates "cleared the named cell" from "cleared the cursor cell" +— clearing `North` by name would have passed against the broken code. Restoring +the arg-discarding arm fails it: `the cursor cell was cleared instead of the +named one: ["Region=East = 3", "Region=South = 2"]`. diff --git a/context/test-evidence/clients.md b/context/test-evidence/clients.md index 66f9975d..3888295e 100644 --- a/context/test-evidence/clients.md +++ b/context/test-evidence/clients.md @@ -3253,7 +3253,7 @@ on where you can go next, and only the second press shows it. Its empty arm is pressed first and asserted against the host's own status, so a fixture that silently had history would be caught rather than quietly testing one arm twice. -### resolve::tests::the_forward_step_mirrors_its_cmd_and_no_key_reaches_it +### resolve::tests::the_forward_step_mirrors_its_cmd_and_one_key_reaches_it **`view-forward` is bound in no keymap, in either front end** — not a browser gap, and not something this ticket predicted. `Keymaps::default_keymaps()` binds @@ -3266,12 +3266,29 @@ only way to observe the forward stack at all. The arm is added anyway and tested by name, because `composed` keys on the command name and the keymap is the *shared* table both ends read: the day a key binds `view-forward`, nothing else must be touched. What keeps that from being -unreachable code with no guard is the second half of this test, which asserts +unreachable code with no guard is the second half of this test, which asserted that no keymap binds `view-forward` **and** that one binds `view-back`. The control matters: without it the check passes against a misspelling, which is the neighbouring-question defect §7 names. When a binding is added, this test fails and is where the gap stops being one. +**improvise-htl6 is that day, and the assertion was inverted rather than +deleted.** `,` now binds `view-forward` (the argument for that key over `>` and +over `Ctrl-R` is in `context/code/tui.md`), the test is renamed, and the second +half now asserts the binding **exists** — with `view-back` kept as the control, +which now guards the opposite failure: a table that binds everything, or a +sweep that reports names it never read. A third assertion pins the key itself, +`composed(&state, ev(Char(',')), "view-forward", &[]) == [Command::ViewForward]`, +so the binding cannot drift onto some other key while the presence check stays +happy. + +**The rival: bind `,` to `view-back` instead** — the misspelling the original +control was written against, and the one a copy-paste actually produces. It +fails this test *and* `ui::app::tests::the_view_history_walks_both_ways_from_the_keyboard`, +because with an empty back stack `view-back` leaves the active view where it is. +Deleting the binding outright is the fault the pre-fix run already +demonstrated. + ### "the deployment notice starts collapsed and remembers being opened" The page's `#backend-notice` was a static `

` carrying roughly ten dense diff --git a/context/test-evidence/command.md b/context/test-evidence/command.md index 2529868f..e6059fc5 100644 --- a/context/test-evidence/command.md +++ b/context/test-evidence/command.md @@ -275,8 +275,60 @@ than decided here: a TUI user who switches view and saves now writes the --- +## `ui/app.rs` — the view history from the keyboard + +### the_view_history_walks_both_ways_from_the_keyboard + +improvise-htl6: `view-forward` was bound in no keymap, in either front end, so +the forward half of the view history was reachable only as `:view-forward` in +the TUI and not at all in the browser. `,` now binds it. Reasoning: +`context/code/tui.md`, "`,` walks the view history forward". + +This is the TUI half of the claim and it is deliberately end-to-end through +`App::handle_key` rather than a keymap-table lookup: the table is what was +wrong, so a test that reads the table cannot be the only witness. It switches +views, presses `<`, asserts it landed back, then presses `,` and asserts it +returned. + +**`<` is the control and it is load-bearing.** Without it, a keymap that bound +*nothing* would fail on the same assertion as one that bound `,` wrongly, and +the two are different defects. **The rival injected** is `,` bound to +`view-back` — the misspelling the sibling test's control was originally written +against — which leaves the active view on `Default` because the back stack is +empty by then, and fails here and in +`resolve::tests::the_forward_step_mirrors_its_cmd_and_one_key_reaches_it`. + +The browser half needs nothing: the keymap is the shared table both ends read, +and `resolve.rs` has had the `view-forward` arm since improvise-cfly. + ## `ui/effect.rs` +### a_switch_the_document_refuses_leaves_the_back_stack_alone + +improvise-swzk: `SwitchView` pushed the leaving frame onto `view_back_stack` +before calling the guarded `App::switch_to_view`, so `:view NoSuchView` buried a +real history frame under a frame naming the view the user was already on. `<` +then returned to where it already was. Reasoning: `context/code/command.md`, +"`SwitchView` — the history records a move that happened". + +The test switches to a real second view, then to a name the document has not +got, asserts the back stack is still one deep, then presses `<` and asserts it +lands on the **first** view. + +**Two faults injected, and the second is the one the brief asks for.** Making the +push unconditional (`if true`) fails this test and +`switch_view_to_same_does_not_push_stack`. Suppressing it entirely (`if false`) — +the over-reach a fix aimed at this bug could plausibly commit — fails eleven +tests including this one's own first assertion, which is why the assertion that +one frame *survives* is here and not only the assertion about where `<` lands. + +**The fixture is load-bearing and was measured, not assumed.** A variant with no +real second view — refuse a switch from `Default`, then `<` — **passes against +the unfixed code**, because the phantom frame it pops names `Default` and +`Default` is where you already are. That is the vacuity review demonstrated +twice on PR #48, and the ticket warned about it: a bystander sitting on the view +it would land on anyway cannot see this. + ### the_document_surface_runs_on_a_non_filesystem_backend improvise-fuj: every effect that touches a document goes through `App::storage`, @@ -4217,6 +4269,66 @@ The neighbouring test `a_refused_switch_corrects_the_asker_and_nobody_else` compared `active_view` alone and stayed green through all of this: the check that answers a neighbouring question, in the place the doctrine warns about. +### an_aggregate_a_session_was_shown_does_not_resolve_against_the_hosts_view + +**The blocker for browser drill, demonstrated rather than argued.** The ticket +records drill as blocked on ADR 001 layer 2, on the grounds that +`ApplyAndClearDrill` is session-local and unaddressable. That reasoning is +about the **commit** path. Entering a drill needs no commit, and after the +view-stack work above a browser already has `<`; what is missing is only the +step that builds the `_Drill` view. + +Everything that step needs is on the wire already: `CreateView`, `SetAxis` and +`SetPageSelection` are `ModelCommand`s that **name their view** (improvise-lltt), +and `SwitchView` is view-half and per-client. What a browser cannot supply is +the *operand* — it holds `CellId::Aggregate(AggregateHash)`, an opaque u128, so +it cannot compose the filter coordinates itself. That is ADR 002 working as +designed, and it points at the host resolving the surrogate. + +`App::resolve_aggregate` already does that and is tested — but **only where the +host stands.** A `RecordHash` falls back to `DataStore`'s maintained index and +so resolves against the *document*, which is why a connected client's `SetCell` +works from any view. An `AggregateHash` has no such index and cannot have one: +an aggregate has no existence outside the layout that derives it. So it is +layout-scoped, and the layout is `app.layout` — built from the **host's** active +view, which since improvise-jytm is not the client's. + +This test seats a session on a view the host is not on, takes an +`AggregateHash` out of the grid `grid_in` derives *for that session*, and shows +the host answering `None` to a surrogate it had itself just projected. The +control is what makes it discriminating: the **same** surrogate resolves once +the host is moved onto that view, so the failure is the host's view and not the +surrogate. Without the control a blanket `None` — a typo in the hash, an empty +layout — would pass. + +The fixture's second view is not a transpose of the first: it puts `Product` on +Row and `Region` on `None`, so its aggregates constrain on a coordinate the +host's layout never names. A fixture that merely reordered the same axes could +resolve by coincidence. + +**The first version of this test had the defect it was written to expose, and +review found it.** It derived the grid from a hard-coded `grid_in(&app, "Other", +…)`, so the session, the switch and `apply_checked` could all be **deleted** and +every assertion still passed — it proved a grid built for a *name*, where its +own name claims a grid a *session was shown*. The view is now read off +`sessions.get(id).view().active_view`, asserted to have moved, and asserted to +differ from the host's; the viewport is the session's own. Injected fault: +delete the `apply_checked` line and the test fails with `left: "Default"`, +which it could not do before. + +**What this does and does not say about the ticket.** It is *not* ADR 001 layer +2: the mutation a drill performs is addressed to no one session — it configures +the shared `_Drill` view exactly as `CreateView`/`SetAxis` already do +unaddressed — and only the resolution of its operand is session-scoped. Nor is +it improvise-fdk1: a browser cannot edit inside a drill grid at all, because +`edit_or_drill` declines records-mode editing out loud before reaching the +cell, so no coordinate rename is reachable and no divergent staging scheme is +created. The single missing piece is that the resolution must run against the +view the aggregate was **seen** in. The cheap shape is the one every other +view-scoped variant already uses: let the command name its view, as +`SetAxis { view, .. }` does, and resolve against a layout derived for that name +rather than against `app.layout`. + ## `command/keymap.rs` — a bound key that builds nothing (improvise-55l5) ### a_bound_key_whose_command_cannot_be_built_says_so @@ -4496,3 +4608,153 @@ So the discriminating input is the **buffer**, not the column: an empty commit creates nothing, inserts no column, and must still start the next row. Injecting the value-blind predicate above turns the branch off entirely and this test fails with `row_count` 1 where 2 is owed, while the headline test above stays green. + +## improvise-lm5e — a type name is not a field + +Sixteen command tests asserted `effects_debug(..).contains("")` and +nothing about the effect's fields, so they answered *which effect was emitted* +where they meant *which category, item, view, path, cell or axis it named* +(`doctrine(check_the_checker)`). The ticket's own reproduction step was run +rather than argued: one patch made fourteen command sites emit the right effect +type carrying a **wrong constant field** — `add-category` naming `"Wrong"`, +`set-cell` writing `0.0` at `Type/Clothing`, `set-axis` ignoring its argument, +`:w ` saving to `/tmp/wrong.improv`, paste writing a constant instead of +the yanked value, the tile bar acting on `Month` whatever the cursor says, page +cycling always landing on the first item, the panel cursor landing on 0 of the +wrong panel, and the formula/view panels deleting and switching to `"Other"`. + +Against that command layer, `cargo test -p improvise-command --lib` reported +**525 passed, 6 failed**. None of the six were in this list except +`commit_item_add_files_the_item_under_the_mode_s_category`, which already +checked one field by substring; the other three were in `ui/app.rs` and two in +`ui/reduce.rs`, so the layer that would have caught a wrong field was never the +command layer. With the strengthened assertions and the same rival: **510 +passed, 21 failed** — all sixteen fire, and the five incidental ones remain. + +Each strengthened test asserts post-fold `App` state where the effect is cheap +and safe to apply, and the effect's own fields by `Debug` where it is not +(`SaveAs`, `RemoveFormula`, `SetPanelCursor`, `SwitchView`, `DeleteView`). +Assertions naming a **fieldless** effect — `Quit`, `Save`, `RebuildLayout`, +`TransposeAxes`, `AbortChain`, `TogglePruneEmpty` — were left alone: for those +the type name *is* the whole claim, so they are complete rather than smoke. + +### add_category_cmd_produces_add_category_effect + +Folds the effect and asserts the full `regular_category_names()`, which pins +both that `Region` arrived and that nothing else moved. See above for the rival. + +### set_cell_cmd_parses_coords_correctly + +The test's own name claimed the coordinates were checked; it asserted only +`SetCell`. It now folds and reads the cell back at `Type/Food, Month/Jan`. + +### set_axis_cmd_recognizes_column_alias + +Asserts `axis_of("Type") == Column` after the fold, and that `Month` is on +Column with it — so an implementation that moved the wrong category, or that +ignored the `col` alias and left `Type` on Row, is separated from one that did +neither. + +### write_cmd_with_path_saves_as + +`SaveAs` is not folded — it would write a file — so the path is asserted on the +effect's own `Debug`. + +### paste_with_yanked_value_produces_set_cell + +The fixture seeds `Type/Clothing, Month/Feb` with `42.0` and yanks `99.0`, so +asserting the post-fold value at the command's key separates "wrote the yanked +value" from "wrote whatever was already there". + +### tile_axis_cycle_produces_cycle_effect + +Reading the effects showed what no assertion had: `TileAxisOp` indexes +`tileable_cats(&ctx.model.category_names())`, **all** categories including the +virtual ones, so tile cursor 0 is `_Index` and not the first regular category. +The test now says so, asserts `_Index` moves to `before.next()`, and asserts +`Type` and `Month` do not move. + +### tile_axis_set_produces_set_axis_effect + +The same, for the explicit-axis arm: `_Index` lands on `Page`, the two regular +categories stay where they were. + +### page_next_cycles_through_page_items + +The name claimed cycling; the assertion was `SetPageSelection`. `Region` carries +`North, South, East`, so the post-fold selection must be `South`. + +### page_prev_cycles_backward + +Its mirror, and the one that pins the wrap: from the first item `[` must land on +`East`, the last. + +### move_panel_cursor_down_from_zero + +`SetPanelCursor` carries a panel *and* a cursor and neither was checked. Both +are now named, so moving the right cursor on the wrong panel fails. + +### delete_formula_at_cursor_with_formulas + +Asserts the `RemoveFormula` names `Profit` in `Type` — the formula the cursor is +on — rather than merely that a formula was removed. + +### switch_view_at_cursor_with_valid_cursor + +Names `Default`, the view under the cursor. + +### delete_view_at_cursor_zero_does_not_adjust_cursor + +Names `Default` too. Its existing negative half — no `SetPanelCursor` at +position 0 — is unchanged. + +### commit_category_add_with_name_produces_add_effect + +Folds and asserts the whole category list, so the name in the minibuffer is the +name that arrives. + +### commit_item_add_files_the_item_under_the_mode_s_category + +This one already checked `"Month"` by substring, which is why it was among the +six the rival caught. It now folds and asserts `Month`'s items are +`Jan, Feb, March` **and** that `Type`'s are untouched — a substring `"Month"` +cannot tell the category field from the item field. + +### execute_command_valid_runs_command + +`:add-category Region` must name `Region`; the assertion said only +`AddCategory`. +### a_sequence_of_moves_folds_in_order_and_the_last_write_wins + +Replaces `law_sequence_associativity` (improvise-gjat), which could not fail. That +test built three effect vectors from one `CmdContext` and asserted +`effects_debug((a ++ b) ++ c) == effects_debug(a ++ (b ++ c))` — the associativity +of `Vec::extend`, true for any deterministic `execute`, and +`doctrine(fold_tests_must_test_order_not_the_axiom)` names exactly this shape. It +never applied an effect, so no command behaviour could move it. + +The property that can fail is the one production actually relies on: a +`Binding::Sequence` flattens into **one** effect vec (gotcha 21), the batch folds +onto the `App` in order, and each `Move` composed against the *same* ctx emits an +**absolute** `SetSelected` — so the moves do not compose and the last write wins. +The test folds one batch per ordering and asserts the resulting cursor, with +`c, b, a` landing somewhere `a, b, c` does not, so an implementation that ignores +order is separated from one that honours it. + +Two faults were injected, and both leave the old assertion passing: + +- **`Move::execute` returns `Vec::new()` when the computed position equals the + current one** — a plausible "skip a move that moves nothing" optimisation, and + one that would break `tests/lockstep.rs`, since the browser resolver mirrors + `Move` line for line including where it clamps (gotcha 47). The old test still + passed (`c` contributed nothing to either grouping, so the two remained equal); + the new one failed `left: (0, 1) right: (0, 0)` — `b` had become the last write. +- **`App::apply_effects` folds the batch in reverse.** The old test passed + untouched, because it formats the vector and never applies it; the new one + failed `left: (1, 0) right: (0, 0)`. + +The second fault is why the batch is flattened rather than applied one command at +a time. A first version of this test called `apply_effects` per command; each +single-`Move` batch reverses to the same cursor, so the reversed fold was +invisible and the test passed against it. One batch is also the shape a +`Binding::Sequence` really has. diff --git a/context/test-evidence/core.md b/context/test-evidence/core.md index 3c7c0865..cd8fe5a5 100644 --- a/context/test-evidence/core.md +++ b/context/test-evidence/core.md @@ -1217,3 +1217,29 @@ view a fresh workbook creates. Making `Workbook::create_view` refuse or rename a name it already holds passes this test and breaks records mode, because both rebuilders depend on the replacement — which is why the fix is a helper beside `create_view` and not a change to it. + +### model::types::formula_tests::count_aggregation + +improvise-a36c. The test asserted `val >= 1.0` for `COUNT(Sales)` at +`Region=East`, where the fixture's answer is exactly `1.0` — so every inflated +count cleared the bar. The ticket's own fault injection (`values.len() + 999.0` +in `eval_agg`) is not the worst case: it fails one *other* test, +`model::types::agg_helper_tests::eval_agg_over_no_rows_is_zero_only_where_an_identity_exists`, +which sees the empty case move from `0` to `999`. Preserve the empty case +(`if values.is_empty() { 0.0 } else { len + 999.0 }`) and the **entire +workspace of 1699 tests passes** while `COUNT` over any non-empty set is wrong +by 999. That measurement, not the ticket's, is what this entry exists to record: +the only thing standing between a wrong `COUNT` and a green suite was a check on +the identity element, which is the one input `COUNT` gets right by accident. + +Two assertions, because one does not separate the neighbouring question. +`assert_eq!(at East, 1.0)` answers *is the count inflated* — it fails the +`+999` rival at `left: Some(1000.0)`. It does **not** answer *does `COUNT` count +anything*: a `Count` arm returning a constant `1.0` satisfies it, and at a +fixture whose expected value is `1` that rival is invisible by construction. +The second assertion, `COUNT` unscoped over all three regions being `3.0`, +is what fails it, at `left: Some(1.0)`. Both rivals were injected and both fail, +at different assertions — the first at the East count, the second at the total. + +The fixture is deliberately asymmetric: one matching cell under the scoped key +and three under the unscoped one, so no single constant can satisfy both. diff --git a/context/test-evidence/formula-and-io.md b/context/test-evidence/formula-and-io.md index 3b08e477..91930f47 100644 --- a/context/test-evidence/formula-and-io.md +++ b/context/test-evidence/formula-and-io.md @@ -1354,3 +1354,73 @@ is the *seating* — with the `improvise-cj4u` fix reverted, `create_view` pivots `Note` onto Column in the source workbook, the file faithfully records `Note: column`, and this test fails. It was written while cj4u was still open and failed then, which is how that was established rather than assumed. + +### import::wizard::tests::build_model_warns_about_values_that_do_not_match_the_date_format + +improvise-yss5, and the ticket said plainly it had **not** been reproduced. It +is real and it is narrower than it reads. `extract_date_component` returns +`None` for a value the detected format cannot parse, and the caller's `if let +Some(..)` simply does not push the derived coordinate — so the row silently +leaves the derived `Date_Month`/`Date_Year` category with nothing reported, the +same shape improvise-2cw9/jc95/5hfh closed elsewhere in this path. + +**Through the shipped CLI and TUI wizard it is unreachable**, and the reason is +worth writing down because it is a property of one function. +`detect_date_format` accepts a format only when **every** distinct non-empty +sample parses, and `extract_date_component` then makes the identical +`NaiveDate::parse_from_str` call — so a mismatch requires the format to come +from somewhere other than detection over the same values. That door is +`ImportPipeline`'s `pub proposals`: a library caller may set `date_format` +itself, and the test does. `a_column_of_mixed_date_formats_is_not_proposed_as_a_time_category` +guards the reachability claim rather than leaving it as prose, per +`doctrine(an_unreachability_claim_is_only_as_wide_as_the_routes_enumerated)`. + +The fixture derives **two** columns (Month and Year) from one `Date` whose three +values include two the format rejects, and the assertion is on the full warning +vector rather than on a substring — so "one located warning per derived column" +is separable from "one line saying some dates did not parse". + +Three rivals, each caught at a different assertion: + +- **Dropping the `count > 0` filter** — warn unconditionally — passed the entire + suite before `extracting_a_date_component_from_values_that_all_parse_warns_about_nothing` + was added, because no existing test enabled date extraction *and* asserted + silence. That is the one-directional fault + `context/design-principles.md` §7 names: a check that only asserts the warning + appears is satisfied by a writer that always warns. It now fails there. +- **Tallying into one counter instead of one per extraction** fails the vector + comparison, which a single-derived-column fixture could not see. +- **Weakening `detect_date_format` from `all` to `any`** fails the reachability + guard here and three analyzer tests, which is the evidence that guard is + load-bearing and not decoration. + +The fix reports and does not relocate: the row still lands with the coordinates +it does have, exactly as before. Changing where such a row is placed is a +behaviour change the ticket did not ask for. + +### import::wizard::tests::a_row_dropped_for_a_missing_category_is_not_charged_to_the_date_tally + +improvise-yss5, found by ChatGPT Codex reviewing PR #150 at `af7fae0e` and +correct. The first fix incremented `unparsed_dates` while walking a record's +categories, **before the record was known to survive that walk**. A row whose +`Date` is unparseable and whose *later* required category is missing is skipped +entirely — it never lands in the imported model — yet it had already been +charged to the derived column's tally. So the warning counted a row nothing else +in the import reports, and its value depended on the **order** the categories +happen to be walked in. + +The mismatches are now collected per record and merged only after +`missing.is_none()`. The fixture only discriminates while `Date` precedes the +missing category, so the test asserts that ordering explicitly rather than +relying on it — a later change to proposal order would otherwise make the test +pass by walking the missing category first and never reaching the bad date. + +Before the fix it reported three warnings where one is correct, naming +`Date_Month` and `Date_Year` for a row that does not exist in the model. +Injecting the rival — merging the per-record tally *before* the `missing` check +rather than after — reproduces exactly that and fails here. + +This is the second time in this ticket that the interesting question was *which +rows the count is over*. The first was one warning per derived column rather +than one overall; this is one warning per **landed** row rather than per +attempted one. diff --git a/context/test-evidence/gp.md b/context/test-evidence/gp.md index 67869bc6..1654d11e 100644 --- a/context/test-evidence/gp.md +++ b/context/test-evidence/gp.md @@ -1221,3 +1221,42 @@ Three rivals, each caught at a different assertion: - **Dropping `#[serde(flatten)]`** fails at the shape assertion with `"mean" ... : null`, since the map would then nest under a `scaling` key and every previously saved model would stop loading. +### fit::tests::a_budget_notice_precedes_a_table_warning_which_precedes_a_preprocess_one + +improvise-ipqu. `FitOutcome::warnings` is three tiers concatenated by two +`splice(0..0, ..)` calls in `fit_measure`, and the resulting order — the +budget's notices, then the feature table's, then `preprocess`'s — is stated in +the field's own doc comment **and** in `context/code/gp.md` §14, and is joined +into a user-facing status line. It was checked nowhere. Swapping the two +statements left all 74 `improvise-gp` tests green, measured before and after. +This is the shape gotcha 43 names — a rule "held up only by the order of two +statements" — and the repair is the same one +`a_published_basis_follows_the_grid_updates_it_describes` made: inject the swap +and watch a test fail. + +**No pre-existing fixture produced more than one tier**, which is why nothing +caught it, and building one is the whole of the work. `all_three_warning_tiers_at_once` +extends `two_categories_of_different_widths` — 60 `Wide` items against a +64-feature budget, so `Wide` is dropped and a notice is emitted — with two +further things, each aimed at exactly one of the other tiers. A `Blank` item on +`_Measure` carrying **no cell anywhere** is a header column (`raw_measures` +reads item names, not data) that is empty in every row, which is what +`preprocess` warns about. And a `Cost` cell at `(w0, n1)`, a coordinate the base +fixture does not use, is one visible record with no `Score` value, which +`feature_table` skips and warns about. Neither addition disturbs the budget +arithmetic: the skipped record contributes no row, so `Wide` is still 60 wide, +and `Blank` costs 2, leaving `60 + 8 + 1 + 2 = 71` over budget by `Wide` alone. + +Asserting that the concatenation *contains* all three would be vacuous +(`doctrine(fold_tests_must_test_order_not_the_axiom)`), so the assertion is on +the three positions. The helper resolving a tier to its index **panics** when a +tier is absent rather than returning a sentinel, so a future change that stops +one tier speaking fails here loudly instead of making the ordering assertion +trivially true over two elements. + +Two rivals, each moving a different boundary, and each caught only by this test +(74 passed, 1 failed, both times). Swapping the two `splice` statements gives +table → notice → preprocess and fails at `notice < table`. Replacing the first +`splice(0..0, table.warnings)` with `extend(table.warnings)` gives notice → +preprocess → table and fails at `table < preprocessed` — which is the rival that +earns the third tier, since the swap alone never moves `preprocess`'s. diff --git a/context/test-evidence/tui.md b/context/test-evidence/tui.md index a6e87bce..dd6462b9 100644 --- a/context/test-evidence/tui.md +++ b/context/test-evidence/tui.md @@ -644,3 +644,195 @@ Fault injected 2026-09-21: restoring the name-only comparison in `AxisFrame::group_runs` fails this with `left: [(Some("Q1"), 4)]` while the five existing agreement tests all pass — the ungrouped `Sep` in their fixture is precisely why they cannot see it. + +--- + +## `src/draw.rs::title_bar_tests` — the title bar's escape hatch + +improvise-t14d. `fill_line` composed a left and a right segment into one row and +did **neither** of the two things that composition needs. It padded with +`left.len() + right.len()` — **bytes** — and it never truncated, so on overflow +it simply concatenated and left ratatui to clip. ratatui clips the *right*, and +the right segment of the title bar is `" ?:help :q quit "`: the only thing on +screen telling a stuck user how to get help or get out. + +**The priority was backwards.** The left segment is decoration — the product +name, the document name, the filename, a dirty marker — and its length is +unbounded because a document name is. The right segment is a fixed 17 cells and +is the escape hatch. Giving the unbounded decoration unconditional priority over +the fixed affordance means the affordance is the first thing sacrificed, and it +is sacrificed at ordinary widths: measured through a real `TestBackend`, a +document named `Quarterly Regional Sales Analysis` stored as +`quarterly-regional-sales-2026.improv` loses `:q quit` **at 100 columns**. + +The byte fault is separate and universal. The title's own format string spells +`·`, two bytes and one cell, so `left.len()` over-counts by one for *every* +document — the padding is one column short and the right segment sits one cell +in from the edge it is anchored to, before any non-ASCII document name makes it +worse. + +`Keeps` is the fix's shape: which segment survives is a per-call-site decision, +not a global rule. The title bar keeps the **right** (the hatch outranks the +name). The two bottom-bar call sites keep the **left**, which is what they did +before — there the left is the user's own typing (`minibuffer_line`) or the +status message (`draw_status`), and the right is a view badge, so right-priority +would have let a long message eat the text the user is editing along with the +cursor. Widths are `ui::text::truncate`'s display cells throughout, so the +overflow is a graceful `…` rather than a mid-token clip of `?:`. + +### the_escape_hatch_outlives_a_long_document_name + +The defect itself, at the five widths the ticket measured (36, 44, 52, 60, 100). +Rendered through a `TestBackend` and read off the terminal buffer, not off +`fill_line`'s return value: the loss happens in ratatui's clip, so a helper's +return value cannot see it. **A test at 80 columns with a short fixture name +passes against the broken code** — title length is the variable, not width. + +Fault injected: `Keeps::Right` → `Keeps::Left` at the `draw_title` call site. +Fails at width 36 with `" improvise · Quarterly Regional S"`; the other four +tests in the module pass, so this one and only this one names the priority. + +### the_document_name_still_leads_the_row + +The other direction, which the escape-hatch assertion alone cannot supply: a fix +that dropped the left segment entirely, or that put the hatch first, satisfies +it. At 80 columns with a short name nothing is truncated, so this pins that the +name leads, the hatch follows, and the filename survives intact. + +### a_multibyte_title_does_not_shift_the_right_segment + +The byte-versus-cell fault. Asserts the row *ends with* the right segment — the +edge it is anchored to — for an ASCII name, an accented one and a CJK one. The +ASCII case is the discriminating one and the surprise: it fails against the +broken code too, because the `·` is in the format string rather than in the +document's name. + +Fault injected: `width - left.width() - right.width()` → +`width.saturating_sub(left.len() + right.len())`, keeping the truncation. Fails +on `"Sales"` with the right segment one cell in from the edge, and takes +`a_composed_line_is_exactly_its_width` with it. The escape-hatch test does not +see it, which is why the two faults need two tests. + +### a_composed_line_is_exactly_its_width + +The property both directions owe, over 0..40 columns, three segment pairs and +both `Keeps` arms: a wide-character left segment, an empty left, and an empty +right. Width 0 and the band where the two segments cannot both fit are the cases +`fill_line`'s arithmetic can get wrong — it subtracts rather than saturating, so +an off-by-one here is a panic and not a cosmetic slip. + +### the_kept_side_is_the_side_that_survives + +`Keeps::Left` has no other witness in this module: every rendering test above +drives the title bar, which keeps the right. Without this, both arms could be +the same code and the suite would stay green — which is what the enum exists to +prevent. + +Fault injected: the `Keeps::Left` arm rewritten to the body of the `Keeps::Right` +arm. This test fails; the other four pass. + +--- + +## `src/ui/tile_bar.rs` + `src/draw.rs` — one hint table on screen, and it is the derived one + +improvise-rr8n. Two hand-written key tables were drawn **beside** derived ones, +in a different notation, and one of them was outright false. + +**The tile bar's non-TileSelect hint named keys that do something else.** It read +`" Ctrl+↑↓←→ to move tiles"`, while `Ctrl-Left/Right/Up/Down` are bound in Normal +to `enter-tile-select` — pressing one *enters* tile mode and moves nothing. It +was also the only persistent on-screen documentation of those keys, and it spelt +`Ctrl+` where `keymap::key_label`, and therefore every derived hint and help row, +spells `Ctrl-`. + +**Nothing could have caught it.** `every_hint_key_resolves_in_its_mode` checks +`AppMode::hint_text()` against the keymaps; a string literal in a widget is +invisible to it. And the shipped fixture `renders_each_tile_with_its_axis_label` +renders at **80 columns**, where the hint does not fit (`x + hint.width() <= +area.x + area.width` is false) and is never drawn — so the one test that pins +this widget's output could not see either hint at all. That is why the new tests +render at 140. + +**The fix deletes rather than rewrites, and that departs from the ticket's AC.** +improvise-rr8n proposed rewriting the string to `" Ctrl-↑↓←→ or T to pick a +tile"` and suppressing the *status bar's* hint in TileSelect. Measured against +the ticket's own second finding, that is backwards: + +``` +tile bar (hand-written): [Enter] cycle axis [r/c/p] set axis [←→] select [Esc] cancel +status bar (derived): hl←→:select Enter/Space:cycle r:row c:col p:page Esc/Tab:back +``` + +The derived line is a strict **superset** — it carries `Space`, `h`/`l` and +`Tab`, which the tile bar's omits — and it cannot go stale, being read off the +keymap. Suppressing it would have deleted information and kept the table that +had just been shown to rot. So the tile bar now advertises nothing, and in +Normal the derived line already says `T:tiles`; only the `Ctrl-`arrow spelling +is lost, and it was never documented correctly. + +**The wizard goes the other way, and there the ticket is right.** Its overlay +draws correct per-step hints, and the status bar drew one static +`Space:toggle c:cycle Enter:next Esc:cancel` under it — wrong on four of eight +steps, including an outright contradiction on `Done`, where the bar says `Esc` +cancels and `Esc` goes *back*. It is static because it is derived from the +`ImportWizard` keymap, while the wizard's real keys are resolved per step inside +`HandleWizardKey`; so the derivation is sound and its *input* is the wrong thing. +`draw_status` therefore suppresses the hint fallback for that one mode. + +The asymmetry is the point: **keep whichever table is derived from the thing it +describes.** For tile select that is the status line; for the wizard it is the +overlay. + +### the_tile_bar_advertises_no_keys_of_its_own + +Renders the bar in Normal and TileSelect at 140 columns and requires that neither +row names `Ctrl`, `Enter`, `Esc`, `←→` or `move tiles`. An absence assertion, so +it is paired with the two below that require the information to still exist +elsewhere. + +Two faults injected, both caught here and by nothing else: + +- The original `else` branch restored — fails in Normal on `"Ctrl"`, quoting the + whole false line. +- Only the `else` branch deleted, keeping the TileSelect literal (the narrow + reading of finding 1) — fails in TileSelect on `"Enter"`. This is the rival + that matters, because it is the fix a reader of finding 1 alone would write, + and it leaves two disagreeing dialects stacked on adjacent rows. + +Note it would also fail against the ticket's own proposed replacement string, +which still contains `Ctrl`. That is deliberate rather than accidental: the +property asserted is "this widget names no keys", not "this widget's keys are +currently right". + +### the_tile_bar_still_draws_its_tiles_at_every_width_it_used_to + +The counterweight. Deleting a `set_string` at the end of a render function is +the kind of edit that takes a neighbour with it, so this pins the tiles and the +`Tiles:` prefix at 80 and 140. It also covers the dead `x += 1` the deletion +orphaned — clippy caught that one, but the test is what says the drawing still +lands. + +### the_wizard_overlay_is_the_only_key_hint_on_screen + +The wizard half. Requires the bottom row in `ImportWizard` to name none of +`Space:toggle`, `c:cycle`, `Esc:cancel`. Fails against the unfixed code quoting +the full row, including the `Esc:cancel` that contradicts the overlay. + +### suppressing_the_wizard_hint_leaves_the_rest_of_the_bar + +The direction the suppression could overshoot: the mode label `IMPORT` and a real +`status_msg` are not hints and must survive. A fix that blanked the bar in that +mode, or that suppressed the *message* along with the hint, fails here. + +### the_status_bar_still_advertises_the_tile_keys + +The counterweight to the tile bar deletion, and the test that rejects the +ticket's own AC: Normal must still say `T:tiles`, and TileSelect must still carry +`Enter/Space:cycle`, `r:row` and `Esc/Tab:back`. + +Fault injected: suppressing the hint fallback unconditionally +(`true => String::new()`) rather than for `ImportWizard` alone. All three wizard +tests pass — including both of the ones *aimed* at the suppression — and only +this one fails. That is the shape design-principles §7 names: the assertion next +door is satisfied by the wrong fix, and the discriminating input is the mode the +change was not supposed to touch. diff --git a/crates/gp-tree/src/fit.rs b/crates/gp-tree/src/fit.rs index 9eb5e2f3..4c1494ab 100644 --- a/crates/gp-tree/src/fit.rs +++ b/crates/gp-tree/src/fit.rs @@ -215,7 +215,8 @@ impl TryFrom for Scaling { fn try_from(raw: RawScaling) -> Result { Scaling::new(raw.mean, raw.std).ok_or_else(|| { format!( - "the saved affine map is not finite (mean {}, std {}), so predictions from it would be NaN. Refit the model", + "the saved affine map is not finite (mean {}, std {}), so predictions \ + from it would be NaN. Refit the model", raw.mean, raw.std ) }) diff --git a/crates/improvise-command/src/command/cmd/cell.rs b/crates/improvise-command/src/command/cmd/cell.rs index bd347e0c..ef92935b 100644 --- a/crates/improvise-command/src/command/cmd/cell.rs +++ b/crates/improvise-command/src/command/cmd/cell.rs @@ -64,11 +64,16 @@ mod tests { ("Type".into(), "Clothing".into()), ("Month".into(), "Feb".into()), ]); - let cmd = PasteCell { key }; + let cmd = PasteCell { key: key.clone() }; let effects = cmd.execute(&ctx); assert_eq!(effects.len(), 1); - let dbg = effects_debug(&effects); - assert!(dbg.contains("SetCell"), "Expected SetCell, got: {dbg}"); + let mut app = crate::ui::app::App::new(two_cat_model(), None); + app.apply_effects(effects); + assert_eq!( + app.model_state.workbook.model.get_cell(&key), + Some(&CellValue::Number(99.0)), + "paste must write the YANKED value at the command's own key" + ); } #[test] diff --git a/crates/improvise-command/src/command/cmd/commit.rs b/crates/improvise-command/src/command/cmd/commit.rs index ce9511ca..4815d166 100644 --- a/crates/improvise-command/src/command/cmd/commit.rs +++ b/crates/improvise-command/src/command/cmd/commit.rs @@ -96,10 +96,12 @@ mod tests { let mut ctx = make_ctx(&m, &layout, ®); ctx.buffers = &bufs; let effects = commit(CommitKind::Category).execute(&ctx); - let dbg = effects_debug(&effects); - assert!( - dbg.contains("AddCategory"), - "Expected AddCategory, got: {dbg}" + let mut app = crate::ui::app::App::new(two_cat_model(), None); + app.apply_effects(effects); + assert_eq!( + app.model_state.workbook.model.regular_category_names(), + vec!["Type", "Month", "Region"], + "the category named in the buffer must be the one added" ); } @@ -136,9 +138,15 @@ mod tests { let cmd = reg .interactive("commit-buffer", &["item".to_string()], &ctx) .expect("item-add mode should supply the category"); - let dbg = effects_debug(&cmd.execute(&ctx)); - assert!(dbg.contains("AddItem"), "Expected AddItem, got: {dbg}"); - assert!(dbg.contains("Month"), "Expected category Month, got: {dbg}"); + let mut app = crate::ui::app::App::new(two_cat_model(), None); + app.apply_effects(cmd.execute(&ctx)); + let model = &app.model_state.workbook.model; + assert_eq!( + model.effective_item_names("Month"), + vec!["Jan", "Feb", "March"], + "the item named in the buffer must land in the MODE's category" + ); + assert_eq!(model.effective_item_names("Type"), vec!["Food", "Clothing"]); } #[test] diff --git a/crates/improvise-command/src/command/cmd/core.rs b/crates/improvise-command/src/command/cmd/core.rs index e0d85709..4af2470f 100644 --- a/crates/improvise-command/src/command/cmd/core.rs +++ b/crates/improvise-command/src/command/cmd/core.rs @@ -193,19 +193,13 @@ impl CmdRegistry { }); } - /// Register a command that needs no interactive context: with args it delegates - /// to `parse`, and without them it errors. + /// Register a command that needs no interactive context: BOTH arms are + /// `parse`, so neither can demote the other — `context/code/command.md`. pub fn register_pure(&mut self, prototype: &dyn Cmd, parse: ParseFn) { self.entries.push(CmdEntry { name: prototype.name(), parse: Box::new(parse), - interactive: Box::new(move |args, _ctx| { - if args.is_empty() { - Err("this command requires arguments".into()) - } else { - parse(args) - } - }), + interactive: Box::new(move |args, _ctx| parse(args)), }); } diff --git a/crates/improvise-command/src/command/cmd/effect_cmds.rs b/crates/improvise-command/src/command/cmd/effect_cmds.rs index e4cce17b..55e8404e 100644 --- a/crates/improvise-command/src/command/cmd/effect_cmds.rs +++ b/crates/improvise-command/src/command/cmd/effect_cmds.rs @@ -18,10 +18,11 @@ mod tests { let cmd = AddCategoryCmd(vec!["Region".to_string()]); let effects = cmd.execute(&ctx); assert_eq!(effects.len(), 1); - let dbg = effects_debug(&effects); - assert!( - dbg.contains("AddCategory"), - "Expected AddCategory, got: {dbg}" + let mut app = crate::ui::app::App::new(two_cat_model(), None); + app.apply_effects(effects); + assert_eq!( + app.model_state.workbook.model.regular_category_names(), + vec!["Type", "Month", "Region"] ); } @@ -38,8 +39,16 @@ mod tests { ]); let effects = cmd.execute(&ctx); assert_eq!(effects.len(), 1); - let dbg = effects_debug(&effects); - assert!(dbg.contains("SetCell"), "Expected SetCell, got: {dbg}"); + let mut app = crate::ui::app::App::new(two_cat_model(), None); + app.apply_effects(effects); + let key = crate::model::cell::CellKey::new(vec![ + ("Type".into(), "Food".into()), + ("Month".into(), "Jan".into()), + ]); + assert_eq!( + app.model_state.workbook.model.get_cell(&key), + Some(&CellValue::Number(42.0)) + ); } #[test] @@ -51,8 +60,11 @@ mod tests { let cmd = SetAxisCmd(vec!["Type".to_string(), "col".to_string()]); let effects = cmd.execute(&ctx); assert_eq!(effects.len(), 1); - let dbg = effects_debug(&effects); - assert!(dbg.contains("SetAxis"), "Expected SetAxis, got: {dbg}"); + let mut app = crate::ui::app::App::new(two_cat_model(), None); + app.apply_effects(effects); + let view = app.model_state.workbook.active_view(); + assert_eq!(view.axis_of("Type"), Axis::Column); + assert_eq!(view.categories_on(Axis::Column), vec!["Type", "Month"]); } #[test] @@ -78,7 +90,10 @@ mod tests { let effects = cmd.execute(&ctx); assert_eq!(effects.len(), 1); let dbg = effects_debug(&effects); - assert!(dbg.contains("SaveAs"), "Expected SaveAs, got: {dbg}"); + assert!( + dbg.contains("SaveAs(\"/tmp/out.improv\")"), + "SaveAs must carry the path it was given, got: {dbg}" + ); } } diff --git a/crates/improvise-command/src/command/cmd/navigation.rs b/crates/improvise-command/src/command/cmd/navigation.rs index 3d54c5b0..5f13f2cb 100644 --- a/crates/improvise-command/src/command/cmd/navigation.rs +++ b/crates/improvise-command/src/command/cmd/navigation.rs @@ -384,52 +384,45 @@ mod tests { assert_eq!(first, second, "ToStart(Row) should be idempotent"); } + /// Evidence: `context/test-evidence/command.md`. #[test] - fn law_sequence_associativity() { + fn a_sequence_of_moves_folds_in_order_and_the_last_write_wins() { let m = two_cat_model(); let layout = make_layout(&m); let reg = make_registry(); let ctx = make_ctx(&m, &layout, ®); + assert!(ctx.row_count() >= 2 && ctx.col_count() >= 2); - let mk_a = || { + let mk = |kind: MoveKind, cmd_name: &'static str| { Move { - kind: MoveKind::Relative(1, 0), + kind, cursor: CursorState::from_ctx(&ctx), - cmd_name: "move-selection", + cmd_name, } .execute(&ctx) }; - let mk_b = || { - Move { - kind: MoveKind::Relative(0, 1), - cursor: CursorState::from_ctx(&ctx), - cmd_name: "move-selection", - } - .execute(&ctx) - }; - let mk_c = || { - Move { - kind: MoveKind::ToStart(true), - cursor: CursorState::from_ctx(&ctx), - cmd_name: "jump-first-row", - } - .execute(&ctx) + let a = || mk(MoveKind::Relative(1, 0), "move-selection"); + let b = || mk(MoveKind::Relative(0, 1), "move-selection"); + let c = || mk(MoveKind::ToStart(true), "jump-first-row"); + + // A `Binding::Sequence` flattens into ONE effect vec (gotcha 21), so the + // fold under test is a single batch. + let seq = |steps: Vec>>| { + let batch: Vec> = steps.into_iter().flatten().collect(); + assert!(!batch.is_empty()); + let mut app = crate::ui::app::App::new(two_cat_model(), None); + app.apply_effects(batch); + app.cursor().selected }; - let mut ab_c = mk_a(); - ab_c.extend(mk_b()); - ab_c.extend(mk_c()); + assert_eq!(seq(vec![a()]), (1, 0)); + assert_eq!(seq(vec![b()]), (0, 1)); + assert_eq!(seq(vec![c()]), (0, 0)); - let mut bc = mk_b(); - bc.extend(mk_c()); - let mut a_bc = mk_a(); - a_bc.extend(bc); - - assert_eq!( - effects_debug(&ab_c), - effects_debug(&a_bc), - "Sequence concatenation should be associative" - ); + assert_eq!(seq(vec![a(), b(), c()]), (0, 0)); + assert_eq!(seq(vec![c(), b(), a()]), (1, 0)); + assert_eq!(seq(vec![b(), a(), c()]), (0, 0)); + assert_eq!(seq(vec![c(), a(), b()]), (0, 1)); } #[test] @@ -480,10 +473,15 @@ mod tests { let ctx = make_ctx(&m, &layout, ®); let effects = PageNext.execute(&ctx); assert!(!effects.is_empty()); - let dbg = effects_debug(&effects); - assert!( - dbg.contains("SetPageSelection"), - "Expected SetPageSelection, got: {dbg}" + let mut app = crate::ui::app::App::new(three_cat_model_with_page(), None); + app.apply_effects(effects); + assert_eq!( + app.model_state + .workbook + .active_view() + .page_selection("Region"), + Some("South"), + "`]` must advance to the NEXT item of the page category" ); } @@ -495,10 +493,15 @@ mod tests { let ctx = make_ctx(&m, &layout, ®); let effects = PagePrev.execute(&ctx); assert!(!effects.is_empty()); - let dbg = effects_debug(&effects); - assert!( - dbg.contains("SetPageSelection"), - "Expected SetPageSelection, got: {dbg}" + let mut app = crate::ui::app::App::new(three_cat_model_with_page(), None); + app.apply_effects(effects); + assert_eq!( + app.model_state + .workbook + .active_view() + .page_selection("Region"), + Some("East"), + "`[` from the first item must wrap to the LAST" ); } } diff --git a/crates/improvise-command/src/command/cmd/panel.rs b/crates/improvise-command/src/command/cmd/panel.rs index 7f2da1c3..29d1153d 100644 --- a/crates/improvise-command/src/command/cmd/panel.rs +++ b/crates/improvise-command/src/command/cmd/panel.rs @@ -360,8 +360,8 @@ mod tests { assert_eq!(effects.len(), 1); let dbg = effects_debug(&effects); assert!( - dbg.contains("SetPanelCursor"), - "Expected SetPanelCursor, got: {dbg}" + dbg.contains("SetPanelCursor { panel: Formula, cursor: 1 }"), + "must move THE FORMULA panel's cursor to 1, got: {dbg}" ); } @@ -414,8 +414,8 @@ mod tests { let effects = DeleteFormulaAtCursor.execute(&ctx); let dbg = effects_debug(&effects); assert!( - dbg.contains("RemoveFormula"), - "Expected RemoveFormula, got: {dbg}" + dbg.contains("RemoveFormula { target: \"Profit\", target_category: \"Type\" }"), + "must remove the formula AT THE CURSOR, got: {dbg}" ); } @@ -428,8 +428,8 @@ mod tests { let effects = SwitchViewAtCursor.execute(&ctx); let dbg = effects_debug(&effects); assert!( - dbg.contains("SwitchView"), - "Expected SwitchView, got: {dbg}" + dbg.contains("SwitchView(\"Default\")"), + "must switch to the view UNDER THE CURSOR, got: {dbg}" ); assert!(dbg.contains("Normal"), "Expected Normal mode, got: {dbg}"); } @@ -515,8 +515,8 @@ mod tests { let effects = DeleteViewAtCursor.execute(&ctx); let dbg = effects_debug(&effects); assert!( - dbg.contains("DeleteView"), - "Expected DeleteView, got: {dbg}" + dbg.contains("DeleteView(\"Default\")"), + "must delete the view UNDER THE CURSOR, got: {dbg}" ); assert!( !dbg.contains("SetPanelCursor"), diff --git a/crates/improvise-command/src/command/cmd/registry.rs b/crates/improvise-command/src/command/cmd/registry.rs index 99905588..de1d2985 100644 --- a/crates/improvise-command/src/command/cmd/registry.rs +++ b/crates/improvise-command/src/command/cmd/registry.rs @@ -22,6 +22,15 @@ use super::text_buffer::*; use super::tile::*; use super::wizard::*; +/// Explicit coordinates win; the cursor is the fallback — `context/code/cli.md`. +fn key_from_args_or_cursor(args: &[String], ctx: &CmdContext) -> Result { + if args.is_empty() { + ctx.cell_key().clone().ok_or("no cell at cursor".into()) + } else { + Ok(parse_cell_key_from_args(args)) + } +} + /// Build the default command registry with all commands. /// Registry names MUST match the `Cmd::name()` return value. pub fn default_registry() -> CmdRegistry { @@ -46,8 +55,8 @@ pub fn default_registry() -> CmdRegistry { key: parse_cell_key_from_args(args), })) }, - |_args, ctx| { - let key = ctx.cell_key().clone().ok_or("no cell at cursor")?; + |args, ctx| { + let key = key_from_args_or_cursor(args, ctx)?; Ok(Box::new(ClearCellCommand { key })) }, ); @@ -240,8 +249,8 @@ pub fn default_registry() -> CmdRegistry { key: parse_cell_key_from_args(args), })) }, - |_args, ctx| { - let key = ctx.cell_key().clone().ok_or("no cell at cursor")?; + |args, ctx| { + let key = key_from_args_or_cursor(args, ctx)?; Ok(Box::new(YankCell { key })) }, ); @@ -257,8 +266,8 @@ pub fn default_registry() -> CmdRegistry { key: parse_cell_key_from_args(args), })) }, - |_args, ctx| { - let key = ctx.cell_key().clone().ok_or("no cell at cursor")?; + |args, ctx| { + let key = key_from_args_or_cursor(args, ctx)?; Ok(Box::new(PasteCell { key })) }, ); @@ -308,8 +317,8 @@ pub fn default_registry() -> CmdRegistry { key: parse_cell_key_from_args(args), })) }, - |_args, ctx| { - let key = ctx.cell_key().clone().ok_or("no cell at cursor")?; + |args, ctx| { + let key = key_from_args_or_cursor(args, ctx)?; Ok(Box::new(DrillIntoCell { key })) }, ); diff --git a/crates/improvise-command/src/command/cmd/text_buffer.rs b/crates/improvise-command/src/command/cmd/text_buffer.rs index 5a373986..01af4e78 100644 --- a/crates/improvise-command/src/command/cmd/text_buffer.rs +++ b/crates/improvise-command/src/command/cmd/text_buffer.rs @@ -177,8 +177,8 @@ mod tests { let effects = ExecuteCommand.execute(&ctx); let dbg = effects_debug(&effects); assert!( - dbg.contains("AddCategory"), - "Expected AddCategory effect, got: {dbg}" + dbg.contains("AddCategory(\"Region\")"), + "must add the category the LINE named, got: {dbg}" ); } @@ -359,7 +359,13 @@ impl Cmd for ExecuteCommand { return vec![effect::change_mode(AppMode::Normal)]; } - match crate::command::parse::parse_line_with(ctx.registry, &raw) { + // Built against the live cursor, exactly as a script's is — improvise-6uva. + let built: Result>, String> = crate::command::parse::segments(&raw) + .into_iter() + .map(|seg| crate::command::parse::parse_segment_interactive(seg, ctx)) + .collect::, _>>() + .map(|built| built.into_iter().flatten().collect()); + match built { Ok(cmds) => { // Leave the command line **first**, before anything that can fail: // an abort must stop the chain, never the bookkeeping (gotcha 21). diff --git a/crates/improvise-command/src/command/cmd/tile.rs b/crates/improvise-command/src/command/cmd/tile.rs index 2027cf37..1ff05418 100644 --- a/crates/improvise-command/src/command/cmd/tile.rs +++ b/crates/improvise-command/src/command/cmd/tile.rs @@ -17,8 +17,15 @@ mod tests { let cmd = TileAxisOp { axis: None }; let effects = cmd.execute(&ctx); assert!(!effects.is_empty()); - let dbg = effects_debug(&effects); - assert!(dbg.contains("CycleAxis"), "Expected CycleAxis, got: {dbg}"); + // The tile cursor indexes EVERY tileable category, virtual ones included, + // so cursor 0 is `_Index` rather than the first regular category. + let before = m.active_view().axis_of("_Index"); + let mut app = crate::ui::app::App::new(two_cat_model(), None); + app.apply_effects(effects); + let view = app.model_state.workbook.active_view(); + assert_eq!(view.axis_of("_Index"), before.next()); + assert_eq!(view.axis_of("Type"), m.active_view().axis_of("Type")); + assert_eq!(view.axis_of("Month"), m.active_view().axis_of("Month")); } #[test] @@ -32,8 +39,12 @@ mod tests { }; let effects = cmd.execute(&ctx); assert!(!effects.is_empty()); - let dbg = effects_debug(&effects); - assert!(dbg.contains("SetAxis"), "Expected SetAxis, got: {dbg}"); + let mut app = crate::ui::app::App::new(two_cat_model(), None); + app.apply_effects(effects); + let view = app.model_state.workbook.active_view(); + assert_eq!(view.axis_of("_Index"), Axis::Page); + assert_eq!(view.axis_of("Type"), m.active_view().axis_of("Type")); + assert_eq!(view.axis_of("Month"), m.active_view().axis_of("Month")); } #[test] diff --git a/crates/improvise-command/src/command/parse.rs b/crates/improvise-command/src/command/parse.rs index 8a40a4f8..3f236d47 100644 --- a/crates/improvise-command/src/command/parse.rs +++ b/crates/improvise-command/src/command/parse.rs @@ -1,7 +1,7 @@ //! Quasi-lisp prefix command parser: `word arg1 arg2 …`, `.` between commands, //! `Category/Item` coordinate pairs, `"quoted strings"`. -use super::cmd::{Cmd, CmdRegistry, default_registry}; +use super::cmd::{Cmd, CmdContext, CmdRegistry, default_registry}; /// Parse a line into commands using the default registry. pub fn parse_line(line: &str) -> Result>, String> { @@ -33,6 +33,40 @@ pub fn parse_line_with(registry: &CmdRegistry, line: &str) -> Result Vec<&str> { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') || line.starts_with("//") { + return Vec::new(); + } + split_on_dot(line) + .into_iter() + .map(str::trim) + .filter(|s| !s.is_empty()) + .collect() +} + +/// Build one segment's command against a LIVE context — `context/code/cli.md`. +pub fn parse_segment_interactive( + segment: &str, + ctx: &CmdContext, +) -> Result>, String> { + let tokens = tokenize(segment.trim()); + let Some(word) = tokens.first() else { + return Ok(None); + }; + ctx.registry + .interactive(word, &tokens[1..], ctx) + .map(Some) + .map_err(|err| { + if err.starts_with("Unknown command") { + err + } else { + format!("{word}: {err}") + } + }) +} + /// Split a line on ` . ` separators (dot must be a standalone word, /// surrounded by whitespace or at line boundaries). Respects quoted strings. fn split_on_dot(line: &str) -> Vec<&str> { @@ -229,4 +263,88 @@ mod tests { assert!(parse_line("add-items").is_err()); assert!(parse_line("add-items Region").is_err()); } + + // ── a segment is built against the cursor it will run against ──────── + + /// Evidence: `context/test-evidence/cli.md`. + #[test] + fn a_segment_is_built_against_the_cursor_it_will_run_against() { + use crate::command::cmd::test_helpers::*; + + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let mut ctx = make_ctx(&m, &layout, ®); + ctx.selected = (1, 0); + assert!(ctx.row_count() >= 2 && ctx.col_count() >= 2); + + let built = parse_segment_interactive("move-selection 0 1", &ctx) + .expect("move-selection parses") + .expect("a segment with a word builds a command"); + let dbg = format!("{:?}", built.execute(&ctx)); + assert!( + dbg.contains("SetSelected(1, 1)"), + "a segment built against the live cursor must move FROM it, not \ + from the origin (improvise-6uva); got: {dbg}" + ); + + // The pure arm is what a script used to go through, and it is what the + // interactive arm must not be mistaken for. + let pure = reg + .parse("move-selection", &["0".to_string(), "1".to_string()]) + .expect("the pure arm parses too"); + assert!( + format!("{:?}", pure.execute(&ctx)).contains("SetSelected(0, 0)"), + "if the pure arm ever learns the cursor, the assertion above stops \ + discriminating" + ); + } + + #[test] + fn segments_drops_comments_and_blanks() { + assert_eq!(segments("# a comment"), Vec::<&str>::new()); + assert_eq!(segments("// also a comment"), Vec::<&str>::new()); + assert_eq!(segments(" "), Vec::<&str>::new()); + assert_eq!(segments("a . . b"), vec!["a", "b"]); + assert_eq!(segments("move-selection 2 0"), vec!["move-selection 2 0"]); + } + + /// Evidence: `context/test-evidence/cli.md`. + #[test] + fn a_pure_commands_two_arms_agree_on_no_arguments() { + use crate::command::cmd::test_helpers::*; + + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let ctx = make_ctx(&m, &layout, ®); + + // `w` with no path saves to the current document. The interactive arm + // used to refuse exactly that, which is the same demotion one level down. + let by_parse = reg + .parse("w", &[]) + .expect("the pure arm accepts a bare `w`"); + let by_ctx = parse_segment_interactive("w", &ctx) + .expect("so must the interactive arm") + .expect("a segment with a word builds a command"); + assert_eq!(by_parse.name(), by_ctx.name()); + assert_eq!( + format!("{:?}", by_parse.execute(&ctx)), + format!("{:?}", by_ctx.execute(&ctx)), + "the two arms of a pure command must build the same thing" + ); + } + + #[test] + fn an_unknown_segment_is_an_error_not_a_silent_skip() { + use crate::command::cmd::test_helpers::*; + + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let ctx = make_ctx(&m, &layout, ®); + let err = parse_segment_interactive("no-such-command", &ctx) + .expect_err("an unknown word is refused"); + assert!(err.contains("no-such-command"), "got: {err}"); + } } diff --git a/crates/improvise-command/src/ui/app.rs b/crates/improvise-command/src/ui/app.rs index c4ebcb81..b026516f 100644 --- a/crates/improvise-command/src/ui/app.rs +++ b/crates/improvise-command/src/ui/app.rs @@ -1704,6 +1704,35 @@ mod tests { App::new(wb, None) } + /// improvise-htl6. Evidence: `context/test-evidence/command.md`. + #[test] + fn the_view_history_walks_both_ways_from_the_keyboard() { + let mut app = two_col_model(); + app.model_state.workbook.create_view("Second"); + app.apply_effects(vec![Box::new(crate::ui::effect::SwitchView( + "Second".into(), + ))]); + assert_eq!(app.view_state.active_view.as_str(), "Second"); + + app.handle_key(KeyEvent::new(KeyCode::Char('<'), KeyModifiers::NONE)) + .unwrap(); + assert_eq!( + app.view_state.active_view.as_str(), + "Default", + "the control: `<` was bound all along, so a table binding nothing \ + cannot pass this test" + ); + + app.handle_key(KeyEvent::new(KeyCode::Char(','), KeyModifiers::NONE)) + .unwrap(); + assert_eq!( + app.view_state.active_view.as_str(), + "Second", + "`,` walks the history forward: before improvise-htl6 no key did, \ + and the forward stack was reachable only as `:view-forward`" + ); + } + /// improvise-8uz: the cursor is **per view**. A characterization test kept /// verbatim across the move to `ViewState::cursors`. #[test] diff --git a/crates/improvise-command/src/ui/effect.rs b/crates/improvise-command/src/ui/effect.rs index 106814df..62a4f6a0 100644 --- a/crates/improvise-command/src/ui/effect.rs +++ b/crates/improvise-command/src/ui/effect.rs @@ -335,15 +335,17 @@ impl Effect for DeleteView { pub struct SwitchView(pub String); impl Effect for SwitchView { fn apply(&self, app: &mut App) { - let current = app.view_state.active_view.clone(); - if current != self.0 { + // History records that the view MOVED, observed after the fact — the + // document may refuse the name (improvise-swzk, `context/code/command.md`). + let previous = app.view_state.active_view.clone(); + app.switch_to_view(&self.0); + if app.view_state.active_view != previous { app.view_state.view_back_stack.push(ViewFrame { - view_name: current, + view_name: previous, mode: app.view_state.mode.clone(), }); app.view_state.view_forward_stack.clear(); } - app.switch_to_view(&self.0); } // Which view this client is looking at is UI, not document (improvise-jytm). // `Dirty` is therefore not the door: see `ClientSession::project`. @@ -1771,6 +1773,33 @@ mod tests { assert!(app.view_state.view_back_stack.is_empty()); } + /// improvise-swzk. Evidence: `context/test-evidence/command.md`. + #[test] + fn a_switch_the_document_refuses_leaves_the_back_stack_alone() { + let mut app = test_app(); + app.model_state.workbook.create_view("View 2"); + SwitchView("View 2".to_string()).apply(&mut app); + + SwitchView("NoSuchView".to_string()).apply(&mut app); + assert_eq!( + app.view_state.active_view.as_str(), + "View 2", + "the fixture must actually refuse: the document has no such view" + ); + assert_eq!( + app.view_state.view_back_stack.len(), + 1, + "nothing was left, so nothing is behind us" + ); + + ViewBack.apply(&mut app); + assert_eq!( + app.view_state.active_view.as_str(), + "Default", + "`<` must reach the real frame, not the one a refused switch buried it under" + ); + } + /// improvise-2avl: the end-of-batch `layout_version` bump was argued safe /// and never tested. Both arms — `context/test-evidence/command.md`. #[test] diff --git a/crates/improvise-command/src/ui/project.rs b/crates/improvise-command/src/ui/project.rs index 3abdd8eb..0d19ac29 100644 --- a/crates/improvise-command/src/ui/project.rs +++ b/crates/improvise-command/src/ui/project.rs @@ -4326,6 +4326,71 @@ mod cell_identity { } } + /// A `RecordHash` is document-scoped; an `AggregateHash` cannot be, and so + /// resolves only where the host stands. Evidence: `context/test-evidence/command.md`. + #[test] + fn an_aggregate_a_session_was_shown_does_not_resolve_against_the_hosts_view() { + let mut app = aggregated_app(); + app.model_state.workbook.create_view("Other".to_string()); + { + let v = app.model_state.workbook.views.get_mut("Other").unwrap(); + v.set_axis("Product", Axis::Row); + v.set_axis("_Measure", Axis::Column); + v.set_axis("Region", Axis::None); + } + app.rebuild_layout(); + + let mut sessions = Sessions::default(); + let (id, _snap) = sessions.connect(&app, 20, 20); + let switch = Command::SwitchView("Other".to_string()); + let checked = Checked::against(&app, &switch).unwrap(); + sessions.apply_checked(&mut app, id, checked); + + // What the host itself is still on. + assert_eq!(app.view_name(), "Default", "the host must not have moved"); + + // Read the view OFF the session, so deleting the switch cannot leave + // this test green (found by review: the name was hard-coded). + let session = sessions.get(id).expect("the session we just connected"); + let seen = session.view().active_view.clone(); + assert_eq!( + seen, "Other", + "the switch did not move this session's mirror" + ); + assert_ne!( + seen, + app.view_name(), + "then the two views are not different" + ); + + let grid = grid_in(&app, &seen, session.viewport()); + let hash = grid + .cells + .iter() + .flatten() + .find_map(|c| match c.id.clone()? { + CellId::Aggregate(h) => Some(h), + _ => None, + }) + .expect("the Other view aggregates over Region, so it has aggregates"); + + assert_eq!( + app.resolve_aggregate(hash), + None, + "if this resolves, the host's layout can see the session's aggregate \ + and the scoping hole is not real" + ); + + // The control: the SAME surrogate, once the host stands where the + // session does. Without it, a blanket `None` would pass above. + app.switch_to_view("Other"); + app.rebuild_layout(); + assert!( + app.resolve_aggregate(hash).is_some(), + "the surrogate is resolvable in principle; only the host's VIEW made it not" + ); + } + /// An aggregate names a coordinate *constraint* and must come back as the same /// one, page and filter coordinates included (gotcha 27). #[test] diff --git a/crates/improvise-core/src/model/types.rs b/crates/improvise-core/src/model/types.rs index 68b6a118..522f7da6 100644 --- a/crates/improvise-core/src/model/types.rs +++ b/crates/improvise-core/src/model/types.rs @@ -1765,11 +1765,12 @@ mod formula_tests { ); } m.add_formula(parse_formula("Count = COUNT(Sales)", "_Measure").unwrap()); - let val = m - .evaluate(&coord(&[("_Measure", "Count"), ("Region", "East")])) - .and_then(|v| v.as_f64()) - .unwrap(); - assert!(val >= 1.0); + let count_at = |k: CellKey| m.evaluate(&k).and_then(|v| v.as_f64()); + assert_eq!( + count_at(coord(&[("_Measure", "Count"), ("Region", "East")])), + Some(1.0) + ); + assert_eq!(count_at(coord(&[("_Measure", "Count")])), Some(3.0)); } #[test] diff --git a/crates/improvise-gp/src/fit.rs b/crates/improvise-gp/src/fit.rs index 31cacef8..e5d6a192 100644 --- a/crates/improvise-gp/src/fit.rs +++ b/crates/improvise-gp/src/fit.rs @@ -785,6 +785,51 @@ mod tests { ); } + /// improvise-ipqu: the three warning tiers are concatenated, and the order + /// is user-facing. `context/test-evidence/gp.md`. + #[test] + fn a_budget_notice_precedes_a_table_warning_which_precedes_a_preprocess_one() { + let (model, view) = all_three_warning_tiers_at_once(); + let outcome = fit_measure(&model, &view, "Score", FIT_INTO, &request()) + .expect("dropping the wide column is enough"); + let at = |needle: &str| { + outcome + .warnings + .iter() + .position(|w| w.contains(needle)) + .unwrap_or_else(|| panic!("no {needle:?} warning in {:?}", outcome.warnings)) + }; + let (notice, table, preprocessed) = (at("budget"), at("no Score value"), at("Blank")); + assert!( + notice < table && table < preprocessed, + "budget notice, then the table's, then preprocess's: {:?}", + outcome.warnings + ); + } + + /// [`two_categories_of_different_widths`] plus the two things that make the + /// other two tiers speak. Reasoning: `context/test-evidence/gp.md`. + fn all_three_warning_tiers_at_once() -> (Model, View) { + let (mut model, view) = two_categories_of_different_widths(); + // A measure with no cell anywhere is a header column that is entirely + // missing in every row, which is what `preprocess` warns about. + model + .category_mut(MEASURE) + .expect("_Measure always exists") + .add_item("Blank"); + // A coordinate the base fixture does not use, carrying `Cost` and no + // `Score`: one visible record `feature_table` must skip. + model.set_cell( + CellKey::new(vec![ + ("Wide".to_string(), "w0".to_string()), + ("Narrow".to_string(), "n1".to_string()), + (MEASURE.to_string(), "Cost".to_string()), + ]), + CellValue::Number(1.0), + ); + (model, view) + } + /// 60 `Wide` items and 8 `Narrow` ones over 60 records, so the two columns /// are 60 and 8 and only one of them has to go. fn two_categories_of_different_widths() -> (Model, View) { diff --git a/crates/improvise-io/src/import/wizard.rs b/crates/improvise-io/src/import/wizard.rs index 77aefd2c..406e424d 100644 --- a/crates/improvise-io/src/import/wizard.rs +++ b/crates/improvise-io/src/import/wizard.rs @@ -187,6 +187,9 @@ impl ImportPipeline { // value; a record is charged to the first category it is missing. let mut skipped_per_category = vec![0usize; categories.len()]; let mut skipped_non_objects = 0usize; + // Per-extraction tally of values the detected format could not parse. A + // derived coordinate that is merely absent is improvise-yss5. + let mut unparsed_dates = vec![0usize; date_extractions.len()]; // Two rows sharing every coordinate are one cell; last-row-wins lost the // earlier one (improvise-2cw9). The map only deduplicates — gotcha 18. let mut cells: Vec = vec![]; @@ -196,6 +199,9 @@ impl ImportPipeline { if let Value::Object(map) = record { let mut coords: Vec<(String, String)> = vec![]; let mut missing: Option = None; + // Charged only once this record survives the walk: a row dropped + // for a LATER missing category never lands (context/test-evidence). + let mut unparsed_here: Vec = vec![]; for (cat_idx, cat_proposal) in categories.iter().enumerate() { let val = map.get(&cat_proposal.field).and_then(scalar_name); @@ -205,12 +211,18 @@ impl ImportPipeline { coords.push((cat_proposal.field.clone(), v.clone())); // Extract date components from this field's value - for (field, fmt, comp, derived_name) in &date_extractions { - if *field == cat_proposal.field - && let Some(derived_val) = extract_date_component(&v, fmt, *comp) - { - wb.add_item(derived_name, &derived_val)?; - coords.push((derived_name.clone(), derived_val)); + for (at, (field, fmt, comp, derived_name)) in + date_extractions.iter().enumerate() + { + if *field != cat_proposal.field { + continue; + } + match extract_date_component(&v, fmt, *comp) { + Some(derived_val) => { + wb.add_item(derived_name, &derived_val)?; + coords.push((derived_name.clone(), derived_val)); + } + None => unparsed_here.push(at), } } } else { @@ -223,6 +235,9 @@ impl ImportPipeline { skipped_per_category[cat_idx] += 1; continue; } + for at in unparsed_here { + unparsed_dates[at] += 1; + } // Attach label values as coords (missing labels become ""). for lab in &labels { @@ -284,6 +299,7 @@ impl ImportPipeline { &skipped_per_category, skipped_non_objects, ); + warnings.extend(unparsed_date_warnings(&date_extractions, &unparsed_dates)); warnings.extend(collision_warning(&collisions)); Ok((wb, warnings)) @@ -329,6 +345,25 @@ fn scalar_name(v: &Value) -> Option { (!name.trim().is_empty()).then_some(name) } +/// One warning per derived date column whose source value some row could not +/// parse: the coordinate is absent, not wrong, so nothing else can report it. +fn unparsed_date_warnings( + date_extractions: &[(&str, &str, DateComponent, String)], + unparsed: &[usize], +) -> Vec { + date_extractions + .iter() + .zip(unparsed) + .filter(|&(_, &count)| count > 0) + .map(|((field, fmt, _, derived_name), count)| { + format!( + "column '{derived_name}' missing from {count} row(s); \ + '{field}' did not match the date format '{fmt}'" + ) + }) + .collect() +} + /// Human-readable warnings for records that could not be placed in the grid. /// Empty when every record was imported. fn skip_warnings( @@ -939,6 +974,127 @@ mod tests { assert_eq!(val, Some(60.0)); } + #[test] + fn a_column_of_mixed_date_formats_is_not_proposed_as_a_time_category() { + let raw = json!([ + {"Date": "01/15/2025", "Amount": 100.0}, + {"Date": "2025-02-05", "Amount": 200.0}, + ]); + let p = ImportPipeline::new(raw); + let date = p + .proposals + .iter() + .find(|p| p.field == "Date") + .expect("Date"); + assert_eq!( + date.date_format, None, + "detect_date_format requires EVERY distinct sample to parse, which is why \ + the honest CLI/wizard route cannot hand build_model a value it will refuse" + ); + } + + /// A pipeline extracting Month and Year from `Date` under `%m/%d/%Y`. + fn extracting_month_and_year(raw: serde_json::Value) -> ImportPipeline { + use crate::import::analyzer::DateComponent; + + let mut p = ImportPipeline::new(raw); + for prop in &mut p.proposals { + if prop.field == "Date" { + prop.kind = FieldKind::TimeCategory; + prop.accepted = true; + prop.date_format = Some("%m/%d/%Y".to_string()); + prop.date_components.push(DateComponent::Month); + prop.date_components.push(DateComponent::Year); + } + } + p + } + + #[test] + fn extracting_a_date_component_from_values_that_all_parse_warns_about_nothing() { + let p = extracting_month_and_year(json!([ + {"Date": "01/15/2025", "Amount": 100.0}, + {"Date": "02/05/2025", "Amount": 200.0}, + ])); + let (_, warnings) = p.build_model_with_warnings().unwrap(); + assert!( + warnings.is_empty(), + "a clean import must stay silent: {warnings:?}" + ); + } + + #[test] + fn a_row_dropped_for_a_missing_category_is_not_charged_to_the_date_tally() { + // `Date` is walked before `Region`, so a bad date is seen first and the + // record is only afterwards dropped for the missing `Region`. + let mut p = extracting_month_and_year(json!([ + {"Date": "01/15/2025", "Region": "East", "Amount": 100.0}, + {"Date": "2025-02-05", "Amount": 200.0}, + ])); + for prop in &mut p.proposals { + if prop.field == "Region" { + prop.kind = FieldKind::Category; + prop.accepted = true; + } + } + let categories: Vec<&str> = p + .proposals + .iter() + .filter(|p| { + p.accepted && matches!(p.kind, FieldKind::Category | FieldKind::TimeCategory) + }) + .map(|p| p.field.as_str()) + .collect(); + assert_eq!( + categories, + vec!["Date", "Region"], + "the fixture only discriminates while Date is walked FIRST" + ); + + let (_, warnings) = p.build_model_with_warnings().unwrap(); + assert!( + warnings.iter().all(|w| !w.contains("Date_Month")), + "the row never landed, so no derived column lost it: {warnings:?}" + ); + assert_eq!( + warnings.len(), + 1, + "only the skipped-record warning: {warnings:?}" + ); + assert!(warnings[0].contains("Region"), "{}", warnings[0]); + } + + #[test] + fn build_model_warns_about_values_that_do_not_match_the_date_format() { + let p = extracting_month_and_year(json!([ + {"Date": "01/15/2025", "Amount": 100.0}, + {"Date": "2025-02-05", "Amount": 200.0}, + {"Date": "2025-03-05", "Amount": 300.0}, + ])); + let (wb, warnings) = p.build_model_with_warnings().unwrap(); + let derived = wb + .model + .category("Date_Month") + .expect("the derived category"); + assert_eq!( + derived.items.keys().map(String::as_str).collect::>(), + vec!["2025-01"], + "two of the three values did not parse, so they contribute no item" + ); + // Two derived columns lost the same two rows, and each says so: a single + // "some dates did not parse" line cannot be told from this one. + assert_eq!( + warnings, + vec![ + "column 'Date_Month' missing from 2 row(s); 'Date' did not match the date \ + format '%m/%d/%Y'", + "column 'Date_Year' missing from 2 row(s); 'Date' did not match the date \ + format '%m/%d/%Y'", + ], + "one located warning per derived column" + ); + } + #[test] fn build_model_extracts_date_month_component() { use crate::import::analyzer::DateComponent; diff --git a/crates/improvise-protocol/src/help.rs b/crates/improvise-protocol/src/help.rs index d7d0566b..8143ec92 100644 --- a/crates/improvise-protocol/src/help.rs +++ b/crates/improvise-protocol/src/help.rs @@ -289,6 +289,7 @@ pub fn describe(command: &'static str) -> &'static str { "toggle-records-mode" => "records mode", "transpose" => "swap rows and columns", "view-back" => "back", + "view-forward" => "forward", "yank" => "yank the cell", other => other, } @@ -498,6 +499,7 @@ fn navigation() -> Vec { Some(HelpLine::Blank), keys(n, "drill-into-cell", None, "Drill into selected cell"), keys(n, "view-back", None, "Go back to previous view"), + keys(n, "view-forward", None, "Go forward again after going back"), ] .into_iter() .flatten() diff --git a/crates/improvise-protocol/src/keymap.rs b/crates/improvise-protocol/src/keymap.rs index 5d6e34a8..a375048b 100644 --- a/crates/improvise-protocol/src/keymap.rs +++ b/crates/improvise-protocol/src/keymap.rs @@ -634,6 +634,9 @@ impl Keymaps { // Drill into aggregated cell / view history normal.bind(KeyCode::Char('>'), none, "drill-into-cell"); normal.bind(KeyCode::Char('<'), none, "view-back"); + // `,` and not `>`: `>` drills into the grid, so the pair that LOOKS + // symmetric is not one (improvise-htl6, `context/code/tui.md`). + normal.bind(KeyCode::Char(','), none, "view-forward"); // Records mode toggle and prune toggle normal.bind(KeyCode::Char('R'), none, "toggle-records-mode"); diff --git a/crates/improvise-protocol/src/resolve.rs b/crates/improvise-protocol/src/resolve.rs index 85f2ac92..517c0b0e 100644 --- a/crates/improvise-protocol/src/resolve.rs +++ b/crates/improvise-protocol/src/resolve.rs @@ -2327,10 +2327,10 @@ mod tests { ); } - /// The forward sibling, reached by name: no keymap binds `view-forward` - /// in either front end (improvise-cfly). See the evidence file. + /// The forward sibling, and the key that reaches it in both front ends + /// (improvise-cfly, improvise-htl6). See the evidence file. #[test] - fn the_forward_step_mirrors_its_cmd_and_no_key_reaches_it() { + fn the_forward_step_mirrors_its_cmd_and_one_key_reaches_it() { let mut state = in_mode(AppMode::Normal); assert_eq!( composed(&state, ev(KeyCode::Char('<')), "view-forward", &[]), @@ -2351,15 +2351,24 @@ mod tests { for (_, keymap) in Keymaps::shared().iter() { bound_commands(keymap, &mut bound); } + // improvise-htl6 inverted this: it asserted view-forward was bound by + // NOTHING, which is where the gap was meant to stop being one. assert!( - !bound.iter().any(|(name, _)| name == "view-forward"), - "a key now reaches view-forward: this test is where that stops \ - being a gap, and the arm above stops being unreachable" + bound.iter().any(|(name, _)| name == "view-forward"), + "view-forward has lost its key again and the arm above is \ + unreachable: the TUI is back to :view-forward and the browser to \ + nothing at all" ); assert!( bound.iter().any(|(name, _)| name == "view-back"), - "and the control: view-back IS bound, so the check above is not \ - vacuous over a misspelling" + "and the control: view-back is bound too, so the check above is \ + not vacuous over a table that binds everything or nothing" + ); + assert_eq!( + composed(&state, ev(KeyCode::Char(',')), "view-forward", &[]), + Some(vec![Command::ViewForward]), + "and the key is `,` — the unshifted twin of `<`, so both halves of \ + the history sit on one physical key" ); } diff --git a/prolog-sessions/kb.pl b/prolog-sessions/kb.pl index ed3eca09..8ded1805 100644 --- a/prolog-sessions/kb.pl +++ b/prolog-sessions/kb.pl @@ -52,6 +52,7 @@ location(wasm_ci,'.github/workflows/ci.yml','wasm job'). location(view_state_membership,'crates/improvise-command/src/ui/app.rs','ViewState doc comment states both membership tests; App carries the session-local residue tag'). location(axis_presentation,'crates/improvise-core/src/view/axis.rs','plain_label/glyph/short_label/initial/color/next on Axis; AxisColor semantic palette'). location(axis_color_resolution,'src/draw.rs','axis_fg maps AxisColor to a ratatui Color, beside minibuf_fg which does the same for MinibufColor'). +location(tui_truncate,'src/ui/text.rs',truncate). invariant(cellkey_sorted,'CellKey coords always sorted by category; build via CellKey::new/with/without'). invariant(display_rounding,'format_f64 is render-only; never feed rounded values back into eval'). invariant(fixedpoint_eval,'recompute_formulas iterates to stability; circular -> CellValue::Error(\"circular\")'). @@ -124,6 +125,18 @@ invariant(normal_mode_is_resolved_not_carried,'AppMode::Normal is resolved throu invariant(the_frame_basis_follows_active_view,'The cursor frame basis re-bases AT THE SWITCH, in the loop, as soon as active_view moves -- not at the next geometry effect. Guarding the COMPARISON on the two names agreeing (PR #78 round 2) exempted the first geometry effect after a mid-batch SwitchView from the check altogether, which is under-reporting and gotcha 35 expensive side. SwitchView stays ViewOnly: the basis is App own bookkeeping and none of it crosses the wire, so this needed no protocol change. Costs one extra view_frame per batch that both switches and moves geometry, DrillIntoCell paying most.'). invariant(encoded_width_mirrors_every_preprocess_branch,'The preflight runs BEFORE preprocess and the exact check AFTER it, so the exact check bounds the error message and not the allocation. encoded_width must mirror EVERY arm of preprocess inference -- Numeric, Date (5 parts, 7 with a time) and Categorical (distinct plus the reserved (missing)) -- plus the .missing flag. It may over-count; it must never under-count. improvise-x1ns was TWO under-counts one inference-branch apart: the categorical (missing) one-hot, and the entire Date arm. The guard is that encoded_width matches Forced WITH NO WILDCARD, so a fourth variant is a compile error rather than a silent categorical fallback -- which is exactly how Date was missed.'). invariant(opfs_sync_handles_are_dedicated_worker_only,'createSyncAccessHandle is spec-ed [Exposed=DedicatedWorker] and is ABSENT -- not rejected, not flagged, not transient -- on a file handle obtained inside a module SharedWorker. Measured 2026-09-21 on Chromium and Firefox: navigator.storage.getDirectory() and getFileHandle() both SUCCEED there and the method is simply not on the object; the real OpfsFs bundle, run unmodified in a shared worker, fails at exactly that call. Playwright WebKit has no OPFS in any context, so it is out one step earlier. Consequence: gotcha 28-s premise -- the handle-s read/write/truncate/flush ARE blocking, which is the whole reason Storage stayed synchronous -- is a DEDICATED-worker-only premise, so a SharedWorker cannot host OpfsFs as written and every \"browser worker\" in that argument means a dedicated one. The trap for anyone costing multi-tab: Sessions, the Addressed filter and the single frame pair are all ready and none of it helps, because the no is entirely on the storage seam. See improvise-12ha for the three probed ways out (async seam / nested worker / relay) and improvise-7gip for the guard that fails to enforce this.'). +invariant(one_display_width_truncator,'src/ui/text.rs::truncate is the one display-width-safe truncator the TUI widgets share; grid.rs and formula_panel.rs call it. import_wizard_ui.rs keeps a separate BYTE-measured truncate deliberately, because its measure decides where every wizard line wraps and bytes are never fewer than cells. The sibling hazard is a byte count used as a COLUMN OFFSET (inner.x + name.len()), which never panics because Buffer::set_string clips, and so shows only as a gap widening with each non-ASCII character.'). +invariant(page_items_ride_inside_cycle,'RenderPageCycle carries the item list INSIDE cycle rather than beside it, so PageAxis::cycle is one predicate and not two (improvise-rw6l). A Filter axis has no cycle, and therefore gets no down-arrow, no menu, no items, is painted as a span rather than a button, and Click::PageItem composes nothing if a click reaches it -- four answers off one home, which is gotcha 27 holding. items.is_some() appears nowhere in the browser. This is stronger than the guard originally asked for (a test that a Filter axis gets no arrow): the wrong state is not constructible.'). +invariant(one_format_plan_per_render,'FormatPlan REPLACED the (comma, decimals) pair in every render path -- GridLayout::display_text, viewport::grid_body and compute_col_widths, project::grid_in, export.rs::render_csv, ui/app.rs -- rather than being added beside it. Two answers to \"what format is this cell\" is the disease (design-principles section 5, improvise-edp).'). +invariant(format_ownership_is_formula_for_order,'A cell format comes from the FIRST formula in model order whose target coordinate matches, the same rule and order that decides whose VALUE it is. A match naming no format falls through to the view -- a match, not a miss. Found by review: a HashMap keyed by target made the SPELLING hash-dependent while the value was not (gotcha 18 shape in a new place), and skipping unformatted formulas let a later FORMAT reach a cell it does not produce.'). +invariant(a_column_total_is_resolved_from_its_column,'GridLayout::column_key(col) -- the page and filter constraints plus the column tuple, the coordinates surviving the ROW aggregation. Reading row 0 let a row-axis formula format reach a total that had summed that row away, and made the spelling depend on row order. Found by review after context/code/core.md had ARGUED the row-0 shortcut was safe.'). +invariant(baseline_is_measured_in_your_own_worktree,'A dispatch brief carried 1638 as the test baseline; 7470b61 measures 1645. The orchestrator had copied the number from the PREVIOUS agent report, taken at 364052b, before PR #123 merged and added seven tests. An agent trusting it would have read +11 for four new tests and gone hunting. Measuring the baseline at YOUR OWN base commit costs one run and is the only thing that makes the delta checkable -- a number carried forward from another report is a claim about a commit you are not on.'). +invariant(a_hidden_toast_keeps_its_text,'The toast element is the record of what was last said and is NOT emptied when it hides (improvise-n6t8). harness/tests/drill.spec.js discriminates between two refusals by reading it, so an emptied node would satisfy not.toContainText(\"aggregate\") VACUOUSLY the moment the timer fired -- a passing assertion about an element with no content. Argued in web/index.html and context/code/clients.md.'). +invariant(headless_cmd_and_script_are_one_function,'run_headless_script DELEGATES to run_headless_commands, so \`improvise cmd\` and \`improvise script\` cannot diverge in what they print, what they exit with, or when they write the document back. Asserted by a_script_reports_exactly_as_cmd_does rather than left to the delegation, because a future caller could inline it.'). +invariant(the_creating_columns_insert_and_so_suppress_the_advance,'A +cat or +measure commit that NAMES something inserts a column at the cursor own index, so an advance computed from the PRE-commit layout is wrong for both -- CursorState::col_count is stale because apply_effects rebuilds only after the batch (gotcha 23). inserts_a_column covers both creating columns and takes the committed VALUE, because an empty name creates nothing and is genuinely at the right edge. created_name is the one home of that rule, read by add_measure, add_category and the predicate.'). +invariant(a_reported_share_cannot_overflow_its_scale,'gp_tree::fit::importance_ranking sums and divides in f64 and narrows only the resulting share, which is bounded by 100 and therefore always finite (improvise-k844). TWO overflows, not one: 100.0*imp[j] associates left so a lone importance over f32::MAX/100 is inf before the divide that would have returned it to range; and the f32 TOTAL itself overflows when several are large, which reassociating to 100.0*(imp[j]/total) turns from NaN% into a SILENT 0% FOR EVERY FEATURE. f64 closes both, since no sum of finite f32s can overflow it. The residual guard answers only an importance that ARRIVES non-finite -- an infinite total apportions nothing, so the ranking is empty rather than NaN, and note NaN <= 0.0 is false so the pre-existing early return passed NaN straight through. Reachable PAST improvise-pv3g because both callers score importance in the target OWN units, making it an MSE difference that grows as the target square.'). +invariant(viewport_extent_is_external_on_both_front_ends,'The viewport extent is an EXTERNAL INPUT that reaches the document, and it is not a browser problem. run_headless_commands reads crossterm::terminal::size() into app.term_{width,height} and CmdContext::{visible_rows,visible_cols} derive from it: 80x24 gives visible_rows 16, 200x60 gives 52. Measured on a 40-row grid, the SAME command sequence -- one PageDown then clear-cell at the cursor -- deleted R12 at 80x24 and R39 at 200x60. So Client::set_viewport is not a browser-introduced input; it is the browser SPELLING of one the TUI has always had, and gotcha 47 \"not derivable\" is true of both front ends. Any ADR treating extent as a browser problem produces a log that replays in a browser and not in a terminal. Cheapest of the five external inputs to close -- one pair of integers with an existing wire spelling, ClientFrame::Viewport -- but it must land in the SAME CHANGE as improvise-6uva, which currently masks it. A test must drive TWO sizes; one cannot see this.'). +invariant(aggregate_hash_is_layout_scoped,'A RecordHash falls back to DataStore maintained index and so resolves against the DOCUMENT, which is why a connected client SetCell works from any view. An AggregateHash has no such index AND CANNOT HAVE ONE -- an aggregate has no existence outside the layout that derives it. So App::resolve_aggregate answers only for the HOST active view, which since improvise-jytm is not the client. Guarded by an_aggregate_a_session_was_shown_does_not_resolve_against_the_hosts_view, whose CONTROL is the load-bearing half: the same surrogate resolves once the host is moved onto that view, so a blanket None -- a typo in the hash, an empty layout -- cannot pass.'). doctrine(bug_first,'write a failing test demonstrating the bug before fixing it'). doctrine(unit_over_e2e,'prefer small unit tests over integration/e2e'). doctrine(suite_speed,'Two numbers, because the ambiguity between them is what let this drift: NO SINGLE TEST BINARY may exceed 2 seconds, and the WORKSPACE WALL CLOCK is the sum of them. Measured at 22d43b2: 1309 tests over 33 binaries, ~2.2s wall, longest single binary 1.42s -- so the workspace is already past 2s and the rule that bites is the per-binary one. Re-MEASURE rather than re-assert: cargo test --workspace prints a \"finished in\" per binary. Excludes compilation, and excludes harness/ (Playwright, minutes, its own CI job).'). @@ -313,6 +326,48 @@ doctrine(a_merge_prints_pop_whether_or_not_it_stashed,'On a conflicted merge wit doctrine(a_missing_loose_ref_is_not_a_deleted_branch,'A watcher reading .git/refs/heads/** directly -- which a worktree-isolated agent must, because the shell guard refuses git in a loop -- cannot distinguish a DELETED branch from one that pack-refs moved into .git/packed-refs. Both present as a vanished file. Observed together in a single event: wave/q-keymap was genuinely deleted by its PR merge while wave/r-browser-page was merely packed. So a MISSING event is a PROMPT TO CHECK WITH GIT, never a conclusion; pair it with git branch --list before acting.'). doctrine(rebuild_the_integration_on_the_new_base_and_diff_the_trees,'When part of a wave merges upstream while an integration is held, rebuilding the integration on the NEW base and diffing the two trees is the strongest available evidence that the upstream merges lost nothing. 2026-09-21: git diff --stat between the locally-integrated preview (old base + four local merges) and gh/main-plus-the-remaining-branch was EMPTY -- byte-identical. That answers the clean-auto-merge hazard DIRECTLY rather than by inference, and every gate run on the first tree transfers to the second by identity. Cheaper than re-gating and strictly better than assuming, because a clean auto-merge can delete tests with no conflict and no compile error.'). doctrine(a_reported_sha_is_correct_and_short_lived,'Three times in one session an integration report named SHAs that were accurate when measured and stale within minutes -- twice because agents pushed self-review fix commits, once because three PRs merged upstream while the preview was held. The remedy is a watcher, not diligence: heads were verified at the start AND the end of a pass and the table was still stale, because the moves landed after the last check. A report that names no SHA is not checkable; a report that names one has a shelf life. Re-read immediately before acting on it -- and the same applies to a SHA written into a PR body.'). +doctrine(an_empty_actions_log_is_retention_not_history,'gh run list came back empty for the improvise release.yml workflow and was read as \"only one tag push ever ran this\". GitHub Actions logs age out at 90 days and the rc1/rc2 tags were five months old; both carry COMPLETE published releases (all four targets, sha256 files, dist-manifest.json, source.tar.gz, installer), so the workflow had demonstrably run end to end the last time a tag and the version agreed. The artefact a run PUBLISHED outlives the run, so ask gh release before gh run -- and gh run list --workflow=release.yml --event=push keeps the tag history even where the logs themselves are gone. Two further claims in the same passage failed the same way and are worth knowing as a set: a recurrence was inferred from run DURATIONS when only one of the three failures had ever been diagnosed (an identical signature is not an identical cause -- the rc1 logs aged out and its failures sit on initial-setup commits, where plan could have failed on an incomplete dist config), and the remotes a tag sat on were asserted rather than asked. Ask; then note that the asking has a shelf life too.'). +doctrine(a_verified_remote_fact_has_a_shelf_life,'v0.1.0-rc3 was measured three times in one session and read differently each time: present on all three remotes and locally (asserted, from the aa4e901 commit message), then on gh alone (git ls-remote), then nowhere at all -- the tag was deleted mid-review. Only the FIRST reading was careless. The second and third were each taken correctly, by two different people, and they contradict. So the rule is NOT ask-rather-than-assert: a verified answer about a remote, a tag, a deployment or a dashboard DECAYS, and prose cannot distinguish a stale line from a live one, so a reader following a checklist at 2am cannot tell which they hold. A document that records an inventory is therefore wrong on a schedule it does not control. Record HOW TO ASK (git tag -l, git ls-remote --tags ) and say in the document that no inventory is recorded, so the blank reads as deliberate rather than as an omission to helpfully fill in. THE KEEPER IS THE ONE WHOSE SUBJECT CANNOT CHANGE: \"the commit message was wrong\" is durable, because its subject is a fixed text; \"the tag is on gh alone\" was true and is now false. That is the test for whether a sentence belongs in context/ at all. The half-lesson that stops one step short, and the reason this fact exists: the gh-alone reading was FOUND by running the command, from which the obvious conclusion is run-the-command -- and that was written into two files as though it settled the matter. Running the command is necessary and INSUFFICIENT, because the answer expires. Four copies of the volatile half had to be removed, one of them in the header of scripts/release-preflight.sh, which is the file a reader opens first.'). +doctrine(a_ready_ticket_may_already_be_fixed,'improvise-pv3g was dispatched to an agent as an open P1 and was already fixed on main: f616487 was an ancestor of the very commit the agent was told to branch from, and bd still showed the ticket OPEN because nobody closed it when the work landed. improvise-uej8 looks the same, named in 7a44616 via PR #114. So bd ready is a list of tickets nobody closed, NOT a list of work outstanding -- the two coincide only while closing is perfect, and in a session with several agents landing several PRs it is not. THE CHECK IS CHEAP AND BELONGS IN EVERY BRIEF: reproduce the bug at HEAD before implementing, and if it does not reproduce, find the commit that fixed it, confirm it is an ancestor of the base, and report the SHA instead of opening a PR. The agent that found this built the ticket fixture and ran the SHIPPED BINARY at the commit before the suspected fix, in a throwaway worktree, showing the bug in both directions rather than inferring from a commit message. The failure this avoids is not wasted time: it is a fabricated fix for an already-fixed bug, which can undo the real one and costs a reviewer trust.'). +doctrine(a_refusal_the_headless_path_discards_is_not_a_refusal,'improvise-pv3g acceptance asked for an actionable refusal naming the cause, and the wording was written, tested and correct. run_headless_commands (src/main.rs) applies effects and never prints view_state.status_msg, and exit_code is set to 1 only for PARSE errors -- so through \`improvise cmd\` a refused command is indistinguishable from a successful one: silence and exit 0 either way. The TUI renders the status line, so the criterion is genuinely met there and the headless path is where it evaporates. No test sees it because a_refusal_names_what_went_wrong_and_stores_nothing asserts the EFFECT status, and the effect is correct; nothing asserts the binary emits it. Filed as improvise-j6x8. The shape to carry: when acceptance says a user is TOLD something, name the surface -- an effect carrying a message is not a user being told, and the layer that drops it is usually one the test suite does not drive.'). +doctrine(a_prose_count_is_checked_by_no_one,'context/code/tui.md said mode_style spells FOURTEEN default-chrome AppMode variants; it spells thirteen, verified against src/draw.rs. The match is exhaustive and sound -- only the sentence was wrong, and nothing in this repo can check a number written in prose. Same failure mode as gotcha 38 subtracting SwitchView and forgetting its two siblings, and gotcha 44 listing gaps that had already closed. THE REPAIR SHAPE: write the ARITHMETIC beside the count so a reader can check it (13 + Editing/RecordsEditing + CommandMode + TileSelect = 17 = the AppMode variant count), and say when it was wrong.'). +doctrine(a_rendered_string_is_pinned_wherever_anything_reads_the_screen,'improvise-771e changed the browser tile bar from Axis::short_label to plain_label. The orchestrator pointed the agent at tests/grid_frame_agreement.rs as the thing that might pin the vocabulary; it does not mention the tile bar at all. harness/tests/drill.spec.js DID -- it asserted the tile text exactly, twice, with \"Quarter Pag\" AS THE EXPECTED VALUE, a Playwright spec in a directory the ticket never named, asserting the bug as its expectation. So when changing a rendered string, GREP FOR THE STRING, not for the function that produces it: a screen-reading test pins the output wherever it lives, and the producer name will not find it. A now-false comment naming short_label in contract.spec.js was found the same way.'). +doctrine(a_duplicated_inheritance_edge_is_invisible_to_a_behavioural_test,'improvise-wiew: parenting RecordsNormal to document_keymap DIRECTLY, when it already reaches it through Normal, is a fault the behavioural test cannot see -- the key still works, so pressing it proves nothing. Only records_normal_inherits_the_document_layer_once_through_normal, which asserts the CHAIN IDENTITY (exactly four deep), catches it. The same asymmetry appeared in PR #112: removing a mode keymap parent failed ONLY the Arc::ptr_eq chain guard while 38 behavioural keymap tests passed, because the mode own AnyChar answered identically. A keymap chain needs a structural assertion beside the behavioural one; the behavioural one is satisfied by any arrangement that happens to route the key.'). +doctrine(distrust_a_context_paragraph_that_argues_a_case_cannot_arise,'context/code/core.md claimed the row-0 totals shortcut was safe \"for the case it cannot arise in\". It arises: a formula target category is any category, so it can own a row-axis item. Such a paragraph is doing the work a type or a test should, and nothing checks it. Note who wrote it: the same agent that had just read the code and had, in the same edit, correctly closed a gap core.md itself had named. Reading the code is what produces both the good paragraph and the wrong one.'). +doctrine(a_clean_merge_is_not_a_tested_merge,'harness/tests/pages.spec.js arrived on main from PR #123 while PR #125 was in flight, asserting a display literal the new decimal default changes. The merge was CLEAN and the combination was WRONG, and only CI merge ref had ever run both sides -- the local run was green on a branch that had never seen the file. So a PR whose change is cross-cutting should merge main and RE-RUN THE GATES before calling itself green, not after CI says otherwise. This is design-principles section 7 third case, arriving again.'). +doctrine(rebuild_the_assembled_site_after_merging_web,'harness/serve is gitignored and built once. After a merge touching web/, Playwright failed 8 tests with \"Quarter Pag\" vs \"Quarter Page\" and missing elements -- nothing was broken: harness/build.sh had run BEFORE the merge, so serve/ held pre-merge web/*.js against a post-merge spec. Re-run web/build.sh and harness/build.sh after any merge touching web/, or debug a product that is not what is being served. The failure mode is indistinguishable from a real regression.'). +doctrine(a_none_that_carries_two_facts_is_a_black_hole,'KeymapSet::dispatch spelled construction as registry.interactive(name,args,ctx).ok()?, so Option::None meant BOTH \"no binding matched\" and \"a binding matched but would not build\" -- and App::dispatch_key answers None with Dirty::NOTHING and no status, so a bound key was silent. improvise-55l5 split them: None means no binding matched and nothing else; a construction failure returns Some(vec![set_status]). THE TEST PAIR IS THE POINT, because one test cannot separate two facts one value carried: the rival that swallows the error fails a_bound_key_whose_command_cannot_be_built_says_so, and the rival that ALSO reports a failed lookup fails an_unbound_key_stays_silent -- without that control the fix writes a status for every unmapped character in every mode.'). +doctrine(surface_the_error_class_no_static_check_can_see,'improvise-55l5 separated the constructor errors into two classes. CLASS A reaches a user and nothing static can see it: \"no cell at cursor\" from six registrations reading ctx.cell_key(), which is STATE-dependent, so no registration-time check applies -- and the worst case is commit-cell-edit, where the user has typed a value in Editing and the batch is discarded. CLASS B is keymap-authoring mistakes that every_bound_command_name_is_registered already refuses at test time, checking both halves of the old expression (gotcha 15). SO THE REPRO THE TICKET ITSELF PROPOSED WAS THE ONE CASE THAT CANNOT HAPPEN. Class B surfaces anyway, deliberately: a list of blessed errors would be a second description of which failures matter, free to disagree with the first.'). +doctrine(cleanup_done_by_one_of_two_call_sites_belongs_to_the_command,'AddRecordRow returned SetCell and never placed the cursor, so the frame observation invalidated it to the origin mid-batch and o opened an editor on ROW 0 -- renaming an unrelated record and moving every measure cell in it (gotchas 1 and 24). But CommitAndAdvance Tab-at-bottom-right path had ALWAYS placed the cursor, at the CALL SITE. One of two callers doing the cleanup is the tell: the placement was a property of the caller where it is a property of the command. Look for this wherever two call sites of one command differ in what they do AFTERWARDS.'). +doctrine(a_reserve_that_caps_hides_its_own_failure,'A fixed-size slot sized by eye needs a guard, and the guard can only see an overflow that GROWS the box. A width or max-width absorbs an over-long value inside a box that never moves, so every bounding-box measurement stays identical and the regression is invisible. Reserve with a FLOOR (min-width) and let the measurement catch the day the reserve stops fitting. improvise-n6t8: web/style.css #mode is min-width: 21ch, and removing it fails both \"the bar moved in CATEGORIES\" and the 16-char check-the-checker. The general form: when a guard measures a consequence, choose the mechanism whose failure PRODUCES that consequence rather than absorbing it.'). +doctrine(dismiss_a_repaint_driven_surface_by_identity_not_by_timer,'A transient UI over a value that is STATE rather than an event must key its dismissal on what it last ACCEPTED, not on a timer. ViewState::status_msg is re-offered by every repaint -- web/app.js calls the sink unconditionally and repaint runs on every keydown -- so a setTimeout alone resurrects the message on the next keypress, which is worse than a message that simply stays. improvise-n6t8 keeps what createToasts last accepted, and keeps it AFTER the toast hides, so an equal offer is recognised as a repaint and dropped. COST, STATED RATHER THAN HIDDEN: two identical messages in a row show once. That is forced -- nothing beside status_msg carries a sequence and model.revision moves for a cursor key as readily as for a re-issued command, so of the two readings of an ambiguous offer only one avoids reintroducing the bug.'). +doctrine(a_user_route_cannot_separate_what_a_user_cannot_separate,'improvise-bd9o guards on state.grid.records rather than on the mode, and the whole argument turns on those two facts being different things. TWO INJECTED RIVALS -- view.mode.is_records() and the keymap mode.is_records() -- PASSED THE ENTIRE WORKSPACE. The end-to-end route test could not see them for a structural reason: it enters records mode the way a user does, where the grid and the mode AGREE. An end-to-end test is the right test for \"is this route silent\" and the WRONG test for \"on which of two agreeing facts does this guard depend\". When a fix is argued on a distinction only DRIFT can expose, the test must STATE the drift rather than reach it -- build the client whose grid and mode disagree. The tell to look for: a paragraph arguing a choice that no existing fixture can exhibit.'). +doctrine(guard_the_entrance_not_the_arm,'improvise-bd9o: a guard on the CellId::Record arm answers \"is this CELL one we cannot commit\" where the question is \"is this GRID one we cannot commit in\". The separating case is the coordinate column, which carries NO CellId at all -- cell.id.as_ref()? short-circuits, so an arm guard never runs and the key does nothing silently. The guard therefore sits before cell_at_cursor, on the observed grid. The general form: when a refusal is about the CONTEXT, guarding a branch inside the context leaves every path that never reaches the branch unguarded, and those paths are the ones that fail silently.'). +doctrine(a_kind_a_name_cannot_carry_must_be_passed_not_re_derived,'CategoryKind::of_name reads Label as Regular BY DESIGN, and that single fact produced improvise-dy3j and improvise-cj4u at two different layers: a file format with nowhere to write the kind, and View::on_category_added asking of_name instead of being told. The cj4u fix is THE SIGNATURE -- passing the kind is what made the compiler name every call site -- and it deleted the two Workbook set_axis(name, Axis::None) corrections that were a second answer to \"where does this category go\", of which only ONE of two callers knew to write it: add_label_category fixed the views existing at that moment while create_view, holding only names, pivoted the label onto Row. Ask of any stand-in accessor which distinctions it deliberately collapses, and then which callers those distinctions matter to.'). +doctrine(a_section_header_is_not_a_place_to_put_a_modifier,'improvise-dy3j proposed \"## Category: Note [label]\" and improvise-pczv forbids it: header_name takes the name as the WHOLE line, totally, so a suffix is indistinguishable from a category literally named \"Note [label]\" -- adopting it would make a legal name unspellable and re-open the ambiguity header_name exists to close. The modifier goes INSIDE the section as an entry line, with a real token (\"kind: \") rather than a lookahead, because the grammar-walking generator reads the grammar through pest_meta and SKIPS predicates. A ticket that proposes a format syntax has usually read the writer and not the grammar.'). +doctrine(agreement_between_two_wrong_copies_is_not_evidence,'A lockstep test that only compares two front ends PASSES when both derive the same WRONG answer -- which is the EXPECTED outcome of a duplicated derivation, not a passing grade. improvise-vw47: both_ends passed against the bug, because cmd/panel.rs and resolve.rs both computed format!(\"View {}\", len + 1) and were wrong identically, so the two documents agreed perfectly. Only the assertions on the RESULTING DOCUMENT caught it -- the view count must rise, the survivor format must be untouched. \"The two ends agree\" and \"the two ends are right\" are different questions, and a duplicated derivation is exactly the case that separates them. A cross-front-end test must assert that what the ends agree on is RIGHT, as well as that they agree.'). +doctrine(a_field_with_no_severity_cannot_be_split_by_guessing,'improvise-q914: the orchestrator brief proposed stdout for a status and stderr for a refusal. ViewState::status_msg is a bare String with NO severity, and BOTH candidate classifiers are disproved by code already in the tree -- \"a refusal aborted\" is false, because effect::AddItem writes its Err to status_msg and does not set abort_effects, so that rule labels a refusal a SUCCESS; and \"a refusal is anything that spoke\" is false, because cmd/fit.rs sets a status on the SUCCESS path carrying the reproducing seed, so that rule labels a deliverable a FAILURE. Both verified in-tree by the orchestrator. Choosing either is design-principles section 7 adjacent-check defect. The honest answer was one undivided stream, and the real fix is a severity on the field -- a wire change, filed as improvise-udt2 naming both disproved rivals so the next attempt does not rediscover them.'). +doctrine(a_narrow_exit_contract_beats_an_overpromised_one,'improvise-q914 asked for a non-zero exit when an effect is refused. Delivered: exit 1 on a parse error or App::last_batch_aborted -- both REAL flags -- and no further, with a status-only refusal printed but still exiting 0 because nothing can classify it. README states it plainly: exit 0 means \"nothing aborted\", not \"everything worked\". A contract that promises less and is TRUE is worth more than one that promises the ACs wording and is guessing, because a script author who trusts the wrong one proceeds on a no-op. The shortfall is recorded on the ticket and filed rather than glossed.'). +doctrine(observe_dirty_per_command_because_save_clears_it,'The headless write-back gate accumulates model_state.dirty PER COMMAND rather than reading it once at the end, because effect::Save CLEARS the flag (gotcha 45). Reading it once would make \`[add-item East, w other.improv]\` look unchanged and SILENTLY DROP the edit from the -f document. Same shape as the other observation gates in this repo: what is observed must be sampled where the thing being observed happens, not where it is convenient to look.'). +doctrine(key_outcome_variants_are_protocol_surface,'improvise-h3y7 audited the wire vocabulary for COMPLETENESS, enumerating Command and ModelCommand against the effects in both directions, and found nothing wrong here. The gap was in the SHAPE of what a resolver may RETURN: KeyOutcome was Local | Upstream | Ignored, with no way to say \"send this frame\" -- so ClientFrame::Save and its Host::handle arm both existed, since improvise-368h, with NO KEY ABLE TO REACH EITHER. An enumeration that stops at the frames and the commands will miss the next one the same way. KeyOutcome variants are part of the protocol surface and belong in any completeness pass.'). +doctrine(a_self_constructed_frame_is_not_reach,'a_client_can_ask_its_host_to_save BUILDS the frame it feeds to the host, so it proves the host ANSWERS the frame and never that anything can SEND one. improvise-368h therefore shipped a feature no keypress could reach, with a passing test over it -- \"a green suite is not evidence\" in its purest form. The repair is an end-to-end test from the INPUT: improvise-jz6w asserts Ctrl-S on a real Client produces the frame, a real Host answers it, and the document comes back off storage. WORTH SWEEPING: check whether other Host::handle arms have only self-constructed-frame coverage.'). +doctrine(a_review_comments_commit_id_is_per_comment_not_per_review,'Measured on PR #122 and re-verified by the orchestrator against the live API: ONE review submitted 19:48-19:53 carries five inline comments, THREE naming 246e8f4a and TWO naming 6a4c7dcd, while its body names 246e8f4a. GitHub pins each comment to the file version it was written against, so a comment on an outdated diff position keeps the older commit while its siblings move on. Two consequences: a per-entry \"EARLIER head\" flag can make a CURRENT review look partly stale, and a head comparison against the review BODY is not even a proxy for its own comments -- the old pr-reviews head check was answering about the WRONG OBJECT, not merely reading too few of them.'). +doctrine(a_multi_assignment_local_is_not_left_to_right_on_bash_5,'\`local n=\"$1\" name=\"$2\" f=\"$DIR/$n.$name.json\"\` creates EVERY name before running the assignments on bash 5, so the third clause reads an unbound variable and set -u kills the shell. It works on macOS bash 3.2. scripts/pr-reviews-test.sh therefore passed 19/19 LOCALLY and 2/19 IN CI ON THE SAME COMMIT. Give each local its own statement -- and note the real lesson: a shell checker must be wired into CI in the SAME change that adds it, because the platform difference is invisible to the machine that wrote it.'). +doctrine(a_dead_command_substitution_wears_jqs_exit_code,'Both bash-5 bugs in scripts/pr-reviews failed as EXIT 2 WITH NO OUTPUT AT ALL, and 2 is jq answering an invalid --argjson with a USAGE error. When set -u kills a command substitution, or gh api --paginate returns several concatenated documents, the empty or malformed string reaches jq and the shell fault at the TOP of the script is reported by the JSON tool at the BOTTOM. Exit 2 plus total silence means \"something upstream of jq produced nothing\", never \"jq computed a wrong answer\".'). +doctrine(a_fixture_must_be_able_to_hold_the_shape_the_live_path_produces,'gh api --paginate emits one JSON array PER PAGE, so a long thread is several concatenated documents. scripts/pr-reviews flattens the live AND the fixture path through one pages_to_array, so a fixture file can carry two concatenated arrays. A fixture that could only hold a single array would exercise a shape the live fetch never produces -- a check adjacent to the thing it names. No PR in this repo has yet reached a second page, so the test is the only thing that will see it before a user does.'). +doctrine(a_prose_argument_is_code_and_can_be_the_bug,'context/code/command.md stated that the measure + \"is the last column, so there is no column to its right for an index to shift into, and both advances are already special-cased at the bottom-right edge\". Verified verbatim on main before the fix. THE FIRST CLAUSE WAS FALSE -- the column that shifts is +measure itself -- and THE SECOND NAMED THE BUG AS A SAFEGUARD. improvise-0ygd is the defect that argument authorised. A wrong argument in context/ is not documentation debt: it is the defect, written down, and it will be read as a reason not to look. This repo keeps its most expensive artefacts in prose that nothing checks, so a paragraph asserting why a case cannot arise is the shape to distrust -- the third instance this session, after the row-0 totals shortcut and gotcha 38 arithmetic.'). +doctrine(a_missing_affordance_claim_must_render_the_screen,'improvise-p8ls claimed a TUI user has no on-screen route to help or out, and was demonstrated VIA THE HELP PRINTOUT -- a function return value -- where the claim was about WHAT IS ON SCREEN. Those are adjacent questions. draw_title has right-aligned \" ?:help :q quit \" unconditionally, every mode, every frame, since da93145 on 2026-04-01; a printout of advertised() cannot see a string draw_title renders. A ratatui TestBackend is cheap, already precedented in src/draw.rs (bottom_bar_tests::bottom_row), and settles it in one run. The ticket EVIDENCE was accurate and its CONCLUSION did not follow: the author checked advertised() and draw_welcome and concluded those were the only homes, where the title bar is a third and is the one always visible -- grepping the FACT rather than the function finds four.'). +doctrine(fix_every_sibling_sentence_or_the_paragraph_contradicts_itself,'The orchestrator corrected gotcha 47 to say ADR 001 clause 4 IS delivered, and left the next three sentences reading \"The fourth is open because App::apply_effects aborts without undoing\" -- a paragraph contradicting itself within one screen, produced by the very edit that was fixing the contradiction. Caught by reading the patched line back rather than trusting the substitution count. This is gotcha 48 turned on its author: a fact stated in more than one place is changed by a grep, and the places include the SENTENCES AROUND THE ONE BEING EDITED, not only other files.'). +doctrine(a_rollback_needs_a_positive_acknowledgement_not_only_a_refusal,'improvise-nxah: a client with two mutating frames in flight cannot tell which one a Rejected answers, so ServerFrame::Accepted is pushed once per accepted mutating frame, LAST, after everything the frame produced -- the same ordering argument Projection::Basis has, and the same shape as the publication gap ADR 001 originally left. And a SINGLE undo slot is unsound rather than merely imprecise: a snapshot taken before the first of two outstanding frames discards the second, which the host DID apply, diverging in the opposite direction. Hence settled plus an in_flight queue that drops one frame and replays the rest.'). +doctrine(a_hard_floor_across_widths_names_the_container_not_the_child,'improvise-tg27 suspected a flex min-width:auto trap swallowing #grid overflow. documentElement.scrollWidth read 1011 AT EVERY WINDOW NARROWER THAN 1011px -- a FLOOR, not a slope. A floor is the signature of a sum of flex:none RESERVES; a slope would have meant a clipped child. The reserves were the app bar (#build-version 14ch + #doc-name 34ch + #view-name 16ch + #mode 21ch + a non-shrinking #status), added the day before by improvise-n6t8, so the PAGE grew the scrollbar while main stayed window-width. Read the SHAPE of the measurement across the parameter before reading the code: constant-then-correct names a floor, and a floor names whoever set it.'). +doctrine(never_hide_the_overflow_you_are_asserting_the_absence_of,'improvise-tg27 rejected body{overflow-x:hidden}, which would have made scrollWidth == clientWidth UNCONDITIONALLY and rendered its whole spec vacuous -- every assertion still passing, the bar still not fitting. The check would then answer \"is overflow hidden\" where it means \"does the content fit\". A fix that makes the measurement unable to fail is not a fix, and it is especially tempting in CSS, where suppressing a symptom and correcting a layout look equally like one line.'). +doctrine(a_deferral_cost_must_be_measured_not_named,'improvise-k844 sat open because context/code/gp.md section 7 deferred it on the claim that reordering the arithmetic \"moves the bit patterns misclass_rate_and_importance_are_bit_for_bit_what_they_were pins\". That test pins permutation_importance RAW SCORES; importance_ranking consumes them AFTERWARDS, so no path it witnesses runs through the share -- and scoring_known_answers.rs is byte-identical across the actual fix, verified by the orchestrator as absent from the diff. A COST PREDICTED FROM A TEST NAME RATHER THAN FROM WHAT IT CALLS is how a one-line fix stays filed, and it reads as diligence, which is why it survives review. Measure the claimed cost or state none. Pairs with an_invariant_is_made_unrepresentable_not_validated: the clamp improvise-pv3g added instead was a call-site check that left inf% in the data behind it.'). +doctrine(a_reported_number_can_overflow_while_every_input_is_finite,'The gp crates learned this twice. standardize sums (t-mean)^2 in f32 (improvise-pv3g) and importance_ranking scaled a share by 100.0* in f32 (improvise-k844), and the second was REACHABLE PAST THE FIRST. Both are fixed by making the arithmetic incapable of it -- a Scaling that refuses a non-finite part, an f64 accumulator -- never by a call-site check, and pv3g own f32 clamp WAS that call-site check: it stopped the abort and left inf% in the data behind it. The silent twin is the half to look for: pv3g had cv_regress pooling a fold it could not standardize, k844 had an f32 total going inf with no panic to announce it. Ask what ELSE consumes the derived quantity.'). +doctrine(a_dev_shell_is_a_third_environment,'.envrc puts nix bash 5.3 on PATH -- the RIGHT INTERPRETER, CI semantics -- and pkgs.mkShell ALSO exports about thirty generically-named lowercase variables the runner does not have: name, out, outputs, system, shell, builder, stdenv, phases, buildInputs, doCheck and more. A script reading one by accident under set -u is SILENT IN THE DEV SHELL AND FATAL IN CI. improvise-f9qf: pr-reviews-test.sh passed 19/19 under macOS bash 3.2 AND 19/19 under the dev shell bash 5.3, while failing 2/19 in CI -- two different reasons for green, one of them the correct interpreter, and unsetting \`name\` alone reproduced CI 2/17 exactly. Note nix shell does NOT export name and nix develop DOES (verified with env -u name as the control), so the dev shell is the one nix entry point that masks this class. The orchestrator own verification of this was itself confounded by inheriting name from a direnv-loaded shell -- it answered \"does name end up set\" where the question is \"does nix shell set it\".'). +doctrine(an_observation_is_a_function_of_the_trajectory_not_the_state,'improvise-ilvl claimed \"an observation is a pure function of state\". It is not -- layout_version, cursor_epoch and model_state.dirty all differ between two routes to an IDENTICAL document (0/0/false against 2/2/true). The weaker true claim, that they are reproducible FROM THE LOG, is all replay needs. But the stronger false one is exactly what would justify SNAPSHOT-PLUS-SUFFIX LOG COMPACTION, and that is wrong silently in the direction that looks like it works: Basis puts document_generation and layout_version ON THE WIRE and clients compare them for EQUALITY, so a compacted replay is not basis-compatible with any client holding a claim, and dirty is the worker OPFS persist trigger so the same document reached by a different route persists a different number of times.'). +doctrine(a_determinism_check_can_pass_because_the_divergent_path_is_unreachable,'Three identical replays of a 26-line script fixture proved the script path deterministic and proved NOTHING, because the fixture cursor moves were silent no-ops (improvise-6uva) -- the divergence had no way to occur. The defect surfaced only when clear-cell FAILED TO PARSE for want of coordinates. Sharper form of \"a green suite is not evidence\": a passing determinism or idempotence check must be shown to be capable of FAILING, by driving the input the check exists to discriminate. It is also how one defect MASKS another -- improvise-up8g viewport hazard is invisible in improvise script precisely because 6uva discards the cursor, so fixing 6uva makes up8g live and they must be closed together.'). +doctrine(an_injected_fault_is_not_reverted_until_the_full_suite_is_rerun,'An agent injected two rivals and reverted one onto the WRONG OCCURRENCE: replace(\".is_measure()\",\".is_index_or_dim()\",1) hits the FIRST match in the file, which was drill_constraint pre-existing predicate rather than the injected one. The commit shipped with TWO faults where it had demonstrated one. Every gate re-run afterwards PASSED over the broken logic -- clippy, fmt, check-evidence, both node suites, the eight-crate wasm gate, the full Playwright run -- because the one gate that could see it, cargo test --workspace, had been measured BEFORE the injections and never again. The PR body therefore carried a figure that was checkable and false. Caught by ChatGPT Codex review. THE RULE: the targeted test going green says nothing about what the revert did on its way past, so the full suite must be re-run after every revert.'). +doctrine(a_fixture_that_names_its_own_precondition_must_read_it_back,'A test asserting that a session was shown something must READ THE VIEW OFF THE SESSION, not hard-code the name. A first version derived its grid from grid_in(&app,\"Other\",..), so the session, the switch and apply_checked could ALL BE DELETED and every assertion still passed -- it proved a grid built for a NAME while its name claimed a grid a SESSION WAS SHOWN. The repair reads sessions.get(id).view().active_view, asserts it MOVED, and asserts it DIFFERS from the host: the assert_ne! is what pins the premise, since assert_eq!(seen,\"Other\") alone passes if the host moved too. Found by review inside a PR whose whole subject was a scoping distinction -- the characteristic defect appearing in the change that documents it.'). work_done('improvise-8tk',bug_fix,'ZZ/:wq quit even when the save failed, silently discarding the model. Save now aborts the effect batch on failure; save_to_current_path/1 returns Result; three tests in ui/effect.rs.'). work_done('improvise-907',bug_fix,'Import wizard step machine redesign: validation_error() gate at ReviewProposals (no-category and over-limit), history-stack previous() bound to Left on every step, and Done made a real confirmation step holding the built workbook. Superseded improvise-gfq, improvise-o1c, improvise-c6j.'). work_done('improvise-6sq',wont_do,'Merging EditOrDrill and EnterEditAtCursorCmd into one EnterEdit{mode} is not safe: they differ in WHEN the cell value is read, not in a mode parameter. Added ui/app.rs::tests::i_and_o_diverge_on_an_empty_records_grid as executable evidence.'). @@ -344,6 +399,26 @@ work_done('improvise-2f97',feature,'kb_error(dangling_citation/3): a name cited work_done('improvise-dj1l',bug_fix,'scripts/kb-merge.pl matched clause keys by unification, which lost and corrupted clauses: two variant-headed rules in one delta went two in, one out (536 before, 2 in delta, 537 after), and a non-ground rule over a predicate holding facts deleted one AND was written with its variable bound. See invariant(key_matching_is_variant_equality_never_unification). Five regression cases added to scripts/kb-test.sh, including one that injects a dropping fold to prove check_total fires; scripts/kb gained a KB_MERGE seam so a broken merge can be injected.'). work_done('pr82-fingerprint-guards-2026-09-20',test,'PR #82, follow-up to #76. Added assert!(app.view().prune_empty) to selecting_the_page_already_selected_invalidates_no_cursor (paged_model(false) plus deleted moved-conjunct had left it GREEN); moved its page-selection precondition ahead of the effect and onto App::view(); corrected the test-evidence entry for every_layout_geometry_effect_is_visible_to_the_narrow_fingerprint, whose stated mechanism measurement contradicts, and recorded which two of its four prepare steps are load-bearing. 1425 passing, unchanged. Key is a dated slug and not an improvise- id, because bd issued none: an ID-shaped non-ID is worse than a dead id, the diagnosis being identical and the repair not.'). work_done('pr90-io-gp-correctness-2026-09-21',fix,'PR #90, wave K. improvise-iot6: render_csv built GridLayout::new, which skips prune_empty, so :export and the CLI export path wrote rows AND columns the grid does not draw -- FOURTH instance of the trap and the first a user could see; fixed with with_frozen_records(m, v, None), the PR #78 grid_in fix line for line. A sweep of the remaining GridLayout::new call sites found all four are test code, so this was the last production instance. improvise-zv8t: fit_measure handed preprocess an Overrides::default(), so a numeric- or date-valued document category was inferred rather than forced categorical and the fit refused as an encoding artefact. Two self-review rounds and one adversarial round on the branch; the adversarial round found F2 (forcing by name, budgeting by index), which this PR itself introduced. 1458 -> 1464 passing.'). +work_done('improvise-oujb',feature,'Every release now carries improvise-web.tar.gz (the assembled landing page + browser app, one improvise-web/ directory, ~1.6 MB) via [[dist.extra-artifacts]] running scripts/build-web-release.sh. web/index.html gained a build-version span, stamped by web/assemble.sh with the same token as the bundles and verified by the same scripts/assemble-stamp.sh gate (fault-injected: assembly exits 1). The wasm-pack and node pins moved to scripts/web-toolchain-versions.env, read by the ci.yml build job via GITHUB_ENV and sourced by the release script. NOT verified: a real tag push -- and note dist SKIPS build-global-artifacts on a PR run, so CI green on the PR is no evidence about the capture; the substitute evidence is a local dist build --artifacts=global, which did produce the artifact in target/distrib. Also unrun: the Linux toolchain branch of the script.'). +work_done('improvise-nn4s',docs,'ADR 004 drafted (context/adr/004-faces-and-themes.md), PROPOSED not accepted -- PR #122, held open because adopting a theming architecture is the user decision. Faces name roles; a theme binds them and is client-local (not a Command, not in ViewState, not projected, not in .improv); inheritance resolves once in improvise-protocol; five portable attributes (fg, bg, weight, slant, underline) and an unrenderable one is ignored. Colour is palette-relative (Default|Named|Indexed) or absolute (Rgb), and the SHIPPED theme is never absolute. Migration needs no flag day because the default theme reproduces today rendering exactly, making each call-site move a TestBackend-verifiable no-op. Format deferred on a stated condition: improvise has no configuration file of any kind, so the first theme file would be the first config file -- a larger decision than theming.'). +work_done('improvise-8l0c',bugfix,'formula_panel.rs cut by bytes and subtracted 1 from an unchecked inner.width. REACHABILITY IS WIDER THAN THE TICKET: draw.rs:297 lays the panel out as [Min(40),Length(side_w)] so a terminal at or below ~42 columns gives it width ZERO, and Frame::render_widget has no empty-area guard -- so the crash needed no Unicode at all and resizing was the common path. grid.rs display-width truncate moved to src/ui/text.rs and shared; prefix_by_width replaced a chars().take tail that over-ran by a cell at width 1. Searching for the CLASS rather than the symptom found two more sites placing a string at inner.x + name.len(). Two rival fixes injected and watched fail: a char-count truncator over-runs on wide characters, and repairing only the subtraction still panics at the boundary and at width 1 -- a test written only at width 0 would have accepted it. Filed improvise-n8vh. PR #124, merge e8f66e6.'). +work_done('improvise-rw6l',feature,'PR #123, merge 7470b61. Page bar chips became buttons carrying a down-arrow; clicking opens a menu of the axis items and choosing one composes SetPageSelection via a new Click::PageItem (indices, like every existing Click variant, so the page never spells a name). No wire change. The menu is the third keyboard-owning state of the kind editing already was. Codex found a real bug -- the menu branch sat above the metaKey guard, blocking Cmd-R, and Ctrl-j moved the menu two lines below a comment saying Ctrl/Alt are the browser\`s -- fixed by ordering plus one bail, and verified on the merged branch by the orchestrator. Shipped with improvise-771e (browser tile bar reads plain_label) and improvise-wiew (document_keymap).'). +work_done('improvise-e0ex',feature,'PR #125, merge 5fdce21. View::new seeds \",.2\"; a FORMAT clause in formula.pest overrides per formula target; FormatPlan in improvise-core replaces the (comma,decimals) pair in every render path; GridLayout::column_key resolves a total format from its own column; web/demo.improv moved to \",.2\". improv.pest untouched. Two Codex findings fixed, both real. Injecting a walker that discards the format clause fails ONE test and passes 242 others in improvise-io -- the measurement that shows raw cannot be the witness. Two fixtures pinned to \",.0\" rather than re-baselined, one of which would have gone VACUOUS WHILE PASSING under the new default. No .improv data line changed. 1654 tests, 37 binaries.'). +work_done('improvise-55l5',bugfix,'PR #126, merge 4625593. dispatch None now means no binding matched and nothing else; a construction failure returns a status. Two rivals injected, each failing exactly one of the paired tests.'). +work_done('improvise-o9uq',bugfix,'PR #126, merge 4625593. add-record-row places its own cursor after the SetCell; target_row handles the already-exists arm. Reproduced through App::handle_key rather than a hand-placed state: o N o r t h Enter deleted East Revenue cell and renamed East to EastNorth. Not blocked by and does not block improvise-fdk1.'). +work_done('improvise-n6t8',feature,'PR #127, merge d46ec59. Every header child that changes with a keystroke is reserved, with #hint the single elastic child; ViewState::status_msg moved to a toast in a fixed #bottom-dock anchored at its bottom so it stacks ABOVE the minibuffer, whose box is byte-identical with and without one. harness/tests/header.spec.js measures the app bar bounding boxes across the NINE modes a browser can reach plus a status message, asserting #mode before each measurement so a key that failed to reach its mode cannot make the comparison vacuous. Three faults injected, all caught. Layout leaves gotcha 48 list of what the harness cannot see; prefers-color-scheme joins it.'). +work_done('improvise-bd9o',bugfix,'PR #128, merge 76d909e. Eleven lines: an entrance guard on state.grid.records in edit_or_drill, before cell_at_cursor, plus CellId::Create declining out loud instead of returning None. Reproduced at HEAD -- improvise-55l5 fixed none of it, because Host::handle answers ClientFrame::Key with an empty vec so the key never reaches dispatch. The silence had TWO sources and the ticket predicted one: the coordinate column carries no CellId at all, which is improvise-fdk1 seen from the key path. Four rivals injected; two (mode.is_records in either place) passed the whole workspace at 1659 and are now caught by a deliberately DRIFTED client. Filed improvise-x0xd.'). +work_done('improvise-dy3j',bugfix,'PR #129, merge bf83294. A Label category is persisted as a \"kind: label\" entry inside its ## Category: section; grammar first, then format.rs and the parse.rs walker. Closes a P1 where regular + label > 12 was PERMANENTLY UNLOADABLE, because every label reloaded as Regular and the 13th was refused. Second worked instance of doctrine(a_roundtrip_equality_is_blind_to_a_loss_both_sides_share), whose text cites only improvise-349m: the kind was dropped on BOTH sides, so the whole characterization suite was silent. The discriminating rival is a reader that parses the kind and discards it -- the file text is then correct and every output-comparing test passes.'). +work_done('improvise-cj4u',bugfix,'PR #129, merge bf83294. View::on_category_added takes a CategoryKind beside the name and both Workbook set_axis corrections are deleted. An injected blanket unseated=true satisfies EVERY label assertion in the repository, so the discriminating controls are that a regular category still takes Row and that a label does not consume the slot the next one wants.'). +work_done('improvise-vw47',bugfix,'PR #130, merge 69c873d. A new view takes the first UNUSED \"View \" rather than views.len() + 1; view::unused_view_name in improvise-core is the one derivation and both resolvers call it. Two rivals injected: fixing panel.rs alone fails both_ends on the document text (host writes View 5 with View 4 format intact, client replaces View 4 and resets it), and starting the search at 1 fails the pre-existing incrementally-names test. Baseline measured at 1667 and final 1670 -- exactly the three tests added.'). +work_done('improvise-q914',bugfix,'PR #131, merge 9d2bdd3, closing improvise-j6x8 as the same fix. improvise cmd and script now print every non-empty status_msg to stderr, clear it per command, exit 1 on a parse error or an aborted batch, and write the document back only if some command raised dirty (accumulated per command). Three clap doc strings stopped advertising JSON. The injected rival -- print on success, stay silent on refusal -- fails 5 of 12 CLI tests, and a test asking only \"was anything printed\" would pass it. tests/headless_cli.rs is the contract at the process boundary, which exists nowhere below std::process::exit.'). +work_done('improvise-jz6w',feature,'PR #134, merge a799e24. KeyOutcome::Request(Request::Save) lets a resolver say \"ask the host\"; Ctrl-S bound through it and removed from the_command_names_that_still_need_the_host_are_these in the same commit. End-to-end guard from a real Client through a real Host to MemoryFs. Three rivals injected and each watched fail. Consequence found and filed rather than patched: Ctrl-S now saves AND opens the browser save dialog, because app.js preventDefaults only unmodified keys -- pre-existing behaviour, newly visible (improvise-clso).'). +work_done('improvise-xgra',tooling,'PR #133, merge 22ab58f. scripts/pr-reviews reads review bodies, INLINE comments and issue comments, interleaved in time order; scripts/pr-reviews-test.sh is a fixture-driven check_the_checker with 22 cases in CI kb job. Six faults injected, each caught by the case that names it. Two bugs were found by Codex on the PR and were bash-5-only, invisible on the macOS shell that wrote them. The agent also walked into this PR own trap while building it: pr-watch > f 2>&1; echo EXIT=$? reported 0 over a FAILING kb job, because the echo took the status.'). +work_done('improvise-0ygd',bugfix,'PR #135, merge 42ec30d, TUI half only. AdvanceDir::Right judged at_bottom_right from the pre-commit col_count, so Tab on +measure ran AddRecordRow and jumped to (row+1,0) although the commit had just inserted a column at the cursor -- the measure just named left unreachable. Enter escaped only because EnterAdvance aborts at bottom-right and the SetSelected restore at the head of the batch held the cursor. The browser half is worse (the whole command vector is discarded through Upstream) and is UNREACHABLE at this head, because improvise-bd9o declines at the entrance of edit_or_drill -- so no browser regression test can be written before improvise-x0xd.'). +work_done('improvise-nxah',bugfix,'PR #136, merge eefd992. The client rolls back its optimistic fold on ServerFrame::Rejected; ServerFrame::Accepted added so an answer addresses a known frame. Five rivals injected, each failing exactly the check aimed at it -- including the single-snapshot design, caught by refusing_one_of_two_frames_in_flight_keeps_the_other. The repro compares against the HOST OWN MIRROR rather than a second client derivation (improvise-vw47 trap) and is total over ViewState rather than a hand-kept field list, asserting the two ends agree BEFORE the refusal so it cannot pass by never having disagreed.'). +work_done('improvise-tg27',bugfix,'PR #138, merge 327a76b. header flex-wrap over a breakpoint; main min-width:0; panels capped at 40vw; harness/tests/narrow.spec.js asserts documentElement.scrollWidth <= clientWidth at 360/520/768, so the page own overflow leaves gotcha 48 list of what the harness cannot see. Fault injected by restoring style.css to 22ab58f: 6 of 9 assertions failed, and the two that passed are the two that should. Merge with improvise-nxah verified STRUCTURALLY -- 198 insertions and ZERO deletions across context/, with evidence entries rising 812 to 818 as the independent witness. Found not predicted: the keyboard already reached every clipped column, so what was broken was a FALSE scroll affordance beside a working one.'). +work_done('improvise-k844',bugfix,'PR #139, merge 1ae2d2e. importance_ranking sums and divides in f64, narrowing only the bounded share. The injected rival is THE TICKET OWN TITLE -- reassociating to 100.0*(imp/total) in f32 passes the lone-importance and sums-to-100 tests and fails the twin and the guard, 58 passed 2 failed, converting NaN% into a serene 0% for every feature. Scoring bit patterns byte-identical, which is the evidence the fix is confined. context/code/gp.md section 7 corrected in place: it had deferred this bug on a cost that does not exist.'). +work_done('improvise-f9qf',tooling,'PR #137, merge a9cad15. scripts/checkers.sh plus scripts/checkers-test.sh (12 cases, each INJECTING rather than asserting). The proof: restoring the multi-assignment local gives 22 passed under the old local gate, 22 passed under direnv, and 2 passed / 20 failed / exit 1 under checkers.sh. The second undetected split is closed -- the four #!/bin/sh and one #!/usr/bin/env sh scripts are now bash, a strict superset needing no porting. Unrun and stated: scripts/build-web-release.sh executes only under dist in build-global-artifacts, which no branch push exercises.'). work_followup('improvise-8tk','improvise-lae','no-file-path UX: prompt for a path rather than only erroring. ExportPrompt is CSV-only, so a new SavePrompt TextEntry mode committing to SaveAs is the likely shape.'). work_followup('improvise-6sq','improvise-ct5','i on an empty records grid reports \'cannot drill\', leaking a pivot-mode concept into records mode'). work_followup('improvise-h0b','improvise-a2x','ViewState field types (AppMode, MinibufferConfig, ViewFrame, KeyCode, KeyModifiers) still derive no Serialize, and improvise-command depends only on serde_json. Deferred: nothing serializes ViewState until improvise-cqi builds a Snapshot, so the derives would have no consumer.'). @@ -423,6 +498,27 @@ decision('improvise-ni6k',cursor_invalidation_is_observed_not_declared,'No effec decision('improvise-864a',pages_artifact_reaped_by_a_foreign_successful_deploy,'Measured over 100 push-to-main runs 2026-09-19..2026-09-21: 12 of 12 artifact-not-found deploy failures had ANOTHER run SUCCESSFUL Pages deployment complete inside their own upload-to-deploy window, against 2 of 56 successes. Direction proved by the oldest run of the 17:47Z batch failing BEFORE the next deploy started and before any deployment existed, since deploy-pages fetches artifact metadata before creating one. NOT queue depth, NOT supersession, NOT retention, NOT the action deleting it. PR #46 tip-of-main guard aimed at the right quantity for the WRONG REASON and still must not ship: the association is not a mechanism, and 5f871f7 survived a foreign deploy inside its own window. Reproduce deliberately before fixing.'). decision('improvise-zv8t',a_document_category_is_never_inferred,'fit_measure now builds its Overrides from the same header[..category_columns] slice it already handed Features::of, forcing each to Forced::Categorical, so the one place the document own answer was known is the only place either caller reads it from. Closes Artefact::NumericCategory and DatePart-over-a-category from the only production entry point. The Artefact arms STAY: crate::dataset is pub and takes the caller Overrides, so a caller that builds one without forcing still produces a Numeric role over a category column -- deleting the arm would derive the classification from one caller habits rather than from the roles. Cost accepted and filed as improvise-e8no: a wide numeric category now one-hots past MAX_FEATURES and refuses a fit that used to ignore it. Do NOT close e8no by making the forcing conditional on cardinality -- that is inference by another name, and makes the ENCODING depend on a budget rather than on what the document says.'). decision('improvise-e5pf',the_mode_settles_where_the_grid_does,'resolve::changed_mode is DELETED. Every composition site emits the plain AppMode::Normal, and ClientState::fold settles against the grid it HOLDS via improvise_protocol::settled_mode -- one rule, also called by ClientSession::fold and App::settle_normal_mode, with App::normal_mode deleted as its host-side duplicate. Composition-time settling was PREDICTION: switch_view_at composes [SwitchView(target), ChangeMode(Normal)] and read the OUTGOING grid. Read-time derivation was rejected for having the same window and for moving the enumeration from composition sites to READERS, where a missed reader is silent. settled_mode settles the Normal pair ONLY -- Editing/RecordsEditing pass through -- which is why a records decision must ask the grid and never the mode. Same shape the repo already prefers: cursor_epoch over matching on CycleAxis, document_generation over matching on LoadModel, the dirty flag over a table of command variants.'). +decision('improvise-oujb',pages_tracks_main_release_captures_a_copy,'User ruling 2026-09-22: \"I like Pages showing the latest main for now.\" So pages-deploy is unchanged -- still push to refs/heads/main -- and a tag CAPTURES the assembled site as improvise-web.tar.gz rather than deploying one. The artifact is a self-hostable copy and a rollback target, NOT what a visitor runs; README says so, because a reader who assumes otherwise is wrong in a way that costs an afternoon. The divergence between a binary user and a site visitor is now deliberate, which is what makes the build-version stamp load-bearing rather than cosmetic. \"For now\" is recorded deliberately: a current preference, not a permanent architecture decision.'). +decision('improvise-oujb',extra_artifacts_not_a_hand_edit,'release.yml is autogenerated by cargo-dist and a hand edit is clobbered by the next dist init, so the web capture is [[dist.extra-artifacts]] in dist-workspace.toml. Verified against dist 0.30.4 -- the pinned version, downloaded, because dist REFUSES to run at all on a cargo-dist-version mismatch so a 0.32.0 test would have proved nothing: dist plan lists improvise-web.tar.gz and dist generate leaves release.yml byte-identical. The build command calls web/build.sh and web/assemble.sh rather than assembling its own way, which is what the ci.yml build job comment exists to protect.'). +decision('improvise-n6t8',reserve_space_do_not_fit_it,'User ruling 2026-09-22: \"As a general rule, there shouldnt be motion of layout elements I expect to be fixed.\" So an element whose CONTENT varies while its POSITION is read as fixed must have its space RESERVED rather than fitted to the text. In the browser header that covers the mode pill (AppMode::label -- NORMAL, CATEGORIES, FORMULA all differ in width, so every mode change shifts everything after it), the hint line (one per mode, reflowing on the same event and compounding it), the status line, the document title and the view chip. It is also the reason a transient message must be a toast that is OUT OF FLOW rather than merely short: shortening reduces the motion and does not remove it. The fragile fix is a hardcoded min-width sized by eye to todays longest label, which silently resumes the jitter the day a longer AppMode variant is added -- the compiler forces label() to be filled in for a new variant and nothing checks that the CSS still fits it, so the guard must be a rendered-layout assertion that walks the modes, with a too-long label injected to prove it discriminates.'). +decision('improvise-pv3g',guard_the_derived_quantity_not_the_inputs,'A finite f32 target can still overflow the spread: standardize sums (t-mean)^2 in f32, so eight rows at a 1.5e20 spread give 1.8e40, past f32::MAX, and std becomes inf -- after which denormalize computes 0.0*inf + mean = NaN, surfacing two crates away in improvise-gp number_text. fit_measure checked every INPUT and saw nothing, because every input was finite. The fix makes the poisoned map unrepresentable rather than validated: Scaling has private fields and one constructor refusing a non-finite part, standardize returns Option, and cv_regress pools nothing for a fold it cannot standardize -- which closes the SILENT half, where the same overflow corrupted R-squared with no panic to announce it. Note the crashing spread SHRINKS as records are added, so guarding the inputs is guarding the wrong side.'). +decision('improvise-x0dc',panel_open_is_not_panel_focus,'RECOMMEND DECLINING as written, pending a user ruling; left open. Esc from a panel mode leaves the panel OPEN, which reproduces, but is deliberate: category_panel_open starts TRUE in Normal mode, so an open panel in Normal is the state the app BOOTS IN -- if that were the bug, the app would boot in the bug. Three independent corroborations: F/C/V are toggle-panel-and-focus while Ctrl-F/C/V are a separate toggle-panel-visibility; Tab in Normal is cycle-panel-focus, meaningless unless panels stay open unfocused; and the panel hint spells enter-mode normal as \"back\", not \"close\". The genuine defect its own evidence points at is improvise-df2w: render.rs formula_rows returns an empty vec when closed and app.js paintPanel hides on rows.length === 0, so an OPEN BUT EMPTY panel is invisible in the browser while the TUI draws its empty message -- two answers to \"is this panel visible\".'). +decision('improvise-wiew',keymap_layer_named_for_its_subject,'document_keymap sits between global and Normal / the three panels / TileSelect; RecordsNormal reaches it THROUGH Normal; Help, ImportWizard and the text-entry modes take global directly. The orchestrator framed the split as \"is the document the subject right now\", which is a judgement; the implementing agent found a STRUCTURAL line instead: every key the global binds is a UI action, whereas page-next resolves to SetPageSelection, which is EffectKind::Model(LayoutGeometry) -- the first shared keys that MUTATE. Under the help overlay that is an invisible change to the grid it covers, so the rule is \"may a fallthrough key mutate the document\" rather than taste. The NAME is the only guard on membership, since nothing in the code stops a mode parenting the layer: document_keymap, never common or shared. The layer owes its own Char-only constraint for the identical fallthrough reason as the global, written down BECAUSE no descendant binds AnyChar today.'). +decision('improvise-e0ex',decimals_by_default,'View::new seeds \",.2\". A ratio under \",.0\" draws as 1, and a format cannot be guessed from a value, so noise on whole numbers (18,400.00) is the cheaper error. The override is spelled IN THE FORMULA per the user ruling -- a formula target IS a _Measure item (gotcha 7), so per-formula and per-measure differ only for a measure no formula owns, and that is raw data whose format is the view job. improv.pest is UNCHANGED: formula_raw already carries the line verbatim, so format_clause lives in formula.pest and the grammar-walking generator sees no new spelling. sp+ and not a lookahead, for WHERE reason -- the generator skips predicates.'). +decision('improvise-o9uq',order_is_the_fix,'add-record-row appends viewport_effects(target_row,0,..) AFTER the SetCell. The order is the entire fix: the same two effects the other way round are the bug verbatim, because the frame observation inside apply_effects invalidates a cursor placed before the append. The acceptance criteria said \"append viewport_effects\" and did not say where, and the two orders are indistinguishable by reading -- so the injected rival is the SWAP, not the omission. target_row is row_count() unless the record already exists, in which case it is that record index: predicting row_count() unconditionally puts the cursor one past the end in that arm.'). +decision('improvise-n6t8',two_status_sinks_rather_than_one_composed_string,'The transport fact and the document fact go to two ELEMENTS, not to one string joined with a separator. composeStatus is deleted. It had kept gotcha 46 by CONVENTION -- a repaint that wrote the whole string could erase a connection error, and the join was what stopped it; two sinks make it STRUCTURAL, because a repaint reaches createShell onModel and cannot address onShell at all. The join unit test was REPLACED and not dropped (\"a repaint model status cannot reach the transport sink at all\"): a separation with no guard is how one regresses into a single sink silently.'). +decision('improvise-dy3j',absence_is_the_compatible_reading_of_a_new_format_line,'kind_kw spells only \"label\" and format_md emits the line only for a CategoryKind::Label, so every file written before the line existed parses unchanged and every pivot category section is byte-identical -- no migration and no moved round-trip expectation. parse_kind_kw is an exhaustive Result-returning match rather than a bool read off the rule PRESENCE, so a future second keyword fails at the walker instead of being read as \"label\" because it arrived on a kind_line.'). +decision('improvise-vw47',unused_name_helper_not_a_create_view_refusal,'Workbook::create_view REPLACES on purpose and must not be touched. Verified: it has exactly two production callers -- ui/effect.rs:310 inside effect::CreateView, and persistence/parse.rs:419 guarded by contains_key so it cannot observe replacement at all. The _Records/_Drill rebuilders are NOT callers; they reach it through effect::CreateView, emitting a fixed name on every entry, which is the SAME effect the user-facing path uses -- so the effect cannot distinguish them and the name must be fixed at the RESOLVER. Refusing or renaming inside create_view turns \"a view was silently reset\" into \"the key does nothing\", a worse bug wearing a better error message. view::unused_view_name takes NAMES rather than a Workbook because the browser holds only ViewListCache, and project::view_summaries maps workbook.views one-for-one including virtual views, so both ends see the same list.'). +decision('improvise-z05h',the_registry_names_the_refusal_not_the_validator,'require_args drops its \`word\` parameter and CmdRegistry::parse prefixes \"{typed}: \", so a refusal names what the USER TYPED rather than what the validator was called about. That deletes 41 command-name literals from cmd/ -- the same argument gotcha 15 makes about registry.rs, where every register* call reads its name off a prototype so a typo is impossible. \`interactive\` deliberately does NOT prefix, because dispatch already does since PR #126 and both would double it.'). +decision('improvise-jz6w',key_outcome_request_is_narrow,'KeyOutcome::Request carries a narrow Request enum (Request::Save, with Request::frame the one mapping to an envelope) and NOT a ClientFrame. Two reasons, the second serious: an arbitrary frame lets a resolver compose a Viewport, whose extent is the PAGE s fact and reaches no derivation from a keymap; and it lets a resolver compose a Commands batch, which is INDISTINGUISHABLE ON THE WIRE FROM A Local while skipping the local fold Local s contract requires -- a desync with nothing to observe it. Guarded rather than asserted: \`requested\` is checked first in step so no name sits in two lists, and a request cannot travel inside a Binding::Sequence (a sequence is one frame), with no_binding_puts_a_request_inside_a_sequence keeping that refusal arm unreachable rather than untested. Not a Command: gotcha 45 reason holds.'). +decision('improvise-xgra',an_inline_comment_at_the_head_satisfies_the_wait,'A pulls/{n}/comments entry carries a commit_id, so it is a claim about WHICH TREE WAS READ, and it is exactly the case that failed on PR #122. The clincher is that GitHub \"Add single comment\" CREATES A REVIEW WHOSE BODY IS EMPTY -- under the old rule that reviewer exit code was right while their words were invisible, so the surfaces and the wait had to move together or the tool would be honest and useless. An ISSUE-LEVEL comment does NOT satisfy it: no commit, so no claim about a tree, and \"I am rebasing shortly\" satisfying a wait for a review is the widening that looks like the fix. There is a fixture and an injected fault for exactly that.'). +decision('improvise-0ygd',split_on_the_buffer_not_the_column,'In records mode +measure is ALWAYS the rightmost column, so cursor.col >= col_max names that one cell and nothing else -- which means the entire \"Tab at the bottom-right starts the next record\" bulk-entry contract is reachable ONLY from that cell with an EMPTY buffer. A guard keyed on the column alone fixes improvise-0ygd and SILENTLY DELETES BULK ENTRY. The injected rival proves it: dropping the value-awareness passes the new test and fails tab_on_an_empty_new_measure_column_still_starts_a_record_row. When one cell is the only member of a class, a guard on the class is a guard on the cell.'). +decision('improvise-nxah',a_refused_frame_is_rolled_back_by_the_fold_not_a_cache,'What rolls back on ServerFrame::Rejected is ViewState -- the thing the fold produces -- and never a cache, because a Command::Model is A NO-OP IN reduce_view: the optimistic fold never touches a cache at all, and page_coords moves only under Projection::Grid. That single fact refutes the bug report own evidence (which blamed page_coords) and dissolves the wave recorded contradiction between nxah and improvise-3al1 -- the staged typing 3al1 needs rides in SetBuffer frames, which carry no model command, are never refused, and have already settled, so rolling back the COMMIT restores the buffer TO THE TYPING. Only a frame carrying a Command::Model is rejectable, stated exhaustively by Checked::against.'). +decision('improvise-tg27',a_reserve_is_a_floor_and_must_reflow_not_widen,'header{flex-wrap:wrap} over a @media breakpoint: a flex item wraps exactly when the line cannot hold it, so the desktop bar is untouched BY CONSTRUCTION rather than by a chosen number -- no magic width to drift. Plus main{min-width:0}, 40vw caps on the side panels, and max-width caps so a reserve cannot exceed its own line. The reflow threshold is deliberately asserted NOWHERE, because #status composes the DOCUMENT NAME: the same 1024px window is one line under a short name and two under a long one. Only 1280 is pinned, being where improvise-n6t8 stability spec runs.'). +decision('improvise-f9qf',one_checker_entry_point,'scripts/checkers.sh is the ONE entry point for every check_the_checker, called verbatim by CI and by the local gate so there is no second invocation to drift. It pins a bash >= 5 (ambient first, nix only when the ambient one is older, exit 3 when neither), SCRUBS THE ENVIRONMENT to an allowlist (env -i plus PATH/HOME/TMPDIR/NIX_*/GITHUB_*, extensible via CHECKERS_KEEP), and refuses any shell shebang in scripts/ that is not #!/usr/bin/env bash. The pinned bash goes on the FRONT of PATH so scripts the suite invokes get it too -- which is where the fault actually lived. CI logs \"checkers: bash 5.2 at /usr/bin/bash\": ambient, no nix, zero startup.'). +decision('improvise-f9qf',nix_shebang_declined_on_availability_not_speed,'Measured warm, 20 invocations: ambient bash -c true 5.1ms; nix shell nixpkgs#bash --command 61.8ms; nix-shell -p bash --run, THE MANPAGE FORM THE USER POINTED AT, 675.2ms -- 132x, and that inside git merge machinery for beads-merge-driver.sh. But the DECIDER is availability: nix is installed on ONLY the kb job. build, test, wasm, js, harness-lint, browser and pages-deploy have none, so a nix shebang needs install-nix-action added to three more jobs, paid every run, to reach a bash those runners already ship. zsh declined too -- not a shebang change (word splitting, 1-based arrays, globbing), sixteen scripts to read, and no zsh on the runner either.'). +decision('improvise-6uva',script_replay_lost_its_context_at_35946af,'improvise script and improvise cmd resolve every command through CmdRegistry::parse -- the PURE arm, which builds CursorState::default() (0,0,0,0) -- never the interactive arm that builds CursorState::from_ctx. So every Move command and enter-advance snapshots a ZEROED cursor AT PARSE TIME, clamps to (0,0) and emits SetSelected(0,0): worse than a no-op, it RESETS the cursor, and no cursor-reading command is reachable from a script. Broken by 35946af (2026-04-04), a performance refactor introducing the register/register_pure/register_nullary split, FIVE MONTHS BEFORE the first wasm-client commit -- so the web refactor is innocent. Both directions demonstrated: at the parent 649d80c a move-then-clear cleared row 2 and a bare clear cleared row 0; at HEAD both clear row 0. THE SHAPE: a registry with two construction arms silently demotes every caller of the weaker arm, and nothing type-checks which arm a caller deserves.'). +decision('improvise-gd28',browser_drill_blocked_by_surrogate_scope_not_layer_2,'The ticket recorded drill as blocked on ADR 001 layer 2 because ApplyAndClearDrill is session-local and unaddressable. That reasoning is about the COMMIT path. Entering a drill needs no commit, and CreateView/SetAxis/SetPageSelection are already unaddressed ModelCommands that NAME THEIR VIEW. What a browser cannot supply is the OPERAND -- CellId::Aggregate(AggregateHash), an opaque u128, which is ADR 002 working as designed. The real gap is one level down from where it was looked for: THE MUTATION AND ITS OPERAND HAVE DIFFERENT SCOPES. The mutation configures the shared _Drill and is addressed to nobody; the operand exists only inside one session layout. So the fix is not a new mechanism but the view:String convention every other view-scoped variant already follows, resolved against a layout derived for that name -- no SessionId needed. fdk1 is also not needed: edit_or_drill declines records-mode editing OUT LOUD before reaching a cell, so a browser cannot edit inside a drill grid at all and no divergent staging scheme is reachable.'). wire_carries(command,both_directions,'user intent up, projection commands down'). wire_carries(snapshot,on_connect_only,'improvise-q08: full ViewState + render cache filled from current viewport'). wire_carries(command,both_directions,'user intent client->server, projections server->client'). @@ -565,6 +661,22 @@ kb_issue_found(ticket_premise_false_8uz,'improvise-8uz asserted selected/row_off kb_issue_found(ticket_premise_false_rml,'improvise-rml called category_panel.rs an \"exact copy\" of tile_bar.rs. It was not: the colors were identical but the labels differed (\"Row ↕\" vs \"Row\", \"None ∅\" vs \"·\"). Only the color table was true duplication; the four label sets are four deliberate vocabularies and were preserved as four methods.'). kb_issue_found(duplicated_active_view,'adding ViewState::active_view created a second copy of \"which view is active\". The improvise-8uz characterization test caught the desync immediately because it called workbook.switch_view directly. Held in lockstep by two writers until improvise-jytm, which made the divergence deliberate: the guard test the_two_active_view_names_never_diverge was REPLACED, not deleted, by the_documents_default_view_outlives_this_clients_switches and a_fresh_session_opens_the_documents_default_view -- a deliberate split with no guard is how one silently regresses into the other.'). kb_issue_found(copilot_stopped_reviewing_this_repo,'Measured 2026-09-20: PRs #12, #21 and #27 carry 2, 8 and 4 copilot-pull-request-reviewer[bot] reviews; #61, #66, #68 and #70 carry none. Both request paths report success and register nothing -- the GraphQL requestReviews mutation with botIds BOT_kgDOCnlnWA returns clientMutationId null, and POST /pulls//requested_reviewers with copilot-pull-request-reviewer[bot] returns 200 with requested_reviewers empty -- and no review_requested timeline event appears. Cause unknown; something changed between #27 and #61. Consequence for the process: AGENTS.md said \"merge only once Copilot passes\", which is an instruction to wait forever on a review that was never requested, since an ABSENT review and an UNREQUESTED one look identical from outside. That is doctrine(review_is_requested_not_inherited) seen from the requesters side, and doctrine(an_advisory_gate_is_not_a_gate) with the gate removed rather than never added. AGENTS.md now says review is an adversarial agent the orchestrator dispatches, naming the head SHA, and keeps the Copilot mechanics for the day it returns.'). +kb_issue_found(github_build_setup_injects_into_the_wrong_job,'dist github-build-setup is documented as running extra steps before dist own build step, and is the obvious way to give a release job a wasm toolchain. Measured on dist 0.30.4 by injecting a named probe step and regenerating: it lands in build-local-artifacts ONLY -- the four-platform matrix job -- and never in build-global-artifacts, which is where a GLOBAL [[dist.extra-artifacts]] is actually built. So it cannot provision anything for a global extra artifact, and the provisioning must live inside the build command itself. The hook reports success and simply lands elsewhere: nothing distinguishes \"injected somewhere useless\" from \"not injected\", which is doctrine(an_empty_actions_log_is_retention_not_history) in its configuration form. NOTE ON CITING: the proposing agent named that doctrine from memory and invented a plausible key for a fact that exists under a different one -- the kb form of the dead-ticket-ID hazard. scripts/kb refused the delta over it, which is the gate doing its job; grep a key before citing it.'). +kb_issue_found(minibufcolor_roles_are_not_what_they_look_like,'MinibufColor Green/Cyan/Yellow was read by the orchestrator as editing/destructive/search. BOTH HALVES ARE WRONG at the eight MinibufferConfig construction sites. Cyan is the FORMULA prompt; there is no search minibuffer at all, SearchMode being Normal plus a flag (gotcha 14) with the search MATCH drawn Black-on-Yellow in the grid. And green/yellow does not split on editing-vs-prompting: item_add is GREEN while category_add is YELLOW, the same act -- typing a name into the document -- on opposite sides. Consequence for improvise-nn4s: its own cheapest proposed step, renaming the three variants to roles, CANNOT BE DONE HONESTLY, because no three-way split fits eight sites; the honest move is a face per text-entry mode, eight faces with three resolving alike today. The general point: a colour-named enum does not merely fail to be themeable, it actively MISLEADS the next reader about what the roles are -- the orchestrator read roles off the colour names and was wrong twice in one sentence.'). +kb_issue_found(stale_count_in_tui_md_default_chrome_arm,'context/code/tui.md line 125 says mode_style spells out \"the fourteen variants that take the default chrome\". The arm lists THIRTEEN -- verified by the orchestrator against src/draw.rs: Normal, RecordsNormal, FormulaEdit, FormulaPanel, CategoryPanel, CategoryAdd, ItemAdd, ViewPanel, ImportWizard, ExportPrompt, SavePrompt, Help, Quit. 13 + Editing + RecordsEditing + CommandMode + TileSelect is the 17 AppMode variants, so the arithmetic confirms thirteen. Nothing checks a count written in prose, which is why it survived; the exhaustive match itself is sound and only the sentence about it is wrong.'). +kb_issue_found(twelve_sites_hardcode_black_ink_on_an_accent,'Twelve TUI sites hand-roll reverse video as fg(Color::Black).bg() -- mode_style in src/draw.rs is one, visible in full. Black ink is hardcoded rather than being the terminal background, which is a latent bug on a light terminal: the accent is palette-relative and survives the user theme, the ink does not. Found while deriving the face vocabulary for ADR 004, which raises it as open question 3: whether reverse is a portable attribute (honest for these twelve, but makes resolution stateful for CSS, Swift and Qt) or whether the answer is a named ink face.'). +kb_issue_found(five_tickets_closed_by_one_unrecorded_commit,'7a44616 (\"fix(panel): five category-panel and axis defects, four of them P1\") closed improvise-uej8, improvise-3b2b, improvise-g3no and improvise-koh6, and ALL FOUR were still OPEN in bd when agents were dispatched at them -- two of them costing a dispatch each. The commit message says \"five defects\" and names none of the ticket ids in a form bd could act on. A commit that closes several tickets should cite every id, because the export is the only thing a later reader greps; see also doctrine(a_ready_ticket_may_already_be_fixed). One misreading compounded it: an earlier agent wrote that a command \"left the host-needed list WITH improvise-koh6\", which reads as context rather than as the close it actually was.'). +kb_issue_found(the_compounding_header_element_was_status_not_the_mode_pill,'improvise-n6t8 was filed naming the mode pill, following the user report. The spec failed first on the header HEIGHT: #status was flex: 0 1 auto beside a flex: 1 1 auto hint, so a longer hint took the shrink out of #status, which wrapped to two and then three lines -- 52.5px to 71.0px. THE OLD HEADER WAS TWO LINES TALL AT NORMAL WITH NO MESSAGE AT ALL. Reserving the pill alone would have left the bar moving and the PR would have shipped looking correct. Fixed with flex: 1 1 0 on the hint and nowrap plus flex: 0 0 auto on #status. The lesson is about the ORDER of work: writing the measurement first is what found an element neither the reporter nor the ticket named.'). +kb_issue_found(the_edit_that_created_the_drift_was_the_one_that_added_the_third_place,'improvise-bd9o added a new section to context/code/clients.md, and ChatGPT Codex caught two sibling passages the new section falsified. gotcha 48 says a fact stated in more than one place is changed by a grep and not an edit -- and this is its sharpest form: WRITING A NEW SECTION IS EXACTLY WHEN ITS SIBLINGS STOP BEING SEARCHED FOR, because the author is composing rather than amending and has no edit site to search from. The same session produced two other instances: a gotcha-38 correction whose own arithmetic went stale, and a release document whose tag inventory was wrong within hours.'). +kb_issue_found(generator_coverage_of_a_new_production_is_checkable_and_unchecked,'context/code/formula-and-io.md says plainly that a new .improv production needs grammar-generator coverage or the proptests quietly stop describing the format -- and nothing measures it. improvise-dy3j PROBED: 14 of 500 generated files carry the new kind: label line and all 14 parse to a CategoryKind::Label, about 7 per 256-case run. Genuine coverage, and free because kind_line is a category_entry alternative. The probe was REMOVED rather than kept, because its rate depends on proptest RNG and as a standing assertion it would fail on a dependency bump for an unrelated reason -- the measurement lives in the argument where it can be re-run. A one-line --probe mode on the existing generator would make this a habit rather than something an agent happened to do.'). +kb_issue_found(the_bug_lived_underneath_a_test_that_named_it,'create_and_switch_view_names_incrementally has asserted \"the name follows the workbook\" since improvise-aaw, and improvise-vw47 lived underneath it for that whole time: every fixture the test uses leaves len + 1 FREE, so it could not separate \"the count\" from \"the first unused name\". It did earn its keep at the fix -- the rival that starts the search at 1 fails it -- but a test whose fixtures never exercise the distinction its NAME claims is a test that will not notice the distinction breaking. When adding a fixture to an existing test, ask which of the properties in its name the new fixture can actually separate.'). +kb_issue_found(a_new_context_doc_must_be_added_to_the_doc_site_sections,'scripts/build-doc-site.mjs (improvise-res9) requires every context/**/*.md to be either published or an explicitly named omission, and it is reachable ONLY through web/assemble.sh in CI -- so a new context/ document fails the \`Build all bundles\` job with \"neither published nor a named omission\", a gate that appears in no brief and in no local gate list. ChatGPT Codex found the same thing about four minutes after the push. Worth adding to the standard brief: a new context/ doc needs a SECTIONS entry. Noted in passing: context/code/doc-site.md was itself already stale in two counts (\"the seven context/code/*.md documents\" where twelve exist, and \"501 entries\" where the checker reports 804), both de-numbered rather than re-asserted.'). +kb_issue_found(a_storage_assertion_cannot_separate_reaching_from_reaching_wrongly,'improvise-jz6w injected Request::Save => ClientFrame::Commands(vec![]) as a rival. The end-to-end test asserts the document comes back off storage -- and an EMPTY BATCH WRITES NOTHING EITHER WAY, so the storage assertion alone cannot separate \"reached the host\" from \"reached the host as the wrong kind of thing\". Asserting the FRAME is the discriminator. The general form: when a test asserts a downstream EFFECT, ask which wrong upstream values produce the same effect, and assert the value where they are not distinguishable by the effect.'). +kb_issue_found(repo_map_said_clause_four_was_undelivered_for_a_day_after_it_landed,'improvise-yqpd delivered ADR 001 clause 4 on 2026-09-21 -- App::snapshot/restore plus a Sessions clone in Host::mutating_frame, restored by undo_frame -- and context/repo-map.md gotcha 47 went on saying \"clause 4 is not delivered\" afterwards. The orchestrator then COPIED that claim into an agent brief as a live constraint, and the agent caught it by reading the tree. Verified: mutating_frame, undo_frame, App::snapshot and App::restore all exist and improvise-yqpd is closed. Corrected, with the correction recorded in the gotcha itself so the next reader sees that the clause list is a claim to check rather than a fact.'). +kb_issue_found(a_layout_measured_before_the_status_settles_is_not_the_layout,'improvise-tg27 first claimed its fix made #hint legible, and its own spec ASSERTED THAT AND PASSED -- because it measured immediately after the grid painted, before #status had composed the transport fact: 186px where the settled value is 1px. The spec now waits for #status before measuring anything. The general form: a page that composes a line from two asynchronous facts has an intermediate layout that is real, renderable and WRONG, and a measurement taken there is a measurement of a state no user sees for more than a frame.'). +kb_issue_found(check_evidence_cannot_see_a_templated_test_name,'scripts/check-comment-runs.py --check-evidence scrapes LITERAL test(\"...\") strings out of *.spec.js, so a Playwright test named by a template literal -- test(\`at ${width}px ...\`) -- is invisible to it and its evidence heading reports as an orphan. improvise-tg27 hit this and restructured to five literally-named tests, each looping widths internally with the width in the assertion message. Parameterise INSIDE one literally-named test rather than looping test().'). +kb_issue_found(k844_description_was_verbatim_e8no_body,'improvise-k844 stored description was a verbatim copy of improvise-e8no body -- the wide-numeric-category budget -- contradicting its own title. Confirmed by diffing the two DESCRIPTION blocks: identical. The agent worked the TITLE, which was right, and the defect had one good consequence: reading e8no text while working k844 is what made it check whether e8no was done, and e8no turned out to be STALE-OPEN with both its guard tests passing. A ticket carrying the wrong body is worse than an empty one, because an agent that trusts it works the wrong problem while believing it has a specification.'). +kb_issue_found(the_old_checker_gate_overrode_every_shebang,'The gate in the orchestrator brief -- \`for t in scripts/*-test.sh; do sh \"$t\"; done\` -- OVERRIDES EVERY SHEBANG with /bin/sh, so on macOS it ran every suite under bash 3.2 in sh-mode regardless of what the file declared. The gate was the disease it was meant to catch. Seven further sites ran a repo script through an explicit \`sh\`, and ONE OF THEM IS dist-workspace.toml, which a first-line shebang gate STRUCTURALLY CANNOT SEE: the shebang rule closes DECLARATIONS and never INVOCATIONS, and that one was found by grepping for the CALL after fixing the declaration, not by the gate going green. Also: scripts/node-test.sh matches *-test.sh and is a test RUNNER, so a naive glob ran it with no arguments and it printed its usage INSIDE A GREEN GATE -- the rule is now that a suite is X-test.sh where X or X.sh exists beside it.'). functional(invariant/2). functional(location/3). functional(doctrine/2). @@ -673,3 +785,4 @@ citation_resolves(decision,A):-decision(B,A,C). citation_body(A,B):-sub_atom(A,C,1,D,')'),!,C>0,C<200,sub_atom(A,0,C,E,B). trim_left(A,B):-sub_atom(A,0,1,C,' ')->sub_atom(A,1,D,0,E),trim_left(E,B);B=A. decision_name(A,B):-citation_body(A,C),sub_atom(C,D,1,E,','),!,F is D+1,sub_atom(C,F,G,0,H),trim_left(H,B). +work_done('improvise-gd28','Virtual-view shape extracted to improvise-core as ViewStep/records_spine/records_steps/drill_steps; cmd/grid.rs keeps only steps_into_active_view, twenty lines of effect translation. No test expectation moved and none was added -- the existing drill tests (Filter placement asserted on the effect Debug, per-view cursor, formula drill) already pinned it. PR 140.'). diff --git a/src/draw.rs b/src/draw.rs index 6e28b667..c287c748 100644 --- a/src/draw.rs +++ b/src/draw.rs @@ -24,11 +24,13 @@ use crate::ui::grid::GridWidget; use crate::ui::help::HelpWidget; use crate::ui::import_wizard_ui::ImportWizardWidget; use crate::ui::panel::Panel; +use crate::ui::text::truncate; use crate::ui::tile_bar::TileBar; use crate::ui::view_panel::ViewContent; use crate::ui::which_key::WhichKeyWidget; use crate::view::AxisColor; use improvise_command::ui::project; +use unicode_width::UnicodeWidthStr; struct TuiContext<'a> { terminal: Terminal>, @@ -169,8 +171,29 @@ fn minibuf_fg(color: MinibufColor) -> Color { } } -fn fill_line(left: String, right: &str, width: u16) -> String { - let pad = " ".repeat((width as usize).saturating_sub(left.len() + right.len())); +/// Which segment survives when the two cannot both fit. The title bar's escape +/// hatch outranks the document name (improvise-t14d) — `context/code/tui.md`. +#[derive(Clone, Copy, Debug)] +enum Keeps { + Left, + Right, +} + +fn fill_line(left: String, right: &str, width: u16, keeps: Keeps) -> String { + let width = width as usize; + let (left, right) = match keeps { + Keeps::Right => { + let right = truncate(right, width); + let left = truncate(&left, width - right.width()); + (left, right) + } + Keeps::Left => { + let left = truncate(&left, width); + let right = truncate(right, width - left.width()); + (left, right) + } + }; + let pad = " ".repeat(width - left.width() - right.width()); format!("{left}{pad}{right}") } @@ -272,7 +295,7 @@ fn draw_title(f: &mut Frame, area: Rect, app: &App) { let file = doc.stored_as.map(|n| format!(" ({n})")).unwrap_or_default(); let title = format!(" improvise · {}{}{} ", doc.name, file, dirty); let right = " ?:help :q quit "; - let line = fill_line(title, right, area.width); + let line = fill_line(title, right, area.width, Keeps::Right); f.render_widget( Paragraph::new(line).style( Style::default() @@ -396,7 +419,7 @@ fn minibuffer_line(prompt: &str, buf: &str, status_msg: &str, width: u16) -> Str true => String::new(), false => format!(" {status_msg} "), }; - fill_line(left, &right, width) + fill_line(left, &right, width, Keeps::Left) } fn draw_bottom_bar(f: &mut Frame, area: Rect, app: &App) { @@ -431,6 +454,9 @@ fn draw_status(f: &mut Frame, area: Rect, app: &App) { let msg = match app.view_state.status_msg.is_empty() { false => app.view_state.status_msg.clone(), + // The wizard overlay draws its own per-step hints; one static table + // under it contradicts them on four of eight steps (improvise-rr8n). + true if matches!(app.view_state.mode, AppMode::ImportWizard) => String::new(), true => app.hint_text(), }; @@ -445,7 +471,7 @@ fn draw_status(f: &mut Frame, area: Rect, app: &App) { " {}{search_part} {msg}", crate::protocol::mode_label(&app.view_state.mode) ); - let line = fill_line(left, &view_badge, area.width); + let line = fill_line(left, &view_badge, area.width, Keeps::Left); f.render_widget( Paragraph::new(line).style(mode_style(&app.view_state.mode)), @@ -561,6 +587,61 @@ mod bottom_bar_tests { .collect() } + /// The wizard overlay draws correct per-step hints; the status bar stacked a + /// second static table under it (improvise-rr8n) — `context/test-evidence/tui.md`. + #[test] + fn the_wizard_overlay_is_the_only_key_hint_on_screen() { + let mut app = App::new(Workbook::new("T"), None); + app.view_state.mode = AppMode::ImportWizard; + let row = bottom_row(&app, 100); + for token in ["Space:toggle", "c:cycle", "Esc:cancel"] { + assert!( + !row.contains(token), + "the status bar names {token:?} under an overlay that contradicts \ + it on four of eight steps: {row:?}" + ); + } + } + + /// Suppressing the hint must not blank the bar: the mode label and any + /// status message still belong there. improvise-rr8n. + #[test] + fn suppressing_the_wizard_hint_leaves_the_rest_of_the_bar() { + let mut app = App::new(Workbook::new("T"), None); + app.view_state.mode = AppMode::ImportWizard; + assert!( + bottom_row(&app, 100).contains("IMPORT"), + "the mode label is not a hint and must survive" + ); + + app.view_state.status_msg = "no array found in that JSON".to_string(); + assert!( + bottom_row(&app, 100).contains("no array found in that JSON"), + "a real status message must still reach the bar" + ); + } + + /// The counterweight to deleting the tile bar's own hint: the tile keys are + /// still advertised, by the derived line. improvise-rr8n. + #[test] + fn the_status_bar_still_advertises_the_tile_keys() { + let mut app = App::new(Workbook::new("T"), None); + assert!( + bottom_row(&app, 200).contains("T:tiles"), + "Normal must still say how to reach tile select" + ); + + app.view_state.mode = AppMode::TileSelect; + let row = bottom_row(&app, 200); + for token in ["Enter/Space:cycle", "r:row", "Esc/Tab:back"] { + assert!( + row.contains(token), + "TileSelect lost {token:?}, which the tile bar's own hint never \ + carried in full: {row:?}" + ); + } + } + fn app_with_minibuffer_and_message() -> App { let mut app = App::new(Workbook::new("T"), None); app.view_state.mode = AppMode::category_add(); @@ -687,3 +768,115 @@ mod key_conversion_tests { assert_eq!(got, key::KeyModifiers::ALT); } } + +#[cfg(test)] +mod title_bar_tests { + use super::{Keeps, draw_title, fill_line}; + use crate::ui::app::App; + use crate::workbook::Workbook; + use ratatui::{Terminal, backend::TestBackend, layout::Rect}; + use std::path::PathBuf; + use unicode_width::UnicodeWidthStr; + + /// The title row as the terminal really receives it: ratatui clips the + /// right, so a helper's return value cannot see the defect. + fn title_row(app: &App, width: u16) -> String { + let mut terminal = Terminal::new(TestBackend::new(width, 1)).expect("test backend"); + terminal + .draw(|f| draw_title(f, Rect::new(0, 0, width, 1), app)) + .expect("draw"); + terminal + .backend() + .buffer() + .content() + .iter() + .map(|c| c.symbol()) + .collect() + } + + fn app_named(name: &str, file: &str) -> App { + App::new(Workbook::new(name), Some(PathBuf::from(file))) + } + + /// improvise-t14d — `context/test-evidence/tui.md`. + #[test] + fn the_escape_hatch_outlives_a_long_document_name() { + let app = app_named( + "Quarterly Regional Sales Analysis", + "quarterly-regional-sales-2026.improv", + ); + for width in [36u16, 44, 52, 60, 100] { + let row = title_row(&app, width); + assert!( + row.contains("?:help") && row.contains(":q quit"), + "the escape hatch is gone at width {width}: {row:?}" + ); + } + } + + /// The name must still lead where it fits, or a fix that simply dropped it + /// would satisfy the assertion above. improvise-t14d. + #[test] + fn the_document_name_still_leads_the_row() { + let app = app_named("Sales", "sales.improv"); + let row = title_row(&app, 80); + let name_at = row.find("Sales").expect("the name is drawn"); + let hatch_at = row.find("?:help").expect("the hatch is drawn"); + assert!( + name_at < hatch_at, + "the name leads and the hatch follows: {row:?}" + ); + assert!( + row.contains("(sales.improv)"), + "a name that fits is not truncated: {row:?}" + ); + } + + /// Display cells, not bytes — the ASCII case is the discriminating one. + /// improvise-t14d, `context/test-evidence/tui.md`. + #[test] + fn a_multibyte_title_does_not_shift_the_right_segment() { + for name in ["Sales", "Ventes régionales", "日本語"] { + let app = app_named(name, "m.improv"); + let row = title_row(&app, 80); + assert!( + row.ends_with(":q quit "), + "the right segment must sit on the right edge for {name:?}: {row:?}" + ); + } + } + + /// Whichever segment yields, the composed line is exactly the width it was + /// given — the property both call-site directions owe. improvise-t14d. + #[test] + fn a_composed_line_is_exactly_its_width() { + for width in 0u16..40 { + for (left, right) in [ + (" improvise · Ünïcödé ", " ?:help :q quit "), + ("", " badge "), + ("日本語 is a wide left segment", ""), + ] { + for keeps in [Keeps::Left, Keeps::Right] { + let line = fill_line(left.to_string(), right, width, keeps); + assert_eq!( + line.width(), + width as usize, + "fill_line({left:?}, {right:?}, {width}, {keeps:?}) = {line:?}" + ); + } + } + } + } + + /// `Keeps::Left` is the other direction, and nothing else in this module + /// separates it from `Keeps::Right`. improvise-t14d. + #[test] + fn the_kept_side_is_the_side_that_survives() { + let (left, right) = ("LEFTLEFTLEFT", "RIGHTRIGHT"); + assert_eq!(fill_line(left.to_string(), right, 12, Keeps::Left), left); + assert!( + fill_line(left.to_string(), right, 12, Keeps::Right).ends_with(right), + "the right segment is kept whole when it is the one that wins" + ); + } +} diff --git a/src/main.rs b/src/main.rs index 62499e61..c714bd49 100644 --- a/src/main.rs +++ b/src/main.rs @@ -344,29 +344,43 @@ fn run_headless_commands(cmds: &[String], file: &Option) -> Result<()> let mut changed = false; for line in cmds { - match command::parse_line(line) { - Ok(parsed_cmds) => { - for cmd in &parsed_cmds { - let effects = { - let ctx = app.cmd_context(KeyCode::Null, KeyModifiers::NONE); - cmd.execute(&ctx) - }; - app.apply_effects(effects); - changed |= app.model_state.dirty; - // The status line is the only channel a command has to - // report anything — `context/code/cli.md`. - let said = std::mem::take(&mut app.view_state.status_msg); - if !said.trim().is_empty() { - eprintln!("{said}"); - } - if app.last_batch_aborted() { - exit_code = 1; + // The whole line is syntax-checked before any of it runs, exactly as + // before; only CONSTRUCTION moved — `context/code/cli.md`. + if let Err(e) = command::parse_line(line) { + eprintln!("Parse error: {e}"); + exit_code = 1; + continue; + } + for segment in command::parse::segments(line) { + let mut failed = None; + let effects = { + // Built against the cursor it is about to run against, so a + // command that snapshots one sees command n-1's (improvise-6uva). + let ctx = app.cmd_context(KeyCode::Null, KeyModifiers::NONE); + match command::parse::parse_segment_interactive(segment, &ctx) { + Ok(Some(cmd)) => cmd.execute(&ctx), + Ok(None) => Vec::new(), + Err(e) => { + failed = Some(e); + Vec::new() } } - } - Err(e) => { + }; + if let Some(e) = failed { eprintln!("Parse error: {e}"); exit_code = 1; + break; + } + app.apply_effects(effects); + changed |= app.model_state.dirty; + // The status line is the only channel a command has to + // report anything — `context/code/cli.md`. + let said = std::mem::take(&mut app.view_state.status_msg); + if !said.trim().is_empty() { + eprintln!("{said}"); + } + if app.last_batch_aborted() { + exit_code = 1; } } } diff --git a/src/ui/tile_bar.rs b/src/ui/tile_bar.rs index fe1694aa..744c7458 100644 --- a/src/ui/tile_bar.rs +++ b/src/ui/tile_bar.rs @@ -126,21 +126,10 @@ impl<'a> Widget for TileBar<'a> { // Draw overflow indicator if tiles remain after the visible area if last_drawn + 1 < labels.len() && x < area.x + area.width { buf.set_string(x, area.y, "▶", Style::default().fg(Color::DarkGray)); - x += 1; } - // Hint - if matches!(self.mode, AppMode::TileSelect) { - let hint = " [Enter] cycle axis [r/c/p] set axis [←→] select [Esc] cancel"; - if x + hint.width() as u16 <= area.x + area.width { - buf.set_string(x, area.y, hint, Style::default().fg(Color::DarkGray)); - } - } else { - let hint = " Ctrl+↑↓←→ to move tiles"; - if x + hint.width() as u16 <= area.x + area.width { - buf.set_string(x, area.y, hint, Style::default().fg(Color::DarkGray)); - } - } + // The bar draws tiles and advertises no keys: the status line's hint is + // derived from the keymap and this one was not (improvise-rr8n). } } @@ -218,4 +207,45 @@ mod tests { " Tiles: [_Index ·] [_Dim ·] [_Measure Col] [Region Row] [Product Col]" ); } + + /// The tile bar advertised keys that do something else (improvise-rr8n) — + /// `context/test-evidence/tui.md`. + #[test] + fn the_tile_bar_advertises_no_keys_of_its_own() { + let app = app_with_two_categories(); + let cache = project::tile_bar_of(&app); + for mode in [AppMode::Normal, AppMode::TileSelect] { + // Wide enough that a hint would fit: the shipped 80-column fixture + // is too narrow to draw one, which is how the false claim survived. + let area = Rect::new(0, 0, 140, 1); + let mut buf = Buffer::empty(area); + TileBar::new(&cache, &mode, 0).render(area, &mut buf); + let text = buf_text(&buf); + for token in ["Ctrl", "Enter", "Esc", "←→", "move tiles"] { + assert!( + !text.contains(token), + "the tile bar names {token:?} in {mode:?}, and nothing checks \ + that claim against the keymap: {text:?}" + ); + } + } + } + + /// The tiles themselves are what the bar is for, and deleting the hint must + /// not delete them. improvise-rr8n. + #[test] + fn the_tile_bar_still_draws_its_tiles_at_every_width_it_used_to() { + let app = app_with_two_categories(); + let cache = project::tile_bar_of(&app); + for width in [80u16, 140] { + let area = Rect::new(0, 0, width, 1); + let mut buf = Buffer::empty(area); + TileBar::new(&cache, &AppMode::TileSelect, 0).render(area, &mut buf); + let text = buf_text(&buf); + assert!( + text.contains("[Region Row]") && text.contains("Tiles:"), + "at width {width} the bar lost its tiles: {text:?}" + ); + } + } } diff --git a/tests/headless_cli.rs b/tests/headless_cli.rs index 5e50217c..f5ae9bb6 100644 --- a/tests/headless_cli.rs +++ b/tests/headless_cli.rs @@ -266,3 +266,94 @@ fn a_run_that_changed_the_document_still_writes_it() { "the -f document lost an edit because `:w` elsewhere cleared the flag: {text}" ); } + +/// A document whose row axis carries three items, so a cursor move has +/// somewhere to go and the three landing places are distinguishable. +fn three_rows(scratch: &Scratch, name: &str) -> PathBuf { + let doc = scratch.path(name); + run_cmd(&["add-category Region"], &doc); + run_cmd(&["add-items Region North South East"], &doc); + doc +} + +fn hidden_line(doc: &Path) -> String { + std::fs::read_to_string(doc) + .expect("the document") + .lines() + .find(|l| l.starts_with("hidden:")) + .unwrap_or("(nothing hidden)") + .to_string() +} + +/// Evidence: `context/test-evidence/cli.md`. +#[test] +fn a_script_can_move_the_cursor_and_the_next_command_sees_it() { + let scratch = Scratch::new("cursor"); + let moved = three_rows(&scratch, "moved.improv"); + let still = three_rows(&scratch, "still.improv"); + + run_cmd(&["move-selection 2 0 . hide-selected-row-item"], &moved); + run_cmd(&["hide-selected-row-item"], &still); + + assert_eq!( + hidden_line(&still), + "hidden: Region/North", + "without a move the cursor is at the origin; if this changes, the other \ + half of this test stops meaning anything" + ); + assert_eq!( + hidden_line(&moved), + "hidden: Region/East", + "`move-selection 2 0` must move the cursor two rows down before the \ + next command reads it (improvise-6uva)" + ); +} + +/// Evidence: `context/test-evidence/cli.md`. +#[test] +fn a_script_cursor_move_survives_the_line_that_made_it() { + let scratch = Scratch::new("cursor-lines"); + let doc = three_rows(&scratch, "lines.improv"); + run_cmd(&["move-selection 1 0", "hide-selected-row-item"], &doc); + assert_eq!(hidden_line(&doc), "hidden: Region/South"); +} + +fn cell_lines(doc: &Path) -> Vec { + std::fs::read_to_string(doc) + .expect("the document") + .lines() + .filter(|l| l.contains(" = ")) + .map(|l| l.trim().to_string()) + .collect() +} + +/// Evidence: `context/test-evidence/cli.md`. +#[test] +fn an_explicit_coordinate_still_beats_the_cursor() { + let scratch = Scratch::new("explicit"); + let doc = scratch.path("cells.improv"); + run_cmd(&["add-category Region"], &doc); + run_cmd(&["add-items Region North South East"], &doc); + run_cmd( + &[ + "set-cell 1 Region/North", + "set-cell 2 Region/South", + "set-cell 3 Region/East", + ], + &doc, + ); + assert_eq!(cell_lines(&doc).len(), 3, "the fixture wrote three cells"); + + // The cursor is at the origin, which is North. Clearing East by name must + // clear East, not whatever the cursor happens to be sitting on. + run_cmd(&["clear-cell Region/East"], &doc); + let left = cell_lines(&doc); + assert!( + left.iter().any(|l| l.contains("North")), + "the cursor cell was cleared instead of the named one: {left:?}" + ); + assert!( + !left.iter().any(|l| l.contains("East")), + "the named cell survived: {left:?}" + ); +}