Also record the improvise-command to improvise-gp and improvise-gp to improvise-core edges. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
126 KiB
Gotchas (the long form)
The numbered gotchas that context/repo-map.md summarises in a line or two each.
The numbers are stable and are cited across the repo (gotcha 24, repo-map gotcha 35)
— a citation means the entry below. Read the ones your diff touches; the repo map's
digest says which those are. Every entry is the original text, moved here unchanged.
Dated claims and counts inside an entry are snapshots: recount rather than cite.
-
Records mode is wide format. A row is one coordinate tuple — the cells sharing everything but
_Measure— and the columns are[coordinates…, **+cat**, raw measures…,+]: a column per regular category, then the column that creates a new category (improvise-2zq, sitting where the column it creates will appear), then a column per raw measure, then the column that creates a new measure (improvise-c5v). Consequences: two measures on one coordinate are ONE row; editing a category column moves every measure cell in the row, not just the one under the cursor;_Dimin the synthetic key carries the column index, not a label, because a category and a measure may share a name — resolve it withGridLayout::record_colinto aRecordCol. Formula targets are not columns (nothing to type into them). Drill pending edits are keyed by(row, RecordCol).prune_axis_itemstakes a predicate, not a flag vector.prune_emptyhands itimpl Fn(usize) -> boolover the data index (group headers consume no index). The domain is derived fromitemsitself, so there is no parallel length a caller could get wrong — the earlier&[bool]+debug_assertpair could disagree between debug and release if a third caller ever sizedkeepfrom something other thanrow_count()/col_count()(improvise-n3wt). It re-marks the survivors rather than filtering the markers (improvise-e265): runs nest, so dropping an item can leave a header with nothing under it or aGroupEndclosing a run that was never reopened, and no per-marker filter can tell.remarkinstead emits, before each kept item, exactly the headers andGroupEnds that make the output's open runs equal the input's at every level — each survivor keeps exactly the runs it was in. Reasoning:context/code/core.md§6.prune_emptyshares oneGridEvalacross the whole row×col emptiness test, and a grid evaluates only throughGridLayout::evaluator, which builds the fold memo from that layout's ownnone_catsand hidden items — so no other grid's memo can reach its cells or totals (improvise-metf, improvise-opbo; the memo itself is improvise-h6z1,core.md§14).GridLayout::newdoes not prune;with_frozen_recordsdoes — and that split has now produced four bugs:view_frame,project::grid_in(PR #78), andpersistence/export.rs::render_csv(improvise-iot6), which wrote rows and columns the grid does not draw for as long as CSV export has existed. Every remainingGridLayout::newcall site in the workspace is test code — verified — so a new production caller almost certainly wantswith_frozen_records(m, v, None). -
Effects never touch
std::fs. Every document read or write goes throughApp::storage: Box<dyn Storage>(improvise-fuj, closed). Two writes still go around it:App's autosave and the headless final write call the path-takingpersistence::save(improvise-31oe.11, open). The host picks the backend:App::newsuppliesPhysicalFs,App::with_storagetakes any other — the browser worker supplies OPFS (improvise-i34, closed), and tests useMemoryFs.improvise-iokeeps path-taking wrappers (save,load,export_csv,parse_csv_with_warnings) for the native CLI, each delegating to a_to/_from/_bytesvariant that takes the backend or the bytes. -
Every effect declares an
EffectKind, and there is no default.ViewOnlynever reaches other clients;Model(CellData | LayoutGeometry | WholeWorkbook)does (improvise-mae). A new effect that forgets to classify itself fails to compile — that is deliberate, since the failure mode is silent: a mis-declaredViewOnlyedit would simply never reach connected clients. Watch two traps: the View lives insidemodel_state.workbook, so "touches model_state" does NOT mean "is a model change" (axis/page/format areLayoutGeometry, cursor and scroll areViewOnly— see improvise-8uz; andSwitchView,ViewBackandViewForwardmoved fromLayoutGeometrytoViewOnlywith improvise-jytm, writingview_stateand nothing else, which is what took the latter two off the wire — but a sound derivation is not a guard: onlySwitchViewhad one, and review showed reclassifying either sibling back was silent across the whole suite); andSave/SaveAstouchmodel_statefor bookkeeping only and areViewOnly.AddCategory/RemoveCategoryare the third trap and go the other way: they look like data edits, but both go throughWorkbook, whoseon_category_added/on_category_removedrewrite every view's axis map, so they areLayoutGeometry. The two "an effect declaring X actually changes X" tests cannot separate them — a category change moves model data and view config, so it clears either bar;structural_effects_declare_their_kindpins the declaration itself. A third trap, now guarded:EffectKindis too coarse to check a mapping.ui/reduce.rs::model_effectssends each of the 26ModelCommandvariants (counted onc1772ca9; gotcha 38) to one effect, and every inverse pair —HideItem/ShowItem,AddItem/RemoveItem,CreateView/DeleteView,SetCell/ClearCell— declares the same kind, so swapping three of them at once left the suite at 918 passing while three wire messages did the exact inverse of what they said. The mapping is name-for-name by construction, soevery_model_command_names_the_effect_of_the_same_nameasserts that, reading the type name off the effect's derivedDebug(Effectis a trait object, sotype_nameis out of reach). A variant whose effect must be named something else is a variant that stopped being derived — it should fail there and be argued about. Their reach is wider than the name suggests:Model::remove_categoryalso drops every formula targeting the category, which is whyDirty::of(LayoutGeometry)reportsformulas: true— see gotcha 35. -
Commands never mutate.
&CmdContextis read-only; returnVec<Box<dyn Effect>>. If you want to touch&mut App, add or extend an Effect. -
CellKeyis always sorted. Use the constructors; equality and hashing rely on canonical form. -
No
Model::add_item. With aWorkbookin hand use the facade:wb.add_item("Region", "East")?/add_item_in_group/remove_item(improvise-tvt). With only aModel, go through the category:m.category_mut("Region").unwrap().add_item("East"). -
Virtual categories.
_Measureitems = explicit items ∪ formula targets. Usemeasure_item_names()/effective_item_names("_Measure").add_formuladoes not add items to_Measure. Formula-target lookup also falls back into_MeasureviaModel::find_item_category. Cell aggregation goes throughapply_agg, formulaSUM(…)/AVG(…)througheval_agg— they differ on empty input (absent vs. A third rule joins them for non-numeric values:aggregate_cell_valuesreduces text by unanimity — one matching value, or several copies of the same value, is that value; differing values, or text among numbers, isCellValue::Error("mixed")(improvise-l4qx). The all-numeric branch is checked first, soCountof a lone number is still1and not the number — that ordering is the whole difference between this rule and "a single value passes through". What the rule forbids is dropping:filter_map(as_f64)used to, and since_Index/_Dimare onAxis::Nonein every default view that made every text cell in every document render blank, including the pipe-quoted text.improvcarries as a first-class value.evaluate_aggregatedandaggregate_rawshare the helper, so a formula ref over mixed values now errors"mixed"rather than silently summing the numeric ones.0for Sum/Count, error for Avg/Min/Max).Model::evaluate_aggregatedtakesAggregation::{Exact, OverHidden}rather thannone_cats: &[String](improvise-5jm5) — the list was only ever tested for emptiness, and the guard against that shape returning is the signature.recompute_formulas/aggregate_rawkeep the list because they walk it. -
Display rounding is view-only.
format_f64(half-away-from-zero) is only called in rendering. Formula eval uses fullf64. Never feed a rounded value back into eval. -
Formula eval is fixed-point.
recompute_formulas(none_cats)iterates until stable, bounded byMAX_EVAL_DEPTH; circular refs converge toCellValue::Error("circular").App::newruns it before the first frame so formula cells render on startup. -
Drill into formula cells strips the
_Measurecoordinate when it names a formula target, somatching_cellsreturns the raw records that feed the formula instead of returning empty. -
Minibuffer keymaps come from
TextEntry. The eight text-entry modes are built by theTextEntrybuilder inimprovise-protocol/src/keymap.rs— seven byTextEntry::new, andRecordsEditingbyTextEntry::with_parentoffEditing's map, which is why a count taken off the::newcall sites reads seven. It binds the shared spine (AnyChar→append-char <buf>, Backspace→pop-char <buf>, Esc→[clear-buffer <buf>, enter-mode <exit>]) and welds every commit key to itsclear-buffervia.commit(key, cmd, args)(improvise-w3q).TextEntry::newtakes theAppModeand reads<buf>off itsMinibufferConfig, so the buffer the prompt names and the buffer the keys edit are the same string by construction (improvise-k8h) — the keymap never spells a buffer name..rebindexists only for CommandMode's Backspace.SearchModeis hand-written on purpose — no commit key, andexit-search-modeowns the query.EnterModeclears the incoming mode's minibuffer throughwrite_buffertoo — buildingSetBufferdirectly there was unreachable only because no shippedAppModeis keyed"search".MinibufferConfig::buffer_keyis aString, not&'static str: a borrowed-'static field makesDeserializeunimplementable, and every text-entryAppModevariant carries aMinibufferConfig(improvise-a2x). A buffer named at runtime is read withcmd::core::read_bufferand written with its mirrorwrite_buffer— "search" is not in the buffers map but its ownViewStatefield, and those two functions are the only code that knows it (improvise-gdl). -
Panel keymaps come from
PanelKeymap. The three panel modes share a spine (Esc/Tab→Normal, arrows and j/k→move-panel-cursor <panel>, every panel's toggle key,:→command mode) built byPanelKeymap::new(panel); the keys that act on the panel's contents arrive through.bind/.bind_args/.deletebecause they vary in shape, not just command name (improvise-4pg).Panel::ALL/name()/key()inui/app.rsdrive the toggle keys for Normal and all three panels, so a fourth panel reaches every keymap from oneALLentry.name()must spell whatparse_panelaccepts. -
Prefix keys are stored as an address, not a keymap.
ViewState::transient_prefixholds the(KeyCode, KeyModifiers)that opened a prefix;KeymapSet::prefix_keymapresolves it back to the sub-keymap, walking parents soRecordsNormalreaches Normal'sg/y/Z(improvise-jc8) — that is the command-layer wrapper; the resolution itself isKeymaps::prefix_keymapinimprovise-protocol/src/keymap.rs.ViewStatemust stay free ofimprovise-commandtypes — it is destined for the protocol crate, whichimprovise-commandwill depend on. Resolution assumes prefixes are flat;prefix_keymaps_are_flatfails the build if a nested one is added. -
Keybindings are per-mode.
ModeKey::from_app_mode()resolves the mode; Normal +search_mode=truemaps toSearchMode. Minibuffer modes bind Enter/Esc asBinding::Sequencesoclear-bufferfires alongside commit/cancel. Hints are checked against the keymaps:every_hint_key_resolves_in_its_mode(command/keymap.rs) splits eachAppMode::hint_text()and requires every key it names to resolve in that mode's map, becauseonly_quit_has_no_hintasserts only that a hint is present. It checks which keys are named, not what they do. Its tokenizer knows the hint dialect explicitly (named keys,Ctrl-<c>,F/C/Valternatives, character runs likehjkl/[:], the em-dash prose tail, CommandMode's:qex-commands) and panics on a token it cannot classify rather than skipping it. -
Registry names come from prototypes, not strings. Every
register*call reads the name off a prototypeCmdinstance, so a typo is impossible (improvise-9cn) — so noregister*call spells a name. Name literals do remain inregistry.rs, and only tests guard them — the keymap testevery_bound_command_name_is_registeredfor theMoveliterals (all bound;registered_names_are_uniquecovers them too), and for the four aliasesregistry.rs::every_alias_resolves_to_a_registered_command(a hand-copied list, not the table) plus theparse.rstestsalias_add_cat_resolves_to_add_category,alias_formula_resolves_to_add_formula,alias_add_view_resolves_to_create_viewandalias_q_bang_resolves_to_force_quit— theMoveprototypes carrycmd_name: "move-selection"/"scroll-rows"/"page-scroll"(the fourjump-*names go throughreg_jump!), and fourr.aliascalls spell an alias and its target (add-cat,formula,add-view,q!). What types can't reach: five commands name themselves off a field (Moveoffcmd_name,ViewNavigate,TileAxisOp,ToggleGroupAtCursor,CommitAndAdvance), so a prototype can name the wrong variant and still compile. That shows up as a duplicate registration, not a mismatch — the wrong-variant entry collides with its sibling and the sibling's name vanishes.registered_names_are_uniquecatches it;every_bound_command_name_is_registeredcatches the keymap side, and checks both halves ofdispatch'sregistry.interactive(name, args, ctx).ok()?— the name resolves and the args parse — because improvise-s13 movedenter-mode ["export-prompt"]/["formula-edit"]out of the nullary class, where a typo'd mode name compiles into a dead key. It builds theCmdContextin each keymap's ownAppMode, sincecommit-buffer itemreadsctx.mode. -
effect_cmd!macro wraps a pre-built effect as a parseable command. Use it for simple wrappers; reach for a hand-writtenCmdonce there's real decision logic. Its counterpart on the effect side issimple_effect!inui/effect.rs, which declares a one-field newtype that writes one place on theApp. Its kind argument is mandatory —simple_effect!(Name(T) => |app| place, EffectKind::ViewOnly)— even though every call site so far answersViewOnly: hard-coding it reintroduced exactly the default gotcha 3 does without, since$placeis an unconstrained expression and a one-lineactive_view_mut().number_formatsetter would have reportedViewOnlywith the suite green (improvise-8fy). Exactly six wrappers returnunless_unknown(needs, effect)rather than the bare effect (gotcha 51):set-axis,set-page,toggle-group,hide-item,show-itemandswitch-view. The rest return bare effects —delete-view(whoseDeleteVieweffect refuses an unknown view itself),add-item,add-items,set-cell,create-view— andremove-formulachecks inexecute. Likewise, mode entry has no per-mode shim commands — bindenter-mode <mode-name>(improvise-s13); the name is decoded byparse_mode_nameinregistry.rs, andEnterModeclears the mode's minibuffer on the way in. -
Float equality inside formula eval has one epsilon, and the div/0 guard shares it.
FLOAT_EQ_EPSILON = 1e-10inmodel/types.rsis used by=/!=and by the division-by-zero guard (rv.abs() < FLOAT_EQ_EPSILON), in botheval_exprand its aggregated twin — six uses across the two evaluators (a div/0 guard plus theEqandNearms in each), one constant. This gotcha previously recorded the opposite (=/!=epsilon, div/0 exact) and was stale; the mix is gone. Why an epsilon at all and why this one:context/code/core.md§4. -
IndexMappreserves insertion order for categories, views, and items. Persistence relies on this — and so, since improvise-shyt, doDataStore::matching_cells/matching_values: the secondary index narrows the candidates, thenmatching_positionsresolves each to its position in the primaryIndexMap, sorts, and reads back. An index whose values areHashSets is for O(1) lookup, never for iteration. Walking it gave a different order in every process, which was not tidiness. Records-mode rows reordered between sessions;SUM/AVGdrifted in the last bits becausef64addition is not associative (Min/Max/Countand the text-unanimity rule of gotcha 7 are order-free, so they were never affected); and worst, the drill snapshot froze its record list in hash order whiledrill_state.pending_editsare keyed by row index (gotcha 1), so the same drill opened in a second process addressed different records — silent misdirected writes, and nothing in the tracker knew. Measured at 18,000 cells: +20% worst case onmatching_values, andmatching_cells7% faster, sorted positions resolvingCellKeys with better locality.DataStore::live_recordswas the open choice and is now decided (improvise-2yc2): it walkscellsand projects insertion order onto records, deliberately paying O(cells) rather than O(records), becausecells' order is already the answermatching_positionsgives and a second answer to "what order is this store in" is the disease. It is derived per call rather than memoised onRecordEntry, since a stored position is a copy and a record deleted and re-created would hold its old one. Note what is not on offer: an unordered return — every iterator has an order, and a type that merely disclaims one still hands the caller hash order, which is the guard living one level away from the hazard and the shape this whole class of bug returns through. The second real instance wasimport::analyzer::analyze_records(improvise-7yx), which built its distinct values through aHashSet:build_modeladds items in exactly that order andIndexMappreserves it, so the same CSV imported twice produced two different.improvfiles and two different row orders. Now first-seen order, pinned by a five-iteration test because eachHashSetgets its ownRandomState. Also answered here since: an emptypartialmatches every cell vacuously (improvise-hkt6) — it used to indexsets[0]and panic, unreachable only because both callers guarded. A group's fold reads through the same rule.Model::fold_storedfolds a collapsed group's stored values once, over every record the group covers, throughDataStore::spanned_values→spanned_positions(improvise-yb4b) — one scan, not a lookup per member.spanned_positionschooses by index-set size between narrowing once by the base key and narrowing once per covered item of the smallest span, whichever visits fewer (improvise-jq0m), and both arms return store order — sorted positions, asmatching_positionsdoes. That is load-bearing, not tidy: a fold that returned hash or per-item order would reintroduce theSUMdrift above, and the choice between the arms would then be visible in the last bits of a total. A partial as wide as the widest stored cell needs no narrowing (improvise-felo.13):DataStorekeepsarities(how many stored cells have each width), andexact_positionsanswers a strictly-sorted partial at least that wide with one probe ofcells, since only an equal cell can match; anything else falls through to the index. The answer is the same sorted positions. -
Commit paths for cell edits split across
commit_cell_value→commit_regular_cell_value/commit_plain_records_editincommand/cmd/commit.rs. Keep the synthetic-records branch in sync with the plain branch when changing behavior. Which cells refuse an editor is one function, asked at both ends of an edit:cmd::core::uneditable(model, layout, row, col)is the shared decline for opening an editor (EnterEditAtCursor) and for committing one (CmdContext::committable_cell_key) — a fold or other aggregate, a formula's result, or aCellId::NonItem(improvise-enz6, improvise-gk51). It reads the cell'sCellIdthroughproject::cell_id, the same derivation the projection uses, and the browser'sresolve::declined_atmatches on the projectedCellId, so the two front ends refuse the same cells. Asking only at open is the bug enz6 was: Enter-to-advance moved onto a collapsed group's row and committed a write to the group coordinate.CellId::NonItem { category, item }is a stored value whose coordinate names no item of its category — records mode can draw one, and no write can address it, so it is projected as its own variant rather than as aRecordthat the host then refuses (gk51 was exactly that mismatch). Its one wording isimprovise_protocol::non_item_refusal. -
Minibuffer commits go through one command.
commit-buffer <formula|category|item>(CommitBuffer { kind: CommitKind }) owns buffer→trim→bail-on-empty→dirty→status for all three; the varying parts are methods onCommitKind. The add effect is emitted last so its failure message wins over the command's optimistic status (improvise-6t3).CommitKind::Itemcarries its target category, resolved fromAppMode::ItemAddbyparse_commit_kindat registry time — socommit-buffer itemis not constructible from a script line. -
An abort stops the chain, never the bookkeeping — and ordering is what separates them. Three effects set
abort_effects, and they are not all saves:SaveandSaveAson a failed write or a missing path (improvise-8tk —SaveAswas the arm left out of the original fix, so:w /bad/path . qquit on a failed write), andAddCategorywhenModel::add_categoryrefuses, because what follows assumes the category exists — from the+catcolumn (improvise-2zq) the rest of the batch would advance the cursor and re-open an editor on a column that was never created. ABinding::Sequenceflattens into one effect vec, so the abort carries across sequence steps; that is a property of the flattening, and it is why improvise-0hb's proposal to apply effects between steps was rejected —apply_effectsresets the flag per batch, so step 2 in a fresh batch would land theQuitafter a failedSave, silently reopening a P1.a_sequence_whose_first_step_aborts_does_not_run_the_secondis the guard. The rule the aborts taught, which is the part to carry: an abort at the tail of a batch also ate the mode restoration and the buffer clearing, stranding the user inCommandMode/Editing/SavePrompt— wheredraw_bottom_barrenders the minibuffer instead of the status line, so the error the effect had just written was never drawn and the operation appeared to do nothing at all. So tidy-up goes at the head, where an abort cannot reach it:ExecuteCommandfronts its batch withReturnToNormalUnlessMoved,CommitAndAdvancewith the mode change,CommitSavewith the mode change (found by Copilot on PR #27). Ordering answers it without giving any effect a "runs anyway" flag, and it does not weaken gotcha 31's observed-not-predicted mode settling: whatever a later effect sets, it sets after, so the mode a:line ends in is unchanged in every case — the only difference is what happens when the batch never reaches the end. An abort is not a rollback; that is the hole clause 4 leaves (gotcha 47). -
The import wizard's back path is a history stack, not a reverse table.
advancepushes the step it leaves ontoImportWizard::history;previouspops it, so back always retraces the path actually taken through the conditional transitions (improvise-907). Preconditions live invalidation_error(), checked on the step where the user can still fix them — never deferred tobuild_model.NameModelis terminal foradvance: Enter there callsbuild(), which parks the workbook inbuiltonDone, andAcceptWizardModeltakes it from there. Going back fromDonediscardsbuilt.ResolveDuplicatesappears only whenImportPipeline::collisions()is non-empty:after_fieldsroutes there fromReviewProposalsorConfigureDates, so it comes after the field and date steps and beforeDefineFormulas, withNameDuplicateCategorybetween them when the answer is Separate. Enter there isWizardOp::ChooseDuplicate(sum, keep separate, or go back).ImportPipeline::on_duplicatedefaults toDuplicatePolicy::Refuse, so a path that never asked — the headless--no-wizardimport included — errors listing the collisions rather than summing them. A collision is a cell written by two records (a tuple and a measure), not a shared tuple: records with disjoint measures on one tuple write different cells and collide with nothing (improvise-9bit). Reasoning:context/code/formula-and-io.md, "Colliding records are a question, not a default". -
app.layoutgoes stale mid-batch.apply_effectsrebuilds only after the whole batch, so an effect that reads the layout after an earlier effect changed the grid needs aRebuildLayouteffect in front of it — the command decides the order (seeEnterEditAtCursorCmd,CommitAndAdvance). Read the cursor's cell text viaApp::display_value_at_cursor(); don't build aCmdContextinsideapply(it readslayout, the memoisedgrid_geometry()and the cursor, and a stale layout there is the bug). Since improvise-felo.13 the rebuild is skipped when nothing it reads has moved:App::refresh_layoutrebuilds only whenlayout_stale(anyEffectKind::Model(_)in the batch, orinstall_workbook), the drawn view's name, or the drill snapshot's pointer (LayoutBasis) changed, so a cursor key rebuilds nothing, and the column widths and row-header width are measured once per layout (App::grid_geometry, dropped with the layout). Two of the three inputs are observed (the view name and the drill pointer);layout_staleis declared fromEffectKind::Model(_)(touches_model), so gotcha 3's classification hole — aViewOnlyeffect that changes what the layout reads — now also leavesapp.layoutstale, where before felo.13 every batch rebuilt. It is still not a hand-kept list of effects. A writer that bypasses effects and changes what the layout reads must callrebuild_layout()itself. Measurements:context/test-evidence/layout-cost.md. -
The cursor is per-client and keyed by view.
ViewState::cursors: HashMap<String, ViewCursor>holdsselected/row_offset/col_offset, keyed by view name; an absent key means the origin. Reach it throughApp::cursor()(aCopysnapshot),App::cursor_mut(), andApp::reset_cursor()— never offView, which no longer has the fields (improvise-8uz). Two consequences. Changing the grid's shape must reset the cursor explicitly:View::transpose_axes/cycle_axisused to do it themselves, and nowTransposeAxes/CycleAxiscallapp.reset_cursor()— a new geometry effect that renumbers rows or columns has to do the same. Replacing the document must clear the map:LoadModelandAcceptWizardModelcallcursors.clear(), because the incoming workbook's view names have nothing to do with the outgoing one's. That clearing replacesWorkbook::normalize_view_state, which is gone — aViewcan no longer carry a stale offset. The same clearing owes the session mirrors:install_workbookbumpsApp::document_generation, andClientSession::projectreseeds any mirror seeded at an earlier one (improvise-cqq). An effect cannot reachSessions, so the generation is the mechanism andSessions::rebaseis only the client-facing half. The same is true of a geometry change, and it owes the mirrors both halves:App::cursor_epochis bumped byreset_cursorand watched bySessions::adopt_cursor_epoch, which resets every mirror and returnsBroadcast::ToAll(Command::ResetCursor)(improvise-gvnb). Doing only the first is strictly worse than doing nothing — the diff then believes every client already holds origin-positioned content and sends nothing, so they stay stale permanently rather than until their next scroll. The trigger is the epoch moving, not the command variant, for the reason gotcha 39 watchesdocument_generationinstead of matching onLoadModel: which effects renumber the grid is a per-effect decision, so a table of variants would be a second description of it, free to disagree. What is broadcast is an invalidation, never a position — another client's transpose must not move where I am looking (that is UI and it is mine, improvise-g0f), only declare that where I was looking has stopped meaning anything. Since improvise-ni6k no effect callsreset_cursorat all — the cursor is invalidated by OBSERVING the grid's frame.apply_effectsdiffs the frame after every model effect, inside the loop, because a batch that reframes and then places the cursor loses the placement otherwise (four records-mode tests pin that position). There is no list of effects anywhere, so the mechanism is total overEffectKind::Model(_)by construction — a genuinely stronger shape than a hand-kept table, and the reason the old "a geometry effect that forgetsreset_cursoris invisible in both directions" hazard is closed.Two arms, and the asymmetry is the whole design (improvise-mcq4).
LayoutGeometryhashes only the active view — it is the held-key path, and other views cannot reframe there sinceview_mut()writes only the active view, with the category and item effects already covered byview_shapes.CellDatahashes every view, because a cell write can prune a row out of a view the host is not on; narrowing that arm to one view measured as free and silently lost a multi-client invalidation, which is gotcha 24's own strictly-worse-than-nothing case. Cost of the wide arm: 0% on the held-key path, +5.7% on an 18k two-viewSetCell, linear in view count, accepted. State the absolute as well as the ratio: the observation roughly doubles held-key latency on large pruned documents (3.04 → 5.69 ms at 18k) and 56 ms at 90,000 cells is 3.4 frames.Totality is conditional on classification, and nothing makes classification total: a
ViewOnlyeffect that renumbers the grid is invisible to all of this (gotcha 3's hole, unchanged). And two known gaps are filed rather than fixed — a geometry effect immediately after a mid-batchSwitchViewskips the check entirely, because there is no basis for the new view yet (improvise-9c9o; under-reporting, which gotcha 35 says is the expensive direction), andModel(WholeWorkbook)matches neither arm, which is benign only because all three such effects go throughinstall_workbook→rebase→ a freshSnapshot. -
ViewStateand its field types areSerialize/Deserialize. It is the wire payload of the client Snapshot (improvise-q08), soAppMode,MinibufferConfig,MinibufColor,ViewFrame,KeyCodeandKeyModifiersall derive both (improvise-a2x), andimprovise-commanddepends onserdefor it. The constraint this puts on new state: no borrowed-'staticfields anywhere ViewState can reach —Deserializecannot produce one, and the error surfaces on every enclosing variant rather than at the offending field.view_state_survives_a_json_round_tripguards it.ViewStateandSnapshotalso deriveCloneandPartialEq, so the bootstrap message can be compared in a round-trip test. -
Axis presentation lives on
Axis; its colour is aFace. Four vocabularies hang off the enum inview/axis.rs—plain_label()("Row"/"Col"/"Page"/"None"),glyph()(whichDisplayappends, giving "Row ↕"),short_label()(three columns for the tile bar;Noneis "·", not a truncation), andinitial()(one letter for the view panel;Noneis empty). Plusnext()for the Row→Column→Page→None rotation.Axiscarries no colour:AxisColorandAxis::color()are gone. A colour is aFace::Axis(axis)inimprovise-protocol/src/face.rs(ADR 004,context/code/protocol.md§10), resolved by theTheme, anddraw.rs::face_styleis the one place a face becomes a ratatuiStyle— the tile bar and the category panel both call it, so neither keeps a table.improvise-corestill names no ratatui type. Not covered here:persistence/format.rsmapsAxisto the on-disk keywords ("row"/"column"/"page"), which is a format concern and stays in the io layer. -
Axis::Filterconstrains without being navigable. Drilling fixes its coordinates onFilter, notPage(improvise-dz8): both restrict which records are visible, but onlyPageis something the user may move or page through. The split shows up as two different questions, and picking the wrong one is the trap: "what data is visible" isView::filtering_cats()(Page ∪ Filter — used byGridLayoutand byadd-record-rowwhen it builds a key from the current constraints), while "what can the user act on" iscategories_on(Axis::Page)(used by[/]page cycling) orView::tileable_cats()(used by all three tile-select sites).Axis::next()mapsFilterto itself and no navigable axis rotates into it, so tile select cannot hand one out. The.improvgrammar acceptsfilterand round-trips a selection with it — a Filter axis only ever occurs in the never-persisted_Drillview, but the alternative was writing it out as some other axis. What a virtual view must be is model-side and has one home (improvise-gd28):view::{records_spine, records_steps, drill_steps}returnVec<ViewStep>— pure data naming noEffect, noAppand no front end — andcmd/grid.rs::steps_into_active_viewis the whole of how the TUI gets there, twenty lines a browser answers its own way. Before the split, the drill's filter placement and the records spine were spelled out twice incmd/grid.rswith nothing keeping them in agreement.DrillStatedeliberately did not move: it holds anRcand itspending_editsare uncommitted writes, so gotcha 30's session-local tag already places it. Reasoning:context/code/core.md§11. -
The browser backend is sync on purpose, with an async seam.
OpfsFs(improvise-io/src/opfs.rs, wasm32 only) implements the same synchronousStoragetrait asPhysicalFs, becauseFileSystemFileHandle.createSyncAccessHandle()yields a handle whose read/write/truncate/flush are blocking — available only inside a dedicated Web Worker, which is where improvise-9x6 puts the app. SoStoragenever became async. What is still async splits out into four explicit methods the worker awaits from its message loop:open(),attach(path)(must precede any read/write of that document — acquiring a handle is async even though using it is not),refresh()(repopulates the indexlistanswers from), andcommit_deletes(). Consequence:deleteis visible immediately but lands in OPFS at the end of the batch — it drops the name from the index and queues aremoveEntry. Handles are exclusive per file, soattachcloses any handle it replaces anddetachreleases the lock.storage_contractinstorage.rsis the behaviour every backend owes; it runs against both host backends and is the written-down specOpfsFsmust satisfy in a browser, where the harness cannot reach. And the premise is dedicated-worker-only, which is narrower than this paragraph used to read.createSyncAccessHandleis spec'd[Exposed=DedicatedWorker], and measured in a moduleSharedWorkeron Chromium and Firefox it is absent — not rejected, not flagged, not transient:getDirectory()andgetFileHandle()both succeed and the method is simply not on the object (improvise-12ha). SoOpfsFscannot be hosted by aSharedWorkeras written, and "a browser worker" in this paragraph means a dedicated one everywhere it appears.OpfsFs::open's own guard does not enforce that — itdyn_intosWorkerGlobalScope, which aSharedWorkerGlobalScopesatisfies, so a shared worker sails past a check whose message names the right requirement and fails later as a rawTypeErrordeep in the glue (improvise-7gip). The consequence for multi-tab is that the machinery everyone reaches for first is the storage seam, notSessions:Sessions, theAddressedfilter and the oneframepair are all ready, and none of that helps, because the no is entirely on the handle. -
"Which view is active" is stored twice, and the copies are now allowed to differ.
Workbook::active_viewis the document's default — what a freshly connected client opens, and what.improvwrites asInitial View.ViewState::active_viewis authoritative for that client, and it is what keysViewState::cursors. The second copy exists becausereduce_viewruns on a client that holds noWorkbook(improvise-cqi); the split is improvise-jytm answering improvise-g0f, on the user's ruling that the model is shared and the UI is not. Two clients may sit on different views of one document. Three things follow.App::resolve_viewis the one answer to what a view name names in this document, falling back to the default so a slice naming a deleted view cannot panic inrebuild_layout;view(),view_mut(),view_name(),project::grid_inandClientSession::view_inall go through it, and a second copy of that fallback was the first thing review found. The guard was replaced, not deleted —the_two_active_view_names_never_divergebecamethe_documents_default_view_outlives_this_clients_switchesplusa_fresh_session_opens_the_documents_default_view, because a deliberate split with no guard is how one silently regresses into the other. And a deleted view is the case the split creates:DeleteViewcan take away the view another client is on, soSessions::adopt_deleted_viewsmoves that mirror to the default and tells it, andeffect::DeleteViewdoes the same for the host — observed ("does this mirror still name a view the document has"), never predicted from the command variant. Testing any of it needs a bystander that can be moved: both audience tests first sat theirs on the default, where a wrongly-addressed relay folds to a no-op, and review demonstrated the vacuity by injectingToAlltwice. One gap remains:Initial Viewno longer follows the TUI user (improvise-azhb). The other — a view-scopedModelCommandnaming no view — is closed (improvise-lltt), and this paragraph described it as an open gap for a day after: every view-scoped variant carriesview: Stringandmodel_effectsmaps it toViewTarget::Named, so a connected client'sSetAxislands on the view it names.ViewTarget::Activeresolves throughApp::view_mut()and is therefore the host's view — but the wire never constructs one, which is what closed it. Follow mode is deliberately not foreclosed. -
Two tests gate
ViewStatemembership.ViewStateis the portable session slice — the one thing serialized into the Snapshot a newly-connected client gets (improvise-q08). A field may live there only if it is portable (plain data from the allowlisted crates, noRc/Arc) and snapshot-safe (a fresh client inheriting the value is correct).wizardfails both —ImportWizardis animprovise-iotype, and a half-open dialog is not something to inherit — anddrill_statefails both — it holds anRc, andpending_editsare uncommitted writes belonging to whoever staged them. Both live onAppunder the session-local residue tag (improvise-h0b). TheViewState: Sendstatic assertion inapp.rs's tests is the compile-time proxy for portability:Rcis notSend, so a re-added shared pointer fails the build (theDeserializederive from gotcha 25 now rejects it too, serde'srcfeature being off).Senddoes not catch the origin-crate or snapshot-safety halves — check those by hand. -
:commands settle their mode by observation, not prediction.ExecuteCommandends every batch withReturnToNormalUnlessMoved(ctx.mode), which drops toNormalonly if the mode is still what the batch started in. There is noEffect::changes_mode()any more: it was a per-type constant, andViewBack/ViewForward/StartImportWizardswap the mode only on some runs, so no up-front answer was right —:importopened the wizard and was snapped straight back out (improvise-toc). A new effect that changes the mode needs no annotation; just don't return the batch's starting mode from a success path. -
There is no aggregate render cache — each widget folds its own stream.
improvise-protocol::renderholds one cache type per widget (GridCache,CategoryTreeCache,FormulaCache,ViewListCache,TileBarCache), each implementingWidgetCachewith its ownUpdatetype, and aProjectionis one such update addressed to one widget (improvise-rkeq).reduce_viewfolds onlyViewStatenow — it no longer takes a cache, andCommand::Projectpasses through it untouched. Two consequences. The overlap between caches is deliberate: the tile bar and the category panel both carry(category, axis)and are updated by separate projections, so neither is woken by the other's changes; do not "fix" it by sharing a variant. Bootstrap is a compaction, not a replay:Snapshot { view, seed: Vec<Projection> }seeds a new client with a batch of the same projections the steady stream carries, so there is no whole-state type that could disagree with the incremental one. The seed is the server's current state, not the head of the history —GridCache::seed()builds it, and the property that earns it isGridCache::folded(&cache.seed()) == *cache. Nothing is defined for the help overlay (its content is static andhelp_pageis already inViewState) or for the import wizard, which now has a shape and deliberately no projection:WizardCacheis built byproject::wizard_cacheand drawn locally, because a half-open dialogue is addressed to precisely the session that opened it, never to every client (improvise-764 — the rendering-side twin of the argument that removedAcceptWizardModelfromModelCommand, gotcha 38). A cache without aProjectionis the shape session-local residue takes when it still has to be drawn by two different front ends. It lives inimprovise-protocolrather thanimprovise-commandfor theflatten_cat_treereason: when the wasm client grows a wizard it must draw these rows, not a second derivation. -
The grid's labels frame its cells, and
applyenforces it — the fold is not commutative. Three consequences inimprovise-protocol/src/render.rs. (a) AGridUpdate::Cellsthat overtakes theRowFrame/ColFramenaming its positions is dropped, becausefit_to_labelstruncates to the labels it has; a client must not reorder the stream, anda_cell_update_that_overtakes_its_labels_is_lostpins it. (b) A label change that renames a position blanks the cell under it:fit_to_labelspreserves by POSITION, which is only right where the label did not change, soapplycallsinvalidate_relabelleditself rather than trusting a producer to sendGridUpdate::Invalidatedfirst.Invalidatedtherefore now means only "contents stale, frame unchanged". (c) every arm that touches labels or widths re-fits —col_widthsis re-fitted with the columns — a new column is drawn at its label's own character count untilColWidthscatches up — because a renderer zips widths with labels and a length mismatch indexes off the end. Fitting on the label arms alone is what let a lateColWidthsput the mismatch straight back — a short update keeps each unheard-of column at its label's width, and a long one is dropped as having overtaken the frame that names it, exactly as (a) holdsCells.GridCachealso carriestotalsandpage_coords(improvise-n10).totalsis framed bycol_frameon the same terms as the cells and widths and re-fits with them, with one asymmetry: an empty strip means "this grid has no totals" (records mode, or an empty grid) and is therefore not padded, where a non-empty one is — padding it would draw a blank total row under a records grid that has none.Invalidatedblanks the totals too: they are contents, and a stale sum under fresh cells is the one number a user takes at face value.page_coordsis framed by nothing (it names categories, not positions) and survives a reshape. Note the fold tests: assertingfold(prefix)+fold(rest) == fold(whole)is vacuous (it is the definition of a left fold), so the tests that earn their place are the order-sensitivity one and the compaction-equivalence one — seedoctrine(fold_tests_must_test_order_not_the_axiom). -
A formula target is unquoted, and pipe quoting has one home.
parse_formulastrips the pipes off a target exactly as it does off anExpr::Ref— the target names a_Measureitem, so a target that kept its delimiters madeadd_formulacreate an item literally named|Total Revenue|: a ghost column the user's real item never matched, andExpr::Ref("Total Revenue")never resolved to. Both the TUI formula minibuffer (AddFormula) and.improvload go through that one call, so both were affected. The writer emitsFormula::raw, nottarget, so file text is unaffected either way — which is exactly why the round-trip tests never saw it, and why the fix repairs existing files on next load with no migration. Pipe quoting has exactly one home:improvise_formula::quote, owning both halves —quote_name/is_bare_name,pipe_quote(unconditional, for a.improvtext cell that must not read back as a number) andunescape_pipe_body.improvise-gpre-exports it,improvise-iocalls it frompersistence/{format,parse}.rs,parser.rs::unquote_pipeis a shim over it, and io's own copy is deleted (improvise-i2m5). The keyword guard is canonical — user-confirmed, so do not revert it as over-caution: an item literally namedSUMis written|SUM|. Bare keywords do parse, but only becauseagg_callneeds a(and PEG backtracking falls through toref_expr— rule ordering, not a stated rule — and a## Formulasline carries formula text verbatim, so a nameformat_mdwrites is read back byparse_formula: one namespace, one spelling rule. One thing the guard does not cover, and one that used to be an exception and no longer is. A section header takes its name as the whole line, totally (improvise-pczv):header_name = { pipe_quoted ~ &NEWLINE | line_name }— the pipe-quoted arm needs the lookahead, or|A|Bwould parse asAand silently dropB, andline_namecatches everything else, so a header name can no longer fail to parse. Four rules share it (model_name,initial_view,category_section,view_section), which is why it is a rule and not four copies. Note a header is still written raw where a reference is quoted — the grammar accepts both arms, so the quoting asymmetry between a header and a reference is a writer convention, not a format one. And the two former unquoting copies differed only on a trailing lone backslash, unreachable from the grammar, where io returned it andparser.rspanicked — the survivingunescape_pipe_bodyis total. Two## Category:sections naming one category are one category, concatenated byParsedFile::push_categoryin pass 1 (improvise-349m) — merging in pass 2 was not idempotent, becausebuild_categorycreates its own section's item-groups before its own>lines whileformat_mdwrites every item before every>line. No grammar change was needed: this is the tree-walker, not the format, and duplicate headers stay legal. AndWHEREnow carriesIS's word boundary — both are compound-atomic withsp+(improvise-r08u), soSUM(Rev WHEREAS x = y)is a syntax error rather than aWHEREon a categoryAS. The guard issp+and not the!bare_charlookahead the ticket proposed, because the grammar-walking generator reads the grammar throughpest_metaand skips predicates, emittingWHEREa=""— a rule whose only guard is a lookahead is a rule the generator can violate. It costs one spelling: a category may no longer abut the keyword. -
Dirtyover-reports on purpose, and the asymmetry is the reason.Dirty::ofmaps anEffectKindto the widgets that kind can reach, and the two directions do not cost the same: over-reporting pays one redundantProjection, under-reporting leaves a client's cache wrong forever, with nothing in the stream to correct it.LayoutGeometrytherefore includesformulas, becauseRemoveCategorywears it andModel::remove_categoryends by dropping every formula targeting the category — a client's formula panel otherwise kept showing a formula the document no longer had. It now coincides withDirty::EVERYTHINGand is still spelled out, because the two are derived from different effects and need not stay equal. The derivation now lives incontext/code/command.md, and the doc comment onDirty::ofpoints there rather than restating it; the reasonLayoutGeometryis spelled out rather than written asEVERYTHINGis a two-line comment inside the match body. It went stale once already — claimingAddCategorywasCellDataafter it was reclassified — so reclassifying an effect means re-reading the prose, not just the comment. -
A client's command has one door, and the door takes the
App.Sessions::apply_checked(app, id, Checked<'_>)is the only way a connected client'sCommandreaches the server —Hostcalls that (improvise-ahdf).Sessions::applyis notpub, delegates toapply_checkedafterChecked::against, and has no production caller: its call sites are tests, which is why it takes a raw&Command.ClientSession::foldand the mirror'sViewStateare private, so a transport cannot reach the mirror at all.apply_switchnow takes aSessionId— it is addressed to exactly one session, which is the opposite of what this gotcha used to say — moves only that session's mirror, and dirties nothing. The one thing it still asks the document is whether the view exists, because a mirror keysViewState::cursorsand is read bygrid_in. The audience is conditional: an accepted switch relays nothing (the asker folded it, and no other client's view moves), a refused one isBroadcast::To(asker, …)— improvise-6c6q, whereToOtherstold every client but the one that was wrong.adopt_active_viewandBroadcast::ToOthersare deleted; the variants areToAllandTo(SessionId, _), the second because the deleted-view case needs a one-session audience that is not the asker. The paragraph's warning about the "what do the others hear" helper survives and is why the 6c6q test drives the asker's ownViewState— that helper is itself deleted. What survives unchanged: the model is shared and the UI is not, andBroadcastis aVecbecause one command can owe two relays with different audiences. The trap this paragraph used to describe is gone withToOthers, and the lesson it taught is not. It was copyingToOthersfromapply_switchintoapply_model, invisible from the bystander's side becauseadopt_active_viewmoved the asker's mirror either way — so the drift sat between the server's picture of the asker and the asker's own slice, where a test written through a "what do the others hear" helper looks away by construction. improvise-6c6q closed the mirror-image hole on the refused path. Two rules survive the mechanism: assert theBroadcastitself, since the audience is the message rather than something to infer from folded state, and seat a bystander somewhere a wrong relay can move it from — a bystander already on the relayed value cannot see a wrongly widened audience either, because the wrong relay folds to a no-op. Review demonstrated that vacuity twice on PR #48. AVecrather than anOptionbecause one command can owe two relays with different audiences, and anOptionwould have had to drop one silently. Everything else is that client's alone and never touches theApp's own view slice: the model is shared, the UI is not. FoldingSwitchViewinto the mirror alone was the bug —viewport()then read the cursor under the new view's key and the nextproject()built that client a grid out of the other view. -
gp-treereturns data and never prints, and a fit takes two things to reproduce. Two rules with one root — the crate is a library a TUI, a wasm worker or a CLI may each host. (a) A decision the user should hear about comes back as data:Dataset::warningscarries the columnspreprocessdropped for being entirely missing (improvise-d44s), andfit.rshands back pooled(prediction, actual)pairs and a score per feature rather than tables.examples/fit_csv.rsowns everyprintln!, and that is the only reason its output stayed byte-identical across these moves — adding aneprintln!to either module is the regression. (b) The seed is necessary and not sufficient:data::Modelrecordsseed: Option<u64>andconfig: Option<FitConfig>, because the same seed under a different population/generations/elite/penalty lands on a different tree (improvise-ksst). Both areOptionfor one reason — everyFitConfigvalue and the seed0are legal, so nothing can double as "unknown". Note#[serde(default)]on anOptionfield is documentation, not the mechanism: serde defaults it anyway, so deleting the attribute fails no test, and a backward-compatibility test that only deletes it is not discriminating. Aim such a test at an absent field loading as the default instead of as unknown. The GA loop itself isfit::fit(improvise-0ai);breedremains one fitness-agnostic generation for callers whose fitness is not "predict this column". (c) A scoring function takes one slice of rows.Row<T>pairs a feature vector with what it predicts, so the parallelx/ylengths thatzipused to truncate silently cannot be built (improvise-dkqn,doctrine(an_invariant_is_made_unrepresentable_not_validated)— the same answerprune_axis_itemsgot, and the reason noResultentered five signatures). The pairing is made at the boundary that builds the data:preprocesswalks one row index and the rows live insideTarget, because aDatasetcarryingrowsandtargetseparately just moves the zip up one level into every caller. The empty row set is one shape, answered once inmean_over. Changing any of this needs bit-level evidence:tests/scoring_known_answers.rspins every path as f32 bit patterns plus anext_u64witness that catches a changed RNG call order, because the CLI prints three or four decimals and byte-identical output alone cannot see a moved accumulation. (d) A fit is storable only when the document can name its features.preprocessinventsRegion=North,Cost.missing,when.year; those are encoding artefacts, not items of the document, andExpr::Refresolves throughModel::find_item_category— so a tree splitting on one emitted a well-formed formula whose every cell wasError("ref:…"), silently (improvise-8xhy).fitted_formulatherefore takes the&Modeland refuses, listing what it could not resolve;formula_textstill spells it, being the tree's own text. The check asks the document, never the name's shape, because a measure literally namedRegion=Northis a legal document and the two are indistinguishable as strings — which also means.missingand the date parts are covered with no second copy ofpreprocess's expansion rules to drift from. This is an interim (improvise-4zl7): aWHEREpredicate needs a scalar coordinate predicate the formula language does not have —Filtergates a formula or an aggregate, it is not anExpr— would still leave.missingand the date parts, and must first answer what such a predicate means wherecontext.get(cat)isNone, sinceFormula::filterfalls through toaggregate_rawand a categorical fit would silently degrade in every view that aggregates over the category. improvise-zv8t closed the categorical half of the accident, and the close cost a document.fit_measurehandedpreprocessanOverrides::default(), so it never said which header columns are document categories although the very next line toldFeatures::of— andpreprocessinfersNumeric → Date → Categoricalfrom the values, so aYearcategory with items2024/2025was a numeric column and a category of ISO dates a date column, both refused withYear IS 2024spellable all along. It now forces the sameheader[..category_columns]slice toForced::Categorical. Two things follow. The date decline is narrower than it read — it covers a genuine date measure, never a category misread as one. And the forcing widens the encoding: a hundred-item numeric category one-hots pastMAX_FEATURESand refuses a fit that previously succeeded by never splitting on it (improvise-e8no — and not to be closed by making the forcing conditional on cardinality, which is inference by another name and makes the encoding depend on a budget rather than on what the document says). TheArtefactarms stay:crate::datasetispuband takes the caller'sOverrides, so the roles are still reachable. Forcing by name while budgeting by index defeats the budget — a category and a measure may legally share a name, andno_ambiguous_column_namesran afterpreprocess, so the expansion the preflight exists to prevent was allocated and only then refused;as_categoriesnow returnsResult<Overrides>, so the ordering is a compile error rather than a convention. The test that missed all this is the shape to remember:improvise_evalfabricates a_Measureitem per feature name, so it asserted the emitter andgp_tree::evalagree against a model that could not exist. It is kept, with its scope now stated, and the property it could not state is asserted separately against a real document. -
The wire vocabulary is derived twice, by the same recipe.
Command's view half is theEffectKind::ViewOnlyeffects targeting the portable slice (decision(improvise-cqi, command_set_derived_not_invented)); its model half,ModelCommand, is theEffectKind::Model(..)effects — 28 of them, brace-matched onc1772ca9(2026-09-24) — minusApplyAndClearDrillandAcceptWizardModel, leaving 26 variants (improvise-kmgs). The two added since this paragraph counted 26 and 24 areReplaceFormula(improvise-km71) andRenameRecordCoordinate(improvise-fdk1);SwitchView,ViewBackandViewForwardleft the model half with improvise-jytm. Every count in the rest of this paragraph is dated, so recount rather than cite it. Completeness is now checked, not counted:ui/reduce/vocabulary_is_complete.rsrequires every effect to be on the wire under its own name or listed inNOT_ON_THE_WIREwith a verdict —Command,Frame,Offwith its argument, orGapnaming the ticket that owns it (SaveAsandExportCsvare gaps, improvise-2mqz). It is the forward twin ofvocabulary_is_composed.rs, and it is what the enumeration below lacked.SwitchViewno longer needs subtracting separately: it is not a model effect any more.ViewBack/ViewForwardleft for a reason this paragraph stated wrongly until 2026-09-21: it said they went for the same reason as the other two exclusions, session-local residue. That is false, and the falseness mattered.ViewState::view_back_stackis inViewState, which is the portable slice — so by the view half's own rule (decision(improvise-cqi): theViewOnlyeffects targeting the portable slice) they were alwaysCommands and were simply missing. The cost of the mis-statement was a live divergence nobody could see:effect::SwitchViewpushes the leaving frame and clears the forward stack, whilereduce_view's arm setactive_viewand nothing else — so no client ever had a back history at all, and that, not the drill commit thedeclined()comment blames, is whyview-backwas dead in a browser (improvise-gd28, PR #115). The lesson is about derivations, not about these two variants: a derivation stated as a rule is only as good as the last time somebody enumerated it against the rule. This one had been checked for correctness — every listed variant belongs — and never for completeness. Note the obviousawkextraction reports 30: it picks upHelpPageSet, which isViewOnly, because that is the lastpub structbefore#[cfg(test)]and the test module mentionsModelChange::— brace-match theimpl Effectblocks instead (doctrine(derive_then_verify)). Three things follow. AModelCommanddoes not mutate:ui/reduce.rs::model_effectsmaps it back to the effect it came from andapply_commandruns that throughApp::apply_effects, so effects stay the only mutation site and theDirtyis the effect's ownEffectKind, never a second classification beside gotcha 35's table. Two model effects are deliberately off the wire.ApplyAndClearDrillandAcceptWizardModelwere once kept on the grounds that the command is portable even where the operand is not; that was the wrong criterion (improvise-3x3u). ACommand::Modelis routed with noSessionId, which is right exactly when the change is addressed to no one session — and these are addressed to precisely one, never the sender: their operands aredrill_state.pending_editsand the built wizard workbook, session-local residue (gotcha 30) private to whoever staged them (improvise-tom1). Unaddressed, B'sApplyAndClearDrillcommits A's uncommitted typing and B'sAcceptWizardModelswaps the document out of a dialogue B never opened — both demonstrated againstSessions::applybefore removal. So 28 − these two = 26 onc1772ca9(it was 26 − 2 = 24 before km71 and fdk1), and the older spelling of this sum (29 − SwitchView − these two = 26) was itself a survival of the same staleness this paragraph documents: it subtractedSwitchViewand forgot its two siblings, so it contradicted the count at the head of the paragraph and the enum’s own 24 variants (found by ChatGPT Codex on PR #116). The effects are untouched and still reached by the key path. Not permanent: multi-client drill wants the addressed form, which is where ADR 001 layer 2 goes anyway. Guarded byno_model_command_commits_another_clients_session_local_state, plus a companion asserting both effects still exist and are still model changes so the first cannot pass by naming nothing.Sessions::apply_checkedroutes the model half toapply_model, which relaysadopt_cursor_epoch'sResetCursorandadopt_deleted_views's per-client switch; a document-replacingLoadModelbumps the generation and lands in the rebase arm. The former claim here — thatViewBack,ViewForwardandLoadModelall end inApp::switch_to_viewso every mirror takes the accepted active view — was wrong on all three clauses after improvise-jytm and is deleted. A keymap lookup still cannot return aCommand, and not for want of vocabulary: a binding names an intent resolved againstCmdContext(move-cursor down) while aCommandis the resolved mutation (SetSelected { row, col }) — improvise-gxi's AC 4 asks for something the split forbids. -
Appliedis an enum because a document swap is not a diff.Sessions::apply_checkedreturnsApplied::Diff { dirty, broadcast },Applied::Rebased { snapshots }or — since ADR 001 layer 1 —Applied::Rejected { reason }, and the enum is the mechanism: a host that wants theDirtymust match, and matching names the case where aSnapshotis owed. The server's mirrors self-heal —ClientSession::projectreseeds on thedocument_generationbump — butViewState::cursorslives on the client, keyed by the outgoing document's view names (gotcha 24), and only a freshSnapshotreplaces it.Command::Modelput the threeWholeWorkbookmutations on the wire (gotcha 38), so the host that must send the snapshots is no longer the host that asked.apply_modeltherefore callsrebaseitself rather than instructing the host to, exactly asapply_switchadopts the accepted view itself. The trigger is the generation moving, not the command variant: aLoadModelthat cannot read its path leaves the document alone, and reseeding on the variant would cost a client its cursors for a failed load —a_load_that_fails_is_not_a_rebaseguards that arm. Arebase: boolbesidedirtywas rejected, because the host that forgets to callrebaseis the host that forgets to read the flag. The third case is a normal outcome, not an error, and it is in the enum for the same reason the other two are: a host that wants theDirtymust match, and matching names it.ServerMsg::Rejectedis the matching frame, addressed to the asker alone — nothing happened, so nobody else is owed anything — and the client keeps it as aRejectionrather than a sentence, because force needs to know which record and which kind (gotcha 43). Fixture warning. When testing a document swap, the incoming document's active view must have the same name as the outgoing one. A rename makesapply_modelbroadcast aSwitchView; the client folds it, and the stale cursor is then read under a key nothing has written — right by accident, with the bug invisible.other_document()renames, and a first version of this test passed because of it;a_document_shaped_differently_under_the_same_view_name()keeps the name and changes only the shape, so there is nothing to hide behind. -
A batch's basis is three tiers, and only two of them can be read off the effect.
Appcarriesdocument_generation(bumped byinstall_workbook),layout_version(per view shape) andrecord_versions(per record) — ADR 001's stale-basis check, and shared document state, so never onViewState(gotcha 30). The unit is the record:record_of(key)is the coordinate tuple minus_Measure(record_row_identity), because renaming a category coordinate in wide records mode moves every measure cell in the row (gotcha 1) — a cell-level unit is violated by one legal edit, so the unit must be at least as coarse as the coarsest legal write. Only theDirtyis read off theEffectKind; both lower tiers are observed around the loop — the source says so atapply_effectsand this paragraph said the opposite until improvise-ilvl's audit. TheEffectKinddecides only whether to look:touches_modelarms the shape snapshot,writes_cellsthe record fingerprints.record_versionsmust be observed, because anEffectKindsays that a batch writes cells, never which;layout_versionis observed for a better reason than necessity —observe_reshapediffsview_shapesbefore and after and bumps only where a view's shape actually moved (improvise-pdvm/nynk), so it is a fact about the document rather than a claim about the batch. Two consequences the declared form did not have. An idempotentset-axisbumps nothing, and batching two real changes into one line or two gives the same2. And the gotcha 21 argument is subsumed rather than restated: an aborted batch moved no shape, so there is nothing to not-claim — which is why observing once after the loop is correct where accumulatingDirtyinside it is.view_shapehashes what is drawn, not occupancy (its doc comment: "Drawn, not configured") — axes, the drawn item names minus hidden ones, folds, page selections and theprune_emptyflag — so an occupancy write does not bumplayout_version: aCellData-only batch that prunes a row out of a view moves the cursor epoch (the frame observation) and notlayout_version, ascontext/code/command.mdrecords (the question that raised was improvise-6nsd, closed). The arming is wider than the tier (touches_modeltakes the shape snapshot), but a bump needs a shape hash to have moved. Three further consequences. A write landing the value already there bumps nothing, which is correct — no basis was invalidated. ALayoutGeometryeffect that drops cells will bump their records when it shares a batch with aCellDataone: an over-bump costing at most an unnecessary rejection, the same directionDirty::ofis deliberately sloppy in. And only raw data is fingerprinted — formula results live inModel::formula_cache, and a record whose derived value moved because someone edited elsewhere has not itself been written. The diff collects into a set: chainingbefore.keys()withafter.keys()counts any rewritten record twice, a bug that survived one test run. Superseded (ADR 009 step 7): the record tier is no longer fingerprinted.apply_effectsaccumulates the store's change log (DataStore::record_changes/drain_changes) into one consolidated batchCellDelta(improvise_incremental::cell_delta) andbump_moved_recordsbumps every record inmoved_records(delta);record_fingerprintsand the text above naming it are historical. -
Storageis not a VFS, and that closes the SQLite question. The trait is whole-documentread/writebytes (gotcha 2,invariant(storage_is_two_ops)) — no offsets, seeking, handles or locking — so ansqlite3_vfs, which needsxRead(offset,len)/xWrite/xTruncate/xLock, has nothing to bind to. SQLite can still sit behind it, as an in-memory database serialized to a blob (rusqliteserialize/deserialize_read_exact, proven with a probe before declining — declining on a capability that actually exists would have been the wrong decline). It is worth nothing: measured on 18,000 cells the SQLite container is 22% larger than.improv, 4.7× larger gzipped, ~160× larger at the floor (24 KB against ~150 bytes), and still rewritten whole on every save, so the one dimension SQLite normally wins — incremental page writes — is unavailable. improvise-9ix was declined on that evidence. Two things generalise past SQLite. A second serialization format is a second implementation of the grammar, obliged to trackimprov.pestforever; and its own round-trip test cannot detect drift from.improv, because it never compares the two — that missing cross-format equality test is what made the ticket look cheap. If a structured-query story is ever wanted it is a one-wayexport_sqlitebesideexport_csv, native-only and feature-gated:libsqlite3-sys' C build fails under this repo's nix cc-wrapper forwasm32-unknown-unknown, which would break thewasmCI gate the day it landed. -
The browser client's render decisions are made in Rust, and the frames it speaks are nobody's but the protocol's.
improvise-wasm-client/src/render.rsbuilds aRenderModelin which every question with a silent wrong answer is already answered, because the DOM half (web/app.js) is untested JavaScript and a wrong answer there shows up as a wrong pixel rather than a failing test. Three such answers.editableis a fact about theCellIdvariant —Recordonly, per ADR 002 — and the trap is the third case:Nonemeans "no projected cell", not "aggregate", so conflating them either hides a cell or offers a write the type system exists to make unconstructible. The selection is placed by reconciling the scroll offset, becauseGridCacheis viewport-indexed whileViewState::selectedis absolute.render.rsdoes it by adding —r + cursor.row_offset == sel_row— rather than subtracting, which is why there is no underflow path and why a selection above the window simply never matches. Omitting the offset entirely looks right until the first scroll, so the test that earns its place uses nonzero and unequal offsets: atselected=(5,1),row_offset=4,col_offset=1it separates "reconciles" from "ignores" and from "swaps the two offsets". The category panel isviewmodel::flatten_cat_tree, never a second walk ofCategoryTreeCache— that second walk was improvise-edp. The page is four string calls:Client::viewport(the handshake — theSnapshotseeds the visible cells, so one built at extent zero is an empty grid and a resize is a fan-out with nothing dirty),on_frame(one frame, one fold, arrival order — gotcha 33 holds inside a batch, so never coalesce),render_model,typed_cell.One envelope pair, two deployments (improvise-xks6).
improvise_protocol::frameownsClientFrame { Viewport, Commands(Vec<_>), Save, Key }andServerFrame { Snapshot(Box<_>), Commands(Vec<_>), Rejected, Accepted }(as ofc1772ca9; this line named a singletonCommandand noSave/Acceptedfor some time after both changed);improvise-ws-serverandimprovise-wasm-clienteachpub usethem and neither defines one. There used to be three pairs for two deployments — the socket's server side, a hand-kept mirror of it in the browser bundle, and a third written for the worker (improvise-tm6) before the projection architecture landed. The question that had to be answered before any of it moved was whether the worker and the socket want the same vocabulary, and the answer is yes: every difference was a date rather than a decision, and the test applied to each was whether the argument for it names the thing they differ in. None did.Viewportis deployment-independent — the extent is not inViewStateand no effect produces it, so no derivation reaches it, and a worker hosting the sameSessionsowesset_extentthe same answer; it is the handshake on both because aSnapshotseeds the visible cells.Keyis deployment-independent too — a binding names an intent resolved against aCmdContexta thin client does not hold (gotcha 38), which says nothing about what the bytes travel over; the socket lacked it because key forwarding was never built on it (improvise-bpq2), andHost::handlenow answersClientFrame::Keywith an explicit empty vec that says so, where the frame used to just fail to parse innext_client_msg. AndCommands(Vec<_>)batches where a singleton cannot, because a fan-out must arrive whole and in sequence (gotcha 33) — a property of the fold, not of the transport; an in-processpostMessageinterleaves exactly as a socket does. The worker pair was not merely different but incoherent by omission: written before ADR 001, it could carryCommand::Model { basis, .. }and had noRejected, so a client speaking it composed a claim that could never be answered. That incoherence — not the difference — is the tell for staleness.The mirror is gone, and so are the two tests that held it honest; that deletion was the ticket's measure of success.
the_socket_framing_matches_the_serverround-tripped hand-listed fixtures through the other crate's types to catch a rename;every_server_variant_is_one_this_crate_mirrorsmatched exhaustively to catch a variant added on the server and never mirrored. With one definition both are type identity: a rename is a compile error at every call site in both crates, and the un-mirrored variant is not a constructible state. That is stronger than either test, and it is the shape to insist on — if a consolidation leaves one of its compatibility tests alive, the consolidation is not finished. What survives isthe_retired_spellings_do_not_parseinframe.rs, aimed at the one thing still possible (the retiredBootstrap/Commandspellings returning as serde aliases, which would be two homes again wearing one type), and it keeps the injected-fault discipline this paragraph earned the hard way: assert the mutated input is still well-formed before feeding it in, and assert the error kind — serde'sunknown variant— not merely that an error occurred, because a rewrite that also breaks the JSON makes the check pass against an enum that really does have the variant. End-to-end compatibility is nowtests/browser_round_trip.rs, which hands the wasm client's own serialized frame to a realHost::handle;improvise-ws-serverstays a dev-dependency of the browser bundle for exactly that.What the harness does and does not reach is gotcha 48's subject —
web/app.jsis no longer unrun (harness/tests/shell.spec.jsexecutes it under Chromium, and its standalone half under Firefox), improvise-hwpi is closed, and the quiet-failure and never-reads-a-key claims that used to stand here went with it. What remains unexercised is named there: the grouped and multi-level DOM, the CSS and theResizeObserverextent arithmetic. What a careful read did establish about the last of these: the observer cannot feed back into itself (#gridis a flex child withoverflow: auto, soreplaceChildrencannot resize it), andROW_PX/COL_PXunder-estimate, so the worst case is a row of over-fetch. What is verified end to end is the Rust half:tests/browser_round_trip.rsdrives improvise-1ey's stated scope against a realHostover a realApptouching the client only through the four string calls. One deployment note that is not a bug:examples/demo.improvleavesCustomer/Dateon the None axis, so every cell is an aggregate and nothing is editable — correct behaviour, bad demo. Pick a model with no regular category on None. -
A batch carries its basis, and the server had to publish it first.
Command::Model { basis, cmd }is a struct variant so the basis cannot be forgotten, andSessions::apply_modelchecks it before applying anything — a stale batch is never applied, not even partially (improvise-xwi6/fkfg, ADR 001 layer 1). Four things to know.Basishas noDefaultand no public constructor: a zero-valued basis is not "no claim", it is the claim that the document is fresh, which in a newAppis true — so a default would be a forgeable bypass rather than an absence.BasisCache::claimis the only door a client has and it returnsNoneuntil the server has published one. Note what that does and does not buy:BasisderivesDeserializebecause it must cross the wire, and its fields are public, so a hand-rolled client can state any tiers it likes. This is an anti-staleness coordination check, not a security boundary —RecordUnstatedcloses omission, not invention, and nothing here tries to close invention, because a client that forges a current basis is indistinguishable from one genuinely caught up. "The only constructor" is a statement about clients written against this crate, which is still worth having: it makes the honest path the easy one. Which records are checked is the server's decision —reduce.rs::records_named, an exhaustive match with one or-pattern for the variants that name no record, so a new record-writing variant fails the build. Reading the list off the client's claim would be a force by omission: the record tier skipped, with no flag to remove and nothing in the message saying so. A claim that omits a record the command writes getsRejection::RecordUnstated, a case of its own because "you lost a race" and "you were never in one" are different things to tell a user.Rejectionkeeps structural and data staleness apart, which is the whole reason ADR 002 put a maintainedRecordHashindex inDataStore:RecordGonemeans nothing resolves and there is nothing to force (force never creates structure),RecordMovedmeans it resolves at another version and force is one keystroke — which is also why the wasm client stores theRejectionitself rather than collapsing it intostatus_msg, since a string cannot carry which record or which kind. And the ADR omitted the publication. It said a batch carries the basis it was composed against and never said how a client learns one; nothing in the projection stream carried any of the three tiers, so the check as written was satisfiable only by forging.Projection::Basis+BasisCacheclose that — the server publishes the two document tiers plus the counters for the records this client's grid names, diffed against what that client was last told and seeded in the bootstrap — and it is emitted after the grid updates in a batch, because the counters describe the cells those updates carry: a basis arriving first would let a client claim a version for a value it had not been shown, the mirror image of gotcha 33. That ordering was documented as load-bearing in three places and held up only by the order of two statements untila_published_basis_follows_the_grid_updates_it_describespinned it (proved by injecting the swap) — the same joba_cell_update_that_overtakes_its_labels_is_lostdoes for the mirror-image rule. Two things are unchecked and deliberately so:Command::SwitchViewcarries no basis (it sits outsideCommand::Model; the workbook refuses a view it does not have, so it cannot corrupt, but it is a hole in the rule), andapply_commanddoes not check at all — the check is a property of the session door, not of the mutation path, which is right because a host holding theAppis the truth rather than a claimant. That second one was a door rather than an exemption, and needed a type to stop being one (improvise-ondj): both took a wireCommandand theirCommand::Model { cmd, .. }arm discarded the basis with.., whileui/reduce.rs's own module doc pointed every future host — the browser worker included — at exactly those two names. The struct variant closed omission from the message; it did nothing to close ignoring on arrival, which is the half a struct variant is usually reached for. So both now takeChecked<'_>, a private-field newtype over a&Commandwhose only constructor isChecked::against(&App, &Command) -> Result<_, Rejection>— also the only caller ofstale_basis.Sessions::apply_checkedreceives the proof andChecked::againstis answered once, before the dispatch, soapply_switch/apply_modelreceive the proof rather than each remembering to ask: an invariant made unrepresentable rather than validated, the same answerprune_axis_itemsandgp-tree'sRow<T>got. Visibility was the weaker option and was rejected for a concrete reason —pub(crate)closes only the out-of-crate path, andreduce_fullhad no production caller anywhere in the workspace, so it would have become dead code. It has since been deleted — which is the argument’s own conclusion arriving, not a contradiction of it; the surviving sibling isreduce_key. This paragraph named it in the present tense for some time after, so read every function name here as a claim to check. The regression guard is therefore the signature: the test that reproduced the bug no longer compiles, and what remains asserts the constructor refuses and that refusing leaves the document untouched. One residual hole, documented on the type and filed: aCheckeddoes not name whichAppit was checked against, so a multi-document host could in principle check one and apply to another. That is also whyui::reduce::caught_up(&App, cmd)is not a bypass: it takes&App, so nothing reachable from a wire can call it. Since improvise-955n the frame, not the command, is what is answered — and only two of the three tiers hoist.CheckedBatch::againstruns before anything in aClientFrame::Commandsis applied.document_generationandlayout_versiondescribe the document, so answering them once for the frame is exactly right, and a first version hoisted all three — which was a lost update: the record tier asks "is the client's picture of this record current", and which records are at stake is a property of the command (records_named). Skipping it for later commands let[SetCell(North), SetCell(South)]overwrite a South somebody else had moved, silently, with the frame reported accepted, and invisible to the other two tiers because aSetCellisCellDataand moves neither. So every mutation answers for its own records against the frame's basis — the one the first basis-carrying command stated, never each command's own field, or a later command could claim a fresher picture than the frame was composed under. The one exemption is stated over a property rather than a list of commands: a record that neither resolves in the document nor is claimed by the basis is one the frame is about to create ([AddItem(East), SetCell(East)], whichBasisCache::claimcannot possibly claim for), and only that combination is exempt; a record that does not resolve but is claimed is stillRecordGone. That theBasisinside a laterCommand::Modelis ignored rather than compared is an honest interim: clause 1 says the frame states one basis, and moving the field fromCommand::ModeltoClientFrame::Commandswould make that unrepresentable-otherwise instead of a convention — a protocol change that belongs with clause 4. Two things worth keeping from the rejected design: arity is not a property of a variant (a futureCommand::RunScriptis one variant performing unboundedly many mutations), so no count over the vocabulary can enforce "one mutation per frame"; and the transaction boundary is not the frame, because one command may already be a chain, which is why clause 4's mechanism belongs atApp::apply_effects. -
The TUI grid draws from its cache, and since improvise-anr9 the cache frames it too.
GridWidgetholds no&Modeland no&View: cell text, aggregate-ness (CellId::Aggregate, ADR 002), column widths, the page bar, the totals strip and now the frame all come offGridCache. The TUI still builds that cache over the whole grid (offsets zero), so a cache position is an absolute position — the browser'sViewportis a real window, the TUI's is not, and the two clients therefore use it differently; column totals must be projected rather than client-derived, because the sum runs over every row including the ones a viewport scrolled past. The frame is anAxisFrameper axis —cats(the categories, outermost first) plusentries: Vec<FrameEntry>interleavingGroup(FrameGroup)headers andGroupEnd { level }markers withItem { tuple, collapsed }label tuples, exactly asGridLayout::row_itemsinterleaves them. That replaced aVec<String>built byGridLayout::row_label, which slash-joined the tuple — the exact renderingtwo_row_categories_produce_cross_product_labelssays must not appear — so the browser drewFood/Alicewhere the TUI drew two header columns, with no groups and no collapse indicators. It is one type and not alabelsplus alevelsbeside it: two answers to "what are the labels" is the disease (improvise-edp), and this is the second cure, which cost a type change rather than an addition. Three things to know before touching it.leaf_labels()is not "the labels" — it is the innermost label, what a one-line summary calls a row by; a draw loop wantsheader_labels(first_rendered),groups()andshares_ancestry(sel), each of which is one pass, becauseitem/label/shows_level/group_ofscanentriesfrom the start and asking them per position is quadratic in the frame and grows with the TUI's absolute scroll offset (found three times on anr9; the per-position forms survive for one-off lookups and say so). The width rule has one home,improvise_protocol::header_level_widths— widest label per level, plus a gap, clamped — reached byAxisFrame::header_widthsand byui::viewport::compute_row_header_width, which borrows level tuples off aGridLayoutrather than building a frame on the keypress path. Acatschange re-identifies every position: a cell's identity iscatszipped with its tuple, soinvalidate_relabelledblanks the whole axis when the categories move, not merely the positions whose tuples differ, and blanks the totals on a recategorization of either axis — a column total is a sum over the rows, so re-identifying the rows leaves a number answering coordinates the grid no longer has (gotcha 33(b): the rule lives inapply, not in a producer's memory). What is still not on the cache isrecord_col, and that is correct: it keys pending drill edits and is session-local (gotcha 1).body_from(idx)takes an argument only because the two front ends scroll differently — a browser's frame is already windowed and passes0, the TUI's covers the whole grid and passes its row offset — not because the rule differs.group_ofanswers group membership, and since improvise-e265 membership is the innermost open run:FrameGroupandFrameEntry::GroupEndeach carry alevel(an index intocats), runs nest, a header at level L replaces L and closes every deeper run, and aGroupEnd { level }closes L and deeper. Before e265 it was the nearest preceding header unless a level-lessGroupEndintervened (improvise-gd83, PR #77), which could not express one run inside another.GroupEnddraws nothing and exists only so membership is not inherited.AxisFramedeserialises throughUncheckedAxisFrameand refuses alevel >= cats.len()— a level naming no category is a malformed frame, not a clamp. AndFrameEntry::Itemis{ tuple, collapsed }:collapsedis the layout's own fold flag, read byAxisFrame::folds, so a client asks the frame whether a position folds rather than re-deriving it from the groups (improvise-4k1j). Both of the gaps this sentence used to list are closed, and it named them as open for a day after:group_runscoalesces by header identity (std::ptr::eq), not by name, so two runs under two headers equal as values are two runs (improvise-h0iv); andAxisFrame::firstcarries noserde(default)— its doc comment says why, since zero is a claim and not an absence (improvise-1wl0).doctrine(a_stale_list_of_what_is_missing_is_worse_than_no_list)is about exactly this paragraph, twice now. Andproject::grid_ofused to evaluate every cell per frame where the widget evaluated only the visible ones; since improvise-felo.13 the host's own layout keeps aGridSummary(whole-grid widths, column formats and totals,ui/viewport.rs::grid_summary, held inApp::grid_geometry()), andgrid_inevaluates only agrid_windowover it. The browser's window therefore costs its own cells. A session on another view, or a rederived model, still takes the whole-gridgrid_bodypath.grid_windowequalsgrid_bodywhere the summary is current. Since improvise-jytmgrid_inmay additionally derive aGridLayoutfor a session on a view the host is not on; the host's layout is reused where the names agree, which is the common case and the only one that can carry a drill snapshot.RecordHashis document-scoped;AggregateHashis layout-scoped and cannot be otherwise (improvise-gd28). ARecordHashresolves throughDataStore's maintained index — but only when the command names a view the document has (improvise-wpxr; Codex P2 on #281).SetCell/ClearCellcarryview, and the lookup is strict: a view the document lacks drew nothing, stored or not, so it isRejection::RecordGonerather than aresolve_viewfallback. An empty record — one the store holds no cell for, which the grid still drew — resolves against that named view's layout, the only place it exists.RenameRecordCoordinateis the exception and resolves in the store alone, since only a record holding cells can be renamed:protocol::named_recordsanswers each command's records asNamedRecord::Drawn { view, record }orNamedRecord::Stored(record), one exhaustive match. Every one of these questions goes throughApp::grids(), aGridsborrow that memoises one derivedGridLayoutper view for as long as the borrow lives (improvise-kimw) — hold one across a check, never across a mutation, since the layouts it holds describe the model it borrowed. An aggregate has no existence outside the layout that derives it, so there is no index to fall back to and none can be built —App::resolve_aggregatetherefore answers only for the host's active view, which since improvise-jytm is not the client's. That, and not ADR 001 layer 2, is what blocks browser drill. The ticket's blocking argument was right about the commit (ApplyAndClearDrillis addressed to one session, gotcha 38) and wrong about the entry: entering a drill needs no commit, andCreateView/SetAxis/SetPageSelectionare already unaddressedModelCommands that name their view. The gap is one level down from where it was looked for — the mutation and its operand have different scopes: the mutation configures the shared_Drilland is addressed to nobody, while the operand exists only inside one session's layout. So the fix is not a new mechanism but theview: Stringconvention every other view-scoped variant already follows, resolved against a layout derived for that name (improvise-obtu). Two things travel with it: editing inside a drill grid from a browser does not follow from it, becauseedit_or_drilldeclines records-mode editing out loud before reaching a cell (improvise-fdk1, since closed, is the separate records-coordinate rename on the wire —RenameRecordCoordinate, gotcha 38 — and did not unblock drill editing); and closing it makes improvise-t219 live,_Drillbeing one shared view inWorkbook::viewsthat two clients drilling would contend for — the same masking relationship improvise-6uva has with improvise-up8g. Guarded byan_aggregate_a_session_was_shown_does_not_resolve_against_the_hosts_view, whose control is the load-bearing half — the same surrogate resolves once the host is moved onto that view, so a blanketNonecannot pass. -
One session loop, two transports — and the browser saves itself.
HostisApp+Sessions+handle(id, ClientFrame) -> Vec<Addressed>and lives inimprovise_command::ui::host, not inimprovise-ws-server, which re-exports it. It moved for improvise-9x6: the worker hosts the same authoritativeAppover aMessagePortand cannot depend on tokio, and a second copy would have been exactly the mirror improvise-xks6 had just deleted for the frames — a driftingApplied/Broadcastmatch is invisible until a client sees it.registrystayed on the server, because a worker hosts one document. What the worker adds is only what aMessagePortdiffers in: one client by construction (theAddressedfilter is honoured anyway, against a futureSharedWorker), and a handshake with no close —ClientFrame::Viewportmust still come first, but a pre-handshake frame is dropped rather than closing a connection that does not exist.Saving is a frame, and dirtiness is read off the classification the effect already carries.
effect::SaveisViewOnly(gotcha 3) — correctly, since it writes this host'sfile_paththrough this host'sStorage— so the derivation that buildsModelCommandfrom the model effects cannot name it, and for a while that meant no client on any transport could ask its host to save.ClientFrame::Savecloses it (improvise-368h): a request carrying no path, because a client cannot see the host's storage, answeredServerFrame::Rejected(NoDocumentPath | SaveFailed)to the asker alone and silently on success — nothing moved, so no widget is dirty. It is a frame and not aCommandfor one concrete reason: as aCommandit put a runtime-fallible operation inside a batchClientFrame::Commandspromises is all-or-nothing, so[Save, SetCell]reported a failed write and then applied the edit. It also fails the test everyCommandpasses — it mutates no shared state and no client's slice, soreduce_viewhas nothing to fold andCheckedhas no basis to answer for. AndMarkDirtyis gone. It was a separate effect the command layer composed beside each mutation — reasonable on the key path, unreachable from anywhere else, because improvise-kmgs derivedModelCommandfrom the effects one at a time and so putSetCellon the wire without its companion (improvise-gv9r): a derivation correct per element and wrong for a set, whose symptom was that "save if dirty" never fired.App::apply_effectsnow raises the flag from the effect'sEffectKind, which makes omitting it unrepresentable rather than validated — declared forWholeWorkbook(a replaced document differs from the file its path names, so aLoadModelthat cannot read its path still raises it, costing one redundant write) and observed forCellDataandLayoutGeometry, wheredocument_content/document_configfingerprint the document before and after, so a write that leaves it as it was raises nothing (improvise-748f). The observation is read inside the loop, andSaveclears the flag in loop order, so a batch that edits and then saves ends clean. The worker's persist trigger is therefore the flag, not a table of command variants: an observation rather than a second description of which commands change the document, for the reasoncursor_epochbeats matching onCycleAxis(gotcha 24) anddocument_generationbeats matching onLoadModel(gotcha 39). It is also strictly tighter — a refused batch raises nothing, and neither does a write that changes nothing, so the "was anything rejected" guard went with it. A browser still never sendsClientFrame::Save, and that is the answer rather than a gap: there is no file the user owns and no Ctrl-S semantics, the document is the OPFS entry, so the worker persists on its own behalf through the one existingSaveeffect. The cost that remains:Storageis whole-document (gotcha 41), so OPFS rewrites the file per accepted model command. Superseded (ADR 009 step 7):document_contentis gone. ForCellDatathe flag isreordered || !delta.is_zero() || document_shape movedcomputed from eachCellDataeffect's own delta (drained after every such effect, so ordering withSaveholds; writes that cancel across effects still leave it set); only the record tier (bump_moved_records) uses the consolidated batch delta;LayoutGeometrystill comparesdocument_config. The text above namingdocument_contentis historical.The async seam does not survive being boxed.
OpfsFs::open/attach/refresh/commit_deletesare on the struct, not onStorage.App::storageis aBox<dyn Storage>and a trait object can name none of the four, so handing the backend to theAppputs the async seam permanently out of reach; the host must hold the concrete backend and give theAppaStorage, which means sharing one. That isimpl<T: Storage + ?Sized> Storage for Rc<T>, and it lives inimprovise-iorather than in each worker (improvise-hmws):impl Storage for Rc<OpfsFs>is two foreign types in any other crate, so improvise-9x6 wrote a forwarding newtype that every future worker would have written again.Rcand notArcbecause the one caller is a Web Worker and wasm32 has no other thread. The flat root is no longer load-bearing:OpfsFs::listjoinsdirlike the other two backends, sostorage_contracttakesdiras a parameter and the three agree at every directory rather than only atPath::new("")where joining is the identity (improvise-vhcz). What the index holds is documents, not entries, and the split between its three writers is the rule:attachindexes nothing (what it creates is zero bytes),Storage::writeindexes the name and un-queues any pending delete of it, andrefreshrebuilds from OPFS skipping zero-byte entries — counting an entry it cannot measure as a document, since the one cause of that is another tab holding the exclusive handle (improvise-vvl3).What no test here can see has shrunk, and precisely.
harness/(improvise-vvl3/xf1) runsimprovise_io::storage::contract::storage_contractagainst a realOpfsFsinside a real dedicated Web Worker under Chromium and Firefox, and thebrowserjob in CI can fail the build — soOpfsFsis no longer compile-checked-and-never-run, and two bugs fell out of its first execution. That is whystorage_contractstopped being a#[cfg(test)]helper and became a public function returningResult: a browser must run that sequence, not a second transcription of it, and a failure has to travel out of a worker as a string. Still unseen: Safari (Playwright's WebKit has no OPFS, so xf1's criterion is met for two engines of three;opfs.spec.jsskips on a runtime probe and an unexpected skip on any other engine is a failure).bindings.rsis NOT in that list, and said it was for months.shell.spec.js's "the standalone deployment" loads the app with no?ws=, which spawns the worker, which imports the glue, which callsbindings.rs::startandOpfsFs::open— and the spec then asserts a grid draws, so the whole chain has run in CI since improvise-vvl3 (Chromium, and Firefox since 2026-09-20). The claim survived in five places (this line, the File Inventory, twokb.plfacts and improvise-bck's grooming note) because each was written before the harness landed and none was re-read after; the spec's own comment says the chain plainly. It is the reason to distrust a "never run" written in prose: nothing checks it, andkb.pl's CI gate checks consistency rather than truth. What was already verified stays:tests/post_message.rsdrives a realWorkerover a realAppon nothing but the JSON aMessagePortcarries. Build note unchanged: the worker bundle needs--enable-nontrapping-float-to-intas well as--enable-bulk-memory, because it carries the wholeAppand so chrono's date arithmetic. -
The page picks a peer, and the demo was built against an assertion.
web/app.jsholds two transports and no protocol knowledge:?ws=selectsimprovise-ws-server, its absence spawnsnew Worker('./worker.js?doc=&seed=', { type: "module" })(improvise-djm).type: "module"is mandatory —worker.jsimports the bundle — and there is noMessageChannel: a dedicated worker's ownpostMessage/onmessageis the port, so the ticket's channel wording was a date rather than a decision. The websocket path is kept, because the ws-server is how a TUI-hosted document is shared and nothing else in the browser can reach it; the cost is contained because the two differ only in where strings come from — handshake, fold, repaint and keyboard are all above the seam — and the presence of the parameter is the whole selection mechanism, a second knob being a second thing to get wrong.#statuscomposes two facts, the transport's and the document's, because a single source let a repaint silently erase a connection error. A worker that rejects instart()fires noerroron the main thread —worker.jscatches its own — so the guard is a first-frame watchdog, deliberately not per-transport: "the peer never answered the handshake" is the one failure that means the same thing over a socket and over a port, and a check aimed at each peer's own failure modes would have missed exactly this one.The demo is a model built to satisfy a check, not an axis flipped and hoped for.
examples/demo.improvleavesCustomer/Dateon None, so every cell is anAggregateand nothing is editable — and the naive fix does not work either, sinceProduct × RegionbyDate_Month × Measurestill aggregates.web/demo.improvputs exactly one category on each visible axis (Productrow,_Measurecolumn,Quarterpage, nothing regular on None) and carriesProfit = Revenue - Cost, so the payoff is on one screen with no keystroke. Settability is asked ofgrid_ofas aCellIdvariant, never re-derived from the axes — a second derivation could agree with itself while disagreeing withrender.rs::editable, which is the whole point of ADR 002 — and the assertion was proved discriminating by pointing it atexamples/demo.improv, where it names the aggregate at (0,0). One category per axis is a property of the demo, not of the format: when this was writtenGridCacheslash-joined a multi-level tuple, so a second category on an axis put that known gap on the front page — improvise-anr9 has since closed it (gotcha 44), so what remains is the demo's own simplicity rather than a rendering limit.web/*.improvis un-ignored in.gitignore(*.improvis ignored repo-wide) and the CI assemble step copies it: leaving it out is an empty grid, not a 404 the page could report. One wart it now puts on the front page: a formula cell is aRecord, so it is offered as editable and a write to it reverts on recompute — pre-existing and matching the TUI, newly visible. -
Keys become commands in the browser, and the server must not know the keymap. The user's ruling on improvise-bpq2, following from "the model is shared, the UI is not" — and it buys two clients different bindings over one document. What made it urgent is not latency.
a_key_resolved_on_the_host_moves_state_no_client_can_hearshows nothing in the projection stream carries the host's ownViewState, so host-side resolution diverges; andHost::handleanswersClientFrame::Keywith an explicit empty vec, so an upstream key is a black hole, not a slow path — everyKeyOutcome::Upstreamnames a binding that does nothing at all in a browser. That test still holds and was not flipped: bpq2 closes the divergence by avoiding it. The method is the ticket's — enumerate the bindings, not the commands (doctrine(binding_dump_diff)) — and where a client cannot compose theCommand, enrich the projection rather than moving resolution back.Three things that cost.
GridExtentonGridCache— the whole grid, of which the labels are one window — because a viewport-indexed cache cannot tell "the last visible row" from "the last row". It is deliberately not framed by the labels (fit_to_labelsleaves it alone) and its batch position is not load-bearing the wayRowFrame-before-Cellsis, since it addresses no position; the producer readsGridLayout::row_count(), and reading it off the projected ranges is invisible in the TUI (which builds at offsets zero, gotcha 44) and is exactly the browser bug. The window is the client's own fact, recorded byClient::set_viewportfrom what it announced — not derivable, since the row frame's own length is the window clipped to the grid and so differs precisely at the bottom edge where the scroll arithmetic matters.Client::viewport(associated, no recording) still exists and is the one that quietly turns cursor keys off;web/app.jsdoes callset_viewport.ClientFrame::Commands(Vec<Command>)replaces the singleton, because one cursor key isSetSelectedplus the offsetsviewport_effectsreturns and a host that folds the selection but not the offset projects the wrong window. It is answered whole, then applied whole (improvise-955n):CheckedBatch::againstchecks the frame before anything runs, so there is no longer a moment at which part of a frame has landed and the rest has been refused, andApplied::Rejectedis unreachable throughHost::commands— still matched, because a host that wants theDirtymust name every case (gotcha 39). The contract is four clauses and all four are now delivered — clause 4 by improvise-yqpd on 2026-09-21, whose mechanism isApp::snapshot/restoreplus aSessionsclone taken inHost::mutating_frameand put back byundo_frame, deliberately not insideapply_effects, which runs on every keypress. This paragraph said it was undelivered for a day after it landed, and an orchestrator brief repeated that from here, so read the clause list as a claim to check. A delivered clause 4 is also what makes the CLIENT's rollback well-defined (improvise-nxah): a refusal means the host applied none of the frame and restored every mirror, so a client keeping its optimistic fold is the only thing left out of step. What rolls back there isViewState, never a cache — aCommand::Modelis a no-op inreduce_view, so the optimistic fold never reached one. The old text, kept because the hazard it names is still the right shape to fear: one basis per frame, checked once, applied in order with each command seeing the last one's state, and all-or-nothing. was that the fourth was open becauseApp::apply_effectsaborts without undoing (gotcha 21 — an abort is not a rollback), so[RemoveItem("North"), SetCell(the North record)]would land the removal, resolve the write to nothing, and report the frame accepted having applied half of itself — partial and silent. That is whatyqpdclosed, and it closed it outsideapply_effectsfor the reason the old text implies: the abort lives on the keypress path and must stay cheap. The old note that it was unreachable from any client here, the only multi-command producer beingkeys::commit, still reads correctly as a caution — reachability is a reason not to rush a fix and not a reason not to notice one, the lost updateCheckedBatchfixed having had the same reachability argument and none of the documentation, which is how it shipped.Two corrections to the ticket's own gap list, both worth more than the code. Panel cursor movement was never a gap: improvise-edp had already made
viewmodel::flatten_cat_treethe one home both ends call, so it is the shared derivation, not a second one — worth re-checking the "known gaps" in other old tickets against what has landed since. And the "unmodified characters only" rule was mis-scoped, not wrong: it was aboutKeyPattern::AnyChar, which is bound only in the text-entry modes, soCtrl-din Normal has an exact binding and mirroring it is a faithful subset. Each resolver mirrors itsCmdline for line, including where siblings disagree —MoveandMoveTileCursoremit even when they clamp,MovePanelCursorreturns nothing when the cursor does not move. Tidying that into one rule would make the client a second derivation, which is whattests/lockstep.rsexists to rule out. What still needs the host is one list,resolve::NEEDS_THE_HOSTinimprovise-protocol/src/resolve.rs(improvise-uln5) — this line named a list inkeys.rsand a test calledthe_normal_mode_keys_that_still_need_the_host_are_thesefor some time after both had gone. Three tests hold it, one per direction:the_command_names_that_still_need_the_host_are_these(no false entry: each name is bound and really is silent),a_help_row_the_browser_cannot_run_is_drawn_markedinwasm-client/render.rs(the help overlay marks those rows, improvise-uln5), andevery_silent_binding_is_on_the_list(every binding of every mode a browser can reach acts, declines out loud, or is on the list — improvise-oyaa, improvise-s2gb). The last replaced a check read off the help rows, which could not see a binding no help row names. Closing a gap therefore means deleting its entry, and the first test fails until you do.What the browser client decides before a frame leaves it (all in
improvise-protocol, so both ends share it). Five rules, each bought by a bug:- Every client cache read goes through
projected_at, which subtracts the frame's own origin (AxisFrame::first) before indexing. A position outside the window the cache holds is unknown, never the cell at the same cache index (improvise-7mro) — once the client scrolled ahead of its cache, an index read answered a different cell's value.unprojectednames that state, and a cell op on it waits rather than guessing. - The keyboard commit writes
ClientState::opened, not the cursor.OpenedCellcaptures the record, theOpenedTarget(Value { measure }orCoordinate { category }), the record's version, thedocument_generationand the view at open, so a commit lands on the cell the editor opened on however the cursor moved since (improvise-8xq0), and claims the basis it was opened against rather than one read at commit, which silently overwrote a value another client wrote while the editor was open (improvise-xz2z). A retry after a crossingLayoutMovedmust match the wholeOpenedTarget, not the record alone, or it wrote whatever cell was under the cursor by then (improvise-8phw for values, improvise-9ww8 for renames). refused_hereexempts a captured editor (improvise-3h9w): withopenedset,commit-cell-editis answered by the capture, so the cursor's current cell cannot refuse a commit aimed elsewhere.- A decline that must stop a binding is
Step::Declined, not aSetStatusbeside the binding's other steps: Enter's weldedclear-bufferotherwise still ran and threw away the typed text (improvise-joed), and a touched editor over a declined cell wentUpstreamwith no word at all (improvise-ik6l). ClientState::notifyqueues a client-side notice at the tail ofin_flight, as if it were a sent frame, so a rebase that rebuilds the view fromsettledand replaysin_flightreplays the notice too instead of erasing it (improvise-n0pb).settle_statusruns on every path that rebuilds the view (bootstrap, accept, refuse, relay), and assigns a message the editor open as it arrives, so an old message loses its owner once that editor closes (improvise-0dbk).EditorSession, the key it is owned by, lives inimprovise-protocol/src/lib.rsbesideViewStateso both front ends ask it (improvise-su4u, improvise-1x9h).
- Every client cache read goes through
-
The browser JavaScript is argued and now partly executed, and the two rules it can break are both about when.
web/app.jsandweb/worker.jswere syntax-checked,.d.ts-checked for call arity and read — never run — and every bug in them was found by reading.harness/tests/shell.spec.jsis the first execution of either: it runsapp.jsunder Chromium against a mocked peer, and it exists because PR #21 declined to add regression tests for its own close-listener fix on the ground that there was nowhere to put them. That decline is only honest because the assertions live there now, and a change toweb/*.jsbelongs in that spec directory rather than in a second ad-hoc surface beside it. Playwright rather than a headless-DOM shim because what goes wrong in these files is a real Worker, a real WebSocket, real OPFS and real event ordering, none of which jsdom has. Two classes of bug still follow from when, both found by review. Anasyncmessagelistener does not serialize.worker.js'shandle()awaitsserver.flush(), so the event loop may start the next message while an earlier one is suspended, interleaving twoon_message/flushpairs and letting a later frame's replies overtake an earlier frame's — which gotcha 33 answers by dropping the overtaken update, not by replaying it. Every frame is therefore chained onto one tail promise and the listener is deliberately notasync; that also turns "frames that arrived beforestart()finished" into an ordinary case of the ordering rule instead of a drain loop, which had the same race twice over becauseserverwas assigned before the loop ran. A watchdog must be disarmed, not pre-empted. The handshake watchdog exists for one condition — the peer never answered — and every loud failure (workererror/messageerror, socketerror) has already written something more precise; the timer then fired 8s later and replaced it, sosetShellFailureclears it. Its neighbour, the socket'scloselistener overwriting a preciseerrorwith "disconnected", is fixed (improvise-taxx, PR #21) — and the ticket's own proposed mechanism was wrong:readyStatecannot discriminate, anopenedflag is the only thing that can. PR #21's other half is the one that keeps getting lost: it routes both close arms throughsetShellFailure, which disarms the watchdog, so a genuine mid-session disconnect is not overwritten eight seconds later. A bug report said it was, measured it honestly against a pre-#21 tree, and then "confirmed" it survived #21 by simulating theopenedflag and not the routing — a simulated fix that is not the fix you named reproduces a bug the real code does not have, and reads exactly like a confirmation. The ticket was filed and closed invalid; the property is now asserted bya socket that opened and then dropped reads as disconnected, and stays that way, which runs past the 8s window, rather than argued in a comment that was wrong twice.What the harness still cannot see, and why the argued-not-tested discipline stays: the grouped and multi-level DOM, the CSS, and the
ResizeObserverextent arithmetic. What a careful read established about the last of these — the observer cannot feed back into itself (#gridis a flex child withoverflow: auto, soreplaceChildrencannot resize it) andROW_PX/COL_PXunder-estimate, so the worst case is a row of over-fetch — is still a read and not a test.A third thing no test here can see, which is why the docs must be read as code. Three comments in one range said "not built yet" about work that had landed in that same range —
README.md's browser section,app.js's keyboard note, andHost::handle'sKeyarm — each because a follow-up commit updated one copy of an argument and not its siblings. When a fact is stated in more than one place, changing it is a grep, not an edit.An evidence heading may be module-qualified, and must be when its leaf is not unique.
scripts/check-comment-runs.py --check-evidencetakes its Rust names fromcargo test --workspace -- --listminus the--ignoredtwin, as full module paths, and refuses an ambiguous leaf naming every candidate rather than warning. Six leaf names collide in this workspace. Note neither half suffices alone: rename one of a colliding pair and the leaf is unique again and resolves silently to the survivor, so qualification is the fix and the refusal is only what forces someone to write it (improvise-rm4s). The residual, filed:--listdoes not name the binary, so two crates sharing a module path are indistinguishable (improvise-sxsf) — measured latent (1444 names, zero collisions) and the proposed per-package fix declined twice over: 18× the cost (45.2s against 2.4s, and--check-evidenceneeds the--ignoredtwin, so 22 cargo invocations), and a package is not a binary —improvisealone has five integration test binaries beside its lib, and an integration test's top-levelfnlists with no module path at all (60 of 1444 names are bare), so-p improviseprefixes all six identically. Per-target is the only shape that would close it. -
A websocket handshake is exempt from the same-origin policy, and that is a server-side problem. There is no CORS preflight, so any page a user visits can open a socket to
improvise-ws-serverfrom that user's own allowed address and read and rewrite every document it holds — cross-site websocket hijacking (improvise-kc7n). No network control can see it: an IP allow-list, a private network and loopback binding all answer "may this client reach the endpoint", and the honest answer is yes; onlyOriginsays what is driving the client. Loopback is the least protected case, not the safest — no proxy in front of it, and loopback is not a boundary a browser enforces.src/origin.rsis the whole decision andtransport::admittedits one caller; it refuses the upgrade with a 403 rather than accepting a socket and ignoring it, andservetakes the policy so a host cannot forget to apply it. Three arms are decided rather than defaulted: loopback allowed (the shipped local recipe serves its page fromhttp://localhost:8080, so the one case worth optimising for must need no flag), absent allowed (no browser omitsOrigin, so absent is a non-browser client an address check already answers — and refusing it breakstests/socket.rs, the repo's own instrument), and two refused (HeaderMap::getis first-wins, so a proxy that appends rather than sets leaves two, and answering from the first lets the second be anything). The deployed Pages origin is deliberately not hard-coded: it can only reach a plain-ws://server through a TLS proxy anyway, so one flag is an honest cost on an already multi-step path where a baked-in default would be a permanent hole for every other deployment. The residual hole is stated rather than guarded — a proxy that stripsOriginturns the check off, which is why the proxy's own allow-list stays inREADME.mdas defence in depth. None of this is authentication: it answers what page is driving a client, never who is connecting, and improvise-kpnr is still open. It correctly does not move toimprovise-commandbesideHost(gotcha 45) — aMessagePorthas no handshake and no headers, and a worker and its page are the same origin by construction, so this is genuinely transport-only and not a mirror waiting to drift. -
A composed TUI row measures in cells and names which side yields.
draw.rs::fill_linetakes aKeeps, because the three rows that share a line genuinely disagree about it: the title bar keeps the right (the?:help :q quitescape hatch outranks an unbounded document name — improvise-t14d), whileminibuffer_lineanddraw_statuskeep the left, which is what they already did, since right-priority there would let a long status message eat the text the user is editing. So the ticket's "give the right segment priority" was correct only for the title bar, and a global rule would have been the wrong shape. Widths go throughui::text::truncate, neverstr::len— and the ticket framed that half wrongly too: the title's own·separator is two bytes and one cell, so a byte count mis-padded every document, not only one with a non-ASCII name.One key table on screen, and it is the one derived from the keymap (improvise-rr8n).
TileBarnow advertises no keys at all: its literal readCtrl+↑↓←→ to move tileswhere those keys are bound toenter-tile-select, and the derived status line is a strict superset (Space,h/l,Tabappear only there). The ticket asked for the opposite — rewrite the literal, suppress the derived line — and was inverted deliberately, because suppressing a derived superset to preserve a literal that had already rotted keeps the stale table and deletes information. The import wizard goes the other way, and the asymmetry is the rule rather than an exception: its status line is derived, but from the mode keymap while the real keys are per-step insideHandleWizardKey, so there the derived table is the one that lies anddraw_statussuppresses the fallback for that one mode. Keep whichever table is derived from the thing it describes. Two blind spots make this class invisible:every_hint_key_resolves_in_its_modecannot see a string literal in a widget (the same gap improvise-wk18 names forhelp.rs), and the widget's own characterization test renders at 80 columns, where the hint does not fit and is never drawn — so the one test pinning that output was structurally incapable of seeing either hint.A scroll is measured where it is drawn (improvise-gtn7.1). The keypress path counted
visible_colsfromterm_widthwhiledraw_contentgave the gridterm_widthminus the 32-column side panel, so with any panel open the cursor scrolled to a column never drawn. Nowviewport::grid_width(term_width, side_panel_open)is the one answer, reached throughApp::grid_width, and bothdraw_contentandApp::cmd_contextread it;App::side_panel_openis the one answer to whether a panel took its column. The tile bar had the same shape:first_tilechose its scroll against a width that began one or two columns before where the tiles were drawn, and both now start attiles_left(start). The grid bug was latent fromb5418f2e, whencompute_visible_colstookterm_width, and was exposed by50e4a372, which opened the category panel by default. Still open: columns of mixed width, where avisible_colscount taken at the old offset is applied at the new one (improvise-gtn7.2). -
The command language reads pipes, refuses surplus, and checks names when it applies (improvise-31oe.2, 31oe.3). Reasoning:
context/code/cli.md§14 and §15. (a) A:argument is read throughimprovise_formula::quote, socreate-view |By area|namesBy area; a surplus argument is refused rather than ignored, andcmd::core::only_when_omittedgates the cursor fallbacks ofclear-cell,yank,pasteanddrill-into-cellso a coordinate typed by the user is never overridden by the cursor. (b)set-axis,set-page,toggle-group,hide-item,show-itemandswitch-viewreturnRequireKnown(Needs)(effect.rs) ahead of the real effect, built byeffect_cmds.rs::unless_unknown. It isViewOnly, writes nothing, is off the wire (listed invocabulary_is_complete.rs) and checks the document at apply time — a:line builds every segment before any runs, so a check inexecuterefusedcreate-view Second . switch-view Second— then refuses and aborts the batch (gotcha 21). (c) A refused name therefore stops the rest of a:line, while headlessimprovise cmdrebuilds the context per segment and runs each as its own batch (improvise-31oe.14, open). -
A date column imports as one hierarchical category, and the import refuses what it cannot keep (improvise-8jk6.3, 8jk6.6, d0n6). Reasoning:
context/code/formula-and-io.md("Import: …") andcli.md§13. The defaultDateShapenests Year > Quarter > Month (the chosen levels) in ONE category named after the column and opens folded to its years;DateShape::Splitis the old flat split, chosen by--split-date <field>headless orson the wizard's date step.Plan::spelling_clashrefuses a raw, unparsed date spelled like a generated item or group (2024,2024-01) because the two would silently merge — asked at the wizard's ConfigureDates step and again at build time, so a headless import cannot reach it either. Headless,--strictturns a partial skip into a refusal before the build, and a folder whose CSVs are all header-only (no records) is refused as an import that kept no record. -
Delivery: a watermark is a
Stamp; a correction istarget - held, applied only at the watermark it starts from.throughis notat(the watermark advances only tothrough), a refused frame restoresDeliverywith the app snapshot, and a lost last notice is found only bypoll. Axis changes refetch the window until ADR 008's render model. The argument iscontext/code/delivery.md(sections 3, 4 and 6); a refused frame restoringDeliveryis §6, Rollback.