Files
improvise/context/repo-map.md
T
Edward Langley e7c63e9530
CI / Build all bundles (push) Has been cancelled
CI / Test and lint (host) (push) Has been cancelled
CI / wasm32 check (browser bundle crates) (push) Has been cancelled
CI / Browser JS unit tests (node --test) (push) Has been cancelled
CI / Knowledge base consistency (push) Has been cancelled
CI / Lint the harness crate (push) Has been cancelled
CI / Browser harness (OPFS contract + shell) (push) Has been cancelled
CI / Deploy to GitHub Pages (push) Has been cancelled
kb+docs: strengthen the check, do not settle for correcting the prose
Three facts from Wave N's Codex round.

The first is the one that generalises. improvise-26dm's round 1 found its OWN
defect — the right-hand side of a completeness diff was a second hand-written
list, not the derived enumeration its prose claimed — and shipped an honest
correction of the prose. Codex asked for the fix instead and was right: the
disclosure was accurate and still the weaker artefact, because the fix was
four lines of plumbing. Correcting prose is the fallback, not the deliverable.

Two traps, both of which caught someone:

cfg(test) and pub(crate) are different walls, and only one is a visibility
keyword. The follow-up said 'pub(crate) -> pub' — filed by the agent that
wrote it and repeated by me when I authorised 'one token' — because both of us
read the item line and not the attribute above it.

And cargo tree shows DEV edges by default, so asking whether a new feature
leaked serde_json into the wasm bundle reports it PRESENT. --edges normal
reports 0. I verified both readings myself. Same class as the difftastic grep
trap: a command that looks like it answers the question, answering a
neighbouring one, in the direction that reads as a finding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 10:44:05 -07:00

