diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 04ca2dcd..b26ed4a0 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,4 +1,4 @@ -{"_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-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":"in_progress","priority":1,"issue_type":"bug","assignee":"agent-cmd","owner":"el-github@elangley.org","created_at":"2026-09-23T01:24:42Z","created_by":"Edward Langley","updated_at":"2026-09-23T01:45:53Z","started_at":"2026-09-23T01:45:53Z","lease_expires_at":"2026-09-23T01:50:53Z","heartbeat_at":"2026-09-23T01:45:53Z","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} @@ -39,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.\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-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":"closed","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-23T01:37:03Z","closed_at":"2026-09-23T01:37:03Z","close_reason":"Decomposition landed in PR #140 (ViewStep/records_spine/records_steps/drill_steps in improvise-core; cmd/grid.rs keeps only steps_into_active_view). PR #141 landed the diagnosis. THE DEPENDENCY ON improvise-7b8v WAS REMOVED, NOT FORCED PAST: the whole finding is that browser drill is NOT blocked on ADR 001 layer 2 -- that argument is about the COMMIT path (ApplyAndClearDrill is addressed to one session), while ENTERING a drill needs no commit and uses ModelCommands that already name their view unaddressed. The real blocker is AggregateHash being LAYOUT-scoped. Remaining implementation is improvise-obtu; contention is improvise-t219; thin spine coverage is improvise-t0ma. improvise-fdk1 is also not needed.","labels":["area:protocol"],"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":0,"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} @@ -71,6 +71,7 @@ {"_type":"issue","id":"improvise-km8","title":"1.2 Fix Cargo.toml metadata","description":"Add missing package fields: description, repository, homepage, documentation, readme, keywords, categories, license. Description: 'Terminal pivot-table modeling in the spirit of Lotus Improv'. Keywords: tui, pivot, spreadsheet, data, improv. Categories: command-line-utilities, visualization.","status":"closed","priority":1,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:06:48Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-09T07:20:17Z","close_reason":"Metadata already present: description, license, repository, homepage, readme, keywords, categories all populated.","dependency_count":0,"dependent_count":2,"comment_count":0} {"_type":"issue","id":"improvise-902","title":"1.1 Audit and remove or update context/SPEC.md","description":"Compare context/SPEC.md against actual code. If spec contradicts code significantly, delete it. Optionally move salvageable conceptual content to docs/design-notes.md with a staleness disclaimer. Do not try to bring spec into sync.","status":"closed","priority":1,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:06:46Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-09T07:10:05Z","close_reason":"Audited SPEC.md against code. Found minor inaccuracies (storage internals, wizard step count, search mode representation) but no major architectural contradictions. Deleted SPEC.md since it was largely redundant with repo-map.md and design-principles.md. Salvaged product vision and non-goals into docs/design-notes.md with staleness disclaimer.","dependency_count":0,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"improvise-tik","title":"Phase 1: Repository hygiene","description":"Raise the quality floor of what a stranger sees when they land on the repo. Subtasks cover spec audit, Cargo.toml metadata, publish-readiness, CSV audit, and example files.","status":"closed","priority":1,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-09T04:05:45Z","created_by":"Edward Langley","updated_at":"2026-04-09T07:22:34Z","closed_at":"2026-04-09T07:22:34Z","close_reason":"All 5 subtasks complete: SPEC.md audited, Cargo.toml metadata fixed, publish dry-run clean, CSV audit done, example files created.","dependencies":[{"issue_id":"improvise-tik","depends_on_id":"improvise-2fr","type":"blocks","created_at":"2026-04-08T23:37:31Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-tik","depends_on_id":"improvise-902","type":"blocks","created_at":"2026-04-08T23:37:30Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-tik","depends_on_id":"improvise-bv1","type":"blocks","created_at":"2026-04-08T23:37:32Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-tik","depends_on_id":"improvise-km8","type":"blocks","created_at":"2026-04-08T23:37:30Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-tik","depends_on_id":"improvise-n1h","type":"blocks","created_at":"2026-04-08T23:37:32Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":5,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-obtu","title":"The aggregate-addressing seam: a view-naming command resolved against that view's layout","description":"The remaining improvise-gd28 work, now that the blocker is correctly diagnosed (see improvise-gd28 and gotcha 44).\n\nAn AggregateHash is layout-scoped and cannot be otherwise, so App::resolve_aggregate answers only for the host's active view -- which since improvise-jytm is not the client's. A browser holding CellId::Aggregate(h) therefore cannot get it resolved.\n\nTHE SHAPE, and it is not a new mechanism: let the command NAME ITS VIEW, exactly as ModelCommand::SetAxis { view: String, .. } already does, and resolve the surrogate against a layout derived for that name rather than against app.layout. grid_in already derives a layout for a session on a view the host is not on, so the machinery exists.\n\nExplicitly NOT the alternative: projecting the coordinates to the client. That is precisely what ADR 002 exists to prevent -- an opaque surrogate stops being opaque and the client starts holding document coordinates.\n\nExplicitly NOT needing a SessionId: the mutation a drill entry performs is addressed to NO ONE session -- it configures the shared _Drill view exactly as CreateView/SetAxis already do unaddressed. Only the RESOLUTION of its operand is session-scoped. That asymmetry is the whole finding.\n\nAlso not needed: improvise-fdk1. 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 the user's \"staging scheme should be identical\" ruling is kept by the browser getting no edits rather than by a second scheme.\n\nNote the contention issue filed separately: _Drill is one view in Workbook::views, so two clients drilling contend.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-23T01:36:28Z","created_by":"Edward Langley","updated_at":"2026-09-23T01:36:28Z","dependency_count":0,"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} @@ -137,7 +138,7 @@ {"_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-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":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-core","owner":"el-github@elangley.org","created_at":"2026-09-21T20:31:29Z","created_by":"Edward Langley","updated_at":"2026-09-23T01:50:18Z","started_at":"2026-09-23T01:50:18Z","lease_expires_at":"2026-09-23T01:55:18Z","heartbeat_at":"2026-09-23T01:50:18Z","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","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} @@ -313,6 +314,7 @@ {"_type":"issue","id":"improvise-pby","title":"2.2 Write the README","description":"Replace existing README.md with new one under 250 lines. Sections in order: Title, one-sentence pitch, inline demo GIF, why this exists (Lotus Improv reference), quick start, key bindings, installation (Nix/crates.io/prebuilt - no Homebrew), codebase overview, expectations disclaimer, license. No badges, TOC, or contributing guide.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:32Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-09T07:02:52Z","close_reason":"README written with all 10 required sections, committed. License updated to Apache-2.0 per user request.","dependency_count":0,"dependent_count":2,"comment_count":0} {"_type":"issue","id":"improvise-ihv","title":"2.1 Add Nix tooling for asciinema, VHS, and cargo-dist","description":"Modify flake.nix to add pkgs.asciinema and pkgs.vhs to dev shell nativeBuildInputs. Add cargo-dist if packaged in nixpkgs. Add runtime deps for VHS (ttyd, ffmpeg) if needed. Verify tools work via nix develop --command. Add nix run app or shell script for demo recording workflow.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:30Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-09T07:30:19Z","close_reason":"asciinema, vhs, cargo-dist added to flake.nix dev shell and verified. cargo-dist wrapper enables 'cargo dist' subcommand. scripts/record-demo.sh added.","dependency_count":0,"dependent_count":3,"comment_count":0} {"_type":"issue","id":"improvise-yk4","title":"Phase 2: README and demo artifacts","description":"The main launch work. README is 80% of the launch, demo artifacts are 20%. Covers Nix tooling for asciinema/VHS, writing the README, creating demo GIF and asciinema casts, and verifying all artifacts.","status":"closed","priority":2,"issue_type":"feature","assignee":"spot","owner":"el-github@elangley.org","created_at":"2026-04-09T04:05:49Z","created_by":"Edward Langley","updated_at":"2026-04-14T07:47:43Z","closed_at":"2026-04-14T07:47:43Z","close_reason":"All 5 subtasks closed; artifacts verified present and tracked in git (README.md, docs/demo.gif, docs/demo.tape, docs/casts/*.cast).","dependencies":[{"issue_id":"improvise-yk4","depends_on_id":"improvise-abz","type":"blocks","created_at":"2026-04-08T23:37:37Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-yk4","depends_on_id":"improvise-d4w","type":"blocks","created_at":"2026-04-08T23:37:36Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-yk4","depends_on_id":"improvise-ihv","type":"blocks","created_at":"2026-04-08T23:37:34Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-yk4","depends_on_id":"improvise-odx","type":"blocks","created_at":"2026-04-08T23:37:35Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-yk4","depends_on_id":"improvise-pby","type":"blocks","created_at":"2026-04-08T23:37:36Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":5,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-t219","title":"_Drill is one shared view, so two clients drilling contend","description":"_Drill is a single view in Workbook::views, and Workbook::create_view REPLACES (deliberately -- see core.md and improvise-vw47). So two clients drilling at once contend: the second drill overwrites the first client's _Drill configuration, and since a View is shared document state the first client's grid silently becomes the second client's drill.\n\nPRE-EXISTING and NOT WORSENED by improvise-gd28's decomposition -- filed so it is not discovered as a regression of that work. Same is true of _Records.\n\nIt is currently unreachable because the browser cannot enter a drill at all (see the aggregate-addressing seam ticket). Closing that makes this live, so the two should be considered together -- the same masking relationship improvise-6uva has with improvise-up8g.\n\nNeeds a per-client view namespace, which is a real design question: a virtual view is document state by construction today (it lives in Workbook::views and is what GridLayout reads), but a drill is a UI act belonging to one client -- \"the model is shared, the UI is not\" says it should not be in the workbook at all. Note VIRTUAL_VIEW_NAMES are never persisted, so there is no format cost to moving them.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T01:36:28Z","created_by":"Edward Langley","updated_at":"2026-09-23T01:36:28Z","dependency_count":0,"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} @@ -320,7 +322,7 @@ {"_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-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":"closed","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-23T02:00:31Z","started_at":"2026-09-23T01:34:40Z","closed_at":"2026-09-23T02:00:31Z","close_reason":"STALE-OPEN, nothing to fix. Fixed by 94a3d17 'fix: the minibuffer row carries a pending status message (improvise-mai9)', which landed 2026-09-20 -- TWO DAYS BEFORE vgp4 was filed. Verified with git merge-base --is-ancestor. Driven as the real user path (empty Region on Row, then i/4/2/Enter, giving Editing plus 'commit-cell-edit: no cell at cursor'), draw_bottom_bar renders the refusal at 30/40/50/60/80 columns; minibuffer_line composes BOTH segments and does not paint one or the other. WHY IT READ AS LIVE, and this is the transferable part: gotcha 21 still describes the PRE-mai9 behaviour in the past tense, because that is the stranding argument it is making. The trap is a correct historical narrative read as a current claim, not a stale gotcha. (Closed with --force: agent-tui handed back and stopped, so the claim is abandoned.)","labels":["tui"],"comments":[{"id":"f79e6ebc-7f67-5135-8f01-9c241e06704e","issue_id":"improvise-vgp4","author":"agent-tui","text":"DOES NOT REPRODUCE at a9cad15 (and not at any commit since 2026-09-20). The\nticket said it might already hold; it does, and the fixing commit is 94a3d17\n\"fix: the minibuffer row carries a pending status message (improvise-mai9)\",\nconfirmed an ancestor of a9cad15 by `git merge-base --is-ancestor` (exit 0).\nmai9 landed TWO DAYS BEFORE this ticket was filed.\n\n`draw_bottom_bar` does NOT paint \"one or the other\". It calls `minibuffer_line`,\nwhich composes BOTH: prompt + buffer + cursor on the left, the pending\n`status_msg` right-aligned. The premise in the ticket title is what gotcha 21\nrecords about the code BEFORE mai9, and gotcha 21 still describes it in the past\ntense for the stranding argument it is making -- which is how this read as live.\n\nDriven as the real user path rather than by inspection, on the worst case the\nticket names (commit-cell-edit, the user in Editing with a typed value):\n\n App with an empty `Region` on Row, so `layout.cell_key(0,0)` is None\n i, '4', '2', Enter\n -\u003e mode stays Editing { minibuf: \"edit\" }, status_msg\n \"commit-cell-edit: no cell at cursor\"\n\nand `draw_bottom_bar` rendered into a TestBackend at five widths:\n\n w=30 \"edit: 42▌ commit-cell-edit: …\"\n w=40 \"edit: 42▌ commit-cell-edit: no cell at…\"\n w=50 \"edit: 42▌ commit-cell-edit: no cell at cursor \"\n w=60 \"edit: 42▌ commit-cell-edit: no cell at cursor \"\n w=80 \"edit: 42▌ commit-cell-edit: no cell at cursor \"\n\nSo the refusal reaches the screen at every width. The ellipsis at 30 and 40 is\nimprovise-t14d's truncation (PR #145) landing in the same row; before it the\nnarrow cases were a hard clip at the same point, so the visible content is\nunchanged and only the mid-token cut is gone.\n\nThe ticket's second question -- \"what should it do when the status is a REFUSAL\nof the very thing the minibuffer is for\" -- is answered by the composition\nrather than by a special case: the refusal is never traded against the prompt,\nonly against the padding between them. At widths where even that runs out, the\nLEFT wins (improvise-t14d's `Keeps::Left` at this call site, which is what this\nrow already did), so a narrow terminal truncates the message rather than the\ntext the user is editing. That is a deliberate choice and is argued in\ncontext/code/tui.md; if it is the wrong one for a refusal specifically, that is\na new ticket about severity, not this one -- and improvise-udt2 is already open\non \"a status message carries no severity\".\n\nReported by Claude (Opus 5), an agent in Edward Langley's session. Closing is\nthe orchestrator's call; I have closed nothing.","created_at":"2026-09-23T01:36:34Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} {"_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} @@ -341,7 +343,7 @@ {"_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} +{"_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":"in_progress","priority":3,"issue_type":"bug","assignee":"agent-core","owner":"el-github@elangley.org","created_at":"2026-09-21T21:59:44Z","created_by":"Edward Langley","updated_at":"2026-09-23T01:39:55Z","started_at":"2026-09-23T01:39:55Z","lease_expires_at":"2026-09-23T01:44:55Z","heartbeat_at":"2026-09-23T01:39:55Z","labels":["import"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-cgqt","title":"Import collision policy should be a wizard step, not a fixed default","description":"improvise-2cw9 decided that two source rows landing on one coordinate tuple are\nSUMMED and reported. That decision is recorded on the bead and is right as a\ndefault -- it is what a pivot table does and what measure_agg defaults to.\n\nIt is WRONG for a measure the user later sets to Avg: the value is already\npre-summed, so the average is taken over the wrong denominator. The warning is\nwhat currently keeps that from being invisible.\n\nThe user is the only one who knows which they want. The shape is a wizard step\nthat asks, on the field where the collision was detected -- not a change to the\ndefault, and not a silent guess from the field's inferred kind.\n\nSearch `duplicate`, `collision`, `aggregate` before filing anything adjacent.\n","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-21T21:59:43Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:59:43Z","labels":["import","ux"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-1uqj","title":"Long CSV rows lose their extra fields — now warned, still dropped","description":"improvise-2cw9's fix made a long CSV row REPORTED rather than silent -- a row\ncarrying more fields than the header now warns with counts and the widest row.\nThe extra values are still DROPPED.\n\nPreserving them means inventing column names, which changes the shape of the\nimported model. That is a wizard decision, not a parser one, which is why the\nimport agent stopped at \"no longer silent\" and flagged it.\n\nRelated and CLOSED: improvise-k8i covered the SHORT direction (a row with fewer\nfields than the header). Its own fix did not reach this half, because the bead\nreproduced only the short case. Read it before starting.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T21:59:42Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:59:42Z","labels":["import"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-is8c","title":"Move Basis from Command::Model to ClientFrame::Commands so one-basis-per-frame is not a convention","description":"Split out of improvise-yqpd, which delivered ADR-001 clause 4 but could not do\nthis half: it is a wire change in improvise-protocol/src/frame.rs.\n\nClause 1 says the FRAME states one basis. Today the Basis still sits inside\nCommand::Model, and CheckedBatch simply uses the one the first basis-carrying\ncommand stated, ignoring any later command's field. That works, and it is a\nCONVENTION rather than something the types enforce.\n\nMoving Basis from Command::Model to ClientFrame::Commands makes one-basis-per-\nframe unrepresentable-otherwise instead of conventional -- the same move the\nstruct variant made for \"the basis cannot be forgotten\" (gotcha 43).\n\nNote what this does NOT change, from gotcha 43: only two of the three tiers\nhoist. document_generation and layout_version describe the document and are\nanswered once per frame; the RECORD tier must still be answered per command,\nbecause which records are at stake is a property of the command (records_named).\nA first version that hoisted all three was a silent lost update.\n","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-21T21:59:42Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:59:42Z","labels":["adr-001","protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -353,7 +355,7 @@ {"_type":"issue","id":"improvise-yae4","title":"Column width overflows before clamping a 65535-column text value","description":"Report-only hunt at 70025e2. No implementation changes.\n\nLocation: crates/improvise-command/src/ui/viewport.rs:66,85\n\nA stored 65535-character ASCII text value causes compute_col_widths/grid_body to panic at w+1 in debug builds. Width is narrowed to u16 before adding the cell gap and clamping to MAX_COL_WIDTH. In release the sum wraps and chooses a minimum-width column instead of the configured maximum. Longer widths can truncate during the earlier as u16 cast even when addition does not overflow. Clamp while still usize or use bounded/saturating conversion before arithmetic. Test widths around 65535 and 65536 as well as ordinary text. Duplicate checks: width, overflow, 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;\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 wide_cell_clamps_without_overflow() {\n use improvise::{ui::app::App,model::cell::{CellKey,CellValue}};\n let mut w=revenue_book();\n w.model.set_cell(CellKey::new(vec![(\"Region\".into(),\"East\".into()),(\"_Measure\".into(),\"Revenue\".into())]),CellValue::Text(\"x\".repeat(65535)));\n let app=App::new(w,None);\n let widths=improvise::ui::viewport::compute_col_widths(\u0026app.model_state.workbook.model,\u0026app.layout,false,0);\n assert!(widths.iter().all(|w| *w\u003c=32));\n}\n```\nRun cargo test wide_cell_clamps_without_overflow -- --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":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:37:41Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:58:44Z","closed_at":"2026-09-21T21:58:44Z","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\nCapped while still usize in the one place every measurement enters (widen). Behaviour identical for every non-overflowing input.\n\nTHE SECOND INSTANCE, which the bead did not name and which was the more dangerous: improvise_protocol::header_level_widths had byte-for-byte the same defect, on the axis gotcha 44 calls the one home for the width rule -- and it is in the PROTOCOL, so it is shared by both front ends. Fixing only the site the bead named would have left the row header and the column widths with two different answers to the same arithmetic.\n\nThe release symptom is the one to lead with, not the panic: the WIDEST value in the document silently rendering at the NARROWEST column width, with no failing test anywhere. Faults injected: debug -\u003e 'attempt to add with overflow'; release -\u003e left [5], right [32], the wrap reported as the minimum width.\n\n'as u16 followed by + 1 and .clamp' is a greppable shape and the grep finds both.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-l3hm","title":"[bug] i on CellId::Create is a silent black hole; Aggregate/Derived decline out loud","description":"## Summary\n`i` / `a` on a `CellId::Create` (records-mode `+` column) is a silent black hole (`KeyOutcome` upstream/none), while Aggregate and Derived decline out loud with `SetStatus`. Same doctrine (`an_upstream_key_is_a_black_hole`) that drove loud declines for the other non-editable ids.\n\n## Repro (reasoning / unit-shaped)\n1. Records mode with a creating column under the cursor (`CellId::Create`).\n2. Press `i`.\n3. Observe: no status, no mode change — key appears dead.\n\n## Expected\nA local `SetStatus` naming that creating/naming a column is not available in the browser yet (same family as Aggregate’s drill message and Derived’s formula message), per clients.md loud-decline doctrine.\n\n## Actual\n`edit_or_drill` match arm `CellId::Create(_) =\u003e None` in `crates/improvise-protocol/src/resolve.rs`, which becomes Upstream/Ignored rather than Local SetStatus. Comment cites improvise-poia (projection identity; closed) — the loud-decline gap remains.\n\n## Evidence\n- Code hunt by external Grok Bot agent for Ed Langley, 2026-09-21 PT.\n- Contrast: `i_on_an_aggregate_declines_out_loud` / `i_on_a_derived_cell_names_the_formula` in the same file.\n- Closed improvise-poia / improvise-gcvb covered identity/commit, not this decline wording.\n\n## Notes\nFound by an external Grok Bot agent smoke/code hunt for Ed Langley (not Claude). Not an upstream black-hole key that is documented as intentionally silent for host features like `[`/`]`/drill wire — this is the client’s own edit-or-drill door answering None.\n","status":"open","priority":3,"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-pj8a","title":"The browser paints the host's hints and help over a smaller capability set, and ten keys are silent","description":"Found by the UX audit. ASSERTED (read: keymap + declined() + Upstream list + help pages).\n\nThe browser paints the HOST'S hint line and the HOST'S help pages over a smaller capability set. Three tiers.\n\n## (a) Loud — honest, but reads as broken\nSix commands are declined() with \"... is not available in the browser yet\": drill-into-cell, toggle-prune-empty, toggle-records-mode, add-record-row, delete-formula-at-cursor, view-back.\n\nFOUR OF THEM ARE ADVERTISED BY THE HINT LINE THE BROWSER ITSELF PAINTS: R:records, P:prune, \u003e:drill, and Del/d:delete in the FORMULAS line. The footer offers a key and the key answers no. Note you can ADD a formula in the browser but not DELETE one.\n\n## (b) Silent — the worse tier\nThe Upstream list resolves to ClientFrame::Key, which Host::handle answers with Vec::new() (ui/host.rs:82). Nothing happens, nothing is said: / n N, Enter (enter-advance), Ctrl-S, Ctrl-Q, z, H, and the g/y/Z prefixes.\n\nThe browser paints the SHARED help overlay, so HELP PAGE 2'S ENTIRE \"SEARCH\" SECTION, and page 3's \"Enter — Edit current cell (same as i)\", ARE TAUGHT TO A BROWSER USER AND DO NOTHING. Enter is the sharpest: on the same help page, `i` works and its documented synonym is inert.\n\nThere is also no which-key popup in the browser, since prefixes go Upstream — so g, y, Z are dead AND unexplained, where the TUI pops a menu.\n\n## (c) Dropped entirely\nCovered by the sibling ticket on page_coords and totals.\n\n## Acceptance Criteria\n- Extend declined() to cover the Upstream names with a status, exactly as (a) does. That turns \"broken\" into \"not here yet\" for ten keys and costs one match arm each. THIS IS THE HIGH-VALUE HALF.\n- Decide what the browser's help overlay should show: either mark the unavailable sections, or derive the pages per surface. The pages are already derived from the keymap (help.rs), so a capability filter is the natural shape.\n- The hint line should not advertise a key the surface declines.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:30:20Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:30:20Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-rr8n","title":"The tile bar's permanent hint is false, and the status bar contradicts the wizard on four of eight steps","description":"Found by the UX audit. Two findings about on-screen text that is wrong or contradictory. Both ASSERTED (read against the keymap), both cheap to confirm in a terminal.\n\n## 1 — the tile bar's permanent hint is FALSE\nsrc/ui/tile_bar.rs:140: let hint = \" Ctrl+^v\u003c-\u003e to move tiles\";\n\nThose four keys are bound to enter-tile-select, not to moving anything (keymap.rs:632):\n normal.bind(KeyCode::Left, ctrl, \"enter-tile-select\"); // and Right/Up/Down\n\nPressing Ctrl-Right ENTERS TILES MODE; nothing moves. This is the only persistent on-screen documentation of those keys and it describes a different command. It also spells `Ctrl+` where keymap::key_label — and therefore every derived hint and help row — spells `Ctrl-`.\n\nFix: \" Ctrl-^v\u003c-\u003e or T to pick a tile\".\n\n## 2 — four hint dialects, two on screen at once, disagreeing\ndraw.rs::draw() stacks the tile bar (row 3) above the status bar (row 4), so in tile-select mode the user sees on adjacent lines:\n\n tile bar: [Enter] cycle axis [r/c/p] set axis [\u003c-\u003e] select [Esc] cancel\n status bar: hl\u003c-\u003e:select Enter/Space:cycle r:row c:col p:page Esc/Tab:back\n\nDifferent notation, different content — the tile bar omits Space, h/l and Tab.\n\nThe import wizard has the same overlap, and there the status bar is WRONG ON FOUR OF EIGHT STEPS:\n\n NameModel overlay: Enter build, \u003c- back, Esc cancel | status: Space:toggle c:cycle | TRUTH: Space and c TYPE INTO THE NAME\n DefineFormulas overlay: n new, d delete, Enter next | status: Space:toggle c:cycle | TRUTH: n/d; Space and c do nothing\n Done overlay: Enter accept, \u003c-/Esc back | status: Enter:next Esc:cancel| TRUTH: Esc goes BACK, does not cancel\n Preview overlay: Press Enter to continue | status: Space:toggle c:cycle | TRUTH: Enter/Space advance\n\nThe `Done` row is an outright contradiction: the status bar tells the user Esc cancels when it goes back.\n\n## Acceptance Criteria\n- Fix the tile-bar string and its Ctrl+ / Ctrl- spelling.\n- SUPPRESS the status-bar hint when an overlay that draws its own is up — make hint_tail(ImportWizard) empty and skip draw_status's hint fallback in TileSelect. The tile bar and wizard already draw correct, local, per-state hints, so the cheap repair is to stop drawing the second table rather than to maintain it.\n- Longer term the wizard's per-step keys belong in advertised() keyed by step, which makes them derived like everything else.\n\nNote the wizard's back path itself is SOUND: Left steps back from every step (every_step_binds_left_as_back proves it), Done's Esc goes back rather than discarding. The only hole is Left being dead while a formula is typed, survivable because Esc cancels the edit first.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:30:19Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:30:19Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-rr8n","title":"The tile bar's permanent hint is false, and the status bar contradicts the wizard on four of eight steps","description":"Found by the UX audit. Two findings about on-screen text that is wrong or contradictory. Both ASSERTED (read against the keymap), both cheap to confirm in a terminal.\n\n## 1 — the tile bar's permanent hint is FALSE\nsrc/ui/tile_bar.rs:140: let hint = \" Ctrl+^v\u003c-\u003e to move tiles\";\n\nThose four keys are bound to enter-tile-select, not to moving anything (keymap.rs:632):\n normal.bind(KeyCode::Left, ctrl, \"enter-tile-select\"); // and Right/Up/Down\n\nPressing Ctrl-Right ENTERS TILES MODE; nothing moves. This is the only persistent on-screen documentation of those keys and it describes a different command. It also spells `Ctrl+` where keymap::key_label — and therefore every derived hint and help row — spells `Ctrl-`.\n\nFix: \" Ctrl-^v\u003c-\u003e or T to pick a tile\".\n\n## 2 — four hint dialects, two on screen at once, disagreeing\ndraw.rs::draw() stacks the tile bar (row 3) above the status bar (row 4), so in tile-select mode the user sees on adjacent lines:\n\n tile bar: [Enter] cycle axis [r/c/p] set axis [\u003c-\u003e] select [Esc] cancel\n status bar: hl\u003c-\u003e:select Enter/Space:cycle r:row c:col p:page Esc/Tab:back\n\nDifferent notation, different content — the tile bar omits Space, h/l and Tab.\n\nThe import wizard has the same overlap, and there the status bar is WRONG ON FOUR OF EIGHT STEPS:\n\n NameModel overlay: Enter build, \u003c- back, Esc cancel | status: Space:toggle c:cycle | TRUTH: Space and c TYPE INTO THE NAME\n DefineFormulas overlay: n new, d delete, Enter next | status: Space:toggle c:cycle | TRUTH: n/d; Space and c do nothing\n Done overlay: Enter accept, \u003c-/Esc back | status: Enter:next Esc:cancel| TRUTH: Esc goes BACK, does not cancel\n Preview overlay: Press Enter to continue | status: Space:toggle c:cycle | TRUTH: Enter/Space advance\n\nThe `Done` row is an outright contradiction: the status bar tells the user Esc cancels when it goes back.\n\n## Acceptance Criteria\n- Fix the tile-bar string and its Ctrl+ / Ctrl- spelling.\n- SUPPRESS the status-bar hint when an overlay that draws its own is up — make hint_tail(ImportWizard) empty and skip draw_status's hint fallback in TileSelect. The tile bar and wizard already draw correct, local, per-state hints, so the cheap repair is to stop drawing the second table rather than to maintain it.\n- Longer term the wizard's per-step keys belong in advertised() keyed by step, which makes them derived like everything else.\n\nNote the wizard's back path itself is SOUND: Left steps back from every step (every_step_binds_left_as_back proves it), Done's Esc goes back rather than discarding. The only hole is Left being dead while a formula is typed, survivable because Esc cancels the edit first.\n","status":"in_progress","priority":3,"issue_type":"bug","assignee":"agent-tui","owner":"el-github@elangley.org","created_at":"2026-09-21T20:30:19Z","created_by":"Edward Langley","updated_at":"2026-09-23T01:37:05Z","started_at":"2026-09-23T01:37:05Z","lease_expires_at":"2026-09-23T01:42:05Z","heartbeat_at":"2026-09-23T01:37:05Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-ppru","title":"Nondefault measure aggregation is lost by the .improv roundtrip","description":"Report-only bug hunt at HEAD 70025e2, 2026-09-21. No implementation changes.\n\nLocation: crates/improvise-io/src/persistence/format.rs; crates/improvise-core/src/model/types.rs:234,473\n\nSet model.measure_agg[Price] to AggFunc::Avg; after parse_md(format_md(wb)) measure_agg is empty and lookup falls back to Sum. This is API/legacy-JSON reachable: no current production setter for measure_agg was found, so priority is lower than ordinary UI persistence defects. Add a grammar-backed representation or explicitly resolve this unsupported state. Duplicate checks: measure_agg, aggregation, average, persist.\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 aggregation_survives_save() {\n let mut w=Workbook::new(\"test\"); w.add_item(\"_Measure\",\"Price\").unwrap();\n w.model.measure_agg.insert(\"Price\".into(),AggFunc::Avg);\n let r=parse_md(\u0026format_md(\u0026w)).unwrap(); assert!(matches!(r.model.measure_agg.get(\"Price\"),Some(AggFunc::Avg)), \"loaded aggregation: {:?}\",r.model.measure_agg);\n}\n```\nRun cargo test aggregation_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":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:27:45Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:58:16Z","closed_at":"2026-09-21T21:58:16Z","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\nNew ## Aggregation section, written SORTED because measure_agg is a HashMap (gotcha 18). The ticket's 'no current production setter' hid a second defect: writing it naively is a gotcha-18 bug the moment anything sets more than one entry. The low priority was about reachability, not about the fix being trivial.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-el9h","title":"The server's mirror settles its mode once and never again, so it diverges from the client it mirrors","description":"Found by the command-layer bug hunt. ASSERTED, with the settling test specified.\n\nClientSession::fold (project.rs:711) settles self.view.mode against self.grid.records — the grid LAST PROJECTED to that client. project_diff (project.rs:758) updates self.grid AFTER the fold and never re-settles.\n\nThe client side does not have this shape: ClientState::fold calls settle_mode() on EVERY fold including Command::Project, so when the new view's grid projection arrives the client re-settles and is correct.\n\nSo for [SwitchView(records-shaped view), ChangeMode(Normal)] — precisely the case PR #98 exists to fix — THE MIRROR KEEPS Normal WHILE THE CLIENT ENDS ON RecordsNormal. That commit's own message says the mirror was given the settle \"so the two cannot diverge in a field nobody currently reads\"; the mechanism as written does not achieve it.\n\nSecond, smaller instance of the same shape: apply_switch (project.rs:930), adopt_deleted_views and adopt_cursor_epoch all write the mirror with bare reduce_view, bypassing fold and therefore the settle — three doors past the one documented as \"the same function the client runs\".\n\n## Why P3\nClientSession::view() has no production caller (grepped across improvise-command, improvise-ws-server, improvise-worker-server), and reseed/connect rebuild the view from fresh_view(app) rather than from the folded mirror, so no Snapshot currently carries the wrong value. It is a correctness hole waiting for its first reader — which is exactly the shape improvise-e5pf was before someone read it.\n\n## Acceptance Criteria\n- The settling test, which is specified and cheap: drive a Host with a pivot-shaped default view and a second records-shaped view, Sessions::connect, send ClientFrame::Commands([SwitchView(\"records view\"), ChangeMode(Normal)]), and compare session.view().mode against a ClientState fed the resulting ServerFrames in arrival order. ClientSession::view is pub so this is reachable from a test in project.rs.\n- Then the one-line fix: re-settle at the end of project_diff after self.grid = desired. Or make fold the only writer of the mirror's ViewState and settle there plus after each projection — which also closes the three bypass doors.\n- Do NOT fix before the test exists: the current behaviour is unobservable, so a fix without a test is unfalsifiable.\n","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:25:15Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:58:44Z","closed_at":"2026-09-21T21:58:44Z","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\nsettle_mode now called from fold AND at the end of project_diff, which also covers the three bare-reduce_view doors. The test was written first and failed exactly as the bead predicted (client RecordsNormal, mirror Normal).\n\nNote the fixture trap found on the way: a fresh View::new carries no axis entry for _Index/_Dim, so is_records_mode can never fire on one. Without the precondition assertion the test would have compared two Normals and PASSED against the unfixed code.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-i1wi","title":"scrolled_to drops viewport_effects' max(1) guard, so click_cell underflows on a zero window","description":"Found by the protocol bug hunt. DEMONSTRATED (debug build).\n\nresolve.rs:525 and :531 —\n\n row_offset = nr.saturating_sub(window_rows - 1);\n col_offset = nc.saturating_sub(window_cols - 1);\n\nIts original, improvise-command/src/command/cmd/navigation.rs:48, has\n\n let vr = visible_rows.max(1);\n let vc = visible_cols.max(1);\n\nTHE MIRROR DROPPED THE GUARD. grid_move (resolve.rs:497) checks window_rows == 0 || window_cols == 0 and returns early — but click_cell (:167) does NOT, and it calls scrolled_to directly.\n\n ClientState::default() with grid.extent = {rows:5, cols:5}, window left at (0,0)\n click(\u0026state, Click::Cell{row:1, col:1})\n -\u003e panicked at panic_const_sub_overflow\n resolve::scrolled_to resolve.rs:525\n resolve::click_cell resolve.rs:176\n\nIn a release wasm build (overflow-checks off) it does not panic: 0usize - 1 wraps and both offsets silently collapse to 0.\n\n## Reachability\nLow from the shipped page. shell.js::extentOf clamps to Math.max(1, ..) and app.js sends set_viewport before any grid arrives, so window == (0,0) needs a click before the handshake. The frame-only Client::viewport twin is deleted (improvise-seik). So this is a latent panic in a pub API and a real mirror divergence, not a live user bug.\n\n## Why it matters anyway\ndoctrine(mirror_the_cmd_line_for_line) is the rule these resolvers live under, and the whole point is that a resolver reproduces its Cmd INCLUDING where siblings disagree. A dropped .max(1) is the mirror silently disagreeing, and tests/lockstep.rs did not catch it because no fixture has a zero window.\n\n## Acceptance Criteria\n- One line at resolve.rs:517: let (window_rows, window_cols) = (state.window.0.max(1), state.window.1.max(1));\n- A lockstep case with a zero window, or an explicit note in the evidence that no fixture has one and why.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:24:49Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:24:49Z","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -430,7 +432,7 @@ {"_type":"issue","id":"improvise-j31x","title":"Server hosts several documents: a registry above Sessions","description":"DEFERRED by the user (2026-09-19) -- recorded so nothing forecloses it, not to be built now.\n\nimprovise is a multi-document app and the server should be able to host several separate models. The shape:\n server -\u003e many documents\n document -\u003e one App + one Sessions + many ClientSessions\nThat is NOT one App per session (what improvise-q08 ticket text says, written before the session model landed) and NOT one App total.\n\nNOTHING THAT HAS LANDED NEEDS TO CHANGE. Sessions does not own the App -- connect(\u0026mut self, app: \u0026App, ..), apply(\u0026mut self, app: \u0026mut App, ..) and project_all(\u0026mut self, app: \u0026App, ..) all take it as a parameter. So Sessions is already document-agnostic and a Sessions instance is implicitly bound to one document generation sequence, which is right: document_generation lives on the App. Multi-document is a REGISTRY ABOVE Sessions mapping a document id to its (App, Sessions) pair.\n\nWHY IT MATTERS BEYOND TIDINESS: sessions editing independent models are trivially non-conflicting, so ADR 001 concurrency design scopes per document FOR FREE -- the basis version tiers live on the App and each document has its own. A server that shared one App across documents would throw that away.\n\nWHAT TO AVOID IN THE MEANTIME: improvise-q08 must not hard-code a single App into a static or a lazily-initialised singleton. The document should be a PARAMETER even while there is only one, and a connection should be attachable to a document even if the MVP answers that from a CLI argument naming one .improv file.\n\nScope when picked up: the registry, document lifecycle (open/close/last-session-leaves), and how a client names the document it wants. Auth and access control are explicitly NOT in scope here -- file them separately if wanted.","status":"closed","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-19T23:32:31Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:33:15Z","closed_at":"2026-09-20T00:33:15Z","close_reason":"Registry landed in crates/improvise-ws-server/src/registry.rs. 1051 tests pass.\n\nIT CAUGHT A REAL BUG BY VERIFYING THE SEAM INSTEAD OF TRUSTING IT. My brief and the q08 notes both said nothing below the registry needs to change. Nearly true -- but Sessions::next_id is a PER-Sessions counter (confirmed: project.rs holds next_id on Sessions, not globally), so EVERY documents first client is SessionId(0), and transport.rs keyed its outs: HashMap\u003cSessionId, _\u003e on it. A registry that kept that key would have delivered document Bs frames to document As client the moment two documents each had one client.\n\nThe server-wide address is the pair, ClientId { document, session }. Registry::route(document, Vec\u003cAddressed\u003e) is the ONLY place a SessionId acquires a document -- the one it came out of, since that is the only one in scope there -- so cross-document delivery of a projection, a SwitchView broadcast (gotcha 36) or an Applied::Rebased fan-out (gotcha 39) is UNREPRESENTABLE rather than checked. The test names the hazard: an_edit_is_projected_only_to_the_edited_documents_clients asserts on_alpha.session == on_beta.session FIRST.\n\nLIFECYCLE, and the non-obvious half. Open at STARTUP from the files named on the command line -- open-on-request was rejected because deciding which paths a client may open IS access control, which this ticket put out of scope; serving only what was named leaves the question unanswered rather than answered badly. NOT closed when the last session leaves: eviction loses unsaved edits, and worse, document_generation and record_versions start at ZERO in a fresh App (gotcha 40), so a client reconnecting with a STALE basis would have it accepted as current -- a hole in the exact mechanism ADR 001 is building, bought for a memory saving nobody asked for. Registry::is_idle is the hook a future policy asks from; nothing evicts.\n\nA client names its document in the URL PATH (ws://host:port/quarterly), which leaves wire.rs COMPLETELY unchanged so the wasm-client frames keep working; / resolves to the only document when there is exactly one, and with two is ConnectError::Ambiguous rather than a coin toss. A refusal is a websocket CLOSE FRAME carrying the reason -- no ServerMsg::Error variant invented, because that is a connection fact and wire.rs only grows variants the mutations demand.\n\nEvery required claim has a counter-test beside it so it cannot pass vacuously -- e.g. the broadcast-isolation fixture gives BOTH documents a view named Second, so betas silence is the routing and not a name it would have rejected anyway.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-2wi8","title":"ADR-001 e: a forced overwrite is visible to the client it overwrote","description":"ADR 001 layer 1, step e. Depends on step c.\n\nWithout this, force is silent data loss with a confirmation step in front of it. The overwritten client edit was already committed, so this is a NOTIFICATION rather than a conflict -- but it must exist, or \"someone else won\" is indistinguishable from \"my edit never happened\".\n\nDesign note: the notification is about the loser OWN prior edit, so it is per-client and therefore UI -- the model is shared, the UI is not. Do not broadcast it as document state.","acceptance_criteria":"A client whose committed edit was overwritten by a force is TOLD. Without this, force is silent data loss with a confirmation step in front of it — 'someone else won' must be distinguishable from 'my edit never happened'. It is a NOTIFICATION rather than a conflict, because the overwritten edit was already committed. Per-client and therefore UI: do not broadcast it as document state (model-shared-ui-not-shared).","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-19T23:27:34Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:56:22Z","labels":["area:protocol"],"dependencies":[{"issue_id":"improvise-2wi8","depends_on_id":"improvise-3al1","type":"blocks","created_at":"2026-09-19T23:27:47Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-vb6l","title":"ADR-001 d: when an intent cannot be re-composed, show what changed","description":"ADR 001 layer 1, step d -- the expensive one, and the honest cost of this design. Depends on step c.\n\nConflict class 2 (shape change vs cell edit) has NOTHING COHERENT TO FORCE: the key names a coordinate that no longer exists, and applying anyway writes a CellKey naming a deleted category -- the ghost-coordinate corruption the whole ADR exists to prevent.\n\nSo for class 2 the UI owes the user WHAT CHANGED plus the concrete alternatives -- re-target the edit, or re-create the structure first -- rather than a bare refusal or a force button that cannot be honoured.\n\nFORCE NEVER CREATES STRUCTURE: re-instating a category someone deleted so an edit can land is a different and LARGER intent than the user expressed. It must be an explicit action, never a side effect of forcing a cell edit.\n\nClass 3 (a formula now shadows the value) is forceable WITH A WARNING: the write lands, the display stays derived.\n\nNOTE FOR WHOEVER PICKS THIS UP: the ADR says plainly that if this dialogue is more than we want to build, the fallback is making shape changes rarer under contention -- e.g. a category deletion being explicit about the cells it destroys -- rather than making the conflict cheaper to resolve. That is a product call, not an implementation detail.","acceptance_criteria":"For conflict class 2 the user is shown WHAT CHANGED plus the concrete alternatives — re-target the edit, or re-create the structure first — rather than a bare refusal or a force button that cannot be honoured. FORCE NEVER CREATES STRUCTURE: re-instating a deleted category so an edit can land is a larger intent than the user expressed and must be an explicit action. Class 3 (a formula now shadows the value) is forceable WITH A WARNING. AND THE PRODUCT CALL COMES FIRST: the ADR says plainly that if this dialogue is more than we want to build, the fallback is making shape changes rarer under contention — e.g. a category deletion being explicit about the cells it destroys — rather than making the conflict cheaper to resolve. Answer that before building.","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-19T23:27:33Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:56:22Z","labels":["area:protocol"],"dependencies":[{"issue_id":"improvise-vb6l","depends_on_id":"improvise-3al1","type":"blocks","created_at":"2026-09-19T23:27:46Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-7b8v","title":"Concurrent edits: advisory reservations (ADR 001, layer 2 — UX only)","description":"Implements layer 2 of context/adr/001-concurrent-edits.md. BLOCKED ON layer 1 by design, not by dependency: correctness must not rest on this layer.\n\nGeneralise the drill staging mechanism so a client publishes the KEYS it has staged, letting other clients grey them out and warn before someone types into a cell that is about to be taken. The VALUES stay private -- the user ruled on that in improvise-tom1: staged edits are uncommitted writes belonging to whoever staged them, which is why gotcha 30 keeps drill_state off ViewState.\n\nTHE DESIGN CONSTRAINT: this layer is ADVISORY ONLY. If a reservation is lost, never arrives, or leaks from a crashed client, the system degrades to \"you find out at commit\" -- never to corruption. That is what makes it safe to be sloppy about timeouts and disconnects, and it is exactly why the basis check must land first and stand alone.\n\nNote the split it implies: the reservation KEY SET is shared (everyone must see it to be warned) while the VALUE is private. That maps onto the existing EffectKind distinction rather than fighting it.\n\nDo not add lease renewal or lock timeouts as correctness machinery. If a reservation outlives its client, the worst outcome is a stale warning, and the basis check still rejects the actual conflict.","acceptance_criteria":"A client publishes the KEYS it has staged; other clients grey them out and warn before typing into a cell about to be taken. THE VALUES STAY PRIVATE — the user ruled on that in improvise-tom1. ADVISORY ONLY: if a reservation is lost, never arrives, or leaks from a crashed client, the system degrades to 'you find out at commit' and NEVER to corruption — which is what makes it safe to be sloppy about timeouts and is why the basis check must land first and stand alone. Do NOT add lease renewal or lock timeouts as correctness machinery.","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-19T23:09:54Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:56:23Z","labels":["area:protocol"],"dependencies":[{"issue_id":"improvise-7b8v","depends_on_id":"improvise-3al1","type":"blocks","created_at":"2026-09-19T23:27:48Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"improvise-7b8v","title":"Concurrent edits: advisory reservations (ADR 001, layer 2 — UX only)","description":"Implements layer 2 of context/adr/001-concurrent-edits.md. BLOCKED ON layer 1 by design, not by dependency: correctness must not rest on this layer.\n\nGeneralise the drill staging mechanism so a client publishes the KEYS it has staged, letting other clients grey them out and warn before someone types into a cell that is about to be taken. The VALUES stay private -- the user ruled on that in improvise-tom1: staged edits are uncommitted writes belonging to whoever staged them, which is why gotcha 30 keeps drill_state off ViewState.\n\nTHE DESIGN CONSTRAINT: this layer is ADVISORY ONLY. If a reservation is lost, never arrives, or leaks from a crashed client, the system degrades to \"you find out at commit\" -- never to corruption. That is what makes it safe to be sloppy about timeouts and disconnects, and it is exactly why the basis check must land first and stand alone.\n\nNote the split it implies: the reservation KEY SET is shared (everyone must see it to be warned) while the VALUE is private. That maps onto the existing EffectKind distinction rather than fighting it.\n\nDo not add lease renewal or lock timeouts as correctness machinery. If a reservation outlives its client, the worst outcome is a stale warning, and the basis check still rejects the actual conflict.","acceptance_criteria":"A client publishes the KEYS it has staged; other clients grey them out and warn before typing into a cell about to be taken. THE VALUES STAY PRIVATE — the user ruled on that in improvise-tom1. ADVISORY ONLY: if a reservation is lost, never arrives, or leaks from a crashed client, the system degrades to 'you find out at commit' and NEVER to corruption — which is what makes it safe to be sloppy about timeouts and is why the basis check must land first and stand alone. Do NOT add lease renewal or lock timeouts as correctness machinery.","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-19T23:09:54Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:56:23Z","labels":["area:protocol"],"dependencies":[{"issue_id":"improvise-7b8v","depends_on_id":"improvise-3al1","type":"blocks","created_at":"2026-09-19T23:27:48Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-ry89","title":"cv_regress/cv_classify with k=1 now return plausible numbers instead of NaN","description":"Wave-4 review (2026-09-19), CONFIRMED, LOW. A signal loss, not a wrong answer.\n\nmean_over(total, 0) == 0.0 replaced a 0.0/0.0 NaN, which is right everywhere it is used -- the reviewer looked for a case where an empty set WINS a comparison it should lose and found none (inside fit every candidate scores the same rows; cv_* pool pairs rather than reduce to a per-fold metric).\n\nBut with k=1, folds(n,1) puts every index in the single fold, so the training complement is EMPTY and the model trains on nothing. Pre-change, standardize(\u0026[]) gave mean = NaN and every pooled prediction was NaN -- the degenerate call announced itself. Post-change:\n k=1 pooled[0] = (1.48e-6, 8.0), nan_predictions = 0\nSo a library caller passing k=1 gets 37 near-zero cross-validated predictions from a tree fit on nothing, and anyone computing R2 off them gets a number rather than a NaN.\n\nexamples/fit_csv.rs guards with k \u003e= 2, so no shipped path hits it. Fix by documenting on cv_regress/cv_classify that k\u003c2 is meaningless, or by returning empty for k\u003c2. Note the folds doc argues max(1) is \"the answer the shape gives\" -- true for folds, and no longer true for the cross-validators.\n\nNIT from the same review: standardize_is_bit_for_bit_what_it_was is insensitive -- both perturbations of standardize left it green (one was caught by cv_regress instead). Its four witnesses do not separate accumulation orders on the 37-row fixture. Worth strengthening while here.","notes":"\n--- PARTIALLY ADDRESSED on PR #11 (2026-09-19) ---\nCopilot independently re-found this. The PR agent DOCUMENTED it rather than\nguarding it, and the reasoning is right: returning empty for k \u003c 2 is a\nbehaviour change to the exact functions scoring_known_answers.rs pins\nbit-for-bit, inside a PR whose entire claim is bit-identity. It deserves its\nown commit where it is the headline rather than a rider on a reshape.\n\nSTILL OPEN: decide guard vs document-only. If guarding, the known-answer\nvectors must be re-established afterwards, not assumed -- that is the whole\npoint of them.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T22:18:04Z","created_by":"Edward Langley","updated_at":"2026-09-19T23:40:24Z","closed_at":"2026-09-19T23:40:24Z","close_reason":"GUARDED, not documented. The returned value is pooled predictions from trees that never saw the row; with one fold there are none, so EMPTY IS THE TRUE COUNT, not a sentinel. Documenting would leave a library function whose degenerate answer looks like a models output.\n\nThe asymmetry is resolved by splitting the QUESTION, not by clamping differently: folds keeps its max(1) (a partition into one piece is still a partition) and gains a note that the answer is its alone; a new private cv_folds(n,k,rng) yields no fold below k=2 and is what both cross-validators ask. Declining BEFORE the shuffle costs the caller generator nothing.\n\nTHE TICKET PREMISE WAS FALSE and was checked rather than assumed: it said returning empty for k\u003c2 would change numbers the known-answer vectors pin bit-for-bit. Those vectors only ever call k=4 and k=3, so the guard changes none of them -- all six pass unchanged, not one pinned number edited.\n\nThe insensitive-witness NIT is fixed too: standardize_is_bit_for_bit_what_it_was pinned four numbers and let r.target/std - mean/std (same algebra, different rounding) through AND past both CV vectors; it now pins all 37 normalized targets, which that injection moves 21 of.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-k2i4","title":"The two nullary model commands are unaddressed and act on one client's staged state","description":"Wave-4 review (2026-09-19), CONFIRMED, LOW. Worth a decision and a note rather than an immediate change, since multi-client is not wired.\n\nApplyAndClearDrill and AcceptWizardModel are safe no-ops when nothing is staged (both early-return), so a client cannot corrupt the document by sending them blind. Two residual issues:\n\n(a) SPURIOUS PROJECTIONS. Dirty comes from the EffectKind, not from whether anything happened, so a no-op still dirties everything at that level and reaches every client. Measured: ApplyAndClearDrill dirties grid+category_tree+formulas; AcceptWizardModel dirties those plus view_list+tile_bar, emitting a non-empty projection list for a no-op.\n\n(b) THE STAGED STATE IS GLOBAL AND THE COMMAND IS NOT ADDRESSED. Sessions::apply routes Command::Model with no SessionId, while drill_state and wizard live on the single authoritative App. So client B sending Model(ApplyAndClearDrill) commits client A uncommitted staged edits, and Model(AcceptWizardModel) installs the workbook A half-finished wizard built -- replacing the document under everyone.\n\nThat collides with the user ruling on improvise-tom1: staged drill edits belong to whoever staged them and must not be visible to others. Committing them on someone else behalf is the stronger version of the same violation. Gotcha 30 calls this state session-local residue; the command layer now lets an unaddressed message act on it.\n\nDECIDE: either these commands become addressed (a SessionId, and the effect acts only on that session staged state -- which means the staging map moves off the single App), or they are removed from the wire vocabulary and stay key-path only. The second is cheaper and consistent with tom1.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T22:18:03Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:58:04Z","closed_at":"2026-09-20T00:58:04Z","close_reason":"Stale -- already resolved by improvise-3x3u, verified during improvise-fkfg. 3x3u removed ApplyAndClearDrill and AcceptWizardModel from ModelCommand entirely; only prose mentions remain in reduce.rs, no variants. So the premise of this ticket (two nullary wire variants acting on one client's staged state) no longer holds: those commands have NO WIRE FORM to carry a basis or a SessionId.\n\nConsequences, both checked while landing ADR 001 layer 1: layer 1 does not make this worse, and layer 1 LANDS HONESTLY WITHOUT IT. What survives is the successor 3x3u itself names -- multi-client drill wants the ADDRESSED form of those two commands, which is ADR 001 layer 2 work (improvise-tom1 and the reservations ticket), not this.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-ls3y","title":"[bug] A wire-driven SwitchView does not push the view history; the effect does","description":"Found during improvise-kmgs (2026-09-19), pre-existing, out of that ticket scope.\n\napply_command SwitchView arm calls app.switch_to_view(name) DIRECTLY, while effect::SwitchView first pushes a ViewFrame onto view_back_stack and clears the forward stack. So a switch driven over the wire is NOT undoable by ViewBack, and a key-driven one is -- two spellings of one user action that differ in whether history records it.\n\nSurfaced while building a ViewBack session test: the test had to walk forward through the EFFECT to fill the stack, because the command path never fills it. That asymmetry is the tell.\n\nDecide which is right rather than blindly making them agree: history is arguably UI state (the model is shared, the UI is not), in which case the per-client stack should be fed by whichever door the client used -- and then the command path is the one that is wrong. Note improvise-jytm will make active_view per-client, which touches the same ground.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T22:11:30Z","created_by":"Edward Langley","updated_at":"2026-09-19T22:50:02Z","closed_at":"2026-09-19T22:50:02Z","close_reason":"Found again by Copilot on PR #11 and fixed in ee767f9, merged as eb0c271. apply_command performed Command::SwitchView itself, calling App::switch_to_view directly instead of running effect::SwitchView -- and the EFFECT is the only thing that pushes onto view_back_stack and clears view_forward_stack. It was also the last hand-rolled mutation left in apply_command, which sat badly against the PR own \"effects are the only mutation site\" thesis.\n\nWAVE 4 IS WHAT MADE IT BITE: ModelCommand::ViewBack pops exactly that stack, so a wire client could switch views and then be unable to go back, while the same two keypresses worked. Before ModelCommand there was no wire way to ask, so the missing push was invisible.\n\nDemonstrated first: a_wire_view_switch_records_history_the_way_a_key_switch_does failed with the document still on Second. Nothing else in the suite could see it -- switch_view_moves_the_document_and_the_client_together checks where a switch LANDED, not what it RECORDED.\n\nPart of Copilot suggestion was declined with reasons on the thread: the arm still returns a hand-written Dirty rather than Dirty::of(Model(LayoutGeometry)), because Dirty::of is a per-KIND over-approximation and a view switch is the one member of that kind which leaves the view list alone -- which view is ACTIVE is not in ViewListCache at all. switching_views_projects_the_axis_widgets_only pins that.","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/AGENTS.md b/AGENTS.md index b8ed1423..62f9e693 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -139,6 +139,21 @@ merges agent branches into its *local* `main` to integrate and run the gates — that is how conflicts get found early — but the push is a branch push, not a `main` push. `origin` and `gitea` are mirrors and take `main` directly. +**So `origin/main` runs ahead of `gh/main`, and a branch destined for a GitHub +PR must merge `gh/main` — never `origin/main`.** This is a trap in this repo's +own topology rather than a slip: the two remotes are both spelled `main`, and +nothing about `git merge origin/main` looks wrong. The session-completion +protocol below literally says `git merge origin/main`, which is right for +`main` itself and wrong for a PR branch. It cost a review round on PR #145, +where four unmerged commits — including another PR's whole diff — were pulled +into a branch based on `gh/main` and had to be rebuilt by cherry-picking. +ChatGPT Codex caught it; nothing in CI could, because the result compiles and +passes. The tell is a PR diff naming files the ticket has no reason to touch, +so **read your own PR's file list before asking for review**. Note the window +is transient — the two remotes are equal immediately after the orchestrator +syncs — which makes it worse, not better: the hazard is absent exactly when +you would think to check for it. + This is easy to drift out of, because merging locally and pushing feels like finishing. It happened in this repo: 27 commits across six agent branches — including a new crate, a new protocol type, and a type moving between crates — diff --git a/context/repo-map.md b/context/repo-map.md index 220d9403..f7145268 100644 --- a/context/repo-map.md +++ b/context/repo-map.md @@ -267,6 +267,11 @@ Region=West, Measure=Revenue = |pending| # pipe-quoted text 49. **A websocket handshake is exempt from the same-origin policy, and that is a server-side problem.** There is no CORS preflight, so any page a user visits can open a socket to `improvise-ws-server` *from that user's own allowed address* and read and rewrite every document it holds — cross-site websocket hijacking (improvise-kc7n). **No network control can see it**: an IP allow-list, a private network and loopback binding all answer "may this **client** reach the endpoint", and the honest answer is yes; only `Origin` says what is **driving** the client. **Loopback is the least protected case, not the safest** — no proxy in front of it, and loopback is not a boundary a browser enforces. `src/origin.rs` is the whole decision and `transport::admitted` its one caller; it refuses the **upgrade** with a 403 rather than accepting a socket and ignoring it, and `serve` **takes** the policy so a host cannot forget to apply it. Three arms are decided rather than defaulted: **loopback allowed** (the shipped local recipe serves its page from `http://localhost:8080`, so the one case worth optimising for must need no flag), **absent allowed** (no browser omits `Origin`, so absent is a non-browser client an address check already answers — and refusing it breaks `tests/socket.rs`, the repo's own instrument), and **two refused** (`HeaderMap::get` is first-wins, so a proxy that *appends* rather than sets leaves two, and answering from the first lets the second be anything). The deployed Pages origin is deliberately **not** hard-coded: it can only reach a plain-`ws://` server through a TLS proxy anyway, so one flag is an honest cost on an already multi-step path where a baked-in default would be a permanent hole for every other deployment. The residual hole is **stated rather than guarded** — a proxy that *strips* `Origin` turns the check off, which is why the proxy's own allow-list stays in `README.md` as defence in depth. **None of this is authentication**: it answers what page is driving a client, never who is connecting, and improvise-kpnr is still open. It correctly does **not** move to `improvise-command` beside `Host` (gotcha 45) — a `MessagePort` has no handshake and no headers, and a worker and its page are the same origin by construction, so this is genuinely transport-only and not a mirror waiting to drift. + +50. **A composed TUI row measures in cells and names which side yields.** `draw.rs::fill_line` takes a `Keeps`, because the three rows that share a line genuinely disagree about it: the title bar keeps the **right** (the `?:help :q quit` escape hatch outranks an unbounded document name — improvise-t14d), while `minibuffer_line` and `draw_status` keep the **left**, which is what they already did, since right-priority there would let a long status message eat the text the user is editing. So the ticket's "give the right segment priority" was correct only for the title bar, and a global rule would have been the wrong shape. Widths go through `ui::text::truncate`, never `str::len` — and the ticket framed *that* half wrongly too: the title's own `·` separator is two bytes and one cell, so a byte count mis-padded **every** document, not only one with a non-ASCII name. + + **One key table on screen, and it is the one derived from the keymap** (improvise-rr8n). `TileBar` now advertises no keys at all: its literal read `Ctrl+↑↓←→ to move tiles` where those keys are bound to `enter-tile-select`, and the derived status line is a strict superset (`Space`, `h`/`l`, `Tab` appear only there). The ticket asked for the opposite — rewrite the literal, suppress the derived line — and was inverted deliberately, because suppressing a derived superset to preserve a literal that had already rotted keeps the stale table and deletes information. The **import wizard goes the other way**, and the asymmetry is the rule rather than an exception: its status line *is* derived, but from the **mode** keymap while the real keys are per-step inside `HandleWizardKey`, so there the derived table is the one that lies and `draw_status` suppresses the fallback for that one mode. **Keep whichever table is derived from the thing it describes.** Two blind spots make this class invisible: `every_hint_key_resolves_in_its_mode` **cannot see a string literal in a widget** (the same gap 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 one test pinning that output was structurally incapable of seeing either hint. + --- ## File Inventory diff --git a/prolog-sessions/kb.pl b/prolog-sessions/kb.pl index 8ded1805..2f631af1 100644 --- a/prolog-sessions/kb.pl +++ b/prolog-sessions/kb.pl @@ -368,6 +368,8 @@ doctrine(an_observation_is_a_function_of_the_trajectory_not_the_state,'improvise 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.'). +doctrine(a_mirror_remote_named_main_is_not_the_pr_base,'AGENTS.md makes origin and gitea mirrors that take main directly while GitHub gets branches and PRs, so ORIGIN/MAIN RUNS AHEAD OF GH/MAIN. An agent running the habitual git merge origin/main therefore pulls UNMERGED work into a branch based on gh/main -- on PR #145 that was four commits including another PR whole diff. The two remotes are both spelled main and nothing about the command looks wrong; nothing in CI can see it, because the result compiles and passes. Caught by ChatGPT Codex; repaired by cherry-picking onto gh/main. THE RULE: a branch destined for a GitHub PR merges gh/main, never origin/main, and you read your own PR file list before asking for review -- a diff naming files the ticket has no reason to touch is the tell. THE WINDOW IS TRANSIENT, equal immediately after an orchestrator sync, which makes it worse rather than better: the hazard is absent exactly when you would think to check.'). +doctrine(a_fixture_width_is_part_of_what_a_test_can_see,'renders_each_tile_with_its_axis_label renders at 80 COLUMNS, where the tile bar hint does not fit and is NEVER DRAWN -- so the one test pinning that widget output was structurally incapable of seeing either the false hint or its removal (improvise-rr8n). A false key literal survived there for that reason alone. The same shape as a one-category fixture or a helper that fabricates the items the code under test needs: the fixture, not the assertion, is what decided the question. When a test pins RENDERED output, the width, height and terminal size are inputs to be chosen deliberately and stated, and a claim about something that only appears at a width the fixture never uses is not tested at all.'). 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.'). @@ -519,6 +521,8 @@ decision('improvise-f9qf',one_checker_entry_point,'scripts/checkers.sh is the ON 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.'). +decision('improvise-rr8n',keep_the_table_derived_from_what_it_describes,'The ticket asked to rewrite the tile-bar hint literal and SUPPRESS the derived status line in TileSelect. The implementing agent did the opposite and the orchestrator ACCEPTED it: the derived status line is a STRICT SUPERSET of the literal (Space, h/l, Tab appear only there) and cannot go stale, so suppressing it would delete information and keep the table that had just rotted. Verified before accepting -- the literal read \"Ctrl+arrows to move tiles\" while those keys are bound to enter-tile-select, so it was already false. Both tile-bar literals deleted; Normal derived line already says T:tiles. The import wizard keeps the ticket ORIGINAL answer, and the asymmetry is the point: ITS status line is derived from the MODE keymap while the real keys are per-step inside HandleWizardKey, so there the derived table is the one that lies. RULE: keep whichever table is derived from the thing it describes. COROLLARY: every_hint_key_resolves_in_its_mode CANNOT SEE A STRING LITERAL IN A WIDGET, the same blind spot improvise-wk18 names for help.rs, so a hand-written key literal is invisible to every hint guard in the repo.'). +decision('improvise-t14d',an_affordance_outranks_a_decoration,'draw.rs::fill_line takes a Keeps parameter rather than a global rule, because the three rows sharing a composed line genuinely disagree about which side yields. The title bar keeps the RIGHT -- the ?:help/:q escape hatch outranks an unbounded document name -- while minibuffer_line and draw_status keep the LEFT, which is what they already did, since right-priority there would let a long status message eat the text the user is editing. So the ticket \"give the right segment priority\" was correct ONLY for the title bar. Second fault, and the ticket framed it wrongly: padding by str::len mis-padded EVERY document including pure-ASCII ones, because the separator dot is in draw_title own format string at two bytes and one cell -- not a non-ASCII-NAME problem as filed. Widths now go through ui::text::truncate.'). 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').