diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index a744dab5..b967f839 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,3 +1,4 @@ +{"_type":"issue","id":"improvise-h3y7","title":"The wire vocabulary must be complete against what needs the server, not merely correct","description":"USER RULING 2026-09-21: \"the wire protocol needs to cover the full range of commands that need the server side.\"\n\nThis is a COMPLETENESS requirement on the vocabulary, and it lands on a\nweakness the wave just demonstrated rather than on a suspicion.\n\nTHE PRECEDENT THAT MAKES IT URGENT. PR #115 found that gotcha 38's account of\nwhy ViewBack/ViewForward were off the wire was FALSE: it said they were\nsession-local residue like ApplyAndClearDrill, but view_back_stack is in\nViewState -- the portable slice -- so by the view half's own rule\n(decision(improvise-cqi): the ViewOnly effects targeting the portable slice)\nthey were always Commands and were simply MISSING. The derivation had been\nchecked for CORRECTNESS -- every listed variant belongs -- and never for\nCOMPLETENESS. The cost was a live divergence nobody could see: reduce_view's\nSwitchView arm set active_view and nothing else while effect::SwitchView pushes\nthe back stack, so NO CLIENT EVER HAD A BACK HISTORY.\n\nSo this bead is: enumerate the vocabulary AGAINST the rule, in both directions,\nand close what the enumeration finds.\n\nTHE KNOWN GAPS AT 523d571, to be treated as a starting list rather than the\nanswer:\n\n1. NO ModelCommand FOR A COORDINATE RENAME. ApplyAndClearDrill's\n RecordCol::Category(_) arm is apply_coordinate_rename -- add an item, then\n move EVERY measure cell in the row (gotcha 1) -- and grepping reduce.rs for\n Rename returns zero. A drill's category-column commit therefore has no wire\n form. This is a missing ModelCommand, NOT a missing addressed form, and is\n much smaller than it was assumed to be.\n\n2. SIX NAMES IN declined() (resolve.rs:232) that a browser cannot ask for at all:\n drill-into-cell, toggle-prune-empty, toggle-records-mode,\n add-record-row, delete-formula-at-cursor, view-back\n plus enter-mode via stranding (SavePrompt, ExportPrompt, CommandMode).\n view-back becomes removable when PR #115 merges -- the comment blaming the\n drill commit is wrong as of that PR. The others each need their own answer:\n some want a ModelCommand, some want a projection, and some may be genuinely\n host-only. SAY WHICH, PER NAME.\n\n3. EIGHT NAMES in the_command_names_that_still_need_the_host_are_these:\n hide-selected-row-item, toggle-group-under-cursor, search,\n search-navigate, search-or-category-add, enter-advance, save, force-quit\n (search-or-category-add leaves with improvise-koh6/PR #114.) Note gotcha 47:\n an UPSTREAM key is a black hole, not a slow path -- Host::handle answers\n ClientFrame::Key with an explicit empty vec -- so every name on this list\n does NOTHING in a browser rather than being merely slower.\n\n4. ClientFrame::Key ITSELF. It exists, is deployment-independent by gotcha 42's\n argument, and is answered with an empty vec. Either it should carry\n something or it should not exist; today it is a promise the protocol does\n not keep.\n\nWHAT THIS BEAD IS NOT: a licence to put everything on the wire. gotcha 38\nrecords two effects deliberately EXCLUDED -- ApplyAndClearDrill and\nAcceptWizardModel -- because their operands are session-local residue addressed\nto precisely one session, and an unaddressed form lets client B commit client\nA's typing (demonstrated against Sessions::apply before removal). The rule is\n\"everything that NEEDS the server\", and residue does not. The test for each\ncandidate is the one gotcha 38 already states: does the argument for excluding\nit name the thing it differs in?\n\nACCEPTANCE: a per-name verdict for every entry in (2) and (3) -- on the wire,\ndeliberately excluded with the argument, or blocked on a named prerequisite --\nplus the enumeration in (1) closed or filed. A list of what is missing is worth\nmore than a partial fix, and doctrine(a_stale_list_of_what_is_missing_is_worse_than_no_list)\nmeans the verdict list has to be maintained where it can be checked, not left\nin this bead.\n","status":"open","priority":1,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T02:51:02Z","created_by":"Edward Langley","updated_at":"2026-09-22T02:51:02Z","labels":["architecture","protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-koh6","title":"[bug] Following the help overlay's N to add a category overwrites a data cell","description":"DEMONSTRATED end to end under Chromium at 523d571, on web/demo.improv, with\nthree controls (C, P, T) in the same loop that DID move -- otherwise a blind\nprobe and a black hole look identical.\n\n cell (0,0) before: \"18,400\"\n keys: N R e g i o n Enter\n cell (0,0) after: \"18400on\" \u003c- Espresso/Revenue, overwritten with text\n categories: UNCHANGED -- no category was created\n status: \"...records mode is not available in the browser yet\"\n\nTHE SEQUENCE, measured one key at a time:\n\n - `N` resolves Upstream. Host::handle answers a Key frame with an empty vec, so\n the FULL DOM snapshot -- mode, hint, status, grid, tiles, minibuffer -- is\n BYTE-IDENTICAL before and after. Completely silent.\n - The user then types the category name into NORMAL, where every character is a\n live Normal binding:\n R -\u003e status \"records mode is not available in the browser yet\"\n e, g -\u003e nothing\n i -\u003e INSERT on the grid cell under the cursor, PRE-FILLED with its value\n o, n -\u003e appended to that edit buffer\n - Enter commits.\n\n`N` is advertised by the browser's OWN help overlay, page 4:\n\"N -- Quick-add a new category (from anywhere)\".\n\nWHY THIS IS NOT THE MINIBUFFER BUG IT WAS REPORTED AS: the minibuffer works\nperfectly. Measured: C then n gives mode NEW CATEGORY, minibuffer \"new category:\"\nin yellow, 1280x28.6 at y=691, and each typed character appears live; Enter\ngives 'Added category \"Reg\"'. Verified in two more modes (F,n -\u003e \"formula:\" cyan;\nC,a -\u003e \"add item to Product:\" green). The user's \"typing blind\" was LITERAL -- a\ndead key followed by characters landing as commands.\n\nTHE CATEGORY THIS ADDS TO THE DOCTRINE: clients.md enumerates black holes as\nkeys that do nothing. A black hole that leaves the keyboard in a MUTATING mode\nis strictly worse and is not currently distinguished. The cost here is a\ncorrupted value, not a confused user.\n\nSECOND, COMPOUNDING PROBLEM: the decline messages actively mislead. Pressing N\nthen R answers \"records mode is not available in the browser yet\" -- confident,\ntrue, and about a different subject entirely, arriving adjacent in time to an\nunanswered gesture. clients.md already names the failure (\"a reader who is told\nsomething plausible stops looking\"); this is a new route to it.\n\nIS THERE A ONE-LINE FIX? NO, and the investigating agent resisted calling it one.\nSearchOrCategoryAdd::execute (command/cmd/search.rs:181) is:\n\n if !ctx.search_query.is_empty() { SearchNavigate(false).execute(ctx) }\n else { vec![SetPanelOpen{Category,true}, change_mode(AppMode::category_add())] }\n\nThe else-branch is exactly two commands the browser already composes elsewhere,\nand search_query is on ViewState, so the client HAS everything it needs. But a\nconditional arm makes N's outcome STATE-DEPENDENT, and\nenter_in_an_empty_view_panel_resolves_locally_rather_than_upstream\n(resolve.rs:1364) exists precisely to forbid making the host-needed list\nstate-dependent. So it is a decision, not a line.\n\nCHEAP INTERIM that stops the data loss without deciding that: one arm in\ndeclined() giving search-or-category-add its own true subject. That ends the\nsilence but NOT the sequel, because the user's next keystrokes still land in\nNORMAL where `i` opens an editor.\n","status":"open","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T00:22:11Z","created_by":"Edward Langley","updated_at":"2026-09-22T00:22:11Z","labels":["browser","data-loss","ux"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-1wft","title":"Browser boot failures have no diagnosis channel, so five different causes all read as silence","description":"Five open beads, filed independently by three different reviewers, are one\nfailure mode: the browser's boot can fail for several reasons and the page\neither says nothing specific or nothing at all.\n\n improvise-i0v9 OPFS handle held by another context (or a prior session)\n improvise-86nw start() rejects for a non-OPFS reason; onFrame then drops\n every frame while `server` is null\n improvise-9cma invalid ?doc= -- generic watchdog text only\n improvise-iobc bad or missing ?seed= -- silent empty document, CORS/404\n visible only in the console\n improvise-vep8 the watchdog arms AFTER `await transport.ready`, so a\n connection hang never arms it at all\n\nTHE SHARED ROOT: there is no channel carrying a boot failure's REASON from the\nworker to the page. gotcha 46 already records the mechanism -- a worker that\nrejects in start() fires NO `error` on the main thread, because worker.js\ncatches its own -- so the only survivor is a generic first-frame watchdog, and\nimprovise-vep8 shows that watchdog does not even arm in one case.\n\nThe result is the worst shape a failure can take, measured on i0v9: `#status`\nreads \"in-page worker - \u003cdoc\u003e\" at 13ms, which looks like SUCCESS, and 8 seconds\nlater is replaced by \"this browser may not allow it\" -- blaming the browser for\na condition the code knows precisely, since it is holding the exact\nNoModificationAllowedError naming the exact document.\n\nWHAT THIS TICKET IS: the diagnosis channel, not the five triggers. The worker\nmust be able to report a structured boot failure across postMessage, and the\npage must render it instead of a timeout. Each of the five then becomes a\nmatter of producing the right reason rather than of plumbing.\n\nTwo things to decide while building it, both raised by the triggers above:\n - a boot failure is a STATE, not a rejected promise (i0v9 needs the page to\n open and paint read-only, which means boot must succeed without a handle)\n - the watchdog stays, but as a LAST resort that is armed before anything can\n hang (vep8), and is disarmed rather than pre-empted by a precise failure\n (gotcha 48 already states that rule and PR #21 implemented half of it)\n\nNot a new investigation: every one of the five is already demonstrated. This\nexists so the five are not fixed five different ways.\n","status":"closed","priority":1,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-21T21:26:34Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:58:54Z","closed_at":"2026-09-21T21:58:54Z","close_reason":"Fixed in the 2026-09-21 wave, integrated into main at 1574 passed / 0 failed / 7 ignored. Playwright 42 passed / 0 failed / 4 skipped (WebKit, no OPFS) -- up from 37, so the count MOVED. All gates green.\n\nTHE CHANNEL: a protocol frame crosses the port as a STRING, a shell notice as an OBJECT. Total discriminator, zero protocol change, since ServerFrame JSON is a string by construction of on_message.\n\nA boot failure is now a STATE, not a rejected promise: a document another context holds the handle to opens READ-ONLY over vfs::ReadOnly rather than dying. Only BootFailed is fatal. The watchdog is armed BEFORE transport.ready and every notice routes through shell.fail, so gotcha 48's disarm half holds.\n\nMoving the watchdog forced its message to change, and that is load-bearing: armed before ready, it can no longer claim 'accepted the connection but sent no document' -- the page does not know that yet. The existing spec asserted the OLD wording and failed, which is the right way round and is how it was found.","labels":["architecture","browser","ux"],"dependency_count":0,"dependent_count":6,"comment_count":0} {"_type":"issue","id":"improvise-55l5","title":"dispatch swallows interactive() errors silently","description":"## Summary\n\n`dispatch` uses `.ok()?` on `interactive()`, swallowing `Err` (argument parse failures, mode mismatch for `commit-buffer item`, etc.). The user presses a key and nothing happens — no status message. Violates `doctrine(an_upstream_key_is_a_black_hole)` for failures that *are* resolvable as user-visible errors.\n\n## Expected / Actual\n\n- **Expected:** Construction/resolve errors surface as status (or equivalent loud feedback).\n- **Actual:** `.ok()?` turns Err into silent no-op.\n\n## Evidence / verify\n\nBind a key to `commit-buffer item` in Normal mode (not ItemAdd). Press the key — command fails to construct with no status message.\n\n## Doctrine\n\n`doctrine(an_upstream_key_is_a_black_hole)` — silent no-ops that look like black holes when the failure is knowable.\n\n## Location\n\n`crates/improvise-command/src/command/keymap.rs` ~L37\n\n## PR / Attribution\n\nPR #100 F05. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","status":"open","priority":1,"issue_type":"bug","owner":"box@local","created_at":"2026-09-21T20:48:19Z","created_by":"external-grok-bot","updated_at":"2026-09-21T20:48:19Z","external_ref":"gh-pr-100-F05","labels":["adversarial-review","pr-100"],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -62,6 +63,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-cfly","title":"The wave ships view history with no key that reaches it: declined() still refuses view-back","description":"Found by the wave's integration agent at preview 30560e0f, and it is a WAVE-LEVEL\nresidue rather than a defect in any branch -- which is why nobody could see it.\n\nPR #115 makes the view history genuinely work for a connected client:\nViewBack/ViewForward join the view half of Command, reduce_view folds all three\nview effects line for line, each client's own view_back_stack is maintained, and\na deleted view is pruned from every client's history via Command::ForgetView.\n\nBUT resolve.rs's composed() has NO view-back or view-forward arm, and declined()\nstill answers \"stepping back to the previous view is not available in the browser\nyet\" -- byte-identical to base.\n\nSo the merged wave contains the CAPABILITY and not the KEY. A browser user\npresses `\u003c` and is told the feature does not exist, over machinery that now\nfully supports it.\n\nWHY NEITHER AGENT COULD SEE IT: #115 was told not to touch resolve.rs (another\nagent owned it) and correctly reported the exact lines instead. #114 owned\nresolve.rs and had no reason to look at declined() at all. Reading #115 alone you\nwould conclude browser view-history works; reading #114 alone you would never\nlook.\n\nTHE EXACT LINES, from #115's report and confirmed by the integration agent as\nstill byte-identical to base:\n\n crates/improvise-protocol/src/resolve.rs\n 239: // NOT \"not built\": it commits a drill first, so composing it would\n 240: // commit another client's typing. Needs addressed commands -- clients.md.\n 241: \"view-back\" =\u003e \"stepping back to the previous view\",\n\nDELETE THE COMMENT WITH THE ARM. Its stated reason is WRONG as of #115: composing\nview-back does not commit another client's typing, because a browser session has\nno drill state to commit. Leaving a false reason behind is worse than leaving the\ndecline.\n\nWHAT TO ADD: a composed() arm returning vec![Command::ViewBack], and the forward\nsibling. Note gotcha 47's rule -- each resolver mirrors its Cmd LINE FOR LINE,\nincluding where siblings disagree -- so check ViewNavigate's own execute() rather\nthan assuming the two directions are symmetric.\n\nPRECONDITION: #115 merged. Nothing else; #115 named this explicitly.\n\nNOT IN SCOPE: line 234, \"drill-into-cell\" =\u003e \"drill\". That one still needs the\n_Drill view session-local and the staging design, and the user has since ruled\nthat browser staging must be identical to the TUI's. See improvise-gd28.\n\nALSO: make sure the wave's PR body does not claim browser view-history works.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T04:17:26Z","created_by":"Edward Langley","updated_at":"2026-09-22T04:17:26Z","labels":["browser","protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-en4s","title":"No guard that a client's formula panel loses a formula its target item's deletion removed","description":"Found while reviewing improvise-g3no; the reasoning is in\ncontext/code/command.md beside the other Dirty notes.\n\nDirty::of(ModelChange::CellData) reports formulas: true. That was written as\ndeliberate over-reporting in the cheap direction -- a cell write cannot change\na formula, so the entry cost one redundant Projection and bought nothing.\n\nIt is now load-bearing. improvise-g3no made Model::remove_item drop the\nformulas targeting the item, and effect::RemoveItem is CellData, so a delete\nreally can take a formula out of the document. Tightening that arm would leave\nevery connected client's formula panel showing a formula the document no longer\nhas, forever, with nothing in the stream to correct it -- gotcha 35's expensive\ndirection, and exactly the failure that put `formulas` on the LayoutGeometry arm\nin the first place.\n\nNothing guards it. removing_a_category_empties_the_clients_formula_panel covers\nthe LayoutGeometry twin only. The missing test is its item-level sibling: a\nsession whose formula panel names a _Measure formula target, a\nModelCommand::RemoveItem for that target, and an assertion that the client's\nFormulaCache no longer names it. Inject the fault by setting formulas: false on\nthe CellData arm of Dirty::of.\n\nLives in ui/project.rs, which is why it is filed rather than written.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T02:27:48Z","created_by":"Edward Langley","updated_at":"2026-09-22T02:27:48Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-xolb","title":"The wave brief's gate list omitted the node-test jobs, the only gate that sees an unpainted RenderModel field","description":"Found by the wave's integration agent diffing my wave brief against .github/workflows/ci.yml.\n\nMY GATE LIST OMITTED A REAL CI JOB:\n\n ./scripts/node-test.sh 'web/tests/*.test.js' ci.yml:239 \"Browser JS unit tests (node --test)\"\n web/tests/bundle/*.test.js ci.yml:371 (needs a built bundle)\n\nAt base: 36 tests over 5 files, 0 fail. Neither my brief nor context/repo-map.md's \"Testing -- the short version\" mentions either.\n\nWHY IT MATTERS, and it is not a formality: web/tests/render_surface.test.js is the MECHANICAL guard at the RenderModel -\u003e DOM boundary. It parses the pub fields off crates/improvise-wasm-client/src/render.rs and requires model.\u003cfield\u003e to appear in web/app.js + web/shell.js.\n\nPROVEN BY INJECTION: adding `pub injected_unpainted_field: u64` to RenderModel fails that gate with actual: ['injected_unpainted_field'], expected: []. cargo test --workspace is COMPLETELY INDIFFERENT to it.\n\nSo an agent working from a gate list that omits it can expose a projected field, never paint it, pass every gate it was told to run, and ship the defect. That is precisely the cross-branch shape a wave produces: one agent adds the field, another owns the painting.\n\nTWO BLIND SPOTS THE GUARD STATES ABOUT ITSELF, confirmed by reading, so it should not be over-trusted either:\n - it is a SUBSTRING search (page.includes(\"model.\" + field)), so a field read only inside a function nothing calls reads as painted\n - its field regex requires exactly four-space indentation and a lowercase name\n\nTHE GENERALISABLE PART: a gate list carried in a brief is a COPY of CI, and a copy drifts. The brief is written from memory and the repo-map, and both were stale here. Checking the brief against ci.yml is a once-per-wave action, not a once-per-agent one -- no single agent would find this, because q and s never touch RenderModel and r and t each individually pass cargo test.\n\nWHAT TO DO: add both node jobs to the repo-map's testing section and to the wave brief template, so the next brief inherits CI rather than memory.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T00:49:15Z","created_by":"Edward Langley","updated_at":"2026-09-22T00:49:15Z","labels":["docs","testing","tooling"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-piyp","title":"every_bound_command_name_is_registered is blind to prefix sub-bindings and F-keys","description":"PROVEN BY INJECTION 2026-09-21 by the wave's integration agent.\n\nevery_bound_command_name_is_registered (improvise-command/src/command/keymap.rs:417) has two blind spots:\n\n - the walk does `Some(Binding::Prefix(_)) | None =\u003e {}` at line 458 -- it NEVER descends into a prefix sub-keymap\n - its private probe_keys() (line 491) covers 0x20..0x7f plus 14 named keys but NO F-keys, while the neighbouring public probe_keys_pub() (line 905) DOES include F(1)/F(2)\n\nINJECTION: two command names bound that no registry entry provides -- one under the `g` prefix, one on F(5) in Normal.\n - every_bound_command_name_is_registered -\u003e ok\n - cargo test --workspace -\u003e 1595 passed, 0 failed, 7 ignored, 37 binaries, BYTE-IDENTICAL to baseline\n\nPOSITIVE CONTROL, which is what separates \"untested path\" from \"broken test\": the same bogus name on Char('~'), a probed non-prefix key -\u003e the test FAILS. So the guard works; its reach is the defect.\n\nCONSEQUENCE: a command name bound only under g/y/Z, or on a function key, can be unregistered -- OR have args that will not parse, since improvise-s13 moved enter-mode into the args-carrying class -- and the whole suite stays green. That is the exact failure the guard was written for, surviving in the region it does not reach.\n\ndump_every_binding DOES descend into prefixes via which_key, but it is a DUMP gated on IMPROVISE_BINDING_DUMP, not an assertion -- inert in CI, so it does not cover the gap.\n\nTHE CONTRAST THAT MAKES THIS WORTH FIXING RATHER THAN NOTING: nothing_declined_is_also_composed in improvise-protocol/src/resolve.rs is the STRONGER guard -- its bound_commands helper DOES recurse into Binding::Prefix and into parents. So the resolution side is guarded where the registration side is not, and an agent reading either test alone would reasonably conclude its bindings are checked. Only comparing the two shows which half is real.\n\nFIX SHAPE: descend into Binding::Prefix in the walk, and make probe_keys cover what probe_keys_pub already does. Both are small. The acceptance is the injection above failing -- test it with a name behind a prefix AND one on an F-key, because they are two different blind spots and one fix may not close both.\n\nRelevant: gotcha 13 (prefixes are stored as an address; KeymapSet::prefix_keymap walks parents so RecordsNormal reaches Normal's g/y/Z) and gotcha 15 (the guard checks BOTH halves of dispatch -- the name resolves and the args parse).\n","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T00:49:14Z","created_by":"Edward Langley","updated_at":"2026-09-22T02:27:16Z","closed_at":"2026-09-22T02:27:16Z","close_reason":"Fixed in PR #112 (wave/q-keymap, head b4bd6bc), both blind spots closed and each injection fails SEPARATELY -- which was the acceptance, because a fix for one need not close the other.\n\n - PREFIX: reachable_names now recurses into Binding::Prefix. Reported via RecordsNormal, which confirms the INHERITED-prefix path rather than only a locally-bound one.\n - F-KEY: probe_keys now DELEGATES to probe_keys_pub, which covers F(1)..=F(12).\n\nTHE FINDING THE BEAD DID NOT PREDICT, and it is the better half: the F-key gap was not an oversight about F-keys. probe_keys and probe_keys_pub were DUPLICATES differing by two lines, and the gap WAS the drift between them. Adding F-keys to both would have closed the symptom and left the mechanism intact. So the duplication doctrine applies to TEST SCAFFOLDING, not only to production derivations -- two copies of a test's probe key space is the same disease as two keymap spines, and it failed the same way.","labels":["command","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -95,7 +97,7 @@ {"_type":"issue","id":"improvise-jkxr","title":"parse_duplicate_categories is vacuous: cannot fail for a merge that drops half the items","description":"## Summary\n\n`persistence::parse::parser_edge_cases::parse_duplicate_categories` only asserts `!item_names.is_empty()` after parsing two `## Category: Type` sections (`- A` then `- B`). That cannot fail for the merge defect the test name and placement imply.\n\n## Evidence / fault injection\n\nProduction merge (`ParsedFile::push_category`) concatenates items on duplicate section names (`invariant(duplicate_category_sections_merge_in_pass_one)`). The nearby test `two_sections_naming_one_category_round_trip_to_a_stable_group_order` does assert both halves survive for groups.\n\nInjected fault mental model: change `push_category` to **replace** the first section with the second (keep only `B`), or keep only the first (`A`). Assert `!item_names.is_empty()` still passes. Only a total wipe of items fails.\n\nExpected assertion shape: both `A` and `B` present (order/content), matching the merge invariant — same standard as the group round-trip neighbour.\n\n## Doctrine\n\n- `doctrine(check_the_checker)` — a check nobody has seen fail for the named defect is indistinguishable from one that always passes\n- `invariant(duplicate_category_sections_merge_in_pass_one)`\n- design-principles §7 testing doctrine\n\n## Attribution\n\nexternal Grok Bot adversarial code review for Ed Langley\n\n## Location\n\n`crates/improvise-io/src/persistence/parse.rs` — `parse_duplicate_categories` (~L1096)","notes":"## Steps to Reproduce\n1. Open crates/improvise-io/src/persistence/parse.rs::parse_duplicate_categories.\n2. Mentally change ParsedFile::push_category to replace (not extend) on duplicate name.\n3. Re-run the test — it still passes.\n\n## Acceptance Criteria\n- Test asserts both items A and B are present after parsing the two Type sections.\n- Injecting a replace-not-extend merge makes the test fail.","status":"closed","priority":2,"issue_type":"bug","owner":"box@local","created_at":"2026-09-21T20:43:15Z","created_by":"box","updated_at":"2026-09-21T21:25:59Z","closed_at":"2026-09-21T21:25:59Z","close_reason":"Fixed in 2b086b2 (formatting follow-up e4adf86) on main.\n\nThe finding was verified by injection rather than taken on report: changing\npush_category's 'existing.items.extend(items)' to 'existing.items = items' --\nso a second section REPLACES the first -- left the old assertion PASSING. The\nstrengthened assertion fails with left: [\"B\"], right: [\"A\", \"B\"].\n\nThe test now asserts assert_eq!(item_names, [\"A\", \"B\"]), pinning order as well\nas membership, since insertion order is load-bearing here (gotcha 18). That is\nthe standard its neighbour two_sections_naming_one_category_round_trip_to_a_stable_group_order\nalready held groups to; this was the item-list half of the same invariant held\nto a weaker one.\n\nEvidence recorded in context/test-evidence/formula-and-io.md.","labels":["adversarial-review","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-12ha","title":"Multi-tab browser editing: a SharedWorker cannot host OpfsFs (measured), so pick a shape","description":"CONTEXT: improvise-i0v9 is the bug (a second tab is dead and silent). This is the\ncapability ticket. The user ruled that read-only second tabs are acceptable \"as\nlong as that's clear\", then asked whether the worker should be shared between\ntabs. The answer was measured rather than recalled.\n\nTHE DECISIVE MEASUREMENT, all three engines:\n\n chromium SharedWorkerGlobalScope, isWorkerGlobalScope true, getDirectory ok,\n getFileHandle ok, hasCreateSyncAccessHandle FALSE\n \"TypeError: fh.createSyncAccessHandle is not a function\"\n firefox identical\n webkit getDirectory itself fails (no OPFS at all -- the gap opfs.spec.js\n already skips on)\n\ncreateSyncAccessHandle is spec'd [Exposed=DedicatedWorker] and both engines\nimplement that faithfully. NOT a flag, NOT transient, NOT permissions. The real\nOpfsFs was then run unmodified inside a module SharedWorker and failed at exactly\nthat call.\n\nSO: a SharedWorker CANNOT host OpfsFs as written. Gotcha 28's premise -- the\nhandle's read/write/truncate/flush ARE blocking, which is what let Storage stay\nsynchronous -- is a DEDICATED-WORKER-ONLY premise.\n\nTwo things cut the other way and are worth keeping:\n - SharedWorker itself is in fine shape: present in all three, MODULE shared\n workers work in all three, and two tabs demonstrably share ONE instance (same\n instance id, connections: 2, both served).\n - WebKit's SharedWorker is healthy; its OPFS is what is missing. WebKit is no\n worse off under a shared-worker design than it is today.\n\nTHREE WAYS OUT, ALL PROBED:\n\nA. SharedWorker host with an ASYNC storage seam. createWritable() IS\n [Exposed=Window,Worker] and works in a shared worker (measured, chromium and\n firefox); reads need no handle at all (getFile().text()). But this is\n precisely what gotcha 28 exists to avoid: Storage::read/write are sync,\n App::storage is a Box\u003cdyn Storage\u003e, effect::Save calls it synchronously\n inside Effect::apply inside App::apply_effects -- the one mutation site. An\n async fn in Effect::apply is a DIFFERENT TRAIT, and it reaches the native TUI\n too. Load-bearing, not plumbing.\n NARROWER VARIANT worth costing first: keep Storage sync over an IN-MEMORY\n document, flushed asynchronously from the message loop -- the shape\n commit_deletes() already has. Storage never becomes async; a fifth awaited\n method persist() joins the four. UNCOSTED: what holds a document in memory\n across a LoadModel, and what a crashed tab loses that a sync handle would not.\n\nB. Nested dedicated worker under a SharedWorker. DEAD. Firefox allows it and the\n nested worker gets a real working sync handle with OpfsFs unchanged; Chromium\n and WebKit do not implement nested workers in shared workers at all\n (\"ReferenceError: Worker is not defined\"). The one shape that would have cost\n nothing works in exactly one engine.\n\nC. Keep the dedicated worker, add a RELAY owning no storage -- a SharedWorker or\n BroadcastChannel moving ClientFrame/ServerFrame JSON between follower tabs and\n the one tab whose dedicated worker holds the handle.\n improvise_protocol::frame is already THE one pair for all deployments\n (improvise-xks6), and the argument recorded there is explicitly\n transport-agnostic. A third transport is the case that consolidation was made\n for.\n - probably free: Worker::on_frame's \"one client by construction\". Gotcha 45\n already says the Addressed filter is honoured against a future SharedWorker.\n Session ids, fan-out and per-session mirrors need nothing new.\n - LOAD-BEARING: leader election and failover. The handle IS released when the\n leading tab closes, but bindings.rs::boot has NO RETRY PATH, so a follower\n cannot be promoted without a reload -- the same \"dead until you reload\"\n through a different door.\n - LOAD-BEARING: the relay must serialize frames onto one tail promise.\n web/worker.js had this exact race and web/serial.js exists because of it\n (gotcha 48). A relay forwarding async reintroduces it ACROSS TABS, where\n gotcha 33 answers an overtaken update by DROPPING it.\n - REVISIT: persist-on-dirty. Storage is whole-document (gotcha 41), so N\n editing tabs is N whole-file rewrites through one handle.\n\nRECOMMENDED ORDER (agent's read, not a ruling): C first, A's narrow variant\nsecond, read-only as the fallback it now is. C's real price -- leader failover --\nis THE SAME MISSING RETRY PATH the read-only fallback needs anyway, so that work\nis not wasted either way.\n\nDO NOT treat this as small because Sessions is ready. That readiness is real and\nis the good news; the no is entirely on the storage side.\n\nUNTESTED: Safari proper. Playwright's WebKit has no OPFS in any context so it is\nunaskable here. Safari 17+ ships OPFS; whether createSyncAccessHandle is\ndedicated-only there too is an EXPECTATION, not a measurement.\nUNPROBED: whether BroadcastChannel beats a SharedWorker relay for C. It is in all\nthree engines and needs no new worker, possibly making C cheaper -- but it fans\nout to everyone, so the Addressed filter would be doing real work rather than\nbeing honoured defensively. One probe settles it.\n","notes":"NEXT CHEAPEST PROBE: whether BroadcastChannel beats a SharedWorker relay for option C. It is in all three engines and needs no new worker. The catch is that it fans out to everyone, so the Addressed filter would be doing real work rather than being honoured defensively (gotcha 45).","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-21T20:40:31Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:42:20Z","labels":["architecture","browser","opfs"],"dependencies":[{"issue_id":"improvise-12ha","depends_on_id":"improvise-i0v9","type":"discovered-from","created_at":"2026-09-21T21:31:54Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-7gip","title":"[bug] OpfsFs::open's dedicated-worker guard checks WorkerGlobalScope, which a SharedWorker satisfies","description":"MEASURED in all three engines at 1f28868.\n\nOpfsFs::open() guards with:\n\n .dyn_into::\u003cweb_sys::WorkerGlobalScope\u003e()\n .map_err(|_| anyhow!(\"OPFS sync access handles require a dedicated Web Worker\"))?\n\n`self instanceof WorkerGlobalScope` is TRUE in a SharedWorkerGlobalScope. So a\nshared worker sails past this check and fails much later as a raw TypeError deep\nin the wasm glue:\n\n [firefox] workerError \"TypeError: arg0.createSyncAccessHandle is not a\n function\" at opfs/pkg/opfs_contract.js:93\n\nThe message names the right requirement; the type cannot check it.\ndyn_into::\u003cweb_sys::DedicatedWorkerGlobalScope\u003e() would.\n\nThis is doctrine(an_invariant_is_made_unrepresentable_not_validated) HALF DONE:\na guard was reached for, and the type chosen is one level too wide, so the\ninvariant is validated-and-wrong rather than unrepresentable.\n\nopfs.rs is wasm32-only with no Rust tests (0t in the inventory), so there is no\ncargo test that can fail first. The instrument that CAN fail first is harness/ --\nthe probe above is exactly the reproduction, and harness/opfs-contract already\nboots the real bundle in a real worker.\n","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:40:31Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:58:57Z","closed_at":"2026-09-21T21:58:57Z","close_reason":"Fixed in the 2026-09-21 wave, integrated into main at 1574 passed / 0 failed / 7 ignored. Playwright 42 passed / 0 failed / 4 skipped (WebKit, no OPFS) -- up from 37, so the count MOVED. All gates green.\n\nGuard is now DedicatedWorkerGlobalScope.\n\nTHE ONE-LINER DOES NOT COMPILE: the type is web-sys feature-gated, so improvise-io/Cargo.toml needed a line too. That is why it looked free and was not.\n\nNOT VERIFIED: that a SharedWorker is actually refused -- the harness has no SharedWorker page. What is asserted is only that a DEDICATED worker still satisfies the narrow type, i.e. the fix did not break the shipped path. Stated explicitly in the evidence rather than implied. improvise-12ha owns the SharedWorker question.","labels":["browser","bug","opfs"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-hs0u","title":"[bug] gg is implemented in the resolver and unreachable from any browser key","description":"DEMONSTRATED at 1f28868.\n\n PROBE 'g': browser = Upstream (silent)\n PROBE 'G': browser = Local[SetSelected { row: 1, col: 0 }, SetRowOffset(1)]\n\ngrid_move's GridMove::ToStart(true) arm EXISTS and is CORRECT. jump-first-row is\nbound only behind the g prefix (keymap.rs:639), and prefixes are unconditionally\nUpstream in resolve(). So a browser user can reach the last row and cannot get\nback to the first.\n\n0 and Home (jump-first-col) both work, so the asymmetry is ROW-ONLY and reads as\na bug rather than as an absence. The resolver arm is dead code in the browser\ntoday.\n\nSame root cause as the paste ticket: every prefix is Upstream, and\nHost::handle answers ClientFrame::Key with an explicit empty vec, so an upstream\nkey is a black hole rather than a slow path (gotcha 47). Fixing prefixes\nresolvably closes gg, yy and ZZ together.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:39:56Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:39:56Z","labels":["browser","bug","parity"],"dependencies":[{"issue_id":"improvise-hs0u","depends_on_id":"improvise-lut7","type":"related","created_at":"2026-09-21T21:31:57Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-hs0u","title":"[bug] gg is implemented in the resolver and unreachable from any browser key","description":"DEMONSTRATED at 1f28868.\n\n PROBE 'g': browser = Upstream (silent)\n PROBE 'G': browser = Local[SetSelected { row: 1, col: 0 }, SetRowOffset(1)]\n\ngrid_move's GridMove::ToStart(true) arm EXISTS and is CORRECT. jump-first-row is\nbound only behind the g prefix (keymap.rs:639), and prefixes are unconditionally\nUpstream in resolve(). So a browser user can reach the last row and cannot get\nback to the first.\n\n0 and Home (jump-first-col) both work, so the asymmetry is ROW-ONLY and reads as\na bug rather than as an absence. The resolver arm is dead code in the browser\ntoday.\n\nSame root cause as the paste ticket: every prefix is Upstream, and\nHost::handle answers ClientFrame::Key with an explicit empty vec, so an upstream\nkey is a black hole rather than a slow path (gotcha 47). Fixing prefixes\nresolvably closes gg, yy and ZZ together.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:39:56Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:39:56Z","labels":["browser","bug","parity"],"dependencies":[{"issue_id":"improvise-hs0u","depends_on_id":"blocked-by:improvise-lut7","type":"blocks","created_at":"2026-09-21T20:40:37Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-hs0u","depends_on_id":"improvise-lut7","type":"related","created_at":"2026-09-21T21:31:57Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-0ygd","title":"[bug] Tab on the +measure column silently discards the commit that Enter accepts","description":"DEMONSTRATED at 1f28868.\n\n PROBE +measure at (0,4), extent GridExtent { rows: 1, cols: 5 }\n PROBE Tab on +measure, the LAST row:\n browser = Upstream (silent)\n tui = doc_changed true, status \"Added new record row\"\n\na_locally_committed_creating_column_agrees_with_the_host passes for\nCreateKind::Measure -- it presses ENTER. Press TAB on the very same cell and:\n\n - resolve.rs::advanced() opens with\n if matches!(advance, Advance::Right) \u0026\u0026 state.grid.records\n \u0026\u0026 row \u003e= row_max \u0026\u0026 col \u003e= col_max { return None; }\n - None propagates out of create_column -\u003e commit -\u003e composed -\u003e Upstream, so\n the WHOLE COMMIT is discarded, not just the advance. The typed measure name\n is lost silently.\n - CommitAndAdvance::execute (cmd/commit.rs) takes the\n at_bottom_right \u0026\u0026 ctx.is_records_mode() branch instead: runs AddRecordRow,\n moves to (row+1, 0) and re-enters the editor. That is the bulk-entry Tab\n contract (see the records-mode Tab memory: Tab always advances to the next\n useful place).\n\n+measure is ALWAYS the last column, so col \u003e= col_max holds for it by\nconstruction. This fires on every single-row records grid and on the last row of\nany records grid.\n\nThis sits inside the already-filed \"records mode is silent in the browser\" gap,\nand closing that gap subsumes it -- but carry the specific observation into that\nticket, because \"records mode is silent\" does not predict \"the same cell answers\nEnter and drops Tab\".\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:39:55Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:39:55Z","labels":["browser","bug","parity"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-wk18","title":"[bug] the advertised-key guard cannot see help.rs literal() lines, so 17 keys are unchecked","description":"DEMONSTRATED at 1f28868.\n\nevery_advertised_command_answers_in_its_own_mode iterates help::advertised(\u0026mode),\nwhich yields (command, args, caption) triples. help.rs ALSO emits 17 literal(..)\nlines naming a KEY STRING and no command, so the guard cannot see them.\nweb/app.js paintHelp renders them verbatim.\n\nOf the 17:\n gg, yy, ZZ -- all three are prefix keys, all three silent in the browser\n :wq :q :q! :import :add-cat :add-item :formula :add-view :set :help and the\n rest -- the whole ex-command reference page; the browser declines at the door,\n so it says something, but it renders a full page of commands it cannot run.\n\nSo the browser paints a help overlay in which, conservatively, an entire page\nplus three scattered lines are inert, and the test that exists to stop exactly\nthat cannot see any of it.\n\nThis is the cheapest of the parity findings and it PROTECTS THE OTHERS: it is\nthe mechanism this repo uses to stop black holes, and it looks away by\nconstruction from the two lines that the gg and paste tickets are about. Without\nit the next gg lands the same way and nothing notices.\n\nFix: either give literal() a command name so the guard can reach it, or add a\nsecond guard over the help pages.\n","notes":"BLOCKED AND RE-SCOPED 2026-09-21 by the keymap agent (PR #112), which took improvise-636d and deliberately did NOT half-do this one. Its reasoning and its proposed design, which is worth more than the ticket had:\n\nWHY IT COULD NOT BE DONE FROM THE KEYMAP GROUND:\n - the guard needs resolve() AND the `furnished` fixture, both private to resolve.rs's mod tests. Duplicating the fixture elsewhere is the disease this repo names repeatedly.\n - the HelpLine reshape both tickets call for breaks the struct patterns at improvise-wasm-client/src/render.rs:346 and src/ui/help.rs:72 -- NEITHER uses `..`, so the reshape is a compile error in a file another agent owns.\n\nTHE PROPOSED SHAPE, designed but not written: keep HelpLine FLAT and PUBLIC, and introduce an internal\n\n HelpRow {\n Keys { mode, command, args, description },\n Prefix { mode, prefix, then, .. },\n Ex { line, .. },\n Prose (HelpLine),\n }\n\nthat pages() RENDERS into HelpLine. One source, no renderer edit, and the guard walks the rows rather than the rendered lines.\n\nThat satisfies uln5's stated blocking cost -- \"carry command/mode/args onto the variant\" -- WITHOUT touching render.rs, which is what made it look expensive.\n\nIT ALSO SPLITS wk18's 17 literal() lines honestly rather than treating them as one lump:\n - gg / yy / ZZ are checkable via Keymaps::prefix_keymap\n - the 14 ex-commands are checkable against the registry from improvise-command's tests\n\nSo the two beads are one change to HelpRow plus two different guards, not one guard over a uniform list.\n\nNOTE THE GROUND THIS NEEDS: resolve.rs's test module, help.rs, and a test that can reach the registry. That is a different partition from the one this wave used, so schedule it with whoever owns resolve.rs rather than with the keymap.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:39:19Z","created_by":"Edward Langley","updated_at":"2026-09-22T02:27:15Z","labels":["bug","parity","testing"],"dependencies":[{"issue_id":"improvise-wk18","depends_on_id":"improvise-uln5","type":"related","created_at":"2026-09-22T02:27:15Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-lut7","title":"[bug] p is composed by the browser and can never do anything, so the name-level derivation lies","description":"DEMONSTRATED at 1f28868.\n\n PROBE yank 'y': browser = Upstream (silent), twice -- y is a Binding::Prefix\n PROBE yank: tui = yanked Some(Number(1200.0)), status \"Yanked\"\n PROBE p: browser = Ignored (silent)\n\ny is Binding::Prefix, and resolve() maps EVERY prefix to Upstream\nunconditionally. Command::SetYanked exists on the wire (reduce.rs:32, folded at\n:212) and NOTHING in resolve.rs composes it. So ViewState::yanked is permanently\nNone in a browser, and composed(\"paste\") returns Some(vec![]) -\u003e\nKeyOutcome::Ignored -\u003e silence.\n\nTHE REASON TO FILE THIS RATHER THAN SHRUG: paste appears on NEITHER the declined\nlist NOR the host-needed list. Every mechanical derivation of \"what the browser\ncan do\" -- including the_command_names_that_still_need_the_host_are_these --\nreports it as SUPPORTED. It is dead, and it is dead for a STATE reason that\nname-level derivations cannot see. That is this repo's most-cared-about failure\nmode: a green derivation over a broken thing.\n\nhelp.rs:546-554 advertises yy and p together as a copy/paste pair, so the browser\npaints a help page promising both.\n\nFix is one arm -- \"yank\" =\u003e Some(vec![Command::SetYanked(..)]), and the cache\nalready holds the value via cell_at_cursor().edit_text -- plus making prefixes\nresolvable. OR, if prefixes stay upstream, move p onto declined() so the\nderivation tells the truth. Either is acceptable; leaving the derivation lying\nis not.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:39:18Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:39:18Z","labels":["browser","bug","parity"],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -343,7 +345,7 @@ {"_type":"issue","id":"improvise-ni6k","title":"Invalidate the cursor by observing the grid frame, not by a per-effect table","description":"Successor to improvise-9mo9, proposed by the agent that implemented 9mo9 against its own work.\n\n9mo9 made ten effects call app.reset_cursor(). That is a PER-EFFECT TABLE, and this repo names that shape as the disease: 'a table of variants would be a second description of it, free to disagree' (gotcha 24, which says exactly this about why cursor_epoch is watched instead of matching on command variants).\n\nThe disagreement arrived the same day it was written: improvise-7kvw is SetPageSelection renumbering the grid under prune_empty without resetting — a gap in the new table, found immediately.\n\nThe repo's own preferred mechanism was available and not taken:\n * cursor_epoch beats matching on CycleAxis (gotcha 24)\n * document_generation beats matching on LoadModel (gotcha 39)\n * model_state.dirty beats a table of command variants (gotcha 45)\n\nAll three are observations. The equivalent here is to fingerprint the grid's FRAME (row/col identity, not contents) before and after a model batch in App::apply_effects, and reset the cursor when it moves. Then no effect declares anything, no new geometry effect can forget, and 7kvw closes as a consequence.\n\nThe agent's reason for not doing it is legitimate and recorded: the ticket and its acceptance criteria both asked for per-effect, and the change touches apply_effects, which WU-B and WU-E were editing concurrently. So this is a deliberate follow-up, not an oversight.\n\nTwo constraints any implementation must keep, both learned the hard way in 9mo9:\n 1. Reset only on SUCCESS. The epoch is document-wide, so a REFUSED add otherwise sends every client a ResetCursor for a change that never happened. The first version of 9mo9 had this bug with 1312 tests green; it was caught by reading the diff, not by a test.\n 2. The negative arm must stay tested, and there are now two arms that cannot see each other's bug: SetNumberFormat (succeeds, reshapes nothing) and a refused AddItem (reshapes nothing because it was refused).\n\n## Steps to Reproduce\n\nNot a defect report — 7kvw is the concrete instance. This ticket is the structural fix that makes instances like it unrepresentable.","acceptance_criteria":"reset_cursor is driven by an observation — the grid's frame fingerprinted before and after a model batch, reset when it moves — rather than by each effect declaring it. improvise-7kvw and any sibling gap close as a consequence rather than needing their own fix. The negative arm still holds: a batch that reshapes nothing invalidates nothing, including a REFUSED one.","notes":"THE GAP IS SIX COMMANDS WIDE, NOT ONE (enumerated 2026-09-20, at a6258f7, after\nthe adversarial review of PR #49). improvise-9mo9 closed the ten it named and\nfiled improvise-7kvw as \"the residual\". That framing undersells this ticket:\n7kvw is one instance of a class, and here is the whole class.\n\nEffects that call app.reset_cursor() -- twelve: AddCategory, AddItem,\nAddItemInGroup, CycleAxis, HideItem, RemoveCategory, RemoveItem, SetAxis,\nShowItem, ToggleGroup, TogglePruneEmpty, TransposeAxes.\n\nModelCommand variants that CAN renumber the grid and do NOT reset:\n - SetPageSelection -- under prune_empty, a different page prunes different\n rows (improvise-7kvw, already filed)\n - AddFormula / RemoveFormula -- a formula target is a _Measure item, so it is\n a column whenever _Measure is on Row or Column\n - SetCell / ClearCell -- under prune_empty, writing into or clearing the last\n value in a row makes that row appear or disappear\n - CleanEmptyRecords -- drops records, so rows, in records mode\n - SortData -- reorders records, which renumbers EVERY row in records mode\n while changing neither the row count nor any label\n\nSortData is the sharpest of these and the best argument for observing rather\nthan declaring: it is the one case where row_count() and the label set are both\nunchanged and every position still names a different record. A guard written as\n\"did the shape change\" would miss it; the frame fingerprint would not, because\nthe fingerprint is over row_items/col_items, which carry the labels IN ORDER.\n\nWHY THIS WAS NOT FIXED PER-EFFECT TOO: the F1 round of the review showed the\ncost. Six accepted-but-no-op commands each needed their own change-detector\n(SetAxis comparing try_axis_of, HideItem/ShowItem comparing is_hidden,\nAddItem/RemoveItem via a shared has_item, RemoveCategory via category().is_some())\n-- six chances to get it wrong, three of which had already been taken across two\nrounds. Adding six more effects to that table doubles the surface without\nchanging the shape of the mistake.\n\nTHE COST THAT STILL HAS TO BE MEASURED BEFORE DOING THIS: a per-batch frame\nfingerprint sits on the keypress path, and benches/grid_projection.rs (landed in\nPR #49) measures a whole-grid pass at ~150ms for 18,000 cells. A fingerprint over\nrow_items/col_items is much cheaper than grid_of -- it hashes labels, it does not\nevaluate cells -- but \"much cheaper\" is the kind of claim that ticket's own\nacceptance criterion exists to refuse. Benchmark it against the same fixture\nsizes before landing.\nTWO MORE INSTANCES, from the adversarial review of PR #49 (2026-09-20).\n\n(1) TogglePruneEmpty on a grid with nothing empty resets the cursor and bumps the\nepoch. It is the only residual no-op that is ORDINARY USE rather than a bad name,\nand it is ONE KEYSTROKE in the TUI. It is not guarded per-effect because guarding\nit means computing the pruned layout to find out whether pruning would change\nanything -- i.e. building the very frame this ticket proposes fingerprinting. It\nis the cleanest single argument for doing this the observed way.\n\n(2) HideItem / RemoveItem on a category parked on Axis::None: the data changes,\nrow_items/col_items do not. A frame fingerprint is right by construction here,\nwhere a per-effect predicate would need its own notion of \"visible axis\" -- and\nPage/Filter make that a judgement call (gotcha 27), not a lookup.\n\nWHAT PR #49 DID FIX PER-EFFECT, so this ticket is not inheriting it: SetAxis on an\nunregistered category, HideItem/ShowItem on a name the grid never drew,\nToggleGroup on a nonexistent group, and AddItem/AddItemInGroup/RemoveItem via an\neffective-items comparison across the mutation (which is itself an observation,\nand the reason it is correct in both directions at once).\n\nThe AddItem case is worth carrying into this ticket's design: the predicate that\nfailed read `category.items` while _Measure's effective items are explicit UNION\nformula targets (gotcha 7). Any fingerprint must be taken over what the GRID\ndraws, never over what the model stores.\nDONE except SortData, which is improvise-gfvj.\n\nClosed as a consequence: improvise-7kvw (SetPageSelection under prune_empty), pinned by paging_across_a_prune_that_drops_a_row_invalidates_the_cursor. Note 7kvw is claimed by claude-agent-cmd with an expired lease -- if that agent also patched it per-effect, the two changes overlap and the per-effect guard should go, since the observation covers it.\n\nAlso covered, with no arm of their own: AddFormula/RemoveFormula (added to the shared reshaping_commands table), TogglePruneEmpty on a grid with nothing empty, and HideItem/RemoveItem on a category parked on Axis::None -- all three now answer by observation.\n\nCost measured as the AC required, benches/reshape_observation.rs: +13% to +20% on one SetCell batch at 400 / 9.6k / 18k / 90k cells. The batch is dominated by rebuild_layout and recompute_formulas.\n\nTHE DESIGN POINT WORTH CARRYING: the cursor's question and layout_version's question are NOT the same, so this is a second observation beside view_shape and not a replacement for it. AddCategory of an empty category moves no row or column but does change the coordinates a client's CellKey hangs off. Unifying them broke tests in both directions -- that is how the distinction was found, not by reasoning.","status":"closed","priority":3,"issue_type":"task","assignee":"claude-agent-protocol","owner":"el-github@elangley.org","created_at":"2026-09-20T18:23:51Z","created_by":"orchestrator","updated_at":"2026-09-21T03:47:21Z","started_at":"2026-09-21T03:28:18Z","closed_at":"2026-09-21T03:47:21Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-5jna","title":"The browser page does not paint the title bar it is now told about","description":"improvise-z754 put the document's name, its dirty marker and what its host\ncalls it on `RenderModel` (`doc_name`, `doc_dirty`, `doc_stored_as`), fed by\n`Projection::Document` and `DocumentCache`. The TUI's `draw_title` now draws\nfrom that same derivation. `web/app.js` does NOT paint them: the header has\n`#view-name`, `#mode`, `#hint` and `#status` and no element for the document.\n\nThat omission is deliberate rather than forgotten. Gotcha 48 says a change to\n`web/*.js` belongs in `harness/tests/shell.spec.js`, and every fixture there\ntoday mocks a peer that FAILS — a refused upgrade, a bad frame, a socket that\ndrops. None drives a real document through the page, so a title-bar element\nadded now would be unrun JavaScript, which is exactly what that rule exists to\nstop.\n\nSo the work is two halves, and the first is the expensive one:\n\n1. A harness fixture that serves a real `Snapshot` to `web/app.js` — either a\n built wasm bundle against a mocked `ws` peer replaying recorded frames, or\n the worker path against a seeded OPFS origin. That fixture is worth more\n than this ticket: it is the first Playwright test that exercises the page's\n PAINT rather than its failure reporting.\n2. A `#doc-name` span, set from `model.doc_name` / `doc_dirty` / `doc_stored_as`,\n with the `[+]` marker spelled the way the TUI spells it.\n\nDo not do 2 without 1.\n","acceptance_criteria":"web/app.js paints the document name, its dirty marker and what the host calls it, from RenderModel's doc_name/doc_dirty/doc_stored_as — and a harness/tests/shell.spec.js fixture that serves a REAL Snapshot asserts it. The fixture comes first: gotcha 48 forbids unrun web/*.js, and every existing harness fixture mocks a FAILING peer, so none of them can see a painted title. Do not add the element without the fixture.","status":"closed","priority":3,"issue_type":"task","assignee":"wave-browser-agent","owner":"el-github@elangley.org","created_at":"2026-09-20T18:11:01Z","created_by":"wave2-b-projection","updated_at":"2026-09-21T04:43:20Z","started_at":"2026-09-21T03:28:12Z","closed_at":"2026-09-21T04:43:20Z","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-rm4s","title":"The evidence check accepts headings naming tests that never run","description":"Found by adversarial review of PR #44 (finding F9), demonstrated with a built fixture that returned 'test-evidence: 3 entries, all name a test that exists', exit 0.\n\nscripts/check-comment-runs.py --check-evidence scrapes source text line by line for 'fn NAME' preceded by a test attribute. Three shapes defeat it:\n\n1. A test behind a cfg feature nobody enables — compiled out, never runs.\n2. A test attribute and fn inside a STRING LITERAL (an r-hash fixture). The scanner reads inside string literals, so this names a test that does not exist at all.\n3. A #[cfg(target_arch = wasm32)] test. This is the realistic one for THIS repo: it has real wasm32-only code (improvise-io/src/opfs.rs, improvise-worker-server) and a wasm CI job that only runs cargo check. Evidence keyed to such a test is accepted while cargo test --workspace never executes it.\n\nAll three are the same defect one layer up from the one the gate exists to prevent: evidence for a check that never runs.\n\nCheap fix the reviewer proposed and I verified is currently free: diff the heading set against the test names cargo actually reports (cargo test -- --list, or the run log) instead of scraping source. Cross-checking all 451 current headings against the executed test names from a real cargo test run came back EMPTY — every heading names a test that really ran — so adopting the stricter source costs nothing today.\n\n## Steps to Reproduce\n\n1. Create context/test-evidence/x.md with a heading naming a_test_that_is_only_text.\n2. Put a #[test] fn a_test_that_is_only_text() {} inside an r-hash string constant in any scanned .rs file.\n3. python3 scripts/check-comment-runs.py --check-evidence -\u003e passes, exit 0.","acceptance_criteria":"A heading naming a cfg-gated, wasm32-only, or string-literal 'test' is rejected; the check is diffed against the test names cargo actually reports rather than scraped from source text. Demonstrated by adding each of the three shapes and watching the check fail.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:06:03Z","created_by":"orchestrator","updated_at":"2026-09-21T04:49:28Z","closed_at":"2026-09-21T04:49:28Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-azhb","title":"Decide who sets a document's default view now that a switch does not","description":"A product question opened by improvise-jytm, which made `Workbook::active_view` the\ndocument's *default* — what a freshly connected client opens — and stopped a view\nswitch from moving it.\n\n`.improv` writes `Initial View` from the workbook (jytm's AC says it must), so the\ndefault is now frozen at load. For the multi-client story that is exactly right. For\nthe single-user TUI, which is the primary use today, it changes a behaviour people\nrely on: \"arrange the views, switch to the one I want this file to open on, save\".\nAfter jytm the file keeps whatever `Initial View` it was loaded with.\n\nThree shapes, none obviously right, which is why this is a ticket and not a fix:\n\n1. A host's own switch also sets the document default. Defensible — the host owns\n the document, there is exactly one of it, and the TUI is where `Initial View` is\n authored — but it re-couples the two copies in `App::switch_to_view`, the exact\n place jytm split them, and a headless ws-server would then have its phantom\n view_state authoring the default.\n2. An explicit command (`:set-default-view`, or a `w` variant). Unambiguous, costs\n the user a keystroke they did not used to need, and needs a keybinding argued.\n3. Leave it. The default is a document property you edit by editing the file.\n\nNothing is broken today in the sense of a wrong answer; the file is written\ncorrectly against the new meaning. What it is, is a silent change in what saving\nrecords, which is worth a decision rather than a discovery.\n\n## Steps to Reproduce\n\n1. `improvise model.improv` where the file says `Initial View: Default`.\n2. Switch to another view (`gv` / the view panel / `:switch-view Second`).\n3. Edit a cell so the document is dirty, then `:w`.\n4. Read the file: `Initial View: Default`. Before improvise-jytm it said `Second`.\n","acceptance_criteria":"A decision is recorded in context/code/command.md and, if it changes behaviour, implemented with a test asserting what a save writes as Initial View. Leaving it frozen at load is an acceptable outcome, but must be the stated answer rather than the default one.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:06:00Z","created_by":"wave2-e-active-view","updated_at":"2026-09-20T18:06:00Z","labels":["area:active-view"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-azhb","title":"Decide who sets a document's default view now that a switch does not","description":"A product question opened by improvise-jytm, which made `Workbook::active_view` the\ndocument's *default* — what a freshly connected client opens — and stopped a view\nswitch from moving it.\n\n`.improv` writes `Initial View` from the workbook (jytm's AC says it must), so the\ndefault is now frozen at load. For the multi-client story that is exactly right. For\nthe single-user TUI, which is the primary use today, it changes a behaviour people\nrely on: \"arrange the views, switch to the one I want this file to open on, save\".\nAfter jytm the file keeps whatever `Initial View` it was loaded with.\n\nThree shapes, none obviously right, which is why this is a ticket and not a fix:\n\n1. A host's own switch also sets the document default. Defensible — the host owns\n the document, there is exactly one of it, and the TUI is where `Initial View` is\n authored — but it re-couples the two copies in `App::switch_to_view`, the exact\n place jytm split them, and a headless ws-server would then have its phantom\n view_state authoring the default.\n2. An explicit command (`:set-default-view`, or a `w` variant). Unambiguous, costs\n the user a keystroke they did not used to need, and needs a keybinding argued.\n3. Leave it. The default is a document property you edit by editing the file.\n\nNothing is broken today in the sense of a wrong answer; the file is written\ncorrectly against the new meaning. What it is, is a silent change in what saving\nrecords, which is worth a decision rather than a discovery.\n\n## Steps to Reproduce\n\n1. `improvise model.improv` where the file says `Initial View: Default`.\n2. Switch to another view (`gv` / the view panel / `:switch-view Second`).\n3. Edit a cell so the document is dirty, then `:w`.\n4. Read the file: `Initial View: Default`. Before improvise-jytm it said `Second`.\n","acceptance_criteria":"A decision is recorded in context/code/command.md and, if it changes behaviour, implemented with a test asserting what a save writes as Initial View. Leaving it frozen at load is an acceptable outcome, but must be the stated answer rather than the default one.","notes":"ANSWERED BY THE GOVERNING PRINCIPLE, user 2026-09-21: \"The whole point of the view / server split is that multiple clients can have different views of the model.\"\n\nThis bead asked who sets a document's default view now that a switch does not. The principle answers it: the DEFAULT belongs to the DOCUMENT and a SWITCH belongs to the CLIENT, and they are different things that happen to name the same kind of value.\n\n - Workbook::active_view is the document's default -- what a freshly connected client opens, and what .improv writes as `Initial View`. It is shared state and changing it is a model change.\n - ViewState::active_view is authoritative FOR THAT CLIENT. Changing it is that client's business and reaches nobody else.\n\nSo \"who sets the default\" is not the same question as \"who switches\", and the bead conflated them because before improvise-jytm they were one field. The remaining REAL question is narrower: what user gesture, if any, should write the document default -- because today nothing does except loading a file, and improvise-azhb's original complaint was that `Initial View` no longer follows the TUI user.\n\nThat is now a small product question rather than an architectural one: should there be an explicit \"make this the document's default view\" action, or does the default only ever come from the file? Note that an implicit answer -- the TUI user's current view silently becoming the default -- is the one option the principle FORBIDS, because it would let one client's UI decide what every other client opens.\n\nFollow mode is deliberately not foreclosed (gotcha 29), but it would have to be opt-in and per-client, never a side effect of navigating.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:06:00Z","created_by":"wave2-e-active-view","updated_at":"2026-09-22T04:17:52Z","labels":["area:active-view"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-ahdf","title":"repo-map gotchas 36 and 43 still name Sessions::apply as the production door","description":"Found by adversarial review of PR #45 (finding F11).\n\nVerified: Sessions::apply has NO production caller. Host calls apply_checked (crates/improvise-command/src/ui/host.rs:144); apply (project.rs:774) delegates to apply_checked (:783) after Checked::against, and its only call sites are tests. context/code/command.md already records this.\n\nTwo repo-map gotchas still say otherwise:\n * gotcha 36: 'Sessions::apply(app, id, cmd) is the only way a connected client's Command reaches the server'\n * gotcha 43: 'Sessions::apply answers once, before the dispatch'\n\nBoth are PRE-EXISTING rather than introduced by #45 — but they are the siblings of the sentence #45 fixed in design-principles §5, and gotcha 48 is the rule they break: 'when a fact is stated in more than one place, changing it is a grep, not an edit.' #45 fixed §5 and the now-false note in command.md; these two were left because they sit inside long paragraphs whose surrounding argument needs reading, not a sed.\n\n## Steps to Reproduce\n\n1. grep -n 'Sessions::apply' context/repo-map.md\n2. Compare against crates/improvise-command/src/ui/host.rs:144 and the note in context/code/command.md.\n\nNote the claims are not merely imprecise: a future agent told that apply is 'the only way' may add a caller to it, bypassing the CheckedBatch frame-level answer that host.rs actually performs.","acceptance_criteria":"Gotchas 36 and 43 name apply_checked where they mean the door production uses, or say explicitly that apply is the single-command convenience its tests drive; a grep for 'Sessions::apply' across context/ finds no claim that contradicts context/code/command.md.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:05:51Z","created_by":"orchestrator","updated_at":"2026-09-21T04:44:18Z","closed_at":"2026-09-21T04:44:18Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-4bwa","title":"Three bindings improvise-e24v listed as needing no enrichment do need it","description":"improvise-e24v listed these under \"the operands are already in ViewState or a\nwidget cache\". Closing the tranche established that each of the three is short\nexactly one fact, and each wants a different answer.\n\ntoggle-prune-empty. TogglePruneEmpty::execute reads ctx.view.prune_empty to word\nits status (\"Showing all rows/columns\" vs \"Hiding empty rows/columns\").\nView::prune_empty is on the View inside the Workbook; it is in no widget cache\nand not in ViewState. A client can compose ModelCommand::TogglePruneEmpty but\ncannot compose the sentence beside it, so mirroring it would diverge on the\nstatus line. Cheapest fix: the flag is one bool on a per-view projection.\n\nview-back. ViewNavigate { forward: false } emits ApplyAndClearDrill *before*\nViewBack, and ApplyAndClearDrill is deliberately off the wire (gotcha 38,\nimprovise-3x3u) because its operand is another session's uncommitted drill\nedits. So this is not an enrichment at all: it is blocked on ADR 001 layer 2's\naddressed form. Leave it upstream and say so.\n\ndelete-formula-at-cursor. The effect is RemoveFormula { target,\ntarget_category }. FormulaCache carries the formula's *raw text*\n(\"Profit = Revenue - Cost\"), from which neither field is recoverable without\nreimplementing parse_formula in the client. Cheapest fix: FormulaCache carries\nthe target and its category beside the text.","acceptance_criteria":"Each of the three is either closed with the enrichment named in its\nparagraph, or declined in writing with the reason. toggle-prune-empty and\ndelete-formula-at-cursor are ordinary projection additions; view-back is NOT\nand must not be mirrored by reconstructing ApplyAndClearDrill on the client,\nwhich is the exact thing improvise-3x3u removed. Whatever lands, the named\nlist the_command_names_that_still_need_the_host_are_these shrinks by exactly\nwhat closed.","status":"closed","priority":3,"issue_type":"task","assignee":"wave-browser-agent","owner":"el-github@elangley.org","created_at":"2026-09-20T18:04:14Z","created_by":"wave2-h-frontend","updated_at":"2026-09-21T04:43:22Z","started_at":"2026-09-21T03:28:13Z","closed_at":"2026-09-21T04:43:22Z","labels":["area:shared-frontend"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-2oxt","title":"The landing page's casts predate the records columns and show the recorder's shell prompt","description":"The four casts under `docs/casts/` were recorded 2026-04-09 (improvise-d4w) and\nare now embedded on the landing page `docs/index.html` (improvise-e61), served\nat `/landing/`. improvise-e61's acceptance criteria asked for them to be checked\nagainst the current UI before embedding. They were, and three things came back.\n\n1. THEY PREDATE THE RECORDS COLUMNS. `improvise-2zq`'s `+cat` column landed in\n `6a929c1` on 2026-09-19 and `improvise-c5v`'s `+` measure column with it —\n five months after the recording. `drill.cast` shows records/drill mode as it\n was BEFORE either column existed, so the drill cast is the one that is\n materially wrong rather than merely old. What still matches: the tile bar's\n `short_label()` vocabulary (`Row`/`Col`/`Pag`/`·`) and the mode line, both\n verified against `crates/improvise-core/src/view/axis.rs`.\n\n2. THEY CARRY THE RECORDER'S SHELL PROMPT. Every cast opens and closes on\n `(0) Mac:edwlan--s059 ~gf/u/edwlan/improvise git-\u003emain \u003cdate\u003e`, which is a\n local hostname and home-directory path on a public page. `formulas.cast` and\n `import.cast` are the worst: the visible opening frame is the prompt, not\n improvise.\n\n3. THE GEOMETRY DISAGREES WITH ITS OWN TICKET. improvise-d4w's description says\n \"Terminal: 100x30\" and improvise-e61 repeats it as the player config; the\n cast headers say `\"width\": 120, \"height\": 37`. The landing page therefore\n sets `cols: 120, rows: 37`, because a 30-row player clips the bottom seven\n rows of a 37-row cast — which is exactly where the tile bar and status line\n are. The recordings also use only about twenty of the thirty-seven rows, so\n each player is roughly half empty.\n\nRE-RECORDING FIXES ALL THREE AT ONCE and needs nothing built: record at the\ngeometry the page declares, from a clean prompt (`env -i` or a bare `PS1`), and\nmake the drill cast show the `+cat`/`+` columns that now exist. Then update the\n`cols`/`rows` in `docs/index.html` if the new geometry differs.\n\n## Steps to Reproduce\n\n1. `python3 -m http.server 8123 --directory docs` and open\n `http://127.0.0.1:8123/index.html`.\n2. Press play on \"Formulas over dimensions\": the first frame is a zsh prompt\n reading `Mac:edwlan--s059 ~gf/u/edwlan/improvise`.\n3. Press play on \"Drill into an aggregate\" and watch the records grid: there is\n no `+cat` column and no `+` measure column, both of which the current build\n draws (`crates/improvise-core/src/view/layout.rs::records_columns`).\n4. `head -c 200 docs/casts/pivot.cast` — the header says `\"width\": 120,\n \"height\": 37`, not the 100x30 improvise-d4w's description claims.","acceptance_criteria":"All four casts under docs/casts/ are re-recorded from a clean prompt (no hostname or home-directory path in any frame), at a geometry matching docs/index.html's cols/rows, with drill.cast showing the current records grid including the +cat and + measure columns. docs/index.html's player config is updated if the new geometry differs. Verified by playing each of the four on the served page and reading the first frame.","status":"closed","priority":3,"issue_type":"bug","assignee":"wave-browser-agent","owner":"el-github@elangley.org","created_at":"2026-09-20T18:01:04Z","created_by":"wave2-p-milestones","updated_at":"2026-09-21T04:43:20Z","started_at":"2026-09-21T03:28:15Z","closed_at":"2026-09-21T04:43:20Z","labels":["area:release"],"dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/prolog-sessions/kb.pl b/prolog-sessions/kb.pl index 23e43ce7..71aa4675 100644 --- a/prolog-sessions/kb.pl +++ b/prolog-sessions/kb.pl @@ -124,7 +124,6 @@ invariant(normal_mode_is_resolved_not_carried,'AppMode::Normal is resolved throu invariant(the_frame_basis_follows_active_view,'The cursor frame basis re-bases AT THE SWITCH, in the loop, as soon as active_view moves -- not at the next geometry effect. Guarding the COMPARISON on the two names agreeing (PR #78 round 2) exempted the first geometry effect after a mid-batch SwitchView from the check altogether, which is under-reporting and gotcha 35 expensive side. SwitchView stays ViewOnly: the basis is App own bookkeeping and none of it crosses the wire, so this needed no protocol change. Costs one extra view_frame per batch that both switches and moves geometry, DrillIntoCell paying most.'). invariant(encoded_width_mirrors_every_preprocess_branch,'The preflight runs BEFORE preprocess and the exact check AFTER it, so the exact check bounds the error message and not the allocation. encoded_width must mirror EVERY arm of preprocess inference -- Numeric, Date (5 parts, 7 with a time) and Categorical (distinct plus the reserved (missing)) -- plus the .missing flag. It may over-count; it must never under-count. improvise-x1ns was TWO under-counts one inference-branch apart: the categorical (missing) one-hot, and the entire Date arm. The guard is that encoded_width matches Forced WITH NO WILDCARD, so a fourth variant is a compile error rather than a silent categorical fallback -- which is exactly how Date was missed.'). invariant(opfs_sync_handles_are_dedicated_worker_only,'createSyncAccessHandle is spec-ed [Exposed=DedicatedWorker] and is ABSENT -- not rejected, not flagged, not transient -- on a file handle obtained inside a module SharedWorker. Measured 2026-09-21 on Chromium and Firefox: navigator.storage.getDirectory() and getFileHandle() both SUCCEED there and the method is simply not on the object; the real OpfsFs bundle, run unmodified in a shared worker, fails at exactly that call. Playwright WebKit has no OPFS in any context, so it is out one step earlier. Consequence: gotcha 28-s premise -- the handle-s read/write/truncate/flush ARE blocking, which is the whole reason Storage stayed synchronous -- is a DEDICATED-worker-only premise, so a SharedWorker cannot host OpfsFs as written and every \"browser worker\" in that argument means a dedicated one. The trap for anyone costing multi-tab: Sessions, the Addressed filter and the single frame pair are all ready and none of it helps, because the no is entirely on the storage seam. See improvise-12ha for the three probed ways out (async seam / nested worker / relay) and improvise-7gip for the guard that fails to enforce this.'). -invariant(global_keymap_binds_char_keys_only,'Every one of the seventeen modes inherits from one global (improvise-636d) carrying ?, : and the panel toggles -- CHAR KEYS ONLY, and that is load-bearing. KeyPattern::AnyChar matches KeyCode::Char and nothing else, so only a Char key is shadowed by a text-entry mode while the user is typing; a global F1 would reach the parent MID-EDIT and discard a half-typed cell. F1 therefore stays on Normal although it runs the same command as ?. Esc is excluded for a different reason: it means something different in nearly every mode. Help is the only mode writing an override, making ? close the overlay.'). doctrine(bug_first,'write a failing test demonstrating the bug before fixing it'). doctrine(unit_over_e2e,'prefer small unit tests over integration/e2e'). doctrine(suite_speed,'Two numbers, because the ambiguity between them is what let this drift: NO SINGLE TEST BINARY may exceed 2 seconds, and the WORKSPACE WALL CLOCK is the sum of them. Measured at 22d43b2: 1309 tests over 33 binaries, ~2.2s wall, longest single binary 1.42s -- so the workspace is already past 2s and the rule that bites is the per-binary one. Re-MEASURE rather than re-assert: cargo test --workspace prints a \"finished in\" per binary. Excludes compilation, and excludes harness/ (Playwright, minutes, its own CI job).'). @@ -310,16 +309,8 @@ doctrine(a_surface_that_paints_another_surface_hints_advertises_what_it_cannot_d doctrine(the_status_bar_and_an_overlay_are_two_writers_of_one_fact,'draw() stacks the tile bar and the import-wizard overlay ABOVE the status bar, so both are on screen together and each draws its own key hints. They disagree in notation and in content, and the wizard status tail is WRONG ON FOUR OF EIGHT STEPS -- on Done it says Esc cancels where Esc goes back, an outright contradiction the user can act on. The overlay hints are correct because they are local and per-step; the status tail is a second table keyed only by AppMode, which cannot see the step. Two writers of one fact is improvise-edp disease in the UI layer. The cheap repair is to stop drawing the second one where an overlay draws its own, not to maintain both.'). doctrine(establish_a_platform_fact_by_running_it,'When a design turns on what a platform API does, measure it in the target engines before designing against it -- a recalled spec and a plausible inference are both worth less than one probe, and the cheapest instrument is usually already in the tree. Measured 2026-09-21: the SharedWorker/OPFS question was settled in minutes by booting the EXISTING harness/opfs-contract bundle in a shared worker, and it returned three results no amount of reading would have produced with confidence -- the method is absent rather than failing, WebKit fails one step earlier for an unrelated reason, and Firefox ALONE permits a nested dedicated worker that gets a working handle (Chromium and WebKit throw \"Worker is not defined\"). That last one is the point: the single shape that would have cost nothing works in exactly one engine, which is indistinguishable from \"it works\" to anyone reasoning from one browser or from the spec. Corollary: report per engine, and never let a per-engine gap hide behind the word \"browsers\".'). doctrine(a_status_line_composed_beside_a_mutation_lies_when_the_mutation_no_ops,'An optimistic status effect emitted ALONGSIDE a fallible mutation reports success the mutation never achieved, and because status is ViewOnly the client folds it locally and no host ever contradicts it. Gotcha 20 already states the cure for one case -- CommitBuffer emits the fallible effect LAST so its failure message wins -- and improvise-g3no found the rule broken in BOTH front ends at once, in the same shape: delete_at_cat_cursor and DeleteCategoryAtCursor each compose [RemoveItem, SetStatus(\"Deleted item ..\")] with the fallible half FIRST. Model::remove_item returns unit, so nothing downstream could have known better. Two rules generalise. A mutation whose failure is reportable must RETURN whether it acted -- a unit-returning mutation makes the lie unavoidable rather than accidental. And the front ends are the wrong place to notice: the same defect appeared independently in a Cmd and in a resolver, which is what a shared derivation exists to prevent, so the fix belongs where both compose from. No test failed, because every test asserted the status string that the code composes unconditionally.'). -doctrine(a_resolution_test_cannot_see_a_missing_inheritance_edge,'MEASURED, not reasoned, on PR #112: removing SearchMode-s parent left every_global_key_resolves_in_every_mode GREEN, because SearchMode-s own AnyChar answers ? with append-char either way -- which is the EXPECTED answer. A mode can be silently disconnected from the global keymap and every behavioural check still passes, because the catch-all that shadows the global is indistinguishable from the absence of the global. Only a STRUCTURAL test sees it, and the one that does asserts Arc::ptr_eq rather than behaviour. Generalises past keymaps: wherever a fallback answers the same thing the inherited value would, a behavioural test cannot distinguish inherited from absent.'). -doctrine(one_table_is_proved_by_identity_not_equality,'PR #112 consolidated several keymap spines into one global. The defect removed was several tables that AGREE TODAY, so the guard has to be Arc::ptr_eq: injecting the RIVAL fix -- giving one mode its own equal-but-separate copy of the global -- leaves every behavioural test green and fails only the pointer check. That rival is the implementation a reasonable person would write, which is why it, and not the bug, was the injection that earned its keep.'). -doctrine(a_consolidation_is_justified_by_an_empty_diff,'PR #112 ran doctrine(binding_dump_diff) and the \`:\` key produced ZERO rows -- every mode that now inherits it already bound it locally. The ABSENCE is the finding: it says the duplicated spines had not yet drifted, so the change is a pure consolidation, and equally that nothing in the suite would have noticed had they drifted. A ticket justified by an asymmetry in the SOURCE is one whose fix should show up as no behavioural diff, and there is no way to state that without running the dump.'). -doctrine(a_completeness_check_closed_over_the_omitting_artefact_is_blind,'web/tests/render_surface.test.js asked whether every RenderModel field is painted, and its DOMAIN was RenderModel -- the very artefact that can omit a field. GridCache::totals and page_coords were projected, folded by the browser cache and painted by NOTHING from improvise-n10 to improvise-p7lh, and the guard truthfully reported an empty set the whole time; so did a hand grep run by a parity audit, which reported \"exposed-but-unpainted is EMPTY\" as good news. The honest question is one level up: every field of every WIDGET CACHE reaches a front end or is named as debt. Generalises: a completeness check closed over the thing that can omit cannot see the omission, and it reports a clean bill rather than an error.'). -doctrine(an_escape_hatch_is_where_a_new_check_goes_vacuous,'Both bugs in PR #113 own new cache-parity guard were in its \"or it is fine because X\" branches, never in the check itself. The whole-cache hand-off test matched the substring &state.grid, which &state.grid.row_frame CONTAINS, so every GridCache field read as covered -- vacuous for precisely the cache the ticket was about. And rustfmt splits state.grid.totals over three lines, so a substring search calls a field that IS read unread. Neither was visible by reading and both fell out of injection. A third hatch remains merely incomplete (improvise-t3oh). When writing a new check, spend the injection budget on its exemptions, not on its main path: an escape hatch fails in the direction that looks like success.'). -doctrine(a_ticket_can_be_one_level_too_high_and_its_fix_unimplementable,'improvise-g3no read the bug as gotcha 20 ordering violated and asked for a Removed answer the front ends could let win, on the premise that \"both ends can already see the tree\". The browser can see the tree and NOT which rows are derived, and SetStatus is ViewOnly so no host-side refusal ever reaches a client -- so the stated fix is unavailable on that side twice over, and following the acceptance criteria literally would have produced a half-fix discovered at the browser boundary. The actual defect was one level down and an ASYMMETRY: remove_category always swept its formulas, remove_item did not. Restoring the sweep makes the optimistic status both ends already compose TRUE, with no wire change and no second classification. Read an acceptance criterion as a hypothesis about the mechanism, not as the mechanism.'). -doctrine(a_cursor_rule_copies_between_panels_only_if_the_list_is_homogeneous,'improvise-uej8. delete-view-at-cursor and delete-formula-at-cursor step the cursor back whenever it is above zero and are RIGHT to, because their lists are one-kind so a cursor that does not move cannot escalate. The category panel interleaves headers and items, and there the unconditional step-back is WORSE THAN THE BUG: it retreats the first item of a category onto its own header -- the same escalation one row earlier -- and a held d walks backwards. The conditional form (step back only when the row below is not a sibling item) is total because CatTreeEntry has two variants. The property to check when copying a cursor rule is HOMOGENEITY, not the panel it came from -- and \"check the other panels for the same shape\" has the answer no, by argument rather than by inspection.'). -doctrine(deliberate_slack_can_become_load_bearing_with_nobody_editing_it,'Dirty::of(CellData) reporting formulas: true was written as deliberate over-reporting -- gotcha 35 records that over-reporting costs one redundant projection while under-reporting leaves a client wrong forever. improvise-g3no made effect::RemoveItem actually able to remove a formula, so that arm is now LOAD-BEARING and narrowing it would strand every client formula panel permanently. Nothing guards it; the existing test covers the LayoutGeometry twin only (improvise-en4s). The line was not edited and its comment still reads as slack. So a change elsewhere can promote an over-approximation into an invariant, and the comment that justified the slack becomes the thing that invites its removal.'). -doctrine(two_tickets_at_different_priorities_can_be_one_bug,'improvise-25bf was filed P3 as \"the category panel Space cycles the axis silently\" and improvise-z336 carried, as its third finding, a P2 \"the Filter: X = Y status is stale exactly while the user tries to lift the filter\". They are the SAME bug: the status persists BECAUSE the key that moved the axis writes nothing over it. Fixing the P3 fixed the P2 most user-visible symptom, and the tickets treat them as unrelated. When a cosmetic bead and a mechanism bead touch the same surface, check whether the cosmetic one is the mechanism seen from the user side.'). -doctrine(a_coordination_check_is_not_an_intent_abstraction,'Two mechanisms can both make a write safe and still not substitute for each other. improvise-gd28: the basis check answers whether a client picture is CURRENT; staging answers whether the USER wants to keep the edit. Proposing write-through because the writes are already basis-checked confuses a concurrency guarantee with a user-facing capability, and the loss -- being able to abandon a drill -- is invisible to any test that asks only whether an edit lands. When arguing that mechanism A makes B unnecessary, name the question each answers before agreeing.'). +doctrine(an_autostash_instruction_is_not_permission_to_pop,'A merge with merge.autoStash enabled prints \"When finished, apply stashed changes with git stash pop\" on a conflict. In this repo the stash stack is SHARED across worktrees and sessions -- the integration agent found SIX other sessions entries on it -- so a reflexive pop takes someone else work. What git had stashed was not even the agent own: bd auto-export of .beads/issues.jsonl, triggered by its own earlier bd list. Identify the entry BY SHA, apply it with \`git stash apply \` and never \`pop\`, then drop that one entry by re-finding its index by SHA. AGENTS.md warns about git stash; it does not warn that an autostash merge PRINTS THE DANGEROUS INSTRUCTION FOR YOU, as advice from git rather than as a choice you made. That is the same shape as the conflicted stash-pop that corrupted .beads/issues.jsonl earlier the same day.'). +doctrine(a_capability_and_its_key_can_land_in_different_tickets,'improvise-gd28 (PR #115) landed the client-side view history -- ViewBack/ViewForward on the wire, per-client stacks, dead frames pruned -- while resolve.rs declined() still refused view-back, because that file belonged to another agent who had no reason to look at it. The merged wave therefore contained the capability and not the key: a user pressing < is told the feature does not exist over machinery that fully supports it. Neither branch is defective and neither agent could see it; reading one you conclude it works, reading the other you never look. So when a wave ENABLES something, check the decline list against what it enabled -- and check the wave PR body does not claim the capability is reachable.'). 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.'). @@ -430,9 +421,7 @@ decision('improvise-ni6k',cursor_invalidation_is_observed_not_declared,'No effec decision('improvise-864a',pages_artifact_reaped_by_a_foreign_successful_deploy,'Measured over 100 push-to-main runs 2026-09-19..2026-09-21: 12 of 12 artifact-not-found deploy failures had ANOTHER run SUCCESSFUL Pages deployment complete inside their own upload-to-deploy window, against 2 of 56 successes. Direction proved by the oldest run of the 17:47Z batch failing BEFORE the next deploy started and before any deployment existed, since deploy-pages fetches artifact metadata before creating one. NOT queue depth, NOT supersession, NOT retention, NOT the action deleting it. PR #46 tip-of-main guard aimed at the right quantity for the WRONG REASON and still must not ship: the association is not a mechanism, and 5f871f7 survived a foreign deploy inside its own window. Reproduce deliberately before fixing.'). decision('improvise-zv8t',a_document_category_is_never_inferred,'fit_measure now builds its Overrides from the same header[..category_columns] slice it already handed Features::of, forcing each to Forced::Categorical, so the one place the document own answer was known is the only place either caller reads it from. Closes Artefact::NumericCategory and DatePart-over-a-category from the only production entry point. The Artefact arms STAY: crate::dataset is pub and takes the caller Overrides, so a caller that builds one without forcing still produces a Numeric role over a category column -- deleting the arm would derive the classification from one caller habits rather than from the roles. Cost accepted and filed as improvise-e8no: a wide numeric category now one-hots past MAX_FEATURES and refuses a fit that used to ignore it. Do NOT close e8no by making the forcing conditional on cardinality -- that is inference by another name, and makes the ENCODING depend on a budget rather than on what the document says.'). decision('improvise-e5pf',the_mode_settles_where_the_grid_does,'resolve::changed_mode is DELETED. Every composition site emits the plain AppMode::Normal, and ClientState::fold settles against the grid it HOLDS via improvise_protocol::settled_mode -- one rule, also called by ClientSession::fold and App::settle_normal_mode, with App::normal_mode deleted as its host-side duplicate. Composition-time settling was PREDICTION: switch_view_at composes [SwitchView(target), ChangeMode(Normal)] and read the OUTGOING grid. Read-time derivation was rejected for having the same window and for moving the enumeration from composition sites to READERS, where a missed reader is silent. settled_mode settles the Normal pair ONLY -- Editing/RecordsEditing pass through -- which is why a records decision must ask the grid and never the mode. Same shape the repo already prefers: cursor_epoch over matching on CycleAxis, document_generation over matching on LoadModel, the dirty flag over a table of command variants.'). -decision('improvise-b8ot',carry_the_answer_on_the_node,'A delegated handler reads its operands off data-row/data-col/data-edit and its branch off the existing editable class, and derives NOTHING from DOM structure. gridPlan already counts DATA rows because a group header is not one, so walking up the table to find the row would be a second derivation of that and would disagree the moment a group header appeared. Ids name SURFACES only -- a per-row id must be unique per document and these are rebuilt every frame. The editor keydown/blur stay direct: one transient node at a time, and blur does not bubble. Five surfaces, one handler each, on the stable roots replaceChildren leaves in place.'). -decision('improvise-p7lh',the_totals_label_is_spelled_twice_on_purpose,'src/ui/grid.rs and web/shell.js both spell \"Total\", which fails design-principles section 5 -- would a third front end write this again. Kept local on the indicator() precedent, where the collapse glyph is spelled at both ends while the FACT is shared, rather than building a protocol string table for one word. Named as debt at the call site and in context/code/clients.md. ORCHESTRATOR RULING: this stands until a SECOND such string appears, at which point it wants a protocol home; one word is not a table.'). -decision('improvise-gd28',browser_drill_stages_identically_to_the_tui,'User ruling 2026-09-21: \"the staging scheme should be identical, because they are solving different problems.\" A wave agent proposed that a browser write through with ordinary SetCell commands rather than staging, since those are already basis-checked. The ruling rejects the substitution: the BASIS CHECK is anti-staleness coordination between clients and answers \"is your picture of this record current\" (ADR 001, gotcha 43, explicitly not a security boundary and not about intent); STAGING is an editing-session abstraction for one user and answers \"does this person want to keep what they typed\". Write-through would silently remove the ability to ABANDON a drill -- a user-facing capability, invisible to any test that only asks whether an edit lands. So pending_edits becomes client state, consistent with the view stack being client-local and the drill being ephemeral: all three say a drill is one person transient workspace. The commit is then a batch of SetCells taking its atomicity from clause 4, which improvise-yqpd delivered -- before that the batch could have half-applied, which is exactly what a staged commit must not do.'). +decision(view_server_split,multiple_clients_hold_different_views_of_one_model,'USER, 2026-09-21: \"The whole point of the view / server split is that multiple clients can have different views of the model.\" This is the RATIONALE, not an implementation convenience, and it is the single premise from which the four rulings of that day follow: the view stack is client-local; drill is an ephemeral view that should not sit in Workbook::views; switching a persisted view is client-only; and browser drill stages identically to the TUI, because staging answers a question about one user intent while the basis check answers one about concurrency. gotcha 29 already recorded \"the model is shared and the UI is not\" as a ruling; this states WHY, and it converts the split from a fact into a TEST. The test to apply to any proposal: does it force two clients into the same view? If it does, it is wrong against the purpose, however convenient. Two corollaries that have already bitten: a view-scoped ModelCommand must NAME its view or it lands on whichever view the host happens to be on (improvise-lltt, fixed -- SetAxis, CycleAxis, TransposeAxes and SetPageSelection all carry \`view\` now); and a server that REJECTS a client switch is the server holding an opinion about a client UI, which is what the client-only ruling removes.'). 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').