629 lines
136 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Repository Map (LLM Reference)
Terminal pivot-table app. Rust 2024, Ratatui TUI, command/effect architecture.
Apache-2.0. Root binary+lib `improvise`; sub-crates under `crates/`:
- `improvise-core` — `Model`, `View`, `Workbook`, number formatting. No UI/IO.
- `improvise-formula` — formula parser, AST, `parse_formula`.
- `improvise-io` — `.improv` save/load, CSV/JSON import, `Storage` backends. No UI/commands.
- `improvise-protocol` — the wire vocabulary and the portable session slice: `Command`, `Projection`, `ViewState`, the per-widget caches, `reduce_view`, key types. Depends only on `improvise-core` + serde. No IO, no command layer, no terminal.
- `improvise-command` — command/effect layer + semantic `App` state (app.rs, effect.rs, cat_tree.rs, viewport.rs). Depends on `improvise-protocol`. No ratatui/crossterm.
- `gp-tree` — problem-agnostic genetic-programming regression trees (`Tree`, `Spec`, `breed`, `simplify`, `render`). Knows nothing about improvise.
- `improvise-gp` — the one seam between the two: emits a fitted `gp_tree::Tree` as improvise formula text (`formula_text` / `fitted_formula`). Depends on `gp-tree`, `improvise-formula` **and `improvise-core`** — `dataset.rs` builds a feature matrix from a `View`'s records (improvise-4s8) and `fitted_formula` takes the `&Model` it must resolve against (improvise-8xhy). Nothing depends on it yet.
- `improvise-ws-server` — the host: tokio + tungstenite, a `Registry` of `Host`s keyed by `DocumentId` (multi-document, ADR 001), `Sessions` fan-out, and the socket framing (`wire.rs`). Native only.
- `improvise-worker-server` — the *other* host: the same `App`, `Sessions` and `Host` as the socket server, behind `self.onmessage`/`self.postMessage` with OPFS underneath. wasm32 only in anger; `worker.rs`/`vfs.rs` are ordinary Rust and are where every decision and every test is. Nothing depends on it.
- `improvise-wasm-client` — the browser client: the fold, the key classifier, the socket framing it mirrors (`socket.rs`), and the render model the DOM paints (`render.rs`). Non-dev deps are `improvise-protocol` + serde + wasm-bindgen and nothing else. The page itself is `web/`.
The main crate (and `improvise-command`) re-export the sub-crates as `crate::{model, view, workbook, format, formula, persistence, import}` so consumer paths stay stable when crates shuffle. The main crate also re-exports `improvise_command::command` as `crate::command` and `improvise_command::ui::{app, cat_tree, effect, viewport}` under `crate::ui`. `ui::app` re-exports the protocol's `AppMode`, `ViewState`, `ViewCursor`, `ViewFrame`, `Panel`, `MinibufferConfig`, `MinibufColor` and `HELP_PAGE_COUNT`, and `command::key` re-exports the protocol's key types, so every pre-existing path still resolves.
Architectural intent lives in `context/design-principles.md` — read that for the "why". This doc is the "where".
---
## How to Find Things
| I need to... | Look in |
|-------------------------------------|-------------------------------------------------|
| Add a keybinding | `improvise-protocol/src/keymap.rs` → `Keymaps::default_keymaps()` (the table; the browser looks up the same bindings). Resolving a bound *name* into effects is `improvise-command/src/command/keymap.rs`. |
| Add a user command | `command/cmd/<submodule>.rs`, register in `registry.rs` |
| Add a state mutation | `ui/effect.rs` → implement `Effect` |
| Change formula eval | `model/types.rs` → `eval_formula` / `eval_expr` |
| Change cell storage / lookup | `model/cell.rs` → `DataStore` |
| Change category/item behavior | `model/category.rs` → `Category` |
| Change view axis logic | `view/types.rs` → `View` |
| Change an axis label, color or cycle | `view/axis.rs` → `impl Axis` (colors resolve in `draw.rs::axis_fg`) |
| Ask "what data is visible" vs "what can the user page" | `View::filtering_cats()` vs `categories_on(Axis::Page)` |
| Change grid layout | `view/layout.rs` → `GridLayout` |
| Change `.improv` format | `persistence/improv.pest` + `persistence/{format,parse}.rs` |
| Change where documents are stored | `storage.rs` → `Storage` impls (`opfs.rs` for wasm) |
| Change number display | `format.rs` → `format_f64` |
| Change CLI args | `main.rs` (clap) |
| Change import logic | `import/wizard.rs` → `ImportPipeline` |
| Change how a gp-tree fit searches | `gp-tree/src/fit.rs` → `FitConfig` / `fit` |
| Change gp-tree CSV preprocessing | `gp-tree/src/data.rs` → `preprocess` |
| Change how a name is quoted anywhere | `improvise-formula/src/quote.rs` — the one home, both halves |
| Change import-wizard keys/steps | `command/cmd/wizard.rs` (keys) + `import/wizard.rs` (steps) |
| Change frame layout | `draw.rs` → `draw()` |
| Change what the browser page draws | `improvise-wasm-client/src/render.rs` → `RenderModel` |
| Change a browser↔server frame | `improvise-protocol/src/frame.rs` → `ClientFrame`/`ServerFrame`. The **one** pair; both ends `pub use` it and neither defines one (improvise-xks6) |
| Change the worker's message loop | `improvise-worker-server/src/worker.rs` → `Worker::on_frame` |
| Change the session loop (either host) | `improvise-command/src/ui/host.rs` → `Host::handle` |
| Change how the browser opens or seeds a document | `improvise-worker-server/src/vfs.rs` → `open` / `documents` |
| Change what a browser resolves without the host | `improvise-protocol/src/resolve.rs` → `resolve`. **Not** `wasm-client/src/keys.rs`, which holds only `BrowserKey` (`KeyboardEvent` → improvise key) — this row named it until 2026-09-21 and that pointer is how a false claim reached `context/` (improvise-e5pf) |
| Change the browser page itself | `web/app.js` (thin), `web/index.html`, `web/style.css` |
| Host another document | `improvise-ws-server/src/registry.rs` → `Registry`, `ClientId` |
| Change app state / modes | `ui/app.rs` → `App`, `AppMode` |
| Write a test for model logic | `model/types.rs` → `mod tests` / `formula_tests` |
| Write a test for a command | `command/cmd/<module>.rs` → colocated `mod tests`; helpers in `cmd/mod.rs::test_helpers` |
---
## Central Pattern: Cmd → Effect
```
keypress → Keymap lookup → Cmd::execute(&CmdContext) → Vec<Box<dyn Effect>> → Effect::apply(&mut App)
(pure, read-only ctx) (list of mutations) (only mutation site)
```
```rust
pub trait Cmd: Debug + Send + Sync {
fn name(&self) -> &'static str;
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>>;
}
pub trait Effect: Debug {
fn apply(&self, app: &mut App);
fn kind(&self) -> EffectKind; // NO default — the compiler makes you classify
}
// EffectKind::{ViewOnly, Model(ModelChange)}
// ModelChange::{CellData, LayoutGeometry, WholeWorkbook}
```
`CmdContext` (see `command/cmd/core.rs`) holds immutable refs to `Model`, `GridLayout`, `AppMode`, cursor/offsets, buffers, yanked cell, key code, expanded cats, and panel/tile cursors.
**Add a command**: implement `Cmd` in a `command/cmd/` submodule, register in `registry.rs`, bind in `default_keymaps()`. Simple wrappers go through the `effect_cmd!` macro in `effect_cmds.rs`.
**Add an effect**: implement `Effect` in `ui/effect.rs`, add a constructor fn if it helps composition.
---
## Key Types (skim; read the source for fields/methods)
**Model** (`model/types.rs`): `categories: IndexMap<String, Category>`, `data: DataStore`, `formulas: Vec<Formula>`, `views: IndexMap<String, View>`, `active_view`, `measure_agg`. `MAX_CATEGORIES = 12` (regular only). Virtual categories `_Index`, `_Dim`, `_Measure` always exist. Use `regular_category_names()` for user-facing pickers.
**CellKey** (`model/cell.rs`): `Vec<(cat, item)>` — **always sorted by category**. Build with `CellKey::new(coords)` / `with(cat, item)` / `without(cat)`. Never construct the inner `Vec` directly.
**CellValue**: `Number(f64) | Text(String) | Error(String)`. Errors surface from formula eval (circular, div/0, etc.).
**DataStore** (`model/cell.rs`): interned symbols + secondary index `(Symbol, Symbol) → {InternedKey}`. Hot-path lookups go through `matching_values` / `matching_cells`.
**Formula AST** (`improvise-formula/src/ast.rs`):
- `Expr { Number, Ref, BinOp, UnaryMinus, Agg, If }`
- `BinOp { Add, Sub, Mul, Div, Pow, Eq, Ne, Lt, Gt, Le, Ge }`
- `AggFunc { Sum, Avg, Min, Max, Count }`
- `Formula { raw, target, target_category, expr, filter }`
**View** (`view/types.rs`): `category_axes: IndexMap<cat, Axis>`, page selections, hidden items, collapsed groups, `number_format`, `prune_empty`. It carries **no cursor** — `selected`/`row_offset`/`col_offset` moved to `ViewState::cursors` (improvise-8uz). `Axis { Row, Column, Page, Filter, None }`. `cycle_axis` rotates Row → Column → Page → None; `Filter` is outside the rotation (see gotcha 27).
**GridLayout** (`view/layout.rs`): pure function of `Model + View`. `cell_key(r,c)`, `cell_value(r,c)`, `drill_records(r,c)`. **Records mode** auto-detects when `_Index` is on Row and `_Dim` is on Column.
**AppMode** (`ui/app.rs`): 17 variants (Normal, Editing, FormulaEdit, FormulaPanel, CategoryPanel, ViewPanel, TileSelect, CategoryAdd, ItemAdd, ExportPrompt, SavePrompt, CommandMode, ImportWizard, Help, Quit, RecordsNormal, RecordsEditing). Eight text-entry variants carry a `MinibufferConfig`. `SearchMode` is Normal + `search_mode: bool`, not its own variant. Per-variant UI facts live on the enum as exhaustive-match methods — `label()`, `hint_text()`, `panel()`, `minibuffer()`, `is_editing()`, `is_records()` (improvise-2hi); add a variant and the compiler names every one you must fill in. `mode_style()` stays in `src/draw.rs` because `Style` is a ratatui type. `App` state is split into `ModelState` (workbook, file_path, dirty) and `ViewState` (mode, cursor/offsets, buffers, panels, search) — see improvise-x2c/ew0. `wizard` and `drill_state` live on `App`, not `ViewState` (improvise-h0b) — see gotcha 24.
**Keymap** (`improvise-protocol/src/keymap.rs`; `dispatch` and `KeymapSet` in `improvise-command/src/command/keymap.rs`): `Binding { Cmd | Prefix(Arc<Keymap>) | Sequence(Vec<…>) }`. Lookup fallback: `exact(key,mods) → Char(NONE) → AnyChar → Any → parent`. 17 mode keymaps, one per `ModeKey`, built by `Keymaps::default_keymaps()`; mode resolved via `ModeKey::from_app_mode()`. `ModeKey` and `AppMode` are two different enumerations that happen to be the same size — `SearchMode` is a `ModeKey` and not an `AppMode`; `Quit` is an `AppMode` with no keymap — so do not "fix" one count to match the other.
---
## `.improv` File Format
Plain-text, markdown-like, defined by `persistence/improv.pest`. Parsed by pest — the grammar is the single source of truth (the grammar-walking test generator reads it via `pest_meta`).
```
v2025-04-09
# Model Name
Initial View: Default
## View: Default
Region: row
Measure: column
|Time Period|: page, Q1
hidden: Region/Internal
collapsed: |Time Period|/|2024|
format: ,.2f
## Formulas
- Profit = Revenue - Cost # defaults to [_Measure]
- Tax = Revenue * 0.1 [CustomCat]
## Category: Region
- North, South, East, West # bare items, comma-separated
- Coastal_East[Coastal] # grouped item, one per line
> Coastal # group definition
## Data
Region=East, Measure=Revenue = 1200
Region=West, Measure=Revenue = |pending| # pipe-quoted text
```
- **Name quoting**: bare `[A-Za-z_][A-Za-z0-9_-]*`, else CL-style `|…|` with escapes `\|`, `\\`, `\n`. Same convention in the formula tokenizer.
- **Write order**: Views → Formulas → Categories → Data. Parser tolerates any order.
- **Gzip**: `.improv.gz` (same text, gzipped).
- **Legacy JSON**: auto-detected by leading `{`; never written.
- **Axis keywords**: `row`, `column`, `page`, `filter`, `none`; `page`/`filter` carry the selected item after a comma.
- **Virtual categories**: `_Index`/`_Dim` never persist; `_Measure` persists only non-formula items (formula targets are rebuilt from the `## Formulas` section).
- **Virtual views**: `_Records`/`_Drill` (`view::VIRTUAL_VIEW_NAMES`) are never written and are dropped on load if an older file carries them; saving from records/drill mode writes the first real view as `Initial View`. Their commands rebuild them on demand.
---
## Gotchas (read before editing)
1. **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; `_Dim` in the synthetic key carries the column **index**, not a label, because a category and a measure may share a name — resolve it with `GridLayout::record_col` into a `RecordCol`. Formula targets are not columns (nothing to type into them). Drill pending edits are keyed by `(row, RecordCol)`.
**`prune_axis_items` takes a predicate, not a flag vector.** `prune_empty` hands it `impl Fn(usize) -> bool` over the *data* index (group headers consume no index). The domain is derived from `items` itself, so there is no parallel length a caller could get wrong — the earlier `&[bool]` + `debug_assert` pair could disagree between debug and release if a third caller ever sized `keep` from something other than `row_count()`/`col_count()` (improvise-n3wt).
**`GridLayout::new` does not prune; `with_frozen_records` does** — and that split has now produced **four** bugs: `view_frame`, `project::grid_in` (PR #78), and `persistence/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 remaining `GridLayout::new` call site in the workspace is test code — verified — so a new production caller almost certainly wants `with_frozen_records(m, v, None)`.
2. **Effects never touch `std::fs`.** Every document read or write goes through `App::storage: Box<dyn Storage>` (improvise-fuj). The host picks the backend: `App::new` supplies `PhysicalFs`, `App::with_storage` takes any other — a browser worker will supply OPFS (improvise-i34), and tests use `MemoryFs`. `improvise-io` keeps path-taking wrappers (`save`, `load`, `export_csv`, `parse_csv_with_warnings`) for the native CLI, each delegating to a `_to`/`_from`/`_bytes` variant that takes the backend or the bytes.
3. **Every effect declares an `EffectKind`, and there is no default.** `ViewOnly` never 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-declared `ViewOnly` edit would simply never reach connected clients. Watch two traps: the *View* lives inside `model_state.workbook`, so "touches model_state" does NOT mean "is a model change" (axis/page/format are `LayoutGeometry`, cursor and scroll are `ViewOnly` — see improvise-8uz; and `SwitchView`, `ViewBack` and `ViewForward` moved from `LayoutGeometry` to `ViewOnly` with improvise-jytm, writing `view_state` and nothing else, which is what took the latter two off the wire — but a sound derivation is not a guard: only `SwitchView` had one, and review showed reclassifying either sibling back was silent across the whole suite); and `Save`/`SaveAs`/`MarkDirty` touch `model_state` for bookkeeping only and are `ViewOnly`. `AddCategory`/`RemoveCategory` are the third trap and go the other way: they look like data edits, but both go through `Workbook`, whose `on_category_added`/`on_category_removed` rewrite **every** view's axis map, so they are `LayoutGeometry`. 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_kind` pins the declaration itself. A third trap, now guarded: **`EffectKind` is too coarse to check a *mapping*.** `ui/reduce.rs::model_effects` sends each of 28 `ModelCommand` variants 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, so `every_model_command_names_the_effect_of_the_same_name` asserts that, reading the type name off the effect's derived `Debug` (`Effect` is a trait object, so `type_name` is 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_category` also drops every formula targeting the category, which is why `Dirty::of(LayoutGeometry)` reports `formulas: true` — see gotcha 35.
4. **Commands never mutate.** `&CmdContext` is read-only; return `Vec<Box<dyn Effect>>`. If you want to touch `&mut App`, add or extend an Effect.
5. **`CellKey` is always sorted.** Use the constructors; equality and hashing rely on canonical form.
6. **No `Model::add_item`.** With a `Workbook` in hand use the facade: `wb.add_item("Region", "East")?` / `add_item_in_group` / `remove_item` (improvise-tvt). With only a `Model`, go through the category: `m.category_mut("Region").unwrap().add_item("East")`.
7. **Virtual categories.** `_Measure` items = explicit items ∪ formula targets. Use `measure_item_names()` / `effective_item_names("_Measure")`. `add_formula` does *not* add items to `_Measure`. Formula-target lookup also falls back into `_Measure` via `Model::find_item_category`. Cell aggregation goes through `apply_agg`, formula `SUM(…)`/`AVG(…)` through `eval_agg` — they differ on empty input (absent vs. A third rule joins them for **non-numeric** values: `aggregate_cell_values` reduces text by **unanimity** — one matching value, or several copies of the same value, is that value; differing values, or text among numbers, is `CellValue::Error("mixed")` (improvise-l4qx). The **all-numeric branch is checked first**, so `Count` of a lone number is still `1` and 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`/`_Dim` are on `Axis::None` in every default view that made **every text cell in every document render blank**, including the pipe-quoted text `.improv` carries as a first-class value. `evaluate_aggregated` and `aggregate_raw` share the helper, so a formula ref over mixed values now errors `"mixed"` rather than silently summing the numeric ones. `0` for Sum/Count, error for Avg/Min/Max). `Model::evaluate_aggregated` takes `Aggregation::{Exact, OverHidden}` rather than `none_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_raw` keep the list because they walk it.
8. **Display rounding is view-only.** `format_f64` (half-away-from-zero) is only called in rendering. Formula eval uses full `f64`. Never feed a rounded value back into eval.
9. **Formula eval is fixed-point.** `recompute_formulas(none_cats)` iterates until stable, bounded by `MAX_EVAL_DEPTH`; circular refs converge to `CellValue::Error("circular")`. `App::new` runs it before the first frame so formula cells render on startup.
10. **Drill into formula cells** strips the `_Measure` coordinate when it names a formula target, so `matching_cells` returns the raw records that feed the formula instead of returning empty.
11. **Minibuffer keymaps come from `TextEntry`.** The eight text-entry modes are built by the `TextEntry` builder in `improvise-protocol/src/keymap.rs` — seven by `TextEntry::new`, and `RecordsEditing` by `TextEntry::with_parent` off `Editing`'s map, which is why a count taken off the `::new` call 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 its `clear-buffer` via `.commit(key, cmd, args)` (improvise-w3q). `TextEntry::new` takes the `AppMode` and reads `<buf>` off its `MinibufferConfig`, 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. `.rebind` exists only for CommandMode's Backspace. `SearchMode` is hand-written on purpose — no commit key, and `exit-search-mode` owns the query. `EnterMode` clears the incoming mode's minibuffer through `write_buffer` too — building `SetBuffer` directly there was unreachable only because no shipped `AppMode` is keyed `"search"`. `MinibufferConfig::buffer_key` is a `String`, not `&'static str`: a borrowed-'static field makes `Deserialize` unimplementable, and every text-entry `AppMode` variant carries a `MinibufferConfig` (improvise-a2x). A buffer named at runtime is read with `cmd::core::read_buffer` and written with its mirror `write_buffer` — "search" is not in the buffers map but its own `ViewState` field, and those two functions are the only code that knows it (improvise-gdl).
12. **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 by `PanelKeymap::new(panel)`; the keys that act on the panel's contents arrive through `.bind`/`.bind_args`/`.delete` because they vary in shape, not just command name (improvise-4pg). `Panel::ALL`/`name()`/`key()` in `ui/app.rs` drive the toggle keys for Normal and all three panels, so a fourth panel reaches every keymap from one `ALL` entry. `name()` must spell what `parse_panel` accepts.
13. **Prefix keys are stored as an address, not a keymap.** `ViewState::transient_prefix` holds the `(KeyCode, KeyModifiers)` that opened a prefix; `KeymapSet::prefix_keymap` resolves it back to the sub-keymap, walking parents so `RecordsNormal` reaches Normal's `g`/`y`/`Z` (improvise-jc8) — that is the command-layer wrapper; the resolution itself is `Keymaps::prefix_keymap` in `improvise-protocol/src/keymap.rs`. `ViewState` must stay free of `improvise-command` types — it is destined for the protocol crate, which `improvise-command` will depend on. Resolution assumes prefixes are **flat**; `prefix_keymaps_are_flat` fails the build if a nested one is added.
14. **Keybindings are per-mode.** `ModeKey::from_app_mode()` resolves the mode; Normal + `search_mode=true` maps to `SearchMode`. Minibuffer modes bind Enter/Esc as `Binding::Sequence` so `clear-buffer` fires alongside commit/cancel. Hints are checked against the keymaps: `every_hint_key_resolves_in_its_mode` (`command/keymap.rs`) splits each `AppMode::hint_text()` and requires every key it names to resolve in that mode's map, because `only_quit_has_no_hint` asserts 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/V` alternatives, character runs like `hjkl`/`[:]`, the em-dash prose tail, CommandMode's `:q` ex-commands) and panics on a token it cannot classify rather than skipping it.
15. **Registry names come from prototypes, not strings.** Every `register*` call reads the name off a prototype `Cmd` instance, so a typo is impossible (improvise-9cn) — there is no name literal in `registry.rs`. What types can't reach: four commands name themselves off a field (`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_unique` catches it; `every_bound_command_name_is_registered` catches the keymap side, and checks **both** halves of `dispatch`'s `registry.interactive(name, args, ctx).ok()?` — the name resolves *and* the args parse — because improvise-s13 moved `enter-mode ["export-prompt"]`/`["formula-edit"]` out of the nullary class, where a typo'd mode name compiles into a dead key. It builds the `CmdContext` in each keymap's own `AppMode`, since `commit-buffer item` reads `ctx.mode`.
16. **`effect_cmd!` macro** wraps a pre-built effect as a parseable command. Use it for simple wrappers; reach for a hand-written `Cmd` once there's real decision logic. Its counterpart on the effect side is `simple_effect!` in `ui/effect.rs`, which declares a one-field newtype that writes one place on the `App`. Its **kind argument is mandatory** — `simple_effect!(Name(T) => |app| place, EffectKind::ViewOnly)` — even though every call site so far answers `ViewOnly`: hard-coding it reintroduced exactly the default gotcha 3 does without, since `$place` is an unconstrained expression and a one-line `active_view_mut().number_format` setter would have reported `ViewOnly` with the suite green (improvise-8fy). Likewise, mode entry has no per-mode shim commands — bind `enter-mode <mode-name>` (improvise-s13); the name is decoded by `parse_mode_name` in `registry.rs`, and `EnterMode` clears the mode's minibuffer on the way in.
17. **Float equality inside formula eval has one epsilon, and the div/0 guard shares it.** `FLOAT_EQ_EPSILON = 1e-10` in `model/types.rs` is used by `=`/`!=` **and** by the division-by-zero guard (`rv.abs() < FLOAT_EQ_EPSILON`), in both `eval_expr` and its aggregated twin — six uses across the two evaluators (a div/0 guard plus the `Eq` and `Ne` arms 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.
18. **`IndexMap`** preserves insertion order for categories, views, and items. Persistence relies on this — and so, since improvise-shyt, do `DataStore::matching_cells`/`matching_values`: the secondary index **narrows** the candidates, then `matching_positions` resolves each to its position in the primary `IndexMap`, sorts, and reads back. **An index whose values are `HashSet`s 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`/`AVG` drifted in the last bits because `f64` addition is not associative (`Min`/`Max`/`Count` and 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 while `drill_state.pending_edits` are 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 on `matching_values`, and `matching_cells` 7% *faster*, sorted positions resolving `CellKey`s with better locality. `DataStore::live_records` was the open choice and is now decided (improvise-2yc2): it walks `cells` and projects insertion order onto records, deliberately paying O(cells) rather than O(records), because `cells`' order is already the answer `matching_positions` gives and a second answer to "what order is this store in" is the disease. It is derived per call rather than memoised on `RecordEntry`, 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 was `import::analyzer::analyze_records`** (improvise-7yx), which built its distinct values through a `HashSet`: `build_model` adds items in exactly that order and `IndexMap` preserves it, so the *same CSV imported twice* produced two different `.improv` files and two different row orders. Now first-seen order, pinned by a five-iteration test because each `HashSet` gets its own `RandomState`. Also answered here since: an **empty `partial`** matches every cell vacuously (improvise-hkt6) — it used to index `sets[0]` and panic, unreachable only because both callers guarded.
19. **Commit paths for cell edits** split across `commit_cell_value` → `commit_regular_cell_value` / `commit_plain_records_edit` in `command/cmd/commit.rs`. Keep the synthetic-records branch in sync with the plain branch when changing behavior.
20. **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 on `CommitKind`. The add effect is emitted **last** so its failure message wins over the command's optimistic status (improvise-6t3). `CommitKind::Item` carries its target category, resolved from `AppMode::ItemAdd` by `parse_commit_kind` at registry time — so `commit-buffer item` is not constructible from a script line.
21. **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: `Save` and `SaveAs` on a failed write or a missing path (improvise-8tk — `SaveAs` was the arm left out of the original fix, so `:w /bad/path . q` quit on a failed write), and `AddCategory` when `Model::add_category` refuses, because what follows assumes the category exists — from the `+cat` column (improvise-2zq) the rest of the batch would advance the cursor and re-open an editor on a column that was never created. A `Binding::Sequence` flattens 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_effects` resets the flag per batch, so step 2 in a fresh batch would land the `Quit` after a failed `Save`, silently reopening a P1. `a_sequence_whose_first_step_aborts_does_not_run_the_second` is 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 in `CommandMode`/`Editing`/`SavePrompt` — where `draw_bottom_bar` renders 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**: `ExecuteCommand` fronts its batch with `ReturnToNormalUnlessMoved`, `CommitAndAdvance` with the mode change, `CommitSave` with 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).
22. **The import wizard's back path is a history stack, not a reverse table.** `advance` pushes the step it leaves onto `ImportWizard::history`; `previous` pops it, so back always retraces the path actually taken through the conditional transitions (improvise-907). Preconditions live in `validation_error()`, checked on the step where the user can still fix them — never deferred to `build_model`. `NameModel` is terminal for `advance`: Enter there calls `build()`, which parks the workbook in `built` on `Done`, and `AcceptWizardModel` takes it from there. Going back from `Done` discards `built`.
23. **`app.layout` goes stale mid-batch.** `apply_effects` rebuilds only *after* the whole batch, so an effect that reads the layout after an earlier effect changed the grid needs a `RebuildLayout` effect in front of it — the command decides the order (see `EnterEditAtCursorCmd`, `CommitAndAdvance`). Read the cursor's cell text via `App::display_value_at_cursor()`; don't build a `CmdContext` inside `apply` (it recomputes column widths).
24. **The cursor is per-client and keyed by view.** `ViewState::cursors: HashMap<String, ViewCursor>` holds `selected`/`row_offset`/`col_offset`, keyed by view name; an absent key means the origin. Reach it through `App::cursor()` (a `Copy` snapshot), `App::cursor_mut()`, and `App::reset_cursor()` — never off `View`, which no longer has the fields (improvise-8uz). Two consequences. **Changing the grid's shape must reset the cursor explicitly**: `View::transpose_axes`/`cycle_axis` used to do it themselves, and now `TransposeAxes`/`CycleAxis` call `app.reset_cursor()` — a new geometry effect that renumbers rows or columns has to do the same. **Replacing the document must clear the map**: `LoadModel` and `AcceptWizardModel` call `cursors.clear()`, because the incoming workbook's view names have nothing to do with the outgoing one's. That clearing replaces `Workbook::normalize_view_state`, which is gone — a `View` can no longer carry a stale offset. The same clearing owes the **session mirrors**: `install_workbook` bumps `App::document_generation`, and `ClientSession::project` reseeds any mirror seeded at an earlier one (improvise-cqq). An effect cannot reach `Sessions`, so the generation is the mechanism and `Sessions::rebase` is only the client-facing half. The same is true of a **geometry** change, and it owes the mirrors *both halves*: `App::cursor_epoch` is bumped by `reset_cursor` and watched by `Sessions::adopt_cursor_epoch`, which resets every mirror **and** returns `Broadcast::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 watches `document_generation` instead of matching on `LoadModel`: 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 calls `reset_cursor` at all — the cursor is invalidated by OBSERVING the grid's frame.** `apply_effects` diffs 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 over `EffectKind::Model(_)` by construction — a genuinely stronger shape than a hand-kept table, and the reason the old "a geometry effect that forgets `reset_cursor` is invisible in both directions" hazard is closed.
**Two arms, and the asymmetry is the whole design (improvise-mcq4).** `LayoutGeometry` hashes only the **active** view — it is the held-key path, and other views cannot reframe there since `view_mut()` writes only the active view, with the category and item effects already covered by `view_shapes`. `CellData` hashes **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-view `SetCell`, 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 `ViewOnly` effect 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-batch `SwitchView` skips 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), and `Model(WholeWorkbook)` matches neither arm, which is benign only because all three such effects go through `install_workbook` → `rebase` → a fresh `Snapshot`.
25. **`ViewState` and its field types are `Serialize`/`Deserialize`.** It is the wire payload of the client Snapshot (improvise-q08), so `AppMode`, `MinibufferConfig`, `MinibufColor`, `ViewFrame`, `KeyCode` and `KeyModifiers` all derive both (improvise-a2x), and `improvise-command` depends on `serde` for it. The constraint this puts on new state: **no borrowed-`'static` fields anywhere ViewState can reach** — `Deserialize` cannot produce one, and the error surfaces on every enclosing variant rather than at the offending field. `view_state_survives_a_json_round_trip` guards it. `ViewState` and `Snapshot` also derive `Clone` and `PartialEq`, so the bootstrap message can be compared in a round-trip test.
26. **Axis presentation lives on `Axis`, colors resolve in `draw.rs`.** Four vocabularies hang off the enum in `view/axis.rs` — `plain_label()` ("Row"/"Col"/"Page"/"None"), `glyph()` (which `Display` appends, giving "Row ↕"), `short_label()` (three columns for the tile bar; `None` is "·", *not* a truncation), and `initial()` (one letter for the view panel; `None` is empty). Plus `color() -> AxisColor` and `next()` for the Row→Column→Page→None rotation. `AxisColor` is a semantic palette: `improvise-core` must not name a ratatui type, so `draw.rs::axis_fg` does the one mapping to a terminal color, exactly as `minibuf_fg` does for `MinibufColor` (improvise-rml). The tile bar and category panel each carried their own copy of the color table before, with nothing keeping them in agreement. Not covered here: `persistence/format.rs` maps `Axis` to the on-disk keywords ("row"/"column"/"page"), which is a format concern and stays in the io layer.
27. **`Axis::Filter` constrains without being navigable.** Drilling fixes its coordinates on `Filter`, not `Page` (improvise-dz8): both restrict which records are visible, but only `Page` is something the user may move or page through. The split shows up as two different questions, and picking the wrong one is the trap: **"what data is visible" is `View::filtering_cats()`** (Page ∪ Filter — used by `GridLayout` and by `add-record-row` when it builds a key from the current constraints), while **"what can the user act on" is `categories_on(Axis::Page)`** (used by `[`/`]` page cycling) or `View::tileable_cats()` (used by all three tile-select sites). `Axis::next()` maps `Filter` to itself and no navigable axis rotates into it, so tile select cannot hand one out. The `.improv` grammar accepts `filter` and round-trips a selection with it — a Filter axis only ever occurs in the never-persisted `_Drill` view, but the alternative was writing it out as some other axis.
28. **The browser backend is sync on purpose, with an async seam.** `OpfsFs` (`improvise-io/src/opfs.rs`, wasm32 only) implements the same synchronous `Storage` trait as `PhysicalFs`, because `FileSystemFileHandle.createSyncAccessHandle()` yields a handle whose read/write/truncate/flush **are** blocking — available only inside a dedicated Web Worker, which is where improvise-9x6 puts the app. So `Storage` never became async. What is still async splits out into four explicit methods the worker awaits from its message loop: `open()`, `attach(path)` (must precede any read/write of that document — acquiring a handle is async even though using it is not), `refresh()` (repopulates the index `list` answers from), and `commit_deletes()`. Consequence: **`delete` is visible immediately but lands in OPFS at the end of the batch** — it drops the name from the index and queues a `removeEntry`. Handles are exclusive per file, so `attach` closes any handle it replaces and `detach` releases the lock. `storage_contract` in `storage.rs` is the behaviour every backend owes; it runs against both host backends and is the written-down spec `OpfsFs` must satisfy in a browser, where the harness cannot reach.
29. **"Which view is active" is stored twice, and the copies are now allowed to differ.** `Workbook::active_view` is the document's **default** — what a freshly connected client opens, and what `.improv` writes as `Initial View`. `ViewState::active_view` is **authoritative for that client**, and it is what keys `ViewState::cursors`. The second copy exists because `reduce_view` runs on a client that holds no `Workbook` (improvise-cqi); the *split* is improvise-jytm answering improvise-g0f, on the user's ruling that **the model is shared and the UI is not**. Two clients may sit on different views of one document. Three things follow. **`App::resolve_view` is the one answer** to what a view name names in this document, falling back to the default so a slice naming a deleted view cannot panic in `rebuild_layout`; `view()`, `view_mut()`, `view_name()`, `project::grid_in` and `ClientSession::view_in` all go through it, and a second copy of that fallback was the first thing review found. **The guard was replaced, not deleted** — `the_two_active_view_names_never_diverge` became `the_documents_default_view_outlives_this_clients_switches` plus `a_fresh_session_opens_the_documents_default_view`, because a deliberate split with no guard is how one silently regresses into the other. And **a deleted view is the case the split creates**: `DeleteView` can take away the view another client is on, so `Sessions::adopt_deleted_views` moves that mirror to the default and tells it, and `effect::DeleteView` does the same for the host — observed ("does this mirror still name a view the document has"), never predicted from the command variant. **Testing any of it needs a bystander that can be moved**: both audience tests first sat theirs on the default, where a wrongly-addressed relay folds to a no-op, and review demonstrated the vacuity by injecting `ToAll` twice. Two gaps remain, both filed: a view-scoped `ModelCommand` names no view, so a connected client's `SetAxis` lands on the host's view (improvise-lltt — deferred on a *condition*, that no shipped client emits `SwitchView`, while the browser already emits `TransposeAxes`), and `Initial View` no longer follows the TUI user (improvise-azhb). Follow mode is deliberately not foreclosed.
30. **Two tests gate `ViewState` membership.** `ViewState` is the *portable* session slice — the one thing serialized into the Snapshot a newly-connected client gets (improvise-q08). A field may live there only if it is **portable** (plain data from the allowlisted crates, no `Rc`/`Arc`) and **snapshot-safe** (a fresh client inheriting the value is correct). `wizard` fails both — `ImportWizard` is an `improvise-io` type, and a half-open dialog is not something to inherit — and `drill_state` fails both — it holds an `Rc`, and `pending_edits` are uncommitted writes belonging to whoever staged them. Both live on `App` under the **session-local** residue tag (improvise-h0b). The `ViewState: Send` static assertion in `app.rs`'s tests is the compile-time proxy for portability: `Rc` is not `Send`, so a re-added shared pointer fails the build (the `Deserialize` derive from gotcha 25 now rejects it too, serde's `rc` feature being off). `Send` does *not* catch the origin-crate or snapshot-safety halves — check those by hand.
31. **`:` commands settle their mode by observation, not prediction.** `ExecuteCommand` ends every batch with `ReturnToNormalUnlessMoved(ctx.mode)`, which drops to `Normal` only if the mode is still what the batch started in. There is no `Effect::changes_mode()` any more: it was a per-type constant, and `ViewBack`/`ViewForward`/`StartImportWizard` swap the mode only on some runs, so no up-front answer was right — `:import` opened 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.
32. **There is no aggregate render cache — each widget folds its own stream.** `improvise-protocol::render` holds one cache type per widget (`GridCache`, `CategoryTreeCache`, `FormulaCache`, `ViewListCache`, `TileBarCache`), each implementing `WidgetCache` with its own `Update` type, and a `Projection` is one such update addressed to one widget (improvise-rkeq). `reduce_view` folds only `ViewState` now — it no longer takes a cache, and `Command::Project` passes 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 is `GridCache::folded(&cache.seed()) == *cache`. Nothing is defined for the help overlay (its content is static and `help_page` is already in `ViewState`) or for the import wizard, which now has a shape and **deliberately no projection**: `WizardCache` is built by `project::wizard_cache` and 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 removed `AcceptWizardModel` from `ModelCommand`, gotcha 38). **A cache without a `Projection` is the shape session-local residue takes when it still has to be drawn by two different front ends.** It lives in `improvise-protocol` rather than `improvise-command` for the `flatten_cat_tree` reason: when the wasm client grows a wizard it must draw *these* rows, not a second derivation.
33. **The grid's labels frame its cells, and `apply` enforces it — the fold is not commutative.** Three consequences in `improvise-protocol/src/render.rs`. (a) A `GridUpdate::Cells` that overtakes the `RowFrame`/`ColFrame` naming its positions is **dropped**, because `fit_to_labels` truncates to the labels it has; a client must not reorder the stream, and `a_cell_update_that_overtakes_its_labels_is_lost` pins it. (b) A label change that *renames* a position blanks the cell under it: `fit_to_labels` preserves by POSITION, which is only right where the label did not change, so `apply` calls `invalidate_relabelled` itself rather than trusting a producer to send `GridUpdate::Invalidated` first. `Invalidated` therefore now means only "contents stale, frame unchanged". (c) **every arm that touches labels *or* widths re-fits** — `col_widths` is re-fitted with the columns — a new column is drawn at its label's own character count until `ColWidths` catches 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 late `ColWidths` put 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) holds `Cells`. `GridCache` also carries `totals` and `page_coords` (improvise-n10). `totals` is framed by `col_frame` on 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. `Invalidated` blanks the totals too: they are contents, and a stale sum under fresh cells is the one number a user takes at face value. `page_coords` is framed by nothing (it names categories, not positions) and survives a reshape. Note the fold tests: asserting `fold(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 — see `doctrine(fold_tests_must_test_order_not_the_axiom)`.
34. **A formula target is unquoted, and pipe quoting has one home.** `parse_formula` strips the pipes off a target exactly as it does off an `Expr::Ref` — the target names a `_Measure` item, so a target that kept its delimiters made `add_formula` create an item literally named `|Total Revenue|`: a ghost column the user's real item never matched, and `Expr::Ref("Total Revenue")` never resolved to. Both the TUI formula minibuffer (`AddFormula`) and `.improv` load go through that one call, so both were affected. The writer emits `Formula::raw`, not `target`, 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 `.improv` text cell that must not read back as a number) and `unescape_pipe_body`. `improvise-gp` re-exports it, `improvise-io` calls it from `persistence/{format,parse}.rs`, `parser.rs::unquote_pipe` is 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 named `SUM` is written `|SUM|`. Bare keywords do parse, but only because `agg_call` needs a `(` and PEG backtracking falls through to `ref_expr` — rule ordering, not a stated rule — and a `## Formulas` line carries formula text verbatim, so a name `format_md` writes is read back by `parse_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|B` would parse as `A` and silently drop `B`, and `line_name` catches 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 and `parser.rs` panicked — the surviving `unescape_pipe_body` is total. Two `## Category:` sections naming one category are **one** category, concatenated by `ParsedFile::push_category` in **pass 1** (improvise-349m) — merging in pass 2 was not idempotent, because `build_category` creates its own section's item-groups before its own `>` lines while `format_md` writes every item before every `>` line. No grammar change was needed: this is the tree-walker, not the format, and duplicate headers stay legal. And **`WHERE` now carries `IS`'s word boundary** — both are compound-atomic with `sp+` (improvise-r08u), so `SUM(Rev WHEREAS x = y)` is a syntax error rather than a `WHERE` on a category `AS`. The guard is `sp+` and **not** the `!bare_char` lookahead the ticket proposed, because the grammar-walking generator reads the grammar through `pest_meta` and **skips predicates**, emitting `WHEREa=""` — 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.
35. **`Dirty` over-reports on purpose, and the asymmetry is the reason.** `Dirty::of` maps an `EffectKind` to the widgets that kind can reach, and the two directions do not cost the same: over-reporting pays one redundant `Projection`, under-reporting leaves a client's cache wrong **forever**, with nothing in the stream to correct it. `LayoutGeometry` therefore includes `formulas`, because `RemoveCategory` wears it and `Model::remove_category` ends 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 with `Dirty::EVERYTHING` and is still spelled out, because the two are derived from different effects and need not stay equal. The derivation now lives in `context/code/command.md`, and the doc comment on `Dirty::of` points there rather than restating it; the reason `LayoutGeometry` is spelled out rather than written as `EVERYTHING` is a two-line comment inside the match body. It went stale once already — claiming `AddCategory` was `CellData` after it was reclassified — so reclassifying an effect means re-reading the prose, not just the comment.
36. **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's `Command` reaches the server — `Host` calls *that* (improvise-ahdf). `Sessions::apply` is not `pub`, delegates to `apply_checked` after `Checked::against`, and has no production caller: its call sites are tests, which is why it takes a raw `&Command`. `ClientSession::fold` and the mirror's `ViewState` are private, so a transport cannot reach the mirror at all. `apply_switch` now **takes a `SessionId`** — 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 keys `ViewState::cursors` and is read by `grid_in`. The audience is conditional: an accepted switch relays **nothing** (the asker folded it, and no other client's view moves), a refused one is `Broadcast::To(asker, …)` — improvise-6c6q, where `ToOthers` told every client but the one that was wrong. `adopt_active_view` and `Broadcast::ToOthers` are **deleted**; the variants are `ToAll` and `To(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 own `ViewState` — that helper is itself deleted. What survives unchanged: the model is shared and the UI is not, and `Broadcast` is a `Vec` because one command can owe two relays with different audiences. **The trap this paragraph used to describe is gone with `ToOthers`, and the lesson it taught is not.** It was copying `ToOthers` from `apply_switch` into `apply_model`, invisible from the bystander's side because `adopt_active_view` moved 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 the `Broadcast` itself**, 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. A `Vec` rather than an `Option` because one command can owe two relays with different audiences, and an `Option` would have had to drop one silently. Everything else is that client's alone and never touches the `App`'s own view slice: **the model is shared, the UI is not.** Folding `SwitchView` into the mirror alone was the bug — `viewport()` then read the cursor under the new view's key and the next `project()` built that client a grid out of the *other* view.
37. **`gp-tree` returns 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::warnings` carries the columns `preprocess` dropped for being entirely missing (improvise-d44s), and `fit.rs` hands back pooled `(prediction, actual)` pairs and a score per feature rather than tables. `examples/fit_csv.rs` owns every `println!`, and that is the only reason its output stayed byte-identical across these moves — adding an `eprintln!` to either module is the regression. (b) **The seed is necessary and not sufficient**: `data::Model` records `seed: Option<u64>` *and* `config: Option<FitConfig>`, because the same seed under a different population/generations/elite/penalty lands on a different tree (improvise-ksst). Both are `Option` for one reason — every `FitConfig` value and the seed `0` are legal, so nothing can double as "unknown". Note `#[serde(default)]` on an `Option` field 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 is `fit::fit` (improvise-0ai); `breed` remains 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 parallel `x`/`y` lengths that `zip` used to truncate silently cannot be built (improvise-dkqn, `doctrine(an_invariant_is_made_unrepresentable_not_validated)` — the same answer `prune_axis_items` got, and the reason no `Result` entered five signatures). The pairing is made at the boundary that *builds* the data: `preprocess` walks one row index and the rows live inside `Target`, because a `Dataset` carrying `rows` and `target` separately just moves the zip up one level into every caller. The empty row set is one shape, answered once in `mean_over`. Changing any of this needs **bit-level** evidence: `tests/scoring_known_answers.rs` pins every path as f32 bit patterns plus a `next_u64` witness 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.** `preprocess` invents `Region=North`, `Cost.missing`, `when.year`; those are **encoding artefacts, not items of the document**, and `Expr::Ref` resolves through `Model::find_item_category` — so a tree splitting on one emitted a well-formed formula whose every cell was `Error("ref:…")`, silently (improvise-8xhy). `fitted_formula` therefore takes the `&Model` and refuses, listing what it could not resolve; `formula_text` still spells it, being the tree's own text. **The check asks the document, never the name's shape**, because a measure literally named `Region=North` is a legal document and the two are indistinguishable as strings — which also means `.missing` and the date parts are covered with no second copy of `preprocess`'s expansion rules to drift from. **This is an interim** (improvise-4zl7): a `WHERE` predicate needs a *scalar coordinate predicate* the formula language does not have — `Filter` gates a formula or an aggregate, it is not an `Expr` — would still leave `.missing` and the date parts, and must first answer what such a predicate means where `context.get(cat)` is `None`, since `Formula::filter` falls through to `aggregate_raw` and 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_measure` handed `preprocess` an `Overrides::default()`, so it never said which header columns are document categories *although the very next line told `Features::of`* — and `preprocess` infers `Numeric → Date → Categorical` from the values, so a `Year` category with items `2024`/`2025` was a *numeric column* and a category of ISO dates a *date column*, both refused with `Year IS 2024` spellable all along. It now forces the same `header[..category_columns]` slice to `Forced::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 past `MAX_FEATURES` and 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). The `Artefact` arms stay: `crate::dataset` is `pub` and takes the caller's `Overrides`, 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, and `no_ambiguous_column_names` ran *after* `preprocess`, so the expansion the preflight exists to prevent was allocated and only then refused; `as_categories` now returns `Result<Overrides>`, so the ordering is a compile error rather than a convention. The test that missed all this is the shape to remember: `improvise_eval` **fabricates** a `_Measure` item per feature name, so it asserted the emitter and `gp_tree::eval` agree 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.
38. **The wire vocabulary is derived twice, by the same recipe.** `Command`'s view half is the `EffectKind::ViewOnly` effects targeting the portable slice (`decision(improvise-cqi, command_set_derived_not_invented)`); its model half, `ModelCommand`, is the `EffectKind::Model(..)` effects — **26** of them since improvise-jytm made `SwitchView`, `ViewBack` and `ViewForward` `ViewOnly` — minus `ApplyAndClearDrill` and `AcceptWizardModel`, leaving **24** variants (improvise-kmgs). `SwitchView` no longer needs subtracting separately: it is not a model effect any more. `ViewBack`/`ViewForward` left for the *same* reason as the other two exclusions — their operand is `ViewState::view_back_stack`, session-local residue belonging to whoever navigated. Note the obvious `awk` extraction reports **30**: it picks up `HelpPageSet`, which is `ViewOnly`, because that is the last `pub struct` before `#[cfg(test)]` and the test module mentions `ModelChange::` — brace-match the `impl Effect` blocks instead (`doctrine(derive_then_verify)`). Three things follow. **A `ModelCommand` does not mutate**: `ui/reduce.rs::model_effects` maps it back to the effect it came from and `apply_command` runs that through `App::apply_effects`, so effects stay the only mutation site and the `Dirty` is the effect's own `EffectKind`, never a second classification beside gotcha 35's table. **Two model effects are deliberately off the wire.** `ApplyAndClearDrill` and `AcceptWizardModel` were once kept on the grounds that the *command* is portable even where the operand is not; **that was the wrong criterion** (improvise-3x3u). A `Command::Model` is routed with no `SessionId`, 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 are `drill_state.pending_edits` and the built wizard workbook, session-local residue (gotcha 30) private to whoever staged them (improvise-tom1). Unaddressed, B's `ApplyAndClearDrill` commits A's uncommitted typing and B's `AcceptWizardModel` swaps the document out of a dialogue B never opened — both demonstrated against `Sessions::apply` before removal. So 29 − `SwitchView` − these two = **26**. 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 by `no_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_checked` routes the model half to `apply_model`**, which relays `adopt_cursor_epoch`'s `ResetCursor` and `adopt_deleted_views`'s per-client switch; a document-replacing `LoadModel` bumps the generation and lands in the rebase arm. The former claim here — that `ViewBack`, `ViewForward` and `LoadModel` all end in `App::switch_to_view` so 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 a `Command`, and not for want of vocabulary: a binding names an *intent* resolved against `CmdContext` (`move-cursor down`) while a `Command` is the *resolved mutation* (`SetSelected { row, col }`) — improvise-gxi's AC 4 asks for something the split forbids.
39. **`Applied` is an enum because a document swap is not a diff.** `Sessions::apply_checked` returns `Applied::Diff { dirty, broadcast }`, `Applied::Rebased { snapshots }` or — since ADR 001 layer 1 — `Applied::Rejected { reason }`, and the enum *is* the mechanism: a host that wants the `Dirty` must match, and matching names the case where a `Snapshot` is owed. The server's mirrors self-heal — `ClientSession::project` reseeds on the `document_generation` bump — but `ViewState::cursors` lives on the **client**, keyed by the outgoing document's view names (gotcha 24), and only a fresh `Snapshot` replaces it. `Command::Model` put the three `WholeWorkbook` mutations on the wire (gotcha 38), so the host that must send the snapshots is no longer the host that asked. `apply_model` therefore calls `rebase` itself rather than instructing the host to, exactly as `apply_switch` adopts the accepted view itself. **The trigger is the generation moving, not the command variant**: a `LoadModel` that cannot read its path leaves the document alone, and reseeding on the variant would cost a client its cursors for a *failed* load — `a_load_that_fails_is_not_a_rebase` guards that arm. A `rebase: bool` beside `dirty` was rejected, because the host that forgets to call `rebase` is the host that forgets to read the flag. The **third** case is a *normal outcome*, not an error, and it is in the enum for the same reason the other two are: a host that wants the `Dirty` must match, and matching names it. `ServerMsg::Rejected` is the matching frame, addressed to **the asker alone** — nothing happened, so nobody else is owed anything — and the client keeps it as a `Rejection` rather than a sentence, because force needs to know *which* record and *which* kind (gotcha 43).
**Fixture warning.** When testing a document swap, the incoming document's active view must have the **same name** as the outgoing one. A rename makes `apply_model` broadcast a `SwitchView`; the client folds it, and the stale cursor is then read under a key nothing has written — right *by accident*, with the bug invisible. `other_document()` renames, and a first version of this test passed because of it; `a_document_shaped_differently_under_the_same_view_name()` keeps the name and changes only the shape, so there is nothing to hide behind.
40. **A batch's basis is three tiers, and only two of them can be read off the effect.** `App` carries `document_generation` (bumped by `install_workbook`), `layout_version` (per `ModelChange::LayoutGeometry` effect) and `record_versions` (per *record*) — ADR 001's stale-basis check, and shared document state, so never on `ViewState` (gotcha 30). **The unit is the record**: `record_of(key)` is the coordinate tuple minus `_Measure` (`record_row_identity`), because renaming a category coordinate in wide records mode moves every measure cell in the row (gotcha 1) — a cell-level unit is violated by one legal edit, so the unit must be at least as coarse as the coarsest legal write. Both tiers are read in `apply_effects` off the same `EffectKind` the `Dirty` is: one classification, two readers, no second table to drift from gotcha 35's. `layout_version` bumps **inside** the loop for the reason `Dirty` accumulates there — an aborted batch must not claim effects it never ran (gotcha 21). `record_versions` **cannot**, because an `EffectKind` says *that* a batch writes cells, never *which*; so the records are **observed**, via a per-record XOR fingerprint of the raw data taken when the batch declares a `CellData` effect and diffed after it. Three consequences. A write landing the value already there **bumps nothing**, which is correct — no basis was invalidated. A `LayoutGeometry` effect that drops cells **will** bump their records when it shares a batch with a `CellData` one: an over-bump costing at most an unnecessary rejection, the same direction `Dirty::of` is deliberately sloppy in. And only *raw* data is fingerprinted — formula results live in `Model::formula_cache`, and a record whose derived value moved because someone edited elsewhere has not itself been written. The diff collects into a **set**: chaining `before.keys()` with `after.keys()` counts any rewritten record twice, a bug that survived one test run.
41. **`Storage` is not a VFS, and that closes the SQLite question.** The trait is whole-document `read`/`write` bytes (gotcha 2, `invariant(storage_is_two_ops)`) — no offsets, seeking, handles or locking — so an `sqlite3_vfs`, which needs `xRead(offset,len)`/`xWrite`/`xTruncate`/`xLock`, has nothing to bind to. SQLite *can* still sit behind it, as an in-memory database serialized to a blob (`rusqlite` `serialize`/`deserialize_read_exact`, proven with a probe before declining — declining on a capability that actually exists would have been the wrong decline). It is worth nothing: measured on 18,000 cells the SQLite container is **22% larger** than `.improv`, **4.7× larger gzipped**, ~**160×** larger at the floor (24 KB against ~150 bytes), and still rewritten whole on every save, so the one dimension SQLite normally wins — incremental page writes — is unavailable. improvise-9ix was declined on that evidence. Two things generalise past SQLite. **A second serialization format is a second implementation of the grammar**, obliged to track `improv.pest` forever; and **its own round-trip test cannot detect drift from `.improv`**, because it never compares the two — that missing cross-format equality test is what made the ticket look cheap. If a structured-query story is ever wanted it is a one-way `export_sqlite` beside `export_csv`, native-only and feature-gated: `libsqlite3-sys`' C build fails under this repo's nix cc-wrapper for `wasm32-unknown-unknown`, which would break the `wasm` CI gate the day it landed.
42. **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.rs` builds a `RenderModel` in 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. **`editable` is a fact about the `CellId` variant** — `Record` only, per ADR 002 — and the trap is the third case: `None` means "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**, because `GridCache` is viewport-indexed while `ViewState::selected` is absolute. `render.rs` does 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: at `selected=(5,1)`, `row_offset=4`, `col_offset=1` it separates "reconciles" from "ignores" *and* from "swaps the two offsets". **The category panel is `viewmodel::flatten_cat_tree`**, never a second walk of `CategoryTreeCache` — that second walk was improvise-edp. The page is four string calls: `Client::viewport` (the handshake — the `Snapshot` seeds 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::frame` owns `ClientFrame { Viewport, Command, Key }` and `ServerFrame { Snapshot(Box<_>), Commands(Vec<_>), Rejected }`; `improvise-ws-server` and `improvise-wasm-client` each `pub use` them 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. `Viewport` is deployment-independent — the extent is not in `ViewState` and no effect produces it, so no derivation reaches it, and a worker hosting the same `Sessions` owes `set_extent` the same answer; it is the handshake on both because a `Snapshot` seeds the *visible* cells. `Key` is deployment-independent too — a binding names an *intent* resolved against a `CmdContext` a 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), and `Host::handle` now answers `ClientFrame::Key` with an explicit empty vec that **says so**, where the frame used to just fail to parse in `next_client_msg`. And `Commands(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-process `postMessage` interleaves exactly as a socket does. The worker pair was not merely different but **incoherent by omission**: written before ADR 001, it could carry `Command::Model { basis, .. }` and had no `Rejected`, 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_server` round-tripped hand-listed fixtures through the other crate's types to catch a **rename**; `every_server_variant_is_one_this_crate_mirrors` matched 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 is `the_retired_spellings_do_not_parse` in `frame.rs`, aimed at the one thing still possible (the retired `Bootstrap`/`Command` spellings 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's `unknown 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 now `tests/browser_round_trip.rs`, which hands the wasm client's own serialized frame to a real `Host::handle`; `improvise-ws-server` stays 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.js` is no longer unrun (`harness/tests/shell.spec.js` executes it under Chromium), 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 the `ResizeObserver` extent arithmetic. What a careful read *did* establish about the last of these: the observer cannot feed back into itself (`#grid` is a flex child with `overflow: auto`, so `replaceChildren` cannot resize it), and `ROW_PX`/`COL_PX` under-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.rs` drives improvise-1ey's stated scope against a real `Host` over a real `App` touching the client only through the four string calls. One deployment note that is not a bug: `examples/demo.improv` leaves `Customer`/`Date` on 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.
43. **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, and `Sessions::apply_model` checks it *before* applying anything — a stale batch is never applied, not even partially (improvise-xwi6/fkfg, ADR 001 layer 1). Four things to know. **`Basis` has no `Default` and no public constructor**: a zero-valued basis is not "no claim", it is the claim that *the document is fresh*, which in a new `App` is true — so a default would be a forgeable bypass rather than an absence. `BasisCache::claim` is the only door a client has and it returns `None` until the server has published one. Note what that does and does not buy: `Basis` derives `Deserialize` because 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* — `RecordUnstated` closes **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 gets `Rejection::RecordUnstated`, a case of its own because "you lost a race" and "you were never in one" are different things to tell a user. **`Rejection` keeps structural and data staleness apart**, which is the whole reason ADR 002 put a maintained `RecordHash` index in `DataStore`: `RecordGone` means nothing resolves and there is nothing to force (**force never creates structure**), `RecordMoved` means it resolves at another version and force is one keystroke — which is also why the wasm client stores the `Rejection` itself rather than collapsing it into `status_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` + `BasisCache` close 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** until `a_published_basis_follows_the_grid_updates_it_describes` pinned it (proved by injecting the swap) — the same job `a_cell_update_that_overtakes_its_labels_is_lost` does for the mirror-image rule. Two things are unchecked and deliberately so: `Command::SwitchView` carries no basis (it sits outside `Command::Model`; the workbook refuses a view it does not have, so it cannot corrupt, but it is a hole in the rule), and `apply_command`/`reduce_full` do 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 the `App` is 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 wire `Command` and their `Command::Model { cmd, .. }` arm **discarded the basis with `..`**, while `ui/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 take `Checked<'_>`, a private-field newtype over a `&Command` whose only constructor is `Checked::against(&App, &Command) -> Result<_, Rejection>` — also the only caller of `stale_basis`. `Sessions::apply_checked` receives the proof and `Checked::against` is answered **once, before the dispatch**, so `apply_switch`/`apply_model` receive the proof rather than each remembering to ask: an invariant made unrepresentable rather than validated, the same answer `prune_axis_items` and `gp-tree`'s `Row<T>` got. Visibility was the weaker option and was rejected for a concrete reason — `pub(crate)` closes only the out-of-crate path, and `reduce_full` has **no production caller anywhere in the workspace**, so it would have become dead code. **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: a `Checked` does not name *which* `App` it was checked against, so a multi-document host could in principle check one and apply to another. That is also why `ui::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::against` runs before anything in a `ClientFrame::Commands` is applied. `document_generation` and `layout_version` describe 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 a `SetCell` is `CellData` and 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)]`, which `BasisCache::claim` cannot possibly claim for), and only that combination is exempt; a record that does not resolve but *is* claimed is still `RecordGone`. That the `Basis` inside a *later* `Command::Model` is ignored rather than compared is an honest interim: clause 1 says the **frame** states one basis, and moving the field from `Command::Model` to `ClientFrame::Commands` would 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 future `Command::RunScript` is 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 at `App::apply_effects`.
44. **The TUI grid draws from its cache, and since improvise-anr9 the cache frames it too.** `GridWidget` holds no `&Model` and no `&View`: cell text, aggregate-ness (`CellId::Aggregate`, ADR 002), column widths, the page bar, the totals strip and now the **frame** all come off `GridCache`. The TUI still builds that cache over the **whole** grid (offsets zero), so a cache position is an absolute position — the browser's `Viewport` is a real window, the TUI's is not, and the two clients therefore use it differently; column totals **must** be projected rather than client-derived, because the sum runs over every row *including the ones a viewport scrolled past*. The frame is an `AxisFrame` per axis — `cats` (the categories, outermost first) plus `entries: Vec<FrameEntry>` interleaving `Group(FrameGroup)` headers with `Item(Vec<String>)` label *tuples*, exactly as `GridLayout::row_items` interleaves them. That replaced a `Vec<String>` built by `GridLayout::row_label`, which slash-joined the tuple — the exact rendering `two_row_categories_produce_cross_product_labels` says must not appear — so the browser drew `Food/Alice` where the TUI drew two header columns, with no groups and no collapse indicators. It is **one** type and not a `labels` plus a `levels` beside it: two answers to "what are the labels" is the disease (improvise-edp), and this is the second cure, which cost a *type* change rather than an addition. Three things to know before touching it. **`leaf_labels()` is not "the labels"** — it is the innermost label, what a one-line summary calls a row by; a draw loop wants `header_labels(first_rendered)`, `groups()` and `shares_ancestry(sel)`, each of which is one pass, because `item`/`label`/`shows_level`/`group_of` scan `entries` from the start and asking them per position is quadratic in the frame *and grows with the TUI's absolute scroll offset* (found three times on anr9; the per-position forms survive for one-off lookups and say so). **The width rule has one home**, `improvise_protocol::header_level_widths` — widest label per level, plus a gap, clamped — reached by `AxisFrame::header_widths` and by `ui::viewport::compute_row_header_width`, which borrows level tuples off a `GridLayout` rather than building a frame on the keypress path. **A `cats` change re-identifies every position**: a cell's identity is `cats` zipped with its tuple, so `invalidate_relabelled` blanks the whole axis when the categories move, not merely the positions whose tuples differ, and blanks the **totals** on a recategorization of *either* axis — a column total is a sum over the rows, so re-identifying the rows leaves a number answering coordinates the grid no longer has (gotcha 33(b): the rule lives in `apply`, not in a producer's memory). What is still **not** on the cache is `record_col`, and that is correct: it keys pending drill edits and is session-local (gotcha 1). `body_from(idx)` takes an argument only because the two front ends scroll differently — a browser's frame is already windowed and passes `0`, the TUI's covers the whole grid and passes its row offset — not because the rule differs. `group_of` answers group **membership**: the nearest preceding header *unless* an `AxisEntry::GroupEnd` closed the run first (improvise-gd83, landed in PR #77). It previously answered "nearest preceding header" unconditionally, which gave an ungrouped item the preceding group. `GroupEnd` draws nothing and exists only so membership is not inherited. Still open beside it: `group_runs` coalesces by name where `FrameGroup` carries a `cat`, so two runs under different headers merge (improvise-h0iv), and `AxisFrame::first` is `serde(default)`, so a frame that loses the field claims it leads the axis (improvise-1wl0). And `project::grid_of` evaluates every cell per frame where the widget evaluated only the visible ones — a widening of an existing whole-grid cost. Since improvise-jytm `grid_in` may additionally **derive a `GridLayout`** for a session on a view the host is not on; the host's layout is reused where the names agree, which is the common case and the only one that can carry a drill snapshot.
45. **One session loop, two transports — and the browser saves itself.** `Host` is `App` + `Sessions` + `handle(id, ClientFrame) -> Vec<Addressed>` and lives in `improvise_command::ui::host`, **not** in `improvise-ws-server`, which re-exports it. It moved for improvise-9x6: the worker hosts the same authoritative `App` over a `MessagePort` and cannot depend on tokio, and a second copy would have been exactly the mirror improvise-xks6 had just deleted for the frames — a drifting `Applied`/`Broadcast` match is invisible until a client sees it. `registry` stayed on the server, because a worker hosts **one** document. What the worker adds is only what a `MessagePort` differs in: **one client by construction** (the `Addressed` filter is honoured anyway, against a future `SharedWorker`), and **a handshake with no close** — `ClientFrame::Viewport` must still come first, but a pre-handshake frame is *dropped* rather than closing a connection that does not exist.
**Saving is a frame, and dirtiness is read off the classification the effect already carries.** `effect::Save` is `ViewOnly` (gotcha 3) — correctly, since it writes *this host's* `file_path` through *this host's* `Storage` — so the derivation that builds `ModelCommand` from the model effects cannot name it, and for a while that meant no client on any transport could ask its host to save. `ClientFrame::Save` closes it (improvise-368h): a **request** carrying no path, because a client cannot see the host's storage, answered `ServerFrame::Rejected(NoDocumentPath | SaveFailed)` to the asker alone and **silently on success** — nothing moved, so no widget is dirty. It is a frame and not a `Command` for one concrete reason: as a `Command` it put a runtime-fallible operation inside a batch `ClientFrame::Commands` promises is all-or-nothing, so `[Save, SetCell]` reported a failed write and then applied the edit. It also fails the test every `Command` passes — it mutates no shared state and no client's slice, so `reduce_view` has nothing to fold and `Checked` has no basis to answer for. **And `MarkDirty` is gone.** It was a separate effect the *command* layer composed beside each mutation — reasonable on the key path, unreachable from anywhere else, because improvise-kmgs derived `ModelCommand` from the effects **one at a time** and so put `SetCell` on the wire without its companion (improvise-gv9r): a derivation correct per element and wrong for a set, whose symptom was that "save if dirty" never fired. `App::apply_effects` now raises the flag for any `EffectKind::Model(_)`, all three `ModelChange` levels included, which makes omitting it unrepresentable rather than validated. It over-approximates in the same direction `layout_version` does — a `LoadModel` that cannot read its path leaves the flag raised, costing one redundant write — and `Save` clears it in loop order, so a batch that edits and then saves ends clean. The worker's persist trigger is therefore the **flag**, not a table of command variants: an observation rather than a second description of which commands change the document, for the reason `cursor_epoch` beats matching on `CycleAxis` (gotcha 24) and `document_generation` beats matching on `LoadModel` (gotcha 39). It is also strictly tighter — a refused batch raises nothing, so the "was anything rejected" guard went with it. A browser still never *sends* `ClientFrame::Save`, and that is the answer rather than a gap: there is no file the user owns and no Ctrl-S semantics, the document *is* the OPFS entry, so the worker persists on its own behalf through the one existing `Save` effect. The cost that remains: `Storage` is whole-document (gotcha 41), so OPFS rewrites the file per accepted model command.
**The async seam does not survive being boxed.** `OpfsFs::open`/`attach`/`refresh`/`commit_deletes` are on the struct, not on `Storage`. `App::storage` is a `Box<dyn Storage>` and a trait object can name none of the four, so **handing the backend to the `App` puts the async seam permanently out of reach**; the host must hold the concrete backend *and* give the `App` a `Storage`, which means sharing one. That is `impl<T: Storage + ?Sized> Storage for Rc<T>`, and it lives in **`improvise-io`** rather 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. `Rc` and not `Arc` because the one caller is a Web Worker and wasm32 has no other thread. **The flat root is no longer load-bearing**: `OpfsFs::list` joins `dir` like the other two backends, so `storage_contract` takes `dir` as a parameter and the three agree at *every* directory rather than only at `Path::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: `attach` indexes nothing (what it creates is zero bytes), `Storage::write` indexes the name and un-queues any pending delete of it, and `refresh` rebuilds 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) runs `improvise_io::storage::contract::storage_contract` against a real `OpfsFs` inside a real dedicated Web Worker under Chromium and Firefox, and the `browser` job in CI can fail the build — so **`OpfsFs` is no longer compile-checked-and-never-run**, and two bugs fell out of its first execution. That is why `storage_contract` stopped being a `#[cfg(test)]` helper and became a public function returning `Result`: 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.js` skips on a runtime probe and an unexpected skip on any other engine is a failure). **`bindings.rs` is 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 calls `bindings.rs::start` and `OpfsFs::open` — and the spec then asserts a grid draws, so the whole chain has run under Chromium in CI since improvise-vvl3. The claim survived in **five** places (this line, the File Inventory, two `kb.pl` facts 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, and `kb.pl`'s CI gate checks consistency rather than truth. What was already verified stays: `tests/post_message.rs` drives a real `Worker` over a real `App` on nothing but the JSON a `MessagePort` carries. Build note unchanged: the worker bundle needs `--enable-nontrapping-float-to-int` **as well as** `--enable-bulk-memory`, because it carries the whole `App` and so chrono's date arithmetic.
46. **The page picks a peer, and the demo was built against an assertion.** `web/app.js` holds two transports and no protocol knowledge: `?ws=` selects `improvise-ws-server`, its absence spawns `new Worker('./worker.js?doc=&seed=', { type: "module" })` (improvise-djm). `type: "module"` is mandatory — `worker.js` `import`s the bundle — and there is **no `MessageChannel`**: a dedicated worker's own `postMessage`/`onmessage` *is* 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. `#status` composes **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 in `start()` fires no `error` on the main thread** — `worker.js` catches 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.improv` leaves `Customer`/`Date` on None, so every cell is an `Aggregate` and nothing is editable — and the naive fix does not work either, since `Product × Region` by `Date_Month × Measure` still aggregates. `web/demo.improv` puts **exactly one category on each visible axis** (`Product` row, `_Measure` column, `Quarter` page, nothing regular on None) and carries `Profit = Revenue - Cost`, so the payoff is on one screen with no keystroke. **Settability is asked of `grid_of` as a `CellId` variant**, never re-derived from the axes — a second derivation could agree with itself while disagreeing with `render.rs::editable`, which is the whole point of ADR 002 — and the assertion was proved discriminating by pointing it at `examples/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 written `GridCache` slash-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/*.improv` is un-ignored in `.gitignore` (`*.improv` is 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** a `Record`, so it is offered as editable and a write to it reverts on recompute — pre-existing and matching the TUI, newly visible.
47. **Keys become commands in the browser, and the server must not know the keymap.** The user's ruling on improvise-bpq2, following from "the model is shared, the UI is not" — and it buys two clients different bindings over one document. What made it urgent is **not** latency. `a_key_resolved_on_the_host_moves_state_no_client_can_hear` shows nothing in the projection stream carries the host's own `ViewState`, so host-side resolution **diverges**; and `Host::handle` answers `ClientFrame::Key` with an explicit empty vec, so **an upstream key is a black hole, not a slow path** — every `KeyOutcome::Upstream` names a binding that does nothing at all in a browser. That test still holds and was not flipped: bpq2 closes the divergence by *avoiding* it. **The method is the ticket's** — enumerate the **bindings**, not the commands (`doctrine(binding_dump_diff)`) — and where a client cannot compose the `Command`, **enrich the projection** rather than moving resolution back.
Three things that cost. **`GridExtent` on `GridCache`** — the whole grid, of which the labels are one window — because a viewport-indexed cache cannot tell "the last visible row" from "the last row". It is deliberately **not framed by the labels** (`fit_to_labels` leaves it alone) and its batch position is not load-bearing the way `RowFrame`-before-`Cells` is, since it addresses no position; the producer reads `GridLayout::row_count()`, and reading it off the projected ranges is **invisible in the TUI** (which builds at offsets zero, gotcha 44) and is exactly the browser bug. **The window is the client's own fact**, recorded by `Client::set_viewport` from what it announced — *not derivable*, since the row frame's own length is the window **clipped to the grid** and so differs precisely at the bottom edge where the scroll arithmetic matters. `Client::viewport` (associated, no recording) still exists and is the one that quietly turns cursor keys off; `web/app.js` does call `set_viewport`. **`ClientFrame::Commands(Vec<Command>)`** replaces the singleton, because one cursor key is `SetSelected` plus the offsets `viewport_effects` returns and a host that folds the selection but not the offset projects the wrong window. It is **answered whole, then applied whole** (improvise-955n): `CheckedBatch::against` checks the frame before anything runs, so there is no longer a moment at which part of a frame has landed and the rest has been refused, and `Applied::Rejected` is unreachable through `Host::commands` — still matched, because a host that wants the `Dirty` must name every case (gotcha 39). The contract is four clauses and **clause 4 is not delivered**: one basis per frame, checked once, applied in order with each command seeing the last one's state, and all-or-nothing. The fourth is open because `App::apply_effects` aborts without undoing (gotcha 21 — an abort is not a rollback), so `[RemoveItem("North"), SetCell(the North record)]` lands the removal, resolves the write to nothing, and reports the frame accepted having applied half of itself — partial *and* silent. It is unreachable from any client in this workspace, the only multi-command producer being `keys::commit`; **reachability is a reason not to rush a fix and not a reason not to notice one** — the lost update `CheckedBatch` fixed had the same reachability argument and none of the documentation, which is how it shipped.
Two corrections to the ticket's own gap list, both worth more than the code. **Panel cursor movement was never a gap**: improvise-edp had already made `viewmodel::flatten_cat_tree` the one home both ends call, so it is the *shared* derivation, not a second one — worth re-checking the "known gaps" in other old tickets against what has landed since. And the "unmodified characters only" rule was **mis-scoped, not wrong**: it was about `KeyPattern::AnyChar`, which is bound only in the text-entry modes, so `Ctrl-d` in Normal has an exact binding and mirroring it is a faithful subset. Each resolver mirrors its `Cmd` **line for line, including where siblings disagree** — `Move` and `MoveTileCursor` emit even when they clamp, `MovePanelCursor` returns nothing when the cursor does not move. Tidying that into one rule would make the client a second derivation, which is what `tests/lockstep.rs` exists to rule out. What still needs the host is a **named list** in `keys.rs` and in `the_normal_mode_keys_that_still_need_the_host_are_these`, so closing one has to come there and say so.
48. **The browser JavaScript is argued *and now partly executed*, and the two rules it can break are both about *when*.** `web/app.js` and `web/worker.js` were 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.js` is the first execution of either: it runs `app.js` under 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 to `web/*.js` belongs 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. **An `async` `message` listener does not serialize.** `worker.js`'s `handle()` awaits `server.flush()`, so the event loop may start the next message while an earlier one is suspended, interleaving two `on_message`/`flush` pairs 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 not `async`; that also turns "frames that arrived before `start()` finished" into an ordinary case of the ordering rule instead of a drain loop, which had the same race twice over because `server` was 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 (worker `error`/`messageerror`, socket `error`) has already written something more precise; the timer then fired 8s later and replaced it, so `setShellFailure` clears it. Its neighbour, the socket's `close` listener overwriting a precise `error` with "disconnected", **is fixed** (improvise-taxx, PR #21) — and the ticket's own proposed mechanism was wrong: `readyState` cannot discriminate, an `opened` flag is the only thing that can. PR #21's *other* half is the one that keeps getting lost: it routes **both** close arms through `setShellFailure`, 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 the `opened` flag **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 by `a 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 `ResizeObserver` extent arithmetic. What a careful read established about the last of these — the observer cannot feed back into itself (`#grid` is a flex child with `overflow: auto`, so `replaceChildren` cannot resize it) and `ROW_PX`/`COL_PX` under-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, and `Host::handle`'s `Key` arm — 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-evidence` takes its Rust names from `cargo test --workspace -- --list` minus the `--ignored` twin, 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: `--list` does 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-evidence` needs the `--ignored` twin, so 22 cargo invocations), and *a package is not a binary* — `improvise` alone has five integration test binaries beside its lib, and an integration test's top-level `fn` lists with **no module path at all** (60 of 1444 names are bare), so `-p improvise` prefixes all six identically. Per-**target** is the only shape that would close it.
49. **A websocket handshake is exempt from the same-origin policy, and that is a server-side problem.** There is no CORS preflight, so any page a user visits can open a socket to `improvise-ws-server` *from that user's own allowed address* and read and rewrite every document it holds — cross-site websocket hijacking (improvise-kc7n). **No network control can see it**: an IP allow-list, a private network and loopback binding all answer "may this **client** reach the endpoint", and the honest answer is yes; only `Origin` says what is **driving** the client. **Loopback is the least protected case, not the safest** — no proxy in front of it, and loopback is not a boundary a browser enforces. `src/origin.rs` is the whole decision and `transport::admitted` its one caller; it refuses the **upgrade** with a 403 rather than accepting a socket and ignoring it, and `serve` **takes** the policy so a host cannot forget to apply it. Three arms are decided rather than defaulted: **loopback allowed** (the shipped local recipe serves its page from `http://localhost:8080`, so the one case worth optimising for must need no flag), **absent allowed** (no browser omits `Origin`, so absent is a non-browser client an address check already answers — and refusing it breaks `tests/socket.rs`, the repo's own instrument), and **two refused** (`HeaderMap::get` is first-wins, so a proxy that *appends* rather than sets leaves two, and answering from the first lets the second be anything). The deployed Pages origin is deliberately **not** hard-coded: it can only reach a plain-`ws://` server through a TLS proxy anyway, so one flag is an honest cost on an already multi-step path where a baked-in default would be a permanent hole for every other deployment. The residual hole is **stated rather than guarded** — a proxy that *strips* `Origin` turns the check off, which is why the proxy's own allow-list stays in `README.md` as defence in depth. **None of this is authentication**: it answers what page is driving a client, never who is connecting, and improvise-kpnr is still open. It correctly does **not** move to `improvise-command` beside `Host` (gotcha 45) — a `MessagePort` has no handshake and no headers, and a worker and its page are the same origin by construction, so this is genuinely transport-only and not a mirror waiting to drift.
---
## File Inventory
Line counts are a **dated snapshot**, not a maintained fact: measured 2026-09-21, and **26 of 61 had drifted past 10%** before that refresh — several by a third. Treat them as "is this file big", never as a figure to cite; re-derive with `wc -l`. Note the `ws-server/` and `improvise-worker-server/` entries use a shorthand prefix that no path-walking refresh resolves, so they drift silently and must be checked by hand. Test counts are informational — run `cargo test --workspace` for live numbers. Files under 100 lines and render-only widgets omitted.
### `improvise-core` (`crates/improvise-core/src/`)
```
model/types.rs 2352 / 74t Model, formula eval (fixed-point), CRUD
model/cell.rs 1234 / 43t CellKey (sorted), CellValue, DataStore (interned + index),
live_records (insertion order — gotcha 18)
model/record.rs 326 / 8t RecordHash / CellId surrogates: the opaque identities a
thin client names a cell by (ADR 002)
model/category.rs 316 / 9t Category, Item, Group, CategoryKind
model/symbol.rs 79 / 3t Symbol interning
view/layout.rs 1251 / 30t GridLayout, drill, records mode, records_columns
view/types.rs 711 / 36t View config (axes, pages, hidden, collapsed, format)
view/axis.rs 205 / 5t Axis enum + label/color/next methods, AxisColor
workbook.rs 345 / 17t Workbook: Model + cross-view ops, item facade
format.rs 229 / 29t format_f64, parse_number_format (display only)
```
### `improvise-formula` (`crates/improvise-formula/src/`)
```
parser.rs 1087 / 67t pest grammar + tokenizer → Formula AST
quote.rs 192 / 7t quote_name / is_bare_name / pipe_quote / unescape_pipe_body:
the one home, both halves (improvise-i2m5). improvise-gp
re-exports it; improvise-io calls it from
persistence/{format,parse}.rs
formula.pest 103 PEG grammar for formula text
ast.rs 77 Expr, BinOp, AggFunc, Formula, Filter
```
### `improvise-io` (`crates/improvise-io/src/`)
```
storage.rs 388 / 13t Storage trait (read/write/list/delete), PhysicalFs, MemoryFs,
`impl Storage for Rc<T>` (improvise-hmws), and
contract::storage_contract — public, not cfg(test), because
a browser must run that sequence (harness/)
opfs.rs 367 / 0t OpfsFs: Storage over the browser's Origin Private File
System. wasm32 only. Run by harness/, not by cargo test
persistence/improv.pest 153 PEG grammar — single source of truth
persistence/mod.rs 403 / 10t document lifecycle: save/load/gzip/legacy-JSON over Storage;
facade re-exporting format_md/parse_md/export_csv*
persistence/format.rs 385 / 11t format_md: Workbook → .improv text
persistence/parse.rs 1519 / 43t parse_md: pest tree-walk → Workbook (+ grammar generator)
persistence/export.rs 219 / 4t export_csv / render_csv. Builds its layout with
with_frozen_records, NOT new — `new` skips prune_empty
(improvise-iot6)
persistence/roundtrip_tests.rs
965 / 45t format_md ↔ parse_md characterization (tests only)
import/wizard.rs 1714 / 55t ImportPipeline + ImportWizard
import/analyzer.rs 403 / 13t Field kind detection (Category/Measure/Time/Skip);
analyze_records is first-seen order, not HashSet (gotcha 18)
import/csv_parser.rs 495 / 20t CSV parsing, multi-file merge
```
### `improvise-protocol` (`crates/improvise-protocol/src/`)
```
lib.rs 561 / 1t ViewState, ViewCursor, ViewFrame, AppMode (17), Panel,
MinibufferConfig, MinibufColor, HELP_PAGE_COUNT
reduce.rs 569 / 9t Command + ModelCommand (the wire format), HelpPage, reduce_view
render.rs 2008 / 35t CellDisplay, WidgetCache trait + one cache per widget
(GridCache/CategoryTreeCache/FormulaCache/ViewListCache/
TileBarCache), AxisFrame/FrameEntry/FrameGroup and
header_level_widths (the grid frame, gotcha 44),
GridCache::seed (the bootstrap compaction), Projection,
bootstrap-only Snapshot
keymap.rs 957 / 0t KeyPattern, Binding, Keymap, ModeKey (17), TextEntry,
PanelKeymap, Keymaps::default_keymaps/prefix_keymap — the
shared table both front ends look up. Tests live with
dispatch in improvise-command/src/command/keymap.rs
viewmodel.rs 168 / 5t flatten_cat_tree — derivations between a cache and a
renderer, called by both front ends (improvise-edp)
key.rs 101 / 3t KeyCode, KeyModifiers, KeyEvent (moved from command/)
frame.rs 330 / 3t ClientFrame/ServerFrame — the ONE envelope pair, both
deployments (improvise-xks6). Nothing else defines one.
ClientFrame::Save is a request, not a Command (gotcha 45)
basis.rs 332 / 5t Basis (the three tiers), Rejection, BasisCache — ADR 001
layer 1. Basis has no Default and no public constructor
help.rs 925 / 7t HelpPage content and Hint { keys: Vec<(KeyCode, KeyModifiers)>,
command, caption }. `compact` drops a chord by its MODIFIERS
(anything beyond shift), never by parsing a label it just
formatted (improvise-thpe) — the guard is the signature
resolve.rs 2477 / 46t the browser's key resolvers: a BrowserKey to the Commands it
composes, mirroring each Cmd line for line INCLUDING where
siblings disagree (gotcha 47). The largest file in the crate
client.rs 255 / 5t ClientState: ViewState + one cache per widget, and the fold
```
### `gp-tree` / `improvise-gp` (`crates/gp-tree/`, `crates/improvise-gp/`)
```
gp-tree/src/lib.rs 669 / 8t Tree, Spec, eval, simplify, render, breed, Population,
SplitMix64 (vendored seeded Rng; seed() keeps the seed so
a fit can be shown and recorded, pinned to the published
reference vectors)
gp-tree/src/data.rs 938 / 19t raw string rows -> f32 feature vectors: per-column type
inference, one-hot, mean-imputation + col.missing flag,
ColumnSpec/encode_row, Dataset (incl. warnings),
Model = trees + plan + seed + FitConfig
gp-tree/src/fit.rs 970 / 25t the search: FitConfig (Default = the CLI's numbers), fit()
-> simplified champion, cv_regress/cv_classify,
permutation_importance, folds/standardize/mse.
Returns data; never prints
gp-tree/examples/fit_csv.rs 585 CLI over the above: flags, demo dataset, all printing
gp-tree/tests/scoring_known_answers.rs
285 / 6t f32 bit patterns + a next_u64 witness: the evidence any
change to the scoring paths needs (gotcha 37c)
improvise-gp/src/lib.rs 844 / 21t formula_text / fitted_formula: a Tree as IF(...) formula
text, validated back through parse_formula. A threshold is
the SHORTEST decimal that reads back as the same f64 (not
the exact expansion), so a row on a split boundary routes
the same in f64 as in f32 eval; non-finite values panic
improvise-gp/src/dataset.rs 632 / 15t a View's records -> the feature matrix a fit consumes —
the improvise-side counterpart of preprocess. Records mode
is already a feature matrix (gotcha 1)
improvise-gp/src/fit.rs 1426 / 23t one fit end to end: a View's records in, an improvise
Formula and the evidence for believing it out. Not in a
command, because none of it is terminal-specific
```
### Command layer (`crates/improvise-command/src/command/`)
```
cmd/core.rs 333 / 2t Cmd trait, CmdContext, CmdRegistry, parse helpers
cmd/registry.rs 822 / 3t default_registry() — all registrations
cmd/navigation.rs 502 / 12t Move, EnterAdvance, Page*
cmd/cell.rs 194 / 6t ClearCell, YankCell, PasteCell, TransposeAxes, SaveCmd
cmd/commit.rs 739 / 11t CommitAndAdvance, CommitBuffer(CommitKind), CommitExport,
CommitSave
cmd/effect_cmds.rs 459 / 5t effect_cmd! macro, 25+ simple wrappers
cmd/fit.rs 280 / 7t fit-measure: evolve a tree over the active view and store it
as an ordinary `## Formulas` line — nothing GP-specific is
left in the document afterwards
cmd/grid.rs 540 / 9t ToggleGroup, ViewNavigate, DrillIntoCell, TogglePruneEmpty
cmd/mode.rs 414 / 11t EnterMode, Quit, EditOrDrill, EnterTileSelect
cmd/panel.rs 688 / 17t Panel toggle/cycle/cursor, formula/category/view panels
cmd/search.rs 206 / 4t SearchNavigate, SearchOrCategoryAdd, ExitSearchMode
cmd/text_buffer.rs 382 / 14t AppendChar, PopChar, CommandModeBackspace, ExecuteCommand
cmd/tile.rs 138 / 5t MoveTileCursor, TileAxisOp
cmd/wizard.rs 244 / 6t HandleWizardKey — per-step import-wizard key tables
keymap.rs 917 / 35t KeymapSet, Keymap::dispatch/dispatch_transient, effects_of,
SetTransientPrefix — what a thin client cannot have. The
table itself is improvise-protocol/src/keymap.rs
parse.rs 232 / 19t Script/command-line parser (prefix syntax)
```
### Semantic UI state (`crates/improvise-command/src/ui/`)
```
ui/effect.rs 2731 / 64t Effect trait, 50+ effect types
ui/app.rs 3407 / 92t App, ModelState, ViewState + dispatch methods, Panel, handle_key, dispatch_key, document_generation, autosave; apply_effects raises `dirty` off EffectKind::Model(_) (gotcha 45)
ui/project.rs 3708 / 55t Dirty, Viewport, grid_of/category_rows/formula_texts/
view_summaries/tile_entries, grid_updates (the diff),
ClientSession + Sessions (connect/apply/rebase/project_all)
ui/reduce.rs 1411 / 18t Checked / CheckedBatch / apply_command / model_effects /
reduce_full / reduce_key — one door from a Command or a
keypress to the authoritative App
ui/host.rs 1493 / 24t Host: one App + one Sessions; handle, commands, project_all,
rebase. Lives here, not in ws-server (gotcha 45)
ui/cat_tree.rs 184 / 8t Category tree flattener for panel
ui/viewport.rs 124 compute_col_widths / compute_row_header_width / visible_cols
(pure geometry; the width rule itself is the protocol's)
```
### TUI rendering, draw, main (`src/ui/`, `src/draw.rs`, `src/main.rs`)
```
ui/grid.rs 985 / 14t GridWidget (ratatui): no &Model, no &View — the frame comes
off GridCache (gotcha 44). Its module doc is the best-written
statement of that rule in the repo
ui/help.rs 603 5-page help overlay (render only)
ui/import_wizard_ui.rs 947 / 9t Import wizard rendering
draw.rs 599 / 5t TUI event loop, frame composition
main.rs 393 CLI entry (clap): open, import, cmd, script
# other ui/*.rs are small panel renderers — skip unless changing layout/style
tests/grid_frame_agreement.rs
422 / 4t the two front ends draw the same grid frame (improvise-anr9)
tests/records_wide_smoke.rs 67 / 1t
tests/shipped_documents_load.rs 42 / 1t
tests/web_demo_model.rs 80 / 2t every visible cell in web/demo.improv is a Record
```
### Browser host and client (`crates/improvise-ws-server/src/`, `crates/improvise-wasm-client/src/`, `web/`)
```
ws-server/registry.rs 595 / 14t Registry of Hosts keyed by DocumentId; ClientId {document,
session} — a SessionId is unique only within its document.
A document is never evicted while the server runs (ADR 001)
ws-server/host.rs 23 / 0t a re-export shim only. The session loop is
improvise-command/src/ui/host.rs (gotcha 45)
ws-server/origin.rs 132 / 6t OriginPolicy: which origins may open a socket. Loopback by
default, --allow-origin widens, absent allowed, two refused
(improvise-kc7n). The only decision transport::admitted makes
ws-server/transport.rs 324 / 4t tokio + tungstenite plumbing, plus the Origin check at the
handshake — a refused upgrade, not an ignored socket
ws-server/main.rs 190 / 5t CLI entry: serve a file on a port; --bind, --allow-origin
ws-server/tests/socket.rs 284 / 2t the one test that uses a real socket: handshake, JSON
framing, SessionId routing, which document a URL asked for
wasm-client/lib.rs 333 / 7t Client: the wasm-bindgen surface (init, on_message, on_frame,
viewport/set_viewport, render_model, set_cell_at, typed_cell)
wasm-client/keys.rs 187 / 5t BrowserKey ONLY — a KeyboardEvent flattened to an improvise
key. It does NOT resolve: `resolve` and KeyOutcome live in
improvise-protocol/src/resolve.rs, shared by both front ends
wasm-client/render.rs 888 / 12t RenderModel: editable / selected / cat tree, decided here
wasm-client/socket.rs 289 / 7t the fold (fold_frame/handle_frame), the two compositions
and typed() text→CellValue. Defines no enums
# state.rs is GONE — ClientState moved to improvise-protocol/src/client.rs
wasm-client/tests/browser_round_trip.rs 384 / 5t improvise-1ey's scope against a real Host
wasm-client/tests/lockstep.rs 1126 / 23t host and client agree on a keypress
web/app.js 496 string calls only, no protocol knowledge, no framework.
Run under Chromium by harness/tests/shell.spec.js — see
gotcha 48 for what that does and does not reach
web/worker.js 64 three string calls, no protocol knowledge. RUN by
shell.spec.js's no-?ws path, with bindings.rs (gotcha 45)
web/index.html, style.css the page; legibility only
web/build.sh 38 wasm-pack build → web/pkg (gitignored). Needs
wasm-opt = ["-O", "--enable-bulk-memory"] or the bundled
wasm-opt rejects LLVM's memory.copy
web/assemble.sh 120 assembles the deployable site (copies web/*.improv)
web/demo.improv 57 the document the site ships and seeds from. Every cell a
Record — asserted (gotcha 46)
```
### Browser worker host (`crates/improvise-worker-server/`)
```
improvise-worker-server/src/worker.rs 738 / 13t Worker: one port, one client; handshake, Addressed
filter, persist trigger (model_state.dirty). Every decision, every test
improvise-worker-server/src/vfs.rs 311 / 6t documents/open: seeding is a Storage::write
improvise-worker-server/src/bindings.rs 158 / 0t wasm32 shim: OpfsFs awaits + postMessage. No Rust test
reaches it, but harness/tests/shell.spec.js DOES: the
no-?ws path runs start()/OpfsFs::open under Chromium.
Called NEVER RUN here for months: see gotcha 45
improvise-worker-server/src/lib.rs 114 / 1t the module doc that states exactly what is and is not
covered — the one correct copy of that fact
improvise-worker-server/tests/post_message.rs 381 / 7t main-thread traffic, strings only
```
### Browser harness (`harness/`) — not a workspace member
```
harness/playwright.config.js 50 shell.spec.js runs on CHROMIUM ONLY (engine-independent
logic; routeWebSocket is slower and flakier across three).
opfs.spec.js runs chromium + firefox + webkit, and webkit
provides NO OPFS at all, so it skips on a runtime probe
harness/tests/shell.spec.js 165 the first execution of web/app.js. A change to web/*.js
belongs here (gotcha 48)
harness/tests/opfs.spec.js 79 drives the OPFS contract page and reads back its result
harness/opfs-contract/src/lib.rs 361 runs improvise_io::storage::contract::storage_contract
against a real OpfsFs inside a real Web Worker
harness/www/opfs/{index.html,worker.js} the page and worker that host it
harness/build.sh 35 wasm-pack build for the contract crate
# gated by the `browser` job in .github/workflows/ci.yml, which can fail the build
```
### Examples
```
examples/gen-grammar.rs Grammar-walking random .improv generator (pest_meta)
examples/pretty-print.rs Parse stdin, print formatted .improv
```
### Context docs
```
.claude/skills/code-review/ Review skill: checks a change against the doctrine in these docs + kb.pl
scripts/check-comment-runs.py The two-line comment cap AND --check-evidence. The flag is
ADDITIVE since improvise-ijql — every invocation that can
exit 0 answers the cap — guarded by a subprocess self-test,
the only level a dispatch bug is visible at. Reasoning:
context/code/tooling.md §2
scripts/check-ci-graph.py pages-deploy must `needs` every job in ci.yml. Carries its
own --self-test; run in the `test` job. README states the
rule and deliberately lists no jobs (improvise-x7cq)
scripts/assemble-stamp.sh The cache-bust stamp gate web/assemble.sh calls, extracted
so its refusals can be exercised without a web/pkg
scripts/assemble-stamp-test.sh Its check_the_checker, run first in the `build` CI job
context/code/tooling.md The repository's own checkers: why an evidence heading's
names come from cargo rather than from the source, and
why a leaf name is not an identity
context/test-evidence/tooling.md Evidence for the gates in scripts/ and for the tests that
hold CI's shape
scripts/nix-seed-flake-inputs.sh Seeds locked flake inputs over git so `nix develop` works in cloud sessions
prolog-sessions/kb.pl Executable knowledge base: invariants, doctrine, decisions (CI-checked)
context/adr/003-live-backed-models.md A model may be backed by a live query source —
improvise as a DB client. Accepted, NOT a priority.
context/adr/002-naming-a-cell.md How a client names a cell: an opaque surrogate on
CellDisplay, whose absence means the cell is an
aggregate. Records why _Index cannot serve.
context/adr/001-concurrent-edits.md Concurrent edits between clients: record-level atomicity,
optimistic basis check per batch, advisory reservations.
Records why a causal clock was rejected.
context/cloud-agents.md Cloud-session environment: tracker sync, bd pitfalls, what is blocked
context/design-principles.md Architectural principles & testing doctrine
context/plan.md Show HN launch plan
context/repo-map.md This file
docs/design-notes.md Product vision & non-goals
```
---
## Wasm
The browser bundle is `improvise-core`, `improvise-formula`, `improvise-io`,
`improvise-protocol`, `improvise-command`, `improvise-wasm-client` and
`improvise-worker-server`. All seven
`cargo check --target wasm32-unknown-unknown` clean with no feature flags
(improvise-ywd), gated by the `wasm` job in `.github/workflows/ci.yml`. That
job gates **eight** crates, not seven: `improvise-gp` was added (improvise-z5eg)
explicitly *not* as a bundle member but as a candidate, because staying
wasm-clean is free now and expensive to retrofit — so the bundle is seven and
the gate is eight, and neither number is the other one's typo.
**A feature that adds a dependency to a bundle crate must be checked with `cargo tree --edges normal`** — the default output includes **dev** edges, so a plain `cargo tree -p improvise-protocol --target wasm32-unknown-unknown` reports a dev-only `serde_json` as *present* and reads exactly like a leak. Check per-crate too, because the `wasm` CI job invokes each crate separately and feature unification cannot reach across that (improvise-26dm added a `test-support` feature on `improvise-protocol` on those terms; it is off in every shipped build).
`improvise-ws-server` is the host and is native only — tokio, tungstenite and
`Box<dyn Storage>` — so `improvise-wasm-client` cannot depend on it in anger. It
no longer needs to: both ends `pub use` `improvise_protocol::frame` and neither
defines a pair (gotcha 42). The **dev**-dependency remains, for
`tests/browser_round_trip.rs`, which hands the client's own serialized frame to
a real `Host::handle`. The root `improvise` crate is
the terminal binary and does **not** build for wasm — it depends on
crossterm; that is correct, not a gap.
`cargo check` passing is not the same as running. Two known runtime hazards
it cannot see: `Instant::now()` panics on wasm (hence `web-time` in
`improvise-command`), and any `std::fs` call compiles but fails in a browser
— see improvise-fuj for the two effects that still bypass `Storage`.
## CLI
```
improvise [model.improv] # open TUI (default)
improvise import data.csv [--no-wizard] [-o out] # import CSV/JSON
improvise cmd 'add-cat Region' -f model.improv # headless command(s)
improvise script setup.txt -f model.improv # run script file
```
Import flags: `--category`, `--measure`, `--time`, `--skip`, `--extract`, `--axis`, `--formula`, `--name`.
---
## Testing — the short version
Full guidance lives in `context/design-principles.md` §7. Quick reminders:
- **Two numbers, not one** — the ambiguity between them is what let the old
"suite runs in <2s" drift: **no single test binary over 2 seconds**, and the
workspace wall clock is the sum. Measured at `63a5c13`: **1464 tests over 35
binaries** (1 ignored), longest single binary **0.6–1.0s across two runs on one machine** —
the spread being its own argument for reading your own run. Excludes compilation,
and excludes `harness/` (Playwright, minutes, its own CI job). *Re-measure* rather
than re-assert — `cargo test --workspace` prints a `finished in` per binary, and
**the portable signal is the ratio, not the seconds**: one binary an order of
magnitude above the run's *median*. `improvise-io`'s lib was that outlier at 1.42s
on three machines and is not any more — improvise-8shu found the cost was a
`pest_meta` re-parse per generated case, not the walk it was blamed on, and a
`OnceLock` took it to 0.21s. The full argument is `context/design-principles.md` §7.
- Commands: build a `CmdContext`, call `execute`, assert on returned effects. No terminal needed.
- Property tests (`proptest`, default 256 cases) cover invariants: CellKey sort, axis consistency, save/load roundtrip, `parse(format(parse(generate())))` stability.
- **Bug-fix workflow**: write a failing test *before* the fix (regression guard). Record the bug in `context/test-evidence/`, **keyed by test name** — *not* in the test's doc comment, which the two-line comment cap forbids and `scripts/check-comment-runs.py` enforces. A heading may be module-qualified and must be when its leaf is not unique.
- Coverage target ~80% line/branch on logic code; skip ratatui render paths.
---
## Key dependencies
ratatui 0.30, crossterm 0.29, clap 4.6 (derive), serde/serde_json, indexmap 2, anyhow, chrono 0.4, pest + pest_derive, flate2 (gzip), csv, enum_dispatch. Dev: proptest, tempfile, pest_meta.