Files
improvise/context/code/protocol.md
T
Edward Langley c7b5b825e8 Merge gh/main (220abc61, #227+#272, #297) into fix/fdk1-coordinate-rename
Conflicts:
- client.rs accepted(): both settles, in `fold`'s order:
  settle_editor (fy9o/p6v6 awaited seed), settle_opened (fdk1 capture),
  then the revision bump.
- resolve.rs edit_or_drill: a Coordinate opens like a Record, through
  p6v6's `opened_on(state, ..)`, which waits while a write is unanswered.
- resolve.rs rename_coordinate: fy9o unwelded `clear-buffer` from the
  commit keys, so the rename clears its own buffer on the success path,
  after ChangeMode(Normal). It passes `advanced(.., writes)`, where
  `writes` is whether the frame composed the rename. The waiting decline
  still returns before any clear (the joed constraint).
- resolve.rs reopened_at: the decline arm first (Coordinate { .. }), then
  `awaiting_seed` after a write, else `opened_on(state, ..)`.
- lockstep.rs: both sides' new tests kept.
- context/test-evidence/clients.md: both sides kept.
- browser_round_trip `a_second_rename_after_a_reorder_renames_the_row_the_host_shows`:
  the reopened editor is now seeded from the answered cell. The test
  asserts that seed, then clears it before typing. Evidence amended.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-24 02:07:26 -07:00

132 KiB
Raw Blame History

improvise-protocol — the wire vocabulary and the portable session slice

The arguments that used to live in this crate's comments (improvise-9c8c). Written for a reader who has the code open. Nothing here restates what the code plainly says; everything here is something somebody got wrong first.

The crate crosses a process boundary, so it depends on improvise-core and serde and nothing else — no terminal backend, no command layer, no IO. improvise-command depends on this, never the reverse, and that direction is what lets a client hold the view half without pulling in the host. boundary_tests::the_wire_types_need_nothing_but_core_and_serde is the part the compiler can hold; the CI wasm job is the other half, since a crate that pulled in crossterm would stop building for wasm32.

Three things live here and it is worth being precise about which travels:

  • ViewState (lib.rs) is a projection of the command stream, not a wire message. The server keeps a mirror of each client's view state by applying the same commands the client applies, through the shared reduce_view. It is serialized at exactly one point — the bootstrap Snapshot (improvise-q08). Steady-state traffic is commands only.
  • Command (reduce.rs) is the wire format, in both directions: user intent client→server, Projections server→client.
  • ClientFrame/ServerFrame (frame.rs) is the framing rather than the vocabulary: which of the above a given JSON blob is.

1. lib.rs — the portable slice

What may live on ViewState

Two properties gate membership, and both are load-bearing (improvise-h0b):

  • portable — plain data from the allowlisted crates, no shared-ownership containers. The ViewState: Send static assertion is the compile-time proxy (Rc is not Send); the Deserialize derive rejects it too, serde's rc feature being off. Send does not catch the origin-crate or snapshot-safety halves — check those by hand.
  • snapshot-safe — a fresh client inheriting the value is correct. Staged uncommitted writes and half-open dialogs are not.

State failing either test lives on App instead, under the session-local residue tag: wizard (an improvise-io type, and a half-open dialog is not something to inherit) and drill_state (holds an Rc, and pending_edits are uncommitted writes belonging to whoever staged them).

The constraint this puts on new fields: 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. That is why MinibufferConfig::buffer_key is a String and not a &'static str (improvise-a2x).

ViewCursor and the cursors map

Per-client (improvise-8uz). It used to live on View, inside the shared workbook, which meant one client scrolling moved everyone's viewport. View no longer carries it at all — nothing persisted it, so there was no saved-position behaviour to preserve. The map is keyed by view name so that switching views restores where you were in each one; an absent key means the origin.

ViewState::active_view is the second copy, deliberately

Workbook::active_view is the document's; ViewState::active_view is the client's, and it is what keys cursors. The second copy exists because reduce_view runs on a client that holds no Workbook (improvise-cqi). The host keeps the two in step; whether they are allowed to diverge — two clients on different views — is improvise-g0f. See repo-map gotchas 29/36.

AppMode::from_name — not every variant is nameable

The list is exactly what improvise-command's registry accepted before the move, carried across unchanged, because changing it here changes what enter-mode accepts in a script. It lives beside AppMode rather than in the registry because the names are part of the keymap vocabulary and the keymap is shared: a browser resolving enter-mode must decode the same spellings, and two tables would be the mirror that move existed to delete.

Three variants are absent, for two reasons. ItemAdd resolves its target category from the runtime mode and never from a script line (gotcha 20), so naming it would produce a mode with nothing to add to. Quit and ImportWizard are entered by their own commands — quit and start-import-wizard — which do work beyond changing the mode, so enter-mode would be a way to reach half of each.

AppMode::FormulaEdit carries what it will commit to (improvise-km71)

FormulaEdit { minibuf, slot } is one mode for two editors. slot is FormulaSlot::New for a/n/o (enter-mode formula-edit, which from_name still builds) or FormulaSlot::Existing { id, was } for edit-formula-at-cursor. It is one variant and not a second AppMode because the keys, the buffer and the panel are identical: a second variant would add a ModeKey, a keymap and a hint for no difference in what the keys do. The slot is ItemAdd's category again, a capture made when the mode is entered. Like ItemAdd it is not nameable from from_name, since a script line has no formula to open. It must be portable and snapshot-safe (§1 above). FormulaId is plain serde data, and a fresh client that inherits an open edit inherits exactly the claim the editor made. The commit re-checks that claim.

Panel

Panel::ALL/name()/key() drive the toggle keys for Normal and all three panel keymaps, so a fourth panel reaches every keymap from one ALL entry. name() must spell what parse_panel accepts. Panel::mode() is the inverse of AppMode::panel() for the three top-level panel modes only — the sub-modes (FormulaEdit, CategoryAdd, ItemAdd) map back to their panel but not forwards, which is what keeps a panel's styling active while the user types into its minibuffer.

AppMode::hint_text() is checked against the keymaps by every_hint_key_resolves_in_its_mode in improvise-command; Quit alone shows nothing, because it is never rendered.

all() walks a cycle, and its totality is tested rather than structural

AppMode::all() and Rejection::all() walk a total, cyclic after(). Both doc comments used to claim a new variant could not be omitted, because after() has no None arm. That is half true and the wrong half. Answering after() for a new variant is compiler-forced; pointing an existing arm at it is not, so a variant nothing names is simply never walked — all().len() came back 17 where the enum had 18, with the suite green (improvise-5ubs). The general statement is doctrine(a_list_checked_for_length_reads_like_a_list_checked_for_completeness): an assert_eq!(cases.len(), 13) catches a case being deleted from a list and never one being absent from it, and the two are written identically.

Closing it needs an enumeration independent of after(), and without one the ticket's own first option restates the problem. There is no such enumeration in stable Rust for an enum with data — variant_count is nightly, a derive macro means a new proc-macro crate, and strum's EnumIter cannot construct these variants at all, since AppMode::ItemAdd carries a category name and Editing a whole MinibufferConfig. What there is is serde: the Deserialize derive already builds the complete variant-name list, from the enum definition, maintained by the compiler, and publishes it in its unknown variant … expected one of … message. variants.rs reads it back and diffs it against the names the cycle actually reached.

Three things this costs, and they are the reason the module is defensive rather than three lines. The message is parsed, so a serde whose wording changes must fail loudly instead of reporting an empty list that passes every cycle — hence the unknown variant prefix assertion and the names.len() > 1 floor. Both live in parse_expected, a seam separated from the deserialization because a should_panic pointed at a real type reaches only one of the three refusals and all three then shared one panic message, so expected = … could not say which had fired (PR #92 review). Each now carries its own wording and is fed a hand-written mutated message; a happy-path case keeps the three refusals from being the only evidence the parser runs. A variant is identified by its own serialization rather than by Debug, because an externally tagged unit variant is a string and a struct variant a one-key object, and reading a name off Debug would depend on the field list. And it checks only that every declared variant is reached: an after() that is not injective — two arms naming one successor — is a different defect this does not see, and would show up as a shorter cycle only if it also orphaned something.

What it does not reach: the fourth instance of this shape, layout_geometry_effects_actually_change_document_config in improvise-command/src/ui/effect.rs, whose assert_eq!(cases.len(), 13) is over a hand-built list of effects, not enum variants, so serde has nothing to say about it. Filed as improvise-26dm rather than bodged, because a fourth hand-rolled attempt in a fourth place is the disease.


2. reduce.rs — the wire vocabulary

Derived, not invented

Command is not a fresh invention (doctrine(derive_the_enum_from_the_effects)):

  • The view half is every EffectKind::ViewOnly effect in improvise-command whose target is the portable slice (decision(improvise-cqi, command_set_derived_not_invented)). Effects touching host-local state (the import wizard, staged drill edits, the storage backend, the layout cache) are deliberately absent.
  • The model half is ModelCommand: every EffectKind::Model(..) effect, one variant apiece, carrying that effect's own fields (improvise-kmgs).

The count is 28 model effects, minus two: ApplyAndClearDrill and AcceptWizardModel, which are not addressable (improvise-3x3u). That leaves 26: 24, plus ReplaceFormula (improvise-km71) and RenameRecordCoordinate (improvise-fdk1). SwitchView, ViewBack and ViewForward are no longer model effects at all (improvise-jytm), so they are not subtracted. This paragraph said "29 minus three, 26" while the enum had 24, and each of those two PRs then corrected it to 25 without seeing the other. Count by brace-matching the impl Effect blocks, as below. A variant with no data mirrors a nullary effect whose operand is document state the authoritative App already holds.

Two derivation traps worth keeping:

  • 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)).
  • improvise-kmgs derived the set one effect at a time, which was correct per element and wrong for a set: it put SetCell on the wire without its MarkDirty companion, so "save if dirty" never fired (improvise-gv9r). MarkDirty is gone; App::apply_effects now raises the flag off EffectKind::Model(_) directly.

The derivation runs backwards too, and ClearBuffer was the proof

A derivation checked only forwards — does every effect have a command — misses the inverse: a command with no effect. Command::ClearBuffer(String) was one. Nothing in improvise-command is named ClearBuffer; the host's clear-buffer command composes core::write_buffer(name, String::new()), which is effect::SetBuffer and therefore inserts, and resolve.rs's write_buffer composes the same Command::SetBuffer. The wire variant removed the key instead. So the one behaviour both ends actually implement disagreed with the variant that names it (improvise-m0sx).

It was deleted rather than given an effect, for three reasons.

  • It is not in the derivation's image. Adding a ClearBuffer effect to justify the variant reverses the rule — the wire would become the source of truth for host semantics, which is the direction decision(improvise-cqi, command_set_derived_not_invented) exists to forbid.
  • It bought nothing. Both readers of the map — cmd::core::read_buffer and resolve.rs's reader — end in unwrap_or_default(), so an absent key and an empty one are indistinguishable to every reader. The only observable difference was ViewState equality between a folded client and a snapshot-seeded one, and deleting the variant settles that equally, by leaving exactly one way to clear.
  • Nothing composed it. Its only three occurrences were the definition, the fold arm, and two of reduce.rs's own tests — which pinned the divergent behaviour, asserting the key was absent. A test can hold a defect in place.

A variant nothing composes is either dead or a promise the protocol does not keep, and that is the hazard the deletion generalises: vocabulary_is_composed.rs reads the variant list off serde's derive — the one enumeration the compiler maintains — and requires each name to be constructed somewhere in non-test source, or to appear in a short NOT_COMPOSED list with its verdict. The list is checked in both directions, so an entry that starts being composed fails just as loudly as one that stops (doctrine(a_stale_list_of_what_is_missing_is_worse_than_no_list)).

The check is a source scan, and what counts as source has one home: improvise_protocol::source_scan::production_code, shared with the forward guard below (improvise-c7mu). It removes three things a scan must not see.

  • Comments and literals. Both are blanked, and newlines are kept. Before this, a // TODO: Command::SetYanked(...) left where the real composer had been deleted still passed all four tests. That is the weaker direction, the one a scan weakness was supposed to be unable to go. It was live in a small way: a /// in project.rs named Command::SwitchView, which has real composers, so no verdict was wrong yet. It also had a mirror: a comment explaining that a NOT_COMPOSED variant is held back would have raised a false "stale entry" alarm.

  • Test items inside a file. Each test-only attribute's item is bounded by balanced brackets on the blanked code (improvise-59q8). It ends at a ; at depth zero, or at the } closing its body unless ;, ., ? or else follows. The first version was a line scan that relied on rustfmt putting an item's closing brace at the attribute's own indentation. The #172 review found three shapes it never bounded:

    • an item ending in ]; or );;
    • a #[cfg(test)] statement spanning lines;
    • a one-line #[cfg(test)] use x;.

    In each case the scan read on to the end of the file and hid every line of production code after it, which is the silent direction for the forward guard. An item the scan cannot bound is now a panic, never a scan to EOF.

    Which cfg is test-only is evaluated, not substring-matched. The predicate is read off the file's text, because a feature value is a literal and blanked in the code. It is evaluated in three-valued logic for a build without test: test is false, and so is feature = "test-support", which this repo never ships (improvise-26dm). Everything else a scan cannot know, such as a target or an ordinary feature, is unknown. An item is test-only when its predicate is false. So cfg(all(test, not(target_arch = "wasm32"))) is test code, which the old "any line containing not(" rule got wrong. cfg(any(test, feature = "serde")) and cfg(not(test)) are production. An inner #![cfg(test)] heading a file makes the whole file test code. One appearing anywhere else is a panic, because this scan cannot bound an inline module's inner attribute.

  • Test-only files. A #[cfg(test)] mod x; makes x.rs and everything under x/ test code, even though nothing inside the file says so. Before improvise-c7mu, this checker's own file and improvise-io's roundtrip_tests.rs were scanned as production.

One limit remains. A Command::X inside matches! or if let is read as a construction rather than a pattern. That can only make the check weaker for that variant; it never raises a false alarm. A variant nothing composes at all is immune, since its only pattern occurrence is its own fold arm, which => identifies.

And forwards: every effect is on the wire or has a verdict (improvise-h3y7)

vocabulary_is_composed.rs checks that the vocabulary does not name more than production composes. The other direction is does the vocabulary name everything it should? That was the direction ViewBack and ViewForward failed: they were missing, and no client had a back history. Until improvise-h3y7 nothing checked it, and the audit that finally ran it by hand under-reported. Brace-matching impl Effect for blocks misses the eight simple_effect! expansions and SetTransientPrefix in command/keymap.rs. All nine are ViewOnly, which is the half the ViewBack bug was in.

improvise-command/src/ui/reduce/vocabulary_is_complete.rs is that direction. It scans the crate's src/ for both ways an effect is declared, reading each name past any attributes and doc comments before it. The macro accepts those, and missing them hid effects (improvise-94x2). It requires each name to be a Command or ModelCommand variant (read off serde's derive) or to appear in NOT_ON_THE_WIRE with a verdict. A verdict is one of four kinds, and the check verifies each kind:

  • Command(v): on the wire under another name (HelpPageNext/Prev/Set → Command::HelpPage). v must be a real variant.
  • Frame(v): answered by a ClientFrame that is not a Command (Save → ClientFrame::Save, gotcha 45). v must be a real frame.
  • Off(why): excluded on purpose, with the argument: session-local residue (gotcha 30), host-local caches, or batch-relative control flow.
  • Gap(ticket): missing, and a ticket owns closing it (SaveAs, ExportCsv → improvise-2mqz).

The list is also checked in reverse. An entry that is now on the wire fails, and so does an entry that names no effect. The scan is production_code, so the comments, strings and test fakes that name an effect do not count as effects. If they did, a stale verdict could outlive the effect it names.

What it does not check is whether a same-named pair agrees: whether reduce_view's arm does what the effect does. The name match is the completeness half. The semantic half stays a per-pair read (improvise-h3y7's notes put it at about 21 pairs). The resolver lists in resolve.rs (declined() and the host-only names) are also not checked against the vocabulary yet. The audit found them stale in three places.

No ModelCommand field is a position (improvise-hkbc)

improvise-6nsd closed "a stale layout tier misdirects a mutation" on one condition: no ModelCommand carries a row or column index. position_free in reduce.rs's tests enforces that at compile time. It destructures every variant with no .., down into RecordCell, and hands each field to a function that accepts only a name, a path, a surrogate, a value or an axis. So a new variant, a new field, or a field retyped to usize fails to build, and the build failure forces the ADR conversation. It cannot see a position spelled as a String. It also does not make the client position-free: improvise-2wu3 resolves a painted (row, col) against the current cache before it ever builds a command.

Why two model effects are key-path only

A command on this wire is routed by Sessions::apply with no SessionId, which is correct precisely when the change is addressed to no one session (gotcha 36). ApplyAndClearDrill and AcceptWizardModel fail that test: their operands — drill_state.pending_edits and the built wizard workbook — are session-local residue, private to whoever staged them (improvise-tom1). Unaddressed, client B's ApplyAndClearDrill commits client A's uncommitted typing, and B's AcceptWizardModel replaces the document out of a dialogue B never opened. Both were demonstrated against Sessions::apply before removal.

Portability of the command was the criterion when they were kept; addressability is the criterion that matters. Not permanent: multi-client drill wants the addressed form (the variant carries a SessionId, the staging map moves onto the session), which is where ADR 001 layer 2 goes anyway.

The view stack is the client's, and the derivation already said so

Two user rulings, 2026-09-21: "The state drill manipulates (the view stack) should be local to the client" and "Drilling should be an ephemeral view, not something that gets persisted." The first one closed a gap in the derivation above rather than widening it (improvise-gd28).

effect::ViewBack and effect::ViewForward are EffectKind::ViewOnly, and every field they write — active_view, view_back_stack, view_forward_stack, mode — is in the portable slice. By the view half's own rule they were always Commands; they were simply missing. So are they now, alongside SwitchView.

What that exposed is that the fold and the effect disagreed. effect::SwitchView pushes the view it is leaving and clears the forward stack; reduce_view's arm set active_view and nothing else. A mirror therefore never recorded any history, and neither did any client — which is the real reason view-back was unavailable in a browser. The declined() comment blamed the drill commit, and that was a second, separable reason: it matters only for a client that stages drill edits, and the browser does not. With the fold repaired, the decline had no reason left standing, and improvise-cfly removed it together with the comment — leaving a false reason behind being worse than leaving the refusal.

reduce_view now mirrors all three effects line for line (doctrine(mirror_the_cmd_line_for_line)), with the direction carried as a Rewind enum rather than a bool so neither arm can read the other's stack.

A frame is somewhere you can go back to

The case a client-local stack creates. A ViewFrame names a view, and another client can delete it — so ViewState::forget_view drops every frame naming a view that has gone, and it is one home: effect::DeleteView calls it for the host, and Command::ForgetView carries it to every client.

Which views vanished is observed, by diffing the document's view-name set across apply_command, never predicted from the command variant — the reason adopt_cursor_epoch watches a counter (gotcha 24) and apply_model watches document_generation (gotcha 39). DeleteView is not the only effect that can drop a view, and a table of variants would be a second description free to disagree with the first.

The audience is ToAll. Any client may have been there, the asker included, and a client that was not folds a no-op. Note this is a different question from adopt_deleted_views, which moves the mirrors sitting on the deleted view: a client that merely visited it is sitting somewhere else entirely, so neither subsumes the other.

A guard was written and then deleted, and that is the part to keep. The first attempt answered this in Sessions::apply_step, correcting a dead landing the way apply_switch corrects a refused switch. It was wrong in a way two passing injections did not show: the correction folds a SwitchView, which pushes the dead view back onto the stack, so < popped it, was corrected, and pushed it again — a back button that never progresses. Pruning makes the dead frame unrepresentable instead, which left the guard no reachable input, so it went: a guard whose fault cannot be injected is indistinguishable from one that always passes (doctrine(check_the_checker)).

Command::SwitchView has a foot in both halves

The SwitchView effect wears EffectKind::Model(LayoutGeometry), so the model-half derivation lands on it too — and ModelCommand deliberately does not name it a second time (improvise-kmgs). ModelCommand::ViewBack and ViewForward also move the active view and so carry the same document half.

Command::ResetCursor is an invalidation, never a position

Derived from App::reset_cursor, the write TransposeAxes and CycleAxis make after changing the grid's shape (gotcha 24). It is a variant rather than a SetSelected plus two offsets for a reason that is not brevity: what the server says is "your cursor is no longer valid", not "be here". Where a client then puts its cursor is its own business — the model is shared, the UI is not — so broadcasting one client's position to everyone would be the wrong message even though origin happens to be where each of them lands today (improvise-gvnb).

Command::Model is a struct variant

So the basis cannot be forgotten. ADR 001's check is worth nothing if a batch may arrive without one, and the only enforceable version of "you must also state X" is not being able to build the message without X. There is no Basis::default() for the same reason — see §5.

Note what the struct variant does not buy: it closed omission from the message and did nothing to close ignoring on arrival, which is the half a struct variant is usually reached for. That hole needed a type — Checked/CheckedBatch in improvise-command (improvise-ondj).

ModelCommand::SetCell takes a RecordCell and nothing else

A CellKey here would require the client to know the coordinate space — every row and column category plus the page and filter selections (gotcha 27), and in records mode the _Dim column-index convention (gotcha 1) — which is precisely the knowledge the server exists to own. The surrogate is a token to echo back; and because it is a record identity, a client holding one for an aggregate cell cannot build this variant at all (ADR 002).

LoadModel and ImportJsonHeadless name paths in the host's Storage, never the client's filesystem (gotcha 2).

The variants are grouped by the ModelChange level their effect declares, because that level is what Dirty::of turns into projections (gotcha 35). Adding a variant here means classifying its effect there.

reduce_view

The same function runs on both ends — the client folds it to draw, the server folds it over its mirror — so the two cannot drift (improvise-cqq, improvise-m91). Total and deterministic: no IO, no clock, no failure.

Command::Project and Command::Model are both no-ops in it, for different reasons. A projection is routed to one widget's WidgetCache instead. A model command is the document's business: what a client must fold arrives as the projections it dirtied, plus — when the active view moved — the SwitchView the host broadcasts.

A ModelCommand does not mutate anything by itself: improvise-command's model_effects maps it back to the effect it came from and runs it through the same App::apply_effects the key path uses. Effects stay the only mutation site, and the Dirty is the effect's own EffectKind — never a second classification beside gotcha 35's table.


3. render.rs — the fold

There is no aggregate render cache

Each widget owns its own cache type and folds its own stream of projections (improvise-rkeq): the grid does not learn that a formula was deleted, and the formula panel does not learn that a cell changed. A Projection is one such update, addressed to exactly one widget, and WidgetCache::apply is the fold.

The overlap between TileBarCache and CategoryTreeCache — both carry (category, axis) — is deliberate, so neither is woken by the other's changes. Do not "fix" it by sharing a variant.

WidgetCache::apply is total and deterministic, like reduce_view: an update naming something the cache does not have is dropped, never a panic. Projections race on the wire, and a client that crashes on a stale index is a client that crashes.

Projection::Basis is not a widget — nothing draws it — but it is folded on exactly the same terms; see §5.

DocumentCache — a widget, not a ViewState field (improvise-z754)

draw_title read workbook.model.name, model_state.dirty and model_state.file_path. None was projected, so a browser could not draw a title bar at all.

A projection rather than a ViewState field, and the argument is gotcha 30's membership test read backwards. ViewState is the portable session slice: per-client UI a client folds its own commands into. These three are facts about the document, shared by every client and writable only by the server — on ViewState they would be document state wearing a session slice's clothes, and reduce_view would need commands nothing composes. The per-widget cache is exactly the shape for "a local copy of what a widget draws, replaced by a Projection".

It is the one widget no EffectKind can gate. dirty is raised by any EffectKind::Model(_) in apply_effects and cleared by effect::Save, which is ViewOnly (gotcha 3) and correctly so. SaveAs moves file_path and is also ViewOnly. So Dirty::of reports NOTHING for exactly the effects that change this widget most visibly, and gating it on Dirty would leave every client showing a [+] for a document that has been saved — the forever-wrong half of gotcha 35. ClientSession::document_update therefore observes: it builds the desired cache and diffs it against the mirror, the same answer record_versions got for the same reason (gotcha 40), and the same reason cursor_epoch beats matching on CycleAxis. The diff, not a flag, is what keeps an ungated widget from projecting on every batch.

A save is projected, not silent. Host::save ends with project_all() and the worker re-projects after its own persist(), because both clear model_state.dirty outside any batch Dirty describes. gotcha 45's "answered silently on success — nothing moved, so no widget is dirty" was true until this cache existed and is now exactly wrong: the title bar moved. A test that reaches for Sessions::project_all by hand proves nothing here, because that is the call the production paths omitted.

stored_as is a name, never a path. The TUI already drew only file_name(), and a socket server projecting file_path would hand a remote client the host's directory layout — useless to it and a leak. What a client can act on is whether the document has a home at all, which is the fact ClientFrame::Save fails on (NoDocumentPath, gotcha 45), so None carries it. A browser's answer is its OPFS entry name; neither end has to know which.

CellId: the kind of identity is the permission

SetCell/ClearCell take a RecordCell and nothing else, Drill takes an AggregateHash and nothing else. A client cannot compose a write against an aggregate or a drill against a stored cell — not because either is checked, but because neither carries an id of the other's type (design-principles §3). It projects a fork the model already makes: EditOrDrill branches on exactly this in the TUI today.

An earlier draft made this Option<CellId>, absence meaning aggregate. The enum is better: an aggregate is not the absence of an identity, it is a different kind of identity — and modelling it as None left Drill with nothing to address.

A RecordCell is (record hash, measure) because the surrogate names the record, not the cell (ADR 002). That is what lets one identity serve both addressing and staleness: record_versions is keyed by the same hash, so a client's own token is the lookup key for its own version, with no CellKey rebuilt on the hot path. measure is absent for a record carrying no _Measure coordinate at all — the shape add-record-row seeds.

CellId::Derived — a formula owns the cell (improvise-ir7l)

A formula target is a real stored coordinate, so the record branch claimed it and every client offered an editor over it. The write landed and was then shadowed forever, because evaluate_aggregated reads formula_cache before raw data — not a revert, a permanent silent shadow.

The fix keeps ADR 002's discipline rather than adding a second answer beside it: a formula-owned cell projects a different kind of identity, so the write is unconstructible instead of flagged. Derived carries the formula's target and no surrogate, so there is nothing a client could spend on a SetCell; that is the same reason Aggregate carries an AggregateHash and not a RecordHash.

In records mode the question is asked of the record's real coordinate. The synthetic (_Index, _Dim) key names no measure, so formula_for can never match it; GridLayout::record_cell_key is the one mapping from a records position to a model cell, and both cell_id and CmdContext::owning_formula go through it. This is not defensive: a measure can be both an explicit _Measure item and a formula target, and the records + column (improvise-poia) is a one-keystroke way to build one.

Two orderings matter in project::cell_id. Derived is asked before the record branch, since the coordinate is stored and the record branch would otherwise win. It is asked after the aggregate branch, because a formula cell that also aggregates is still drillable (gotcha 10) — Aggregate and Derived are not alternatives for the same cell, and reversing the two turns a drillable cell into a dead one.

The predicate itself is Model::formula_for(&CellKey) in improvise-core, the one home of "is this cell derived". It already existed three times inside model/types.rs as an open-coded loop; the TUI's EditOrDrill and the producer now call the same function rather than each spelling the rule. The TUI had to change in the same breath: it re-derives from the Model it holds, which is legitimate (it is the authority), but it must give the same answer or the two front ends diverge — design-principles §5.

CellId::Create — the column writes no cell (improvise-poia)

The two records-mode creating columns — + and +cat (gotcha 1) — projected None, which means "no projected cell". So a client could not tell a column that creates something from a padded slot, and gotcha 42's conflation had a third instance.

The ticket offered two designs and both assumed a cell write. They do not happen: commit.rs::add_measure emits exactly one effect, AddItem { category: "_Measure", item: name }, and add_category emits AddCategory(name) and deliberately writes no coordinate on the current row (improvise-2zq). The record under the cursor is not part of the operation, and neither is any value — what the user types is a name. So there is no "distinct command carrying the record surrogate plus the new measure name" to invent, and no two-command flow to sequence: one existing ModelCommand does it, and the address it needs is the name.

What was missing was therefore never an address but a recognition. Create carries the kind and nothing else, because the kind plus the typed name is the whole operand. It is not a SetCell target, so render.rs::editable stays Record-only and record_at_cursor still answers None — both by matching, which the compiler now forces.

No client composes it yet, and that is one gap rather than this one: keys.rs::commit returns early for every records-mode commit, because a category column renames every measure cell in the row and that fork needs record_col. The creating columns do not need it, so the early return is broader than its reason — improvise-gcvb, which also records the two things that make it more than a one-liner: an inserted +cat column means the TUI does not advance, and AddCategory can abort.

CellId::Coordinate — a drawn coordinate is not "no cell" (improvise-exdg)

A records-mode coordinate cell (the Region column holding North) used to project None. That was a third meaning of None, next to the padded slot and the blanked position: a visible cell that holds text and whose write belongs to the host. So absence was encoding a permission, the same conflation Create removed for the creating columns (improvise-poia). The pinning test, a_coordinate_column_is_projected_with_no_identity, asserted the bug and is gone.

Since improvise-fdk1 it carries { record, category }, the operands of ModelCommand::RenameRecordCoordinate. Editing a coordinate renames it across every measure cell in the row (gotcha 1). When exdg landed, no client composed that operation, so the variant was left empty on the grounds that the operand belongs with the command that consumes it. That command now exists, and both operands come from the projection:

  • record is the row's own record. A client could borrow the hash from a sibling Record cell in the same row. That would be a second derivation of which cells share a record, which is what ADR 002 forbids. It would also have nothing to borrow in a document with no measures, where the coordinate is the only cell a row has. For the same reason published_basis counts a Coordinate's record, or a rename of such a row would be RecordUnstated.
  • category is the column's identity. Its label is not: a category and a measure may share a name (gotcha 1), so reading the category off the column label would be a guess.

RenameRecordCoordinate is position-free (improvise-hkbc): it names the record by surrogate and the new coordinate by value. records_named names the record, because a rename moves it (gotcha 43). Creating the item is internal to the effect. render.rs::editable still answers false for a coordinate: the page's inline editor writes stored cells only, and the rename's producer is the keyboard path (context/code/clients.md). typed_cell_command, record_at_cursor and record_cell_at_cursor still answer None for it, so paste onto a coordinate is still improvise-kkcy.

CellDisplay::id — None is not "aggregate"

That is Some(CellId::Aggregate(..)), and the distinction is the whole point of the enum. None means the slot holds no projected cell: a GridCache position padded by fit_to_labels, or one blanked by a reframing. Every records-mode column is some kind of cell (Record, Derived, Create or Coordinate). Conflating the two either hides a cell or offers a write the type system exists to make unconstructible.

CellDisplay::edit_text — the difference corrupts data

What an editor opened on this cell should start with: the value as the user last typed it, not as it is drawn. text has been through format_f64, so a stored 1200 is drawn 1,200 under a comma-grouping format; seeding an editor with that and committing it parses as CellValue::Text("1,200"), and the number is gone. Display rounding is view-only and must never be fed back into the model (design-principles §4) — and an editor's seed is exactly a feedback path.

It is the projection of App::display_value_at_cursor, which is what EditOrDrill seeds the TUI's buffer from, so both front ends start from one string rather than each deriving one. None where there is nothing to edit: positions whose id is None, plus pivot cells with no key. A records-mode cell always carries one, even an empty one. An error cell carries none, because its text is a diagnostic and not a value.

A browser found this: tests/lockstep.rs drove i through the host and the client over the same grid, and they disagreed by a comma.

It is the value's exact spelling, not its Display (improvise-umi4). CellValue's Display writes a non-whole number {:.4}, so this field carried 1.2346 for a stored 1.23456789, and i then Enter with nothing typed stored the rounded number in both front ends. Every arm that seeds an editor now goes through CellValue::edit_text (core.md §17). That is the shortest decimal that parses back to the same f64.

An untouched editor writes nothing (improvise-ixxx)

An exact seed is not enough, because the commit reclassifies it. typed() reads text, not the cell it came from, so i then Enter on Text("007") stored Number(7). A raw CellValue::Error has the edit text ERR:msg, and the same keys stored it as Text("ERR:msg"). After umi4 the seed differs from the stored value only when typed(edit_text(v)) != v. In every such case the right answer is to keep v, which is what an untouched commit means.

So a commit whose text is the seed the editor captured as it opened writes nothing. resolve::untouched(Option<&OpenedEditor>, &str) is the one home for that, beside typed(). Every commit path reaches it:

  • TUI, pivot and records. CommitAndAdvance carries opened, which commit_at_cursor takes from CmdContext::opened_editor. It skips only commit_cell_value: the mode change, the cursor restore, the advance and the re-entry all still run, so records Tab still advances. The script form (commit-cell-edit <mode> <value> <coords>) carries None and always writes, since no editor was opened. The drill's staging goes through the same commit_cell_value, so it is covered too.
  • Browser keyboard. resolve::commit → written, which returns an empty write for an untouched buffer and leaves ChangeMode and advanced alone.
  • Browser mouse. The seed is a field of #188's EditTarget (opened), captured by edit_target_at from the cache's edit_text beside the record and its version. typed_target_frame starts with the untouched check, and the page passes no seed (typed_cell(target, text)). The <input> shows edit_seed(target), so what the page shows and what it compares against are one capture (improvise-ugj4).

The seed is captured at open, never recomputed at commit. Where the editor opened is where OpenedEditor is written. For the TUI that is EditOrDrill and EnterEditAtCursor. For the browser it is resolve::opened_on, which edit_or_drill and advanced share, and ClientState::settle_editor for an editor that arrived without its buffer. For the mouse it is edit_target_at. The reason is concurrency. If another client writes the cell while this editor is open, a seed recomputed at commit has moved to the new value. The untouched buffer then differs from it, so the stale text gets written over the other client's value. The browser's basis claim would not stop that write, because it is taken when the commit is composed. Comparing against the captured seed avoids that lost update as well.

OpenedEditor lives on ViewState (opened_editor) and travels as Command::SetOpenedEditor, with the ViewOnly effect of the same name. It is a struct on purpose. What else an open editor must capture, namely which record it edits (improvise-2wu3) and at what version (improvise-0j0f), belongs in the same object. EnterMode into an editing mode writes None, because an editor opened that way has no seed and must not inherit an earlier one.

Untouched means not edited, not merely equal (improvise-2054). Comparing content alone dropped a deliberate write. Open on 7, let another client write 5, then press Backspace and 7: the buffer spelled the seed again, so the commit wrote nothing and the user was not told. OpenedEditor therefore carries touched, and untouched requires !touched && text == seed. The edited write is then composed, and if the cell moved, the basis check (improvise-0j0f) refuses it loudly rather than dropping it silently.

resolve::edited(opened, buffer) is the one rule for what marks an editor. It is an edit of the editor's own buffer, edit, by append-char or pop-char. AppendChar/PopChar in the TUI and the browser's two arms each call it and emit SetOpenedEditor once, on the first edit. Two things deliberately do not mark it:

  • A key into another buffer. Typing :enter-mode editing goes into command. If that marked the stale editor, it would hide the stale-seed case the EnterMode guard exists for, and no test could see the guard.
  • clear-buffer. Esc and the post-commit clear are not the user's edits. If they marked the editor, the guard above would again be redundant and unpinnable.

The comparison stays exact: whitespace is content, and nothing trims.

The mouse editor reports its first edit. Its edits happen in a DOM <input> that Rust never sees, so the page's input listener, registered { once: true }, swaps its target for edited_target(target), which is EditTarget::edited: the same capture with touched set (improvise-jibp). Before that, the mouse path compared content alone, so the 2054 case survived there. The <input> used to be seeded from the painted editText while EditTarget captured the cache. repaint returns early while an editor is open, so a paint that went stale then showed old text against a newer seed and version (improvise-ugj4). The page now seeds from edit_seed(target), and RenderCell no longer carries edit_text, so the paint cannot be asked.

This does not change classification. 007 typed fresh is still Number(7), and a_touched_browser_commit_still_writes pins that.

numeric and error are decided by the server for the same reason: it is the side that knows which it was, and re-deriving client-side is how the two ends drift apart. CellDisplay is deliberately not drawn from a CellValue — by the time a cell reaches a client the number formatting has already happened, and text is what a renderer paints.

CellDisplay::value — what yank copies (improvise-dsxq)

The value the host evaluated at this position, the same Option<CellValue> that text was formatted from: evaluate_aggregated over the view's none_cats in a pivot grid, records_value in a records grid. It exists because the browser's yy has to copy what the TUI's YankCell copies, and neither string on the cell can be turned back into it:

  • edit_text was CellValue::to_string(), which writes a non-whole number {:.4}, so 1.23456789 became 1.2346. improvise-umi4 has since made it exact, but the next bullet still holds.
  • Re-parsing any text with typed() turns number-like text into a number — Text("007") becomes Number(7.0).
  • In a pivot grid edit_text is the stored cell (get_cell), so an aggregate or a formula cell has none and yanked nothing at all.

So the value is projected, not re-derived (gotcha 47): a client that wants the value asks for the value. It is kept separate from edit_text rather than replacing it, because the two answer different questions — what an editor starts with is a string, deliberately the one the TUI's buffer holds. (That string was itself lossy until improvise-umi4. The fix changed what the TUI's own editor shows, which was the point.)

In a records grid the TUI does not yank this value: YankCell and PasteCell resolve the synthetic (_Index, _Dim) key, so the TUI yanks None and pastes a junk cell into the document (improvise-6p8q). The browser is right there and the TUI is wrong, which is why the lockstep yank test stays on a pivot grid.

CellDisplay's equality is the wire's, not f64's (improvise-4w09)

CellDisplay's == has one job: the grid diff (project::grid_updates) and the seed property (GridCache::folded(&g.seed()) == g, gotcha 32) run on it. The question it answers is therefore "could a client tell these apart?", not "are these the same number?".

A derived PartialEq answered the second question. value holds an f64, and NaN != NaN. So once #178 added value, a cell holding NaN, which .improv carries as a first-class value (improvise-55v2), was unequal to itself. The diff resent it on every grid-dirty projection, and the seed property was false for exactly the documents that hold one. It also cost Eq on CellDisplay, GridCache and GridUpdate.

So PartialEq is written by hand and compares value with same_on_the_wire, which has two clauses:

  • Every NaN is equal, whatever its bits. The wire spells them all "nan" (model/cell.rs::wire_f64), and they cannot all share a payload: 0.0 / 0.0 on x86 sets the sign bit, and a client that parsed "nan" holds f64::NAN. Bit equality alone would fix the self-diff and still call the host's NaN and the client's NaN different.
  • Any other number is its bits, so 0.0 and -0.0 differ. serde_json writes them differently, and a change between them is one the client does not yet have.

That is an equivalence relation, so Eq is back on all three types. CellValue's own PartialEq is unchanged: formula evaluation and model code keep IEEE semantics. Only the projection, whose equality means "nothing to send", adopts the wire's.

The hand-written impl destructures CellDisplay exhaustively, so adding a field fails to compile until it is compared. The dangerous failure for a diff is under-reporting (gotcha 35), and a field left out of == is exactly that. Evidence: ui::project::tests::a_grid_holding_nan_quiesces and render::tests::a_cell_equals_another_exactly_when_the_wire_cannot_tell_them_apart.

A number must read back as the number written (improvise-6oy4)

serde_json's default f64 parser is fast and best-effort: it does not guarantee reading back the f64 that serialized the text. Its float_roundtrip feature does, at roughly twice the cost of float parsing. Without it, the review of PR #178 measured about one ordinary decimal in seven coming back one ULP off, both in CellDisplay::value (host to client, so yy copied a neighbour) and in a SetCell (client to host, so a typed or pasted number landed as a neighbour).

The feature is on for every workspace crate with a normal dependency on serde_json (improvise-dz8c). Each crate's own manifest names it, because each build is made from one crate alone: the wasm CI job and wasm-pack invoke per crate, and feature unification cannot reach across separate invocations (context/repo-map.md, ## Wasm). Enabling it in one crate and relying on the others to inherit it works in cargo test and nowhere else.

Why "can parse" and not "parses a frame". The first version of the rule covered only the three crates that parse frames and listed them by hand. Frames are not the only place a user's number crosses a JSON parser. improvise-io's legacy-JSON .improv load (removed since, improvise-tt22), improvise-command's JSON import (StartImportWizard, ImportJsonHeadless) and the root binary's improvise import all parsed with the lossy default, and in the TUI binary nothing enabled the feature (improvise-6yvg, reproduced: 0.24815714285714285 stored as …283). The worker bundle happened to be exact through unification, so one import gave different numbers in the two front ends. "Parses JSON input" cannot be read off a manifest, but "has a normal serde_json dependency" can, and it over-approximates in the cheap direction: the feature changes only parsing, so a crate that only serializes pays nothing for it. Dev-dependencies are out, because a test build ships nowhere. That also leaves out gp-tree, whose only serde_json is a dev-dependency (its example CLI reads a saved model). Every number it persists is an f32, and 3M random f32s all read back exactly through the lossy parser, against about 30% of random f64s.

Why a declaration and not the build graph. The first check asked cargo tree whether each listed crate's build had the feature. That answer can be yes by accident. improvise-command depends on improvise-io, so with the feature removed from command's manifest, command's own test build still has it, and its import test stays green. The rule requires the crate's own manifest to say it, so a later change to a dependency cannot take it away.

Why not enable it once, in improvise-protocol. That removes the list, but it would give the protocol a normal serde_json dependency it has no use for (its repo-map entry says core and serde only). It would also reach only builds that include the protocol, and improvise-io does not depend on it. Under this rule the protocol's one serde_json dependency, the optional test-support one, carries the feature too. That is off in every shipped build, so it costs nothing.

every_crate_that_can_parse_json_enables_float_roundtrip (tests/json_numbers.rs) reads cargo metadata --no-deps and names every package, and every target-specific entry, whose normal serde_json dependency lacks the feature. A new crate is covered the day it declares the dependency. It does not reach harness/opfs-contract, which is its own workspace and only serializes.

The end-to-end tests cannot guard this alone. They run in one unified build with improvise-ws-server as a dev-dependency, so they stay green with the feature removed from the browser bundle.

AxisFrame — the grid's frame, and why it is one type

AxisFrame (improvise-anr9/s1xw) replaced a Vec<String> of labels that GridLayout::row_label built by slash-joining a multi-level tuple — the exact rendering two_row_categories_produce_cross_product_labels asserts must not appear. The protocol carried a form of the labels the TUI itself considered wrong, so the browser drew Food/Alice where the TUI drew two header columns, with no groups and no collapse indicators (gotcha 44).

It is one type rather than a labels plus a levels beside it, and that is the whole point: two answers to "what are the labels" is the disease this projection architecture exists to cure (improvise-edp). A renderer asks this type and there is nothing else to ask. Note what this cure cost that flatten_cat_tree did not: a Vec<String> had to change type, and the shared derivation could not be added beside the old one.

cats is the categories outermost-first; entries interleaves Group(FrameGroup) headers with Item(Vec<String>) label tuples exactly as GridLayout::row_items interleaves them. On the row axis the TUI paints a Group as its own row; on the column axis it paints the runs as spans above the headers. That difference is painting, and it is the only thing that differs.

cats may be empty — an axis carrying no category is a real layout (one nameless row or column), not a gap, hence levels() returning at least one. AxisFrame::flat leaves cats empty on purpose, so it is not a way to smuggle a multi-level axis in flattened.

FrameGroup::collapsed is on the wire rather than left to the client because it is a fact about the view, which only the server holds. There is no FrameGroup::cat any more (improvise-7xyx). Its doc comment promised that a client echoes it back in ToggleGroup, but no client ever did. resolve.rs has no toggle-group-under-cursor arm (z is on the still-needs-the-host list), and neither renderer read the field. A promise about an interaction no code performs is the stale-sibling defect, so the field went with it. It is not a loss for z. The TUI's ToggleGroupAtCursor also places the cursor on the layout the toggle will produce, which a thin client cannot compute. So composing z in a browser needs its own design, and the operand should come back with that design rather than wait for it.

AxisFrame: four accessors that are not for a draw loop

item, label, shows_level and group_of each scan entries from the start, so asking them per position is quadratic in the frame length and, in the TUI, grows with the absolute scroll offset. That defect was found three times on improvise-anr9. The per-position forms survive for one-off lookups only, and their two-line warnings are the thing standing between a future renderer and a measured regression.

  • shows_level is the worst of the four: it materializes every item on every call. It is deliberately not made cheaper, because a fast per-position form is an invitation to the loop header_labels exists to replace.
  • group_of walks entries twice over, once to place idx and once backwards.

The one-pass forms a draw loop wants:

  • header_labels(first_rendered) — every header label with suppression already applied, indexed [data index][level], "" where the label repeats. The suppression rule still has exactly one home (fn shows); this is shows_level applied in bulk, not a second copy of it.
  • groups() — group_of for every position, in one pass.
  • shares_ancestry(with) — the TUI's column-header underline: at the leaf level only the selected column matches, at a higher level every column under the same ancestor. There is deliberately no per-position form beside it, because one would be a second answer paid for per (column, level) — two frame scans apiece.

leaf_labels() is not "the labels". It is the innermost label of each item: the single most specific name a position has, what a test or a one-line summary calls a row by. A renderer reaching for it on a multi-level axis is drawing the wrong header, not a shorter one.

first_rendered is the first data index actually on screen. That entry always shows its full tuple, or a scrolled viewport loses its group context at the top edge (improvise-jjx).

body_from(idx) drops the group header that owns the first drawn item: a frame opening mid-group carries that header so group_of can answer for it, but it is above the first drawn row and must not be painted as one. This is the same cut GridLayout::data_row_to_visual has always made.

Two things about its argument, and the first of them used to be the whole story and was wrong. It exists partly 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 (gotcha 44). But idx alone cannot say whether the frame leads the axis, and at the top there is no header "above" to drop: it is content, and a collapsed first group has no other affordance. That is AxisFrame::first and improvise-0o6x — §3a.

group_of answers membership, and the paragraph that stood here said the opposite: that it answers "nearest preceding header", deliberately, because that is what the TUI had always painted, and that encoding an end boundary would mean changing AxisEntry in improvise-core and was "filed rather than smuggled into" improvise-anr9. All of that was true when written. The filed ticket is improvise-gd83 and it has landed — AxisEntry::GroupEnd closes a run, groups() clears on it, and the TUI's painted span changed with it. The mechanism and its two emission arms are §3a.

That correction is here rather than only in §3a on purpose. This paragraph forbade, in terms, the change §3a describes; a reader arriving at the stale copy first would conclude the rule had been smuggled past its own prohibition. When a fact is stated twice, changing it is a grep (gotcha 48) — and the two copies being in one file is what made this worth saying out loud.

The width rule has one home

header_level_widths (improvise-1125): per level, the widest label in display cells plus a gap, clamped to [MIN_ROW_HEADER_W, MAX_ROW_HEADER_W]. It lived twice before — inline in src/ui/grid.rs::render_grid and again in improvise-command::ui::viewport::compute_row_header_width — and a browser sizing its own row header would have been a third.

It takes an iterator of borrowed tuples rather than an AxisFrame because its other caller holds a GridLayout and is on the keypress path (reached from App::cmd_context); building a frame there to ask the question would clone every row tuple on every key (raised by Copilot on PR #23). AxisFrame::header_widths is the frame-shaped entry point, derived rather than projected as a field because the frame is the whole input — nothing about the model is needed, so a shared function cannot fall out of step with the data the way a separately-sent field could.

GridCache is viewport-indexed, and GridExtent is not

A cache position is a position within the viewport: the client does not know the model's coordinates, and the server is free to change what a row means as long as it says so.

GridExtent is how big the whole grid is, of which the frames are one window (improvise-bpq2). It is the one fact a client needs to move its own cursor: row_frame.len() is what this client was sent, so a client holding only its window cannot tell "the last visible row" from "the last row" and cannot clamp — a cursor clamped against the window stops at the bottom of the screen. It is deliberately not framed by the frames, unlike cells, col_widths and totals: it is the size of the thing they are a window onto, so fit_to_labels must leave it alone.

For a client whose window covers the whole grid — the TUI, which builds its cache at offsets zero — it equals the frame counts, and that coincidence is exactly what makes a derived version look correct there and fail in a browser. It is a named pair rather than two usize on GridCache, because beside row_frame/col_frame two bare counts read as "how many rows are in the frame", which is the confusion the field exists to remove.

GridUpdate::Extent's batch position is not load-bearing the way RowFrame-before-Cells is: it addresses no position, so it cannot overtake or be overtaken. It is emitted first only because outermost-first reads better.

GridCache::totals — empty means "no totals", not "not caught up"

Framed by col_frame exactly as cells and col_widths are, with one asymmetry: an empty strip means this grid has no totals to draw (records mode, or an empty grid) and is therefore not padded, where a non-empty one is. Padding an empty one would draw a blank total row under a records-mode grid that has none.

The emptiness is the server's answer rather than a client's re-derivation, and the totals must be projected rather than client-derived: the sum runs over every row including the ones this viewport has scrolled past, so a client holding only its window could not compute it.

page_coords is framed by nothing — it names categories, not positions — so it survives a reshape that drops every row and column. It carries Page and Filter (View::filtering_cats, gotcha 27): both restrict what is visible, and the bar states what is being looked at, not what the user may page through.

CategoryTreeUpdate::Items and ::Axes have no producer, deliberately

document_projections only ever sends Categories(..), which replaces the whole panel. The other two arms are derived rather than invented: the effects that move a category tree come in three shapes — the list reorders (AddCategory/RemoveCategory), one category's items move (AddItem/ RemoveItem), several axes move at once (SetAxis, and CycleAxis through it) — and doctrine(derive_the_enum_from_the_effects) is the same recipe that built ModelCommand. An arm the derivation produces is not speculation just because the producer has not reached for it.

What the producer reaches for instead is the coarse arm, on purpose: category_tree_of walks every category once, the panel is small, and Dirty::of cannot say which category moved anyway — an EffectKind says that a batch changed the tree, never which part, exactly as it cannot say which records a CellData batch wrote (gotcha 40). Emitting Items/Axes therefore needs the tree observed and diffed, the way document_update observes the title bar, and that is a bandwidth optimisation for a widget that costs a few hundred bytes. It is not worth doing until category-tree traffic is measured to matter.

The contrast with the deleted Invalidated is the point: these are unused, and Invalidated was redundant — its meaning already lived in running code. Unused-but-derived earns a note; redundant earns a deletion.

The fold is not commutative — three rules in apply

(a) A Cells update that overtakes its frames is dropped, not guessed at. fit_to_labels truncates to the frames it has. A client must not reorder the stream.

(b) A frame 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 declare it. It blanks the totals too, because they are contents and a stale sum under fresh cells is the one number a user takes at face value.

There used to be a GridUpdate::Invalidated arm meaning "contents stale, frame unchanged", and it is deleted (improvise-n89z). It never had a producer: grid_updates diffs, so it always knows which cells moved and sends Cells, and GridCache::seed compacts. There is no state in which a server knows the contents are stale and not which ones — and if there ever were, the answer is a fresh Snapshot through rebase, not a blanking the client cannot act on. Meanwhile its stated meaning had migrated into running code: invalidate_relabelled blanks exactly what a reframing invalidates, totals included. So it was redundant, not merely unused, and three documents described it as load-bearing, which is worse than silence — a reader reasoning about the fold's guarantees assumed something produced it. the_retired_invalidated_spelling_does_not_parse keeps it from returning as a serde alias.

(c) Every arm that touches a frame or the widths re-fits. A renderer zips widths with labels, and a length mismatch indexes off the end. Fitting on the frame arms alone is what let a late ColWidths put the mismatch straight back: a short update keeps each unheard-of column at its own widest level label until ColWidths catches up, and a long one is dropped as having overtaken the frame that names it, exactly as (a) holds Cells.

The fallback width is measured in display cells, the same unit header_widths and the authoritative ColWidths use: chars().count() would draw 日本 two cells wide where it needs four, so a cache caught between ColFrame and ColWidths would render the new column too narrow. The materialization inside fit_to_labels is deliberate — AxisFrame::item scans, so asking it per column would make a re-fit quadratic.

invalidate_relabelled — a cats change re-identifies everything

A cell's identity is cats zipped with its item tuple, so a change of cats re-identifies every position at once — even the positions whose labels are byte-identical. Region = [North] becoming Department = [North] leaves items equal and names a different cell, so the tuples alone cannot answer this and every surviving position is stale.

The sharpest half is invisible: a preserved CellDisplay carries an editable CellId::Record (ADR 002), so a client would go on offering an edit that addresses the record the old coordinate named — a misdirected write, in the same family as gotcha 18's drill snapshot, and one no rendering shows.

Otherwise the comparison is on the item tuples, not the entries: a group header appearing or collapsing changes the frame's shape without renaming any surviving position, and blanking on that would throw away cells the very next Cells update would have to resend.

Two asymmetries about totals:

  • A relabelled column drops its total (improvise-hkt6): the total under it is the sum of the old column. A relabelled row does not, because a total spans every row and is not framed by any one of them.
  • A recategorization of either axis blanks every total. A column total is a sum over the rows, so re-identifying the rows leaves a number that answers coordinates the grid no longer has.

This is what obliges the producer to diff the totals against the frame the label updates will leave rather than against the mirror they change: a column renamed while its total stayed the same number is blanked here, and a producer comparing mirror.totals would find them equal and send nothing. grid_updates compares framed.totals for exactly that reason. The blanking stays anyway, because rule (b) exists to stop apply trusting a producer to remember — the rule lives on both sides or on neither.

LabelAxis is private so the row and column cases share one invalidation rule rather than two copies that can disagree.

Bootstrap is a compaction, not a replay

A server does not replay history to a late client — it holds the current state and expresses it as the same updates the steady stream carries, so bootstrap is not a second code path and there is no whole-state shape that could disagree with the incremental one. The property that earns it is GridCache::folded(&cache.seed()) == *cache, and it is the ordering that earns it: the frames frame the cells, so they go first.

Snapshot { view, seed } is the only thing on the wire that is not an event, and view is the one point at which ViewState is serialized (improvise-q08).

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 (doctrine(fold_tests_must_test_order_not_the_axiom)).

CategoryTreeCache carries every category, _-prefixed included

improvise-vjms: the TUI panel has always listed them, and a browser panel that quietly did not would be a second, divergent answer to "what are the categories". So the _-prefixed convention does reach the client — it is presentation, and both ends render the same rows from this one source.

The trap is _Measure, whose items are derived: explicit items ∪ formula targets (gotcha 7). Whoever builds these rows must go through Model::effective_item_names; reading category("_Measure").items compiles, looks right, and silently omits every formula target.

Three mutations reach this panel because the effects are three shapes: adding or removing a category reorders the whole list, adding or removing an item touches one category, and a transpose moves several axes at once without touching membership. Which categories are expanded is ViewState::expanded_cats, per-client, so the flattening is a client-side concern and does not belong on the wire.

FormulaCache and ViewListCache take one whole-list update, because add and delete are the only effects that touch them and both are cheap to send whole. Which view is active is not in ViewListCache: that is ViewState::active_view, carried by Command::SwitchView, because it is per-client (improvise-8uz). Likewise which tile the cursor is on is ViewState::tile_cat_idx.

WizardCache has a shape and deliberately no Projection

improvise-764, and that absence is the ticket's answer to "where does the wizard state live". A Projection is broadcast to every connected client's widget, which is right exactly when the change is addressed to no one session. A half-open import dialogue is the opposite: it belongs to precisely the session that opened it. That is already settled doctrine elsewhere — ImportWizard lives on App and not on ViewState, and AcceptWizardModel was removed from ModelCommand for this same reason (improvise-tom1) after B was shown swapping the document out of a dialogue B never opened. Putting the wizard on the broadcast stream would have re-created that bug on the rendering side: every client drawing a dialogue one of them opened.

So it is a cache in shape and a local render model in fact, built from the authoritative App by the host that holds the wizard. 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 here rather than in improvise-command for the flatten_cat_tree reason: the browser client depends on improvise-protocol alone, and when it grows a wizard it must draw these rows, not a second derivation. When multi-client import arrives it wants the addressed form, which is where ADR 001 layer 2 goes anyway.

Every field is already render-ready — the widget that used to build these reached through ImportWizard into ImportPipeline, FieldKind, DateComponent and a whole Workbook; none of those cross now. Two substitutions are made here so both ends spell them the same way: an undetected date format reads "?", and the root array path reads "(root)" rather than empty. WizardProposal::kind_label is carried rather than re-derived from kind, so the two ends cannot disagree about what "Measure (numeric)" is called.

WizardStepKind mirrors improvise_io::import::WizardStep, spelled again rather than re-exported, because this crate must not depend on improvise-io (the wizard is an io type, gotcha 30) and a client that draws the wizard must still be able to name the step it is on. WizardFieldKind is the semantic half of a colour decision — the terminal mapping lives in draw.rs, exactly as AxisColor/axis_fg does (gotcha 26).

Projection carries no multi-widget variant

The envelope a widget subscribes by: it folds the payload of the variant that names it and ignores the rest. There is deliberately no variant carrying several widgets' updates together — that would be the aggregate cache in another form.

Nothing is defined for the help overlay: its content is static and help_page is already in ViewState.


ColWidths stays diffed against the mirror, and the ticket's reason was wrong

improvise-44ad asked whether grid_updates should diff col_widths against the label-folded cache rather than against the mirror, as improvise-hkt6 already obliged for totals. Folding the labels re-fits the widths, so under a fold-relative diff the ColWidths a reframe currently carries becomes redundant and is suppressed. Declined, and the two things learned are worth more than the change.

The stated cost does not exist. The ticket says the move breaks a_client_on_a_socket_is_seeded_and_gets_its_edit_back, and that that assertion is §3(c)'s standing evidence — so rewriting it inside a change §3(c) guards would be the improvise-q0vb / PR #12 shape. Measured 2026-09-21 by deleting the re-fit from fit_to_labels: three existing unit tests fail (a_col_widths_update_is_fitted_to_the_labels_it_is_framed_by, a_column_without_its_width_yet_is_measured_in_display_cells, col_widths_always_span_the_column_labels) and the socket test is not one of them. What it actually asserts is that a reframe carried widths — a statement about the producer's diff, not about the consumer's re-fit. §3(c) is triply guarded elsewhere, so the evidence argument was about the wrong test.

The real reason to decline is coupling. The widths a label-fold invents are each column's own label width, which is an approximation the consumer applies until the authority catches up. Diffing the producer's output against it makes what the server sends depend on a fallback rule defined in the client. Today both are in this crate and the dependency is invisible; the day a second client implements the fold, the producer's message set silently depends on that client's approximation. The redundant ColWidths per reframe is what buys out of that, and it is the direction Dirty::of is deliberately sloppy in (§5, repo-map gotcha 35): over-sending costs one projection, under-sending leaves a client wrong with nothing in the stream to correct it.

So the asymmetry between totals and col_widths is stated rather than discovered. totals is fold-relative because hkt6 found a real hole — a rename that leaves the number equal sends no Totals and the client blanks its own. col_widths has no such hole, because the re-fit is a safe fallback rather than a stale value.

What was landed is the half the ticket calls "the better test either way": a_client_never_holds_widths_of_a_different_length_from_its_labels asserts the invariant §3(c) is really about, at every step of a fold that widens before its widths arrive and narrows before they shrink — not merely at the end, which is where a message-count assertion looks.

GridCache::records — what kind of grid this is (improvise-e5pf)

The host answers "is this the wide records layout" with GridLayout::is_records_mode(), which reads the axes. A thin client has no GridLayout, and it was answering the neighbouring question instead — ViewState::mode.is_records() — at three decision points in resolve.rs. The two agree only while the mode and the layout agree, and Command::ChangeMode(AppMode::Normal) is precisely the message that separates them: the host settles Normal against the layout the batch ends on (App::settle_normal_mode), and reduce_view folds the variant it was handed.

So the fact is projected, not derived: GridCache::records, carried by GridUpdate::Records. Deriving it on the client from row_frame.cats containing _Index would have been a second implementation of is_records_mode — improvise-edp's disease — and deriving it from the mode is the bug itself.

Like GridUpdate::Extent it frames nothing, so apply does not re-fit and its position in a batch is not load-bearing: it describes what kind of grid the frames describe, not any position in one.

The settling is observed, never composed — and the first version of this fix got that wrong. It settled at each composition site, in resolve::changed_mode, against the grid the client held at the moment it resolved the key. ChatGPT Codex found the hole on PR #98: switch_view_at composes [SwitchView(target), ChangeMode(Normal)], so the settling read the outgoing view's grid and records → pivot emitted RecordsNormal while pivot → records emitted Normal. The drift the fix existed to close, by a second door.

The unreachability argument the first version leaned on covered the toggle route only — R is a stranding decline in the browser — and records-ness is auto-detected from the axes (gotcha 1), so a view switch changes it with nothing in the batch saying "records". doctrine(an_unreachability_claim_is_only_as_wide_as_the_routes_enumerated).

So changed_mode is deleted, every site emits the plain AppMode::Normal, and ClientState::fold settles after every fold against the grid it holds: self.view.mode = settled_mode(&self.view.mode, self.grid.records). That is route-independent — it needs no enumeration of composition sites, which is the part nothing checks — and it is the same shape the repo already prefers elsewhere: cursor_epoch over matching on CycleAxis (gotcha 24), document_generation over matching on LoadModel (gotcha 39), the dirty flag over a table of command variants (gotcha 45).

settled_mode is the one rule, in improvise-protocol, and all three ends call it: ClientState::fold, ClientSession::fold (the server's mirror, so the two cannot diverge in a field nobody currently reads) and App::settle_normal_mode. App::normal_mode is gone — it was that function's host-side duplicate. It settles the Normal pair and nothing else: Editing/RecordsEditing pass through untouched, which is exactly why a records decision asks the grid and never the mode.

There remains a window, and it is inherent: between the local optimistic fold of a SwitchView and the arrival of the target view's projection, the client holds the outgoing grid and cannot know the target's records-ness. The mode settles the moment the projection lands. No design removes that window — read-time derivation has exactly the same exposure, since it would read the same stale grid — so the honest statement is that the mode settles when the grid does, on both ends.

The three decision points ask state.grid.records. What that buys concretely: the commit at a records cell declines rather than composing typed_cell_command against a grid whose columns are [coords…, +cat, measures…, +] (gotcha 1), which was a wrong write and strictly worse than the black hole it was written to produce.

§3a. The frame's group boundaries and its position in the axis

Two facts AxisFrame gained after improvise-anr9, both about group runs.

FrameEntry::GroupEnd closes a run. group_of used to answer "nearest preceding header", which is not membership: expand_category emitted a header on entering a group and nothing on leaving one, so an ungrouped item after a grouped run inherited it (improvise-gd83). The §3 paragraph that recorded the old rule — and forbade this change — is corrected there, not merely superseded here. AxisEntry::GroupEnd is the boundary, carried onto the wire as FrameEntry::GroupEnd; groups() clears current on it and group_of stops walking. It is emitted only on Some -> None, never between two groups — the next header already ends the previous run — and at the end of an expansion whose last item was grouped, which is the across-prefix arm cross_product creates.

Nothing draws a GroupEnd. Both front ends skip it and it consumes no data index, so it is invisible except through group_of/groups.

AxisFrame::first is the absolute data index of the frame's first item. The windowing rule carries the nearest header above a scrolled window so group_of can answer, and body_from drops it on the way to the screen. At the top of an axis there is no "above", so that same leading header is content — a collapsed first group has no other affordance (improvise-0o6x). idx alone cannot tell the two apart, because the browser's frame is already windowed and always passes 0; first + idx == 0 can.

The TUI builds over the whole grid so first is always 0 there, and the browser's comes from the projected window. A frame built by hand in a test must set it, which is why it is a plain field rather than derived.

It carried #[serde(default)], which was a defect rather than a decision and is now gone (improvise-1wl0): first: 0 is not an absence but the claim "this frame leads the axis", which is the claim body_from acts on — so a frame that lost the field deserialized as leading, and a scrolled browser drew the carried header above its first row. Same argument as Basis having no Default: a zero-valued claim that happens to be true in the common case is a forgeable bypass rather than an absence.

Dropping the attribute was preferred to Option<usize> or a two-variant enum, and the reason is about the wire rather than the type. There is exactly one production producer (project.rs::axis_frame, which reads window.start), so a frame is never partially constructed and "unknown" is not a state any honest peer can be in — making it representable would oblige body_from to answer for it, which is the same question one level down. A missing field is now a loud parse failure at the transport, where a wire-format skew belongs. No skew ships today, both ends being one workspace at one commit; this is about the day there is one, and about the attribute reading as a considered compatibility decision when it was not.

The Default derive still yields first: 0, and that is not the same hole: an AxisFrame::default() has no entries, so body_from returns an empty slice on either arm and the claim is vacuous. The hazard was specifically a populated frame acquiring the claim by omission.

group_runs / group_run_heads are the coalescing rule's one home. The TUI and render.rs::col_groups each coalesced adjacent columns sharing a group, kept in step by a comment (improvise-8qlr). The TUI's one divergence is that it labels its first visible column even mid-run, so a scrolled grid does not draw an unlabelled group line.

A run is opened by a HEADER, not by a name (improvise-h0iv). Both prior copies coalesced by name alone, and improvise-8qlr preserved that because it was a consolidation: changing the rule in the commit that moved it would have left the agreement test unable to tell "both ends agree" from "both ends changed together". With one home that argument is spent, and the rule can be changed once for both ends — which is the shape to notice, since a consolidation's frozen behaviour reads as decided afterwards and nothing says it was only frozen for the duration of the move.

The ticket's own proposed fix does not work, and that is worth more than the fix. h0iv suggested comparing the (cat, name) pair; FrameGroup carried cat then (improvise-7xyx has since removed it). But its own reachability case is one category: Month grouped into Q1, nested under Region, giving North/Q1 and South/Q1 adjacent — the two headers are (Month, Q1) and (Month, Q1), equal as values, so the pair separates nothing. the_two_headers_are_equal_as_values_so_comparing_them_cannot_split_them pins that as whole-value equality, so the next reader does not re-propose it. What separates them is that they are two different FrameEntry::Group entries, and groups() already borrows each from self.entries — so the comparison is std::ptr::eq on the header, which is the literal statement of "the same header opened both".

The case that settled the product question was not the span but the indicator. Two adjacent same-named runs might well be one span a user wants spanned, which is why h0iv was filed rather than fixed. What is not arguable is column_groups_coalesce_by_name_as_the_tui_does, whose fixture merged Month/Q1 (expanded) with Fiscal/Q1 (collapsed) and whose own message said the merged span is "drawn with the FIRST one's indicator" — a collapsed group rendered as expanded, with no affordance saying otherwise. A span boundary is a matter of taste; a wrong collapse glyph is a wrong fact. So: two runs. Reversing it is one comparison, and the test is renamed column_groups_coalesce_per_header_as_the_tui_does rather than deleted.

4. frame.rs — one envelope pair, two deployments

The framing adds as little as it can get away with

doctrine(derive_the_enum_from_the_effects): inventing wire variants ahead of the mutations that need them produces a protocol nobody can implement.

There were three pairs for two deployments (improvise-xks6)

The socket's server side, a hand-kept mirror of it in the browser bundle, and a third pair 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. They do, and the test applied to each difference was whether the argument for it names the thing they differ in. None did — every difference was a date rather than a decision:

  • Viewport is deployment-independent. The extent is the one session input no Command carries by construction: it is a fact about a terminal or a browser window, it is not in ViewState, and no effect produces it, so no derivation reaches it. 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 — one built at extent zero is an empty grid the client would immediately have to ask again for.
  • Key is deployment-independent too. A keymap 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 the variant because key forwarding had never been built on it — a gap (improvise-bpq2), not a design.
  • Commands(Vec<_>) batches where a singleton cannot. A fan-out must arrive whole and in sequence, because a GridUpdate::Cells that overtakes its frames is dropped, not replayed. That is a property of the fold, not of the transport: an in-process postMessage interleaves exactly as a socket does. Commands(vec![c]) expresses everything a Command(c) variant could.

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 (doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)).

What survives is the_retired_spellings_do_not_parse, aimed at the one thing still possible; see context/test-evidence/protocol.md.

ClientFrame::Commands — the frame is the unit of atomicity

Not the command (ADR 001, improvise-955n). The host answers for every command in the frame before applying any, so a frame is never applied partially because of a refusal. It used to apply in order and abandon at the first rejection, which left a hole ADR 001's "never applied, not even partially" does not have.

The frame states one basis, and it is the only claim a client can construct: a basis is published between batches and never during one, so no client can hold one describing the document as it would be half-way through its own frame. Commands then run in order, each against what its predecessor left, and data dependence between them is expected — a chain may create a record and then write it.

But only two of the three tiers are answered once. document_generation and layout_version describe the document, so answering them on the frame's first mutation is right. The record tier is asked of every mutation, because which records are at stake is a property of the command rather than of the frame — skipping it for later commands let one client's chain overwrite a record another had moved, silently. The exemption that keeps data-dependent chains working is narrower than "later commands are not checked": a record that neither resolves nor is claimed is one the frame is about to create, and only that combination is exempt. A record that resolves is answered for however the claim is written. CheckedBatch in improvise-command carries the derivation.

All-or-nothing is the contract and clause 4 is not yet delivered. That is the other way a frame stops half-way, and it is not a refusal: a chain whose later command fails at apply time leaves its predecessors applied, because App::apply_effects aborts without undoing (gotcha 21 — an abort is not a rollback).

ClientFrame::Save is a request, not a Command

improvise-368h. It carries no path, because a client cannot see the host's storage: effect::Save writes to this host's file_path through this host's Storage (gotcha 2), and which document a host is hosting is the host's own fact. A client naming a path would be naming a namespace it cannot read — the same mistake ModelCommand::SetCell avoids by taking a surrogate rather than a CellKey.

It is a frame and not a Command, and the reason is atomicity. It began as a Command, which put a runtime-fallible operation inside a batch Commands guarantees is applied whole or not at all — so [Save, SetCell] reported a failed write and then applied the edit anyway, and a client reading "refused" could not tell which. Saving 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. Here it cannot be composed with a mutation at all — the batching question stops being a rule and becomes unconstructible — and it sits beside Viewport, the other session input no Command carries.

Success is silent: the document did not move, so no widget is dirty and nobody else is owed anything.

ClientFrame::Key — and a correction

The doc used to say "the host runs it through the keymap against the authoritative App". It does not. Host::handle answers this arm with an explicit empty vec, because a key resolved on the host moves state no client can hear (a_key_resolved_on_the_host_moves_state_no_client_can_hear): the projection stream carries nothing of the host's own ViewState, so host-side resolution diverges. improvise-bpq2 closes that by avoiding it — the browser resolves keys against the shared keymap (§6) — so an upstream key is a black hole, not a slow path, and every KeyOutcome::Upstream names a binding that does nothing at all in a browser. The variant is kept because the list of keys that still need the host is named in improvise-wasm-client/src/keys.rs, so closing one has to come there and say so.

It carries (KeyCode, KeyModifiers) rather than a KeyEvent because KeyEvent is not Serialize — the same pair Command::SetTransientPrefix already puts on the wire.

ServerFrame has three variants because Sessions::apply has three cases

Applied::Diff, Applied::Rebased, Applied::Rejected (gotcha 39).

Snapshot is boxed: it carries a whole ViewState plus the seed that redraws the document, and every Commands frame would otherwise be allocated at that size for a variant sent once per connection. Serde sees through the box, so the JSON is unchanged. It is sent again to every client whenever the document is replaced, because a client's cursors are keyed by the outgoing document's view names and only a fresh snapshot replaces them (gotcha 24).

ServerFrame::Commands carries both of the things a client folds, because on the wire they are the same thing: a Command::Project is a widget update, and any other Command is a peer's accepted move that this client's own view slice must fold too (gotcha 36). Order is load-bearing, so a transport must deliver a batch whole and in sequence.

Rejected is addressed to the asker alone — nothing happened, so nobody else is owed anything — and it carries no new state, because the client already holds what is current through the projections it has been folding all along. That is what makes rejection cheap enough to be a normal outcome rather than an error.

Accepted, and why a rejection alone is not a protocol (improvise-nxah)

ServerFrame::Rejected told a client that a frame did nothing. Nothing told it that a frame did something. That asymmetry is not cosmetic, because the browser folds a frame's view commands optimistically before sending it: a refusal must put them back, and to know which frame's folds to put back the client has to be able to retire the ones that landed. With only a refusal on the wire, a client holding two frames in flight cannot tell which of them a Rejected answers — so the rollback either addresses the wrong frame or never happens. This is the same shape as the publication gap ADR 001 originally left: it said a batch carries a basis and never said how a client learns one, and Projection::Basis had to close it. Here it said a batch may be refused and never said how a client learns it was not.

So a mutating frame is answered exactly once, Accepted or Rejected, and the two facts that make that a usable rule are already load-bearing elsewhere. Only Command::Model can be refused — Checked::against says so exhaustively and Host::commands only enters mutating_frame when one is present — so a frame carrying no Command::Model is answered by neither, and a client can tell by inspecting the frame it is about to send rather than by remembering a rule. And Rejection::refused_an_edit already separates the two Save refusals from the batch refusals, so a failed write cannot consume the answer owed to an edit.

Accepted carries no state, and it goes last in the frame's answer, for the reason a published basis goes last (§5): everything the frame moved has already arrived as projections, so a client discarding its undo knows exactly what it is discarding. A client that folded the acknowledgement first would be retiring a frame whose effects it had not yet been shown.


5. basis.rs — what a batch was composed against

The problem is not ordering

The server already serialises every write — one App, one apply_effects — so there is a total order and nothing here is about ordering. What was missing is noticing that a client composed an edit against a picture of the document that has since moved: the same stale-basis shape as the cursor under a reshaped grid (gotcha 24) and the session mirror after a document swap (gotcha 39), one level up.

Three tiers, and the tiering is the whole economy

tier moved by a batch is stale if
document_generation a WholeWorkbook effect it moved at all
layout_version an observed change to any view's drawn shape it moved at all
records a CellData effect on that record any record the batch names moved

A cell edit is stale when the layout moved, even though no record it names was touched, because the shape its keys hang off changed underneath it. That is ADR 001's class 2 — A removes the category Region while B commits an edit keyed by Region=East — and it is corruption rather than a lost keystroke. Detecting it falls out of the tiering with no lock anywhere: a cell-level lock would not have covered it, because holding Region=East × Revenue does not stop anyone deleting Region.

The client understands none of this

A Basis is three opaque tokens — nothing in it is a structure to interpret, the same discipline as the cell surrogate it is keyed by (ADR 002). The server publishes the current one as a Projection, the client keeps it in a BasisCache and hands back claim() when it composes a write.

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 brand-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: it 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.

There is deliberately no flag that skips any of this, because "a mechanism whose only purpose is to violate the invariant is not a feature, it is the invariant deleted with extra steps". A force re-composes the same intent against the current basis and submits a fresh batch; it never applies a stale one.

claim omits rather than invents

A record the cache has never heard of is left out, not defaulted to zero. Defaulting would be a claim the client cannot support, and the server would read it as "untouched" — the exact forged-basis case RecordUnstated exists to catch.

The publication is what ADR 001 omitted

The ADR says "a batch carries the basis it was composed against" and never says how a client comes to know one; nothing in the projection stream carried any of the three tiers, so the check as written was satisfiable only by forging. That is the one place that document met the code and was wrong. Projection::Basis + BasisCache close it.

It is folded like any other widget (improvise-rkeq) — not a widget, nothing draws it, but it subscribes on exactly the same terms, and the alternative was a second server→client channel with its own ordering rules for a payload that changes at precisely the moments a projection already does. The update replaces wholesale, as Projection::Formulas does, so "has it changed" and "send it" collapse into one decision.

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 the fold's frame-before-cells rule.

Which records are checked is the server's decision

reduce.rs::records_named in improvise-command, 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.

published() takes a BTreeMap

So the record list is deduplicated and in a stable order. The server diffs the published basis against what it last sent this client, and a list whose order followed grid traversal would report a change every time a row moved.

Rejection keeps structural and data staleness apart

Which is the whole reason ADR 002 put a maintained RecordHash index in DataStore: a regenerate-on-demand scheme could not tell a record that had scrolled out of view from one that no longer existed.

  • RecordMoved is class 1. Somebody wrote the record since this client read it. Force is coherent and should be one keystroke: the user has seen the other value and chooses to overwrite it.
  • RecordGone and LayoutMoved are class 2. There is nothing coherent to force — the key names a coordinate that no longer exists, and applying anyway is exactly the ghost-coordinate corruption this check exists to prevent. Force never creates structure: re-instating a category somebody deleted so an edit can land is a larger intent than the user expressed and must be an explicit action. What the UI owes here is what changed and the concrete alternatives, not a button.
  • RecordUnstated is a case of its own because "you lost a race" and "you were never in one" are different things to tell a user — and because naming it is what closes the force-by-omission hole. A well-formed client cannot produce it: it composes a write from a cell it is drawing, and the published basis covers every record its grid names.

This is also why the wasm client stores the Rejection itself rather than collapsing it into status_msg: a string cannot carry which record and which kind.

Two classes live in one type

Rejection is a refusal of a frame, not of a command — since improvise-368h a ClientFrame::Save the host cannot honour is answered here too, and that is not a refused command at all.

  1. Staleness (ADR 001) — DocumentReplaced, LayoutMoved and the three record variants. The only class where force means anything.
  2. A request the host cannot honour — NoDocumentPath and SaveFailed (improvise-368h). Nothing went stale; there is nothing to re-compose.

They share this type rather than getting refusal channels of their own, for the reason the type exists: a refusal is "nothing happened, here is why", addressed to the asker alone, and a second channel would be a second set of ordering and addressing rules for one idea. A third class is coming and is not here yet: a transaction rolled back because a later command in a chain could not be applied (clause 4 of the frame contract). Until rollback exists there is nothing to report, because nothing is undone.

NoDocumentPath is decided before anything runs, by the same constructor that answers for a basis, so a save that cannot land is refused rather than attempted. What the asker should do about it differs by front end: a TUI prompts for a path (improvise-lae), a browser worker whose document is its storage entry never sees this at all. SaveFailed carries no message because Rejection is Copy and an I/O error's text is not — the host keeps the text in its own status line, and what crosses the wire is the one fact a client can act on: the document it is editing is not on disk. Reporting a failed write as success is the thing this exists to stop.


6. keymap.rs — one table, both ends

This used to live in improvise-command, where only the terminal could reach it, and the browser client carried a hand-written classifier answering the same question for the subset it could resolve. That is two definitions of one vocabulary kept in step by a lockstep test over a hand-written list of keys — and doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished) says such a pair is one definition waiting to be made.

The move is the one gotcha 13 already anticipated: ViewState::transient_prefix stores a prefix as an address rather than an Arc<Keymap> precisely so ViewState could stay free of improvise-command types while the keymap came the other way.

The boundary that did not move

A lookup yields a command name and arguments. Turning that into something that runs needs a CmdContext — a Model, a GridLayout, an App — which a thin client does not have, so intent → mutation stays where it was: improvise-command resolves a name through its CmdRegistry into Effects, and a browser client resolves the subset it can compose into Commands.

That is a better seam than the one it replaces. The drift risk used to be "which key does what" — ~170 bindings, no direct test, invisible when wrong. It is now "which command names can I execute", which is a list.

What stayed in improvise-command, and why each had to:

  • Keymap::dispatch and KeymapSet, which pair this table with a CmdRegistry and return Box<dyn Effect>.
  • SetTransientPrefix, which is an Effect — it writes ViewState::transient_prefix, and Effect is the command layer's trait.

The keymap's own tests live with dispatch in improvise-command/src/command/keymap.rs, for the same reason: they need a registry to check a bound name against.

ModeKey and AppMode are two different enumerations

They happen to be the same size (17). SearchMode is a ModeKey and not an AppMode — it is Normal plus search_mode: bool; Quit is an AppMode with no keymap. Do not "fix" one count to match the other. ModeKey::from_app_mode is exhaustive on purpose: a new AppMode without a keymap silently swallows every keypress, so the compiler must flag it.

Lookup fallback order

exact(key, mods) → Char with NONE (shift is implicit in the char) → AnyChar → Any → parent. Keymap::with_parent is Emacs-style inheritance, and it is what lets RecordsNormal reach Normal's g/y/Z prefixes.

Keymaps::prefix_keymap deliberately goes through lookup, which walks parents, for exactly that reason. Resolution assumes prefixes are flat; prefix_keymaps_are_flat fails the build if a nested one is added.

lookup_local is exhausted before the parent is consulted, and that one line of ordering is what makes the global below cost nothing.

The global — all seventeen modes, and the answer really is "all"

global_keymap() binds ? (help), : (the command line) and the three panel toggles, and every mode's keymap is built Keymap::with_parent(global). The user's ask (improvise-636d) said "almost all", and the honest answer after naming the exceptions one at a time is none of them — no mode needs to opt out, because every mode that must not answer a global key already shadows it locally:

  • the eight text-entry modes and SearchMode bind KeyPattern::AnyChar, so ? types a literal ? and the parent is never reached;
  • ImportWizard binds KeyPattern::Any, so nothing escapes it either;
  • Help binds its own ?, which closes the overlay rather than re-entering the mode already showing. That is the one product call the ticket left open, and it is decided here.

None of that is an override anyone had to write — the first two fall out of the fallback order, and only Help's is deliberate.

The global may bind Char keys only, and that is load-bearing rather than tidy. AnyChar matches KeyCode::Char and nothing else, so a global F1 would reach the parent while the user is typing and throw away a half-typed cell edit. That is why F1 stays bound on Normal alone although it runs the same command as ?, and why the_global_binds_only_unmodified_char_keys asserts the shape rather than the list. A chord is excluded for the same reason: lookup_local's Char-with-NONE retry is what a shifted ? relies on, and it does not run for a CONTROL binding.

Esc is not a candidate and should not become one. It means something different in nearly every mode — leave the panel, cancel the edit, close the overlay, abandon the wizard — so a global Esc would be shadowed everywhere it mattered and wrong wherever it was not.

What the change is worth: before it, ? resolved in 2 of 17 modes (Normal, and RecordsNormal through the one inheritance edge), missing from all three panels, TileSelect, SearchMode, ImportWizard and Help itself — exactly the modes a lost user is standing in. bind_panel_toggles had two call sites and : was written into two spines while ? was in one; that asymmetry was the evidence that nothing kept the spines in agreement, and it is what no_mode_binds_a_panel_toggle_of_its_own now keeps.

every_modes_keymap_chain_ends_at_the_same_global compares with Arc::ptr_eq rather than by value, because "several tables that agree today" is the thing being removed, and two equal-but-separate globals would satisfy any value comparison.

The layer below it: document_keymap (improvise-wiew)

The global above is all seventeen modes, and that answer is unchanged. What changed is that a second shared layer now sits between it and some of them.

The user asked for [/] "probably in the global keymap", and then answered the question that left open himself: "the help display (and import wizard, etc.) shouldn't respond to these keys, but the 'normal' modes like categories/tiles/formulas/etc. should."

The distinction is is the document the subject right now, and it is genuinely a new one rather than the global's argument applied again. Every key the global binds — ?, :, the three panel toggles — is a UI action: it opens a room, it does not change the document. page-next/page-prev resolve to ModelCommand::SetPageSelection, which is EffectKind::Model(LayoutGeometry). They are the first shared keys that mutate, and under the Help overlay that is a mutation the user cannot see, because the overlay is covering the grid it just moved.

So the chain is three deep:

global                  ?  :  and the panel toggles          — every mode
  └── document          [  ]                                 — Normal, the three
        ├── Normal          panels, TileSelect; RecordsNormal
        │     └── RecordsNormal                                through Normal
        ├── FormulaPanel / CategoryPanel / ViewPanel
        └── TileSelect
  ├── Help               global only
  ├── ImportWizard       global only
  └── the text-entry modes and SearchMode, unchanged

The name is the guard. Nothing in the code stops the next person parenting Help to this layer; what stops them is that it is called document_keymap and not common or shared, so its name answers "should my new mode inherit it?" A name describing who inherits a layer invites everything to join, and the distinction dies within two waves.

RecordsNormal inherits it once, through Normal. It already parents Normal, so it reaches the layer transitively; parenting it directly as well would be a second edge with nothing keeping the two in step. That is asserted on the chain identity rather than on behaviour, because a duplicated edge answers page-next correctly on the day it is added and only diverges later.

The layer owes the same Char-only constraint as the global, and for the identical reason: it is reached by fallthrough, so a non-Char binding on it would fire while a descendant's AnyChar was typing. No descendant binds AnyChar today — which is exactly why the constraint is written down as a test rather than left to the arrangement. An unconstrained shared layer is where the next "just put it in the shared one" lands.

Two things that cost nothing and were checked anyway. Text entry is untouched: the fallback order is exact → Char(NONE) → AnyChar → Any → parent, so a text-entry mode's AnyChar answers [ before any parent is consulted, and typing a bracket into a category name still types it. And prefix_keymaps_are_flat is about the sub-keymaps a prefix opens, not about inheritance depth, so a third level of parents violates nothing it asserts.

The browser follows for free: resolve.rs dispatches on the command name, so every mode that now reaches page-next composes it in a browser too, with no second table to update. That is the whole point of one shared keymap.

TextEntry — the spine, welded

Every text-entry mode has the same spine: characters append to one named buffer, Backspace pops from it, Esc clears it before leaving. Only the buffer, the exit mode and the commit keys differ. Building them through here means a new text mode cannot accidentally type into one buffer while clearing another, or drop the clear entirely — which would leave the next entry pre-filled with the last one.

TextEntry::new takes the AppMode and reads the buffer name off its MinibufferConfig, so the buffer the prompt describes and the buffer the keys edit are the same string by construction rather than by agreement (improvise-k8h) — the keymap never spells a buffer name.

commit(key, cmd, args) welds the command to its clear-buffer so no caller can bind a commit without its clear (improvise-w3q).

The two cell-editing commit keys are the exception, and the clear moved rather than disappeared (improvise-fy9o). commit-cell-edit and commit-and-advance-right reopen an editor on the cell they advance to, and the welded clear ran after them, so it blanked the re-seeded buffer. The reopened editor looked empty, and a second Enter committed "" and cleared the landed cell. The user's ruling is that the reopened editor shows the landed cell's current value, so with improvise-ixxx's untouched rule an unchanged Enter writes nothing. commit_reopening(key, cmd, args) therefore binds the command alone, and the command clears its own buffer at its head:

  • in the TUI, CommitAndAdvance does it right after the mode change;
  • in the browser, resolve::cleared does it, reached from both commit and create_column.

Being at the head is also gotcha 21's rule: a head clear survives an abort where the trailing weld did not. The corner commit and the +cat insertion reopen nothing, and they still leave no text behind.

After a write, the browser's reopened editor takes its seed from the host's answer, not from its cache (improvise-3uzg). The first fix (improvise-3tt4) seeded from the cache everywhere except the cell the frame had just written. That assumed the write changed the meaning of only that one cell, which is false. Under prune_empty, emptying the last value in a row takes the row away. The advance then lands on a renumbered position, and the cache still holds the old record there. The browser opened on South's 2 while the TUI opened on West's 3, and typing 5 wrote 25 into West. Any write can change what another position means, and the client cannot tell which. So the rule is stated once, over the write, never over a list of cases:

  • A commit that writes reopens its editor as OpenedEditor::awaiting(), an empty buffer whose seed is still to come. create_column is included, because AddItem adds a column.
  • ClientState::settle_awaited_seed fills the seed from the grid the client holds, but only once no frame it sent is still unanswered. The answer folds the host's projection before Accepted, so the grid is the host's by then. accepted() therefore settles.
  • A commit that writes nothing awaits as well while any earlier frame's write is unanswered. Otherwise it seeds from the cache at once. "Nothing changed, so the cache is current" was the rule here, and it was false (improvise-mdfc). This commit changed nothing, but an earlier one might have, and the cache predates it until the answer arrives. Tab Tab pressed quickly at the rightmost column opened the second editor on the value from before the first Tab's write: 505 where the TUI stores 5095. So the test is "does this frame write, or is a write still in flight", which is writes || in_flight.iter().any(|sent| sent.awaited). It checks any in-flight write, not the newest frame: a frame that writes nothing stays in the queue behind an unanswered write, so the newest frame can be one of those.
  • Every opener asks the in-flight half, because it lives in opened_on (improvise-p6v6). mdfc first put the whole test inside advanced, which left i/a (edit_or_drill) seeding from the cache with no in-flight check. i 9 Tab Esc i 5 Enter, typed faster than the answer, stored 15 in the browser where the TUI stores 195. The two halves now live in different places, for a reason. "A write is still in flight" is true of the client, so opened_on asks it, and no opener can forget. "This frame writes" is true only of the commit being resolved, which is not in in_flight yet, so advanced still asks it itself. Folding both into opened_on would lose the second: the 3tt4 and 3uzg tests fail when advanced defers entirely. The click-edit path (socket.rs::edit_target_at, improvise-ugj4) also seeds from the cache, but it claims the record at the version it read. Behind an unanswered write the host therefore refuses its commit as RecordMoved: a loud failure, not a silent overwrite.
  • The seed settles only into an open editor (improvise-4cb5). An Esc typed before the answer arrives leaves the awaiting opened_editor in place. The late seed would then fill the edit buffer in Normal mode, where the TUI's is empty.
  • awaiting has no serde(default) (improvise-mgis), for the reason AxisFrame::first has none (§3a, improvise-1wl0). awaiting: false is a claim, "the seed is settled". A peer that dropped the field would hand over an editor that stays empty forever, and whose Enter writes nothing. A missing field is therefore a parse failure.

Asking the host to send the seed was the alternative. It would have needed a new message, and the host already sends it: the landed cell's edit_text in the projection it owes this client anyway. Two things are accepted costs:

  • Until the answer arrives, the editor is empty. Over a worker that takes well under a frame.
  • A key typed before the answer arrives marks the editor touched, and the late seed then does not overwrite it. What the user sees is then what gets written. That is never another record's number only because of the mdfc rule above: before it, an untouched commit behind an unanswered write opened on the stale cache, and typing then extended the wrong number. What is left is a divergence from the TUI rather than a wrong record. A key that beats the answer replaces the value the TUI would have extended: back to back, i 9 Tab Tab 5 Enter stores 5 or 5095 in Chromium over the worker (improvise-n4i9).

The TUI is unchanged: its RebuildLayout in front of EnterEditAtCursor already reads the renumbered grid (gotcha 23). This also took CellValue::edit_text out of the client bundle. That call formats an f64, which is what compiled to i64.trunc_sat_f64_s, so a093d126's nontrapping-float-to-int flag on the wasm-client is reverted. A ?ws= page loads only the client bundle, so it works without the feature.

Reordering the weld to [clear-buffer, cmd] was tried first and rejected. Four readers take a Sequence's first step as its identity: keymap::runs, help.rs, and two resolver audits. So every hint on those keys started reporting clear-buffer. text_entry_modes_clear_the_buffer_they_type_into keeps its guarantee by asking a plain-Cmd commit key's command what it emits.

rebind exists for exactly one case: CommandMode's Backspace, which exits the mode on an empty : line instead of popping a character that isn't there.

Eight AppMode variants carry a MinibufferConfig, and buffer_of's callers are all eight. Since improvise-636d there is one constructor — TextEntry::new(mode, exit, parent) — because every text-entry mode now has a parent: seven take the global directly and RecordsEditing takes Editing's map, which reaches the global through it. with_parent is gone, and with it the "a count off the ::new call sites reads seven" trap this paragraph used to warn about: the call sites are all eight.

SearchMode is hand-written on purpose and does not use TextEntry: it has no commit key, and its query is owned by exit-search-mode rather than by clear-buffer. "search" is not in the buffers map but its own ViewState field, and cmd::core::read_buffer/write_buffer are the only code that knows it (improvise-gdl).

Category and item add stay open after a commit, so Tab and Enter do the same thing: file this one and start the next. ItemAdd's keymap is built with an empty category name because the category only shapes the prompt text, never the buffer.

PanelKeymap

The three panels share a spine — Esc/Tab leave for Normal, arrows and j/k move that panel's cursor. The keys that act on the panel's contents differ in shape, not just in command name (Category's n takes an argument, View has no a, Formula has no Enter), so they arrive through chained methods rather than a fixed-arity signature (improvise-4pg). delete() binds d and Delete together so a panel cannot offer one without the other.

The toggle keys and : are no longer part of this spine: they are the global's, so PanelKeymap::new takes the parent and binds neither. bind_panel_toggles has exactly one call site — global_keymap — and a new panel reaches all seventeen keymaps from one Panel::ALL entry rather than four.

Normal-mode bindings worth a note

i/a pass their target editing mode as an argument to edit-or-drill, so the command itself stays mode-agnostic; RecordsNormal overrides just those two bindings to say records-editing. Ctrl-S saves to the known path and Ctrl-W asks for one — the pair a user with no file path needs, and what the "No file path" status message points at (improvise-lae).


7. viewmodel.rs — the residue between a cache and a renderer

improvise-edp asked for a ViewModel layer, a SurfaceViewModel paired with every SurfaceCache. Most of that layer turned out not to exist: after improvise-35e each widget already takes exactly one cache plus the mode, and a cache built per-widget is already render-shaped, so there is nothing left to adapt. What this module holds is the residue — derivations genuinely between a cache and a renderer, because they mix a cache with per-client ViewState.

Today that is exactly one: flatten_cat_tree. It lives here rather than in improvise-command because two ends need it and only one of them can reach that crate — the browser client depends on improvise-protocol alone (deliberately; see its Cargo.toml), and move-panel-cursor walks this flattening, so resolving that key client-side (improvise-bpq2) would mean re-deriving it. Two derivations of one flattening is how the two ends drift.

CatTreeEntry carries the axis outright, so a renderer needs neither a View nor the cache the entry came from: the entry list is what the panel draws. expanded is ViewState::expanded_cats — per-client, which is why this is derived here and not sent — and the index into the result is ViewState::cat_panel_cursor.


8. key.rs

These mirror the subset of the terminal backend's key API the command, keymap and effect layers actually use, so those layers carry no terminal-backend dependency. Variant names match crossterm's so call sites only need an import change. The only place both type families may appear is the TUI boundary (src/draw.rs), which converts incoming backend events into these before calling App::handle_key.

KeyCode::Null stands in for "no key" (headless command execution) and for any backend key code this crate does not model. KeyEvent is not Serialize — ClientFrame::Key and Command::SetTransientPrefix both carry the (KeyCode, KeyModifiers) pair instead.


9. Corrections made while relocating this prose (2026-09-20)

Four comments in this crate were wrong about the code and were fixed rather than moved. Recorded because the campaign's own evidence is that a stale argument reads exactly like a live one:

  1. frame.rs, ClientFrame::Key said "the host runs it through the keymap against the authoritative App". Host::handle returns an empty vec for that arm. This is the fourth sibling of gotcha 48's "three comments in one range said not built yet" — improvise-bpq2 moved resolution to the client and updated one copy of the argument, not all of them.
  2. reduce.rs, every_command_round_trips_through_json said the model half brings "four payload types the view half never carried — CellKey, CellValue, Axis and PathBuf". ModelCommand carries no CellKey at all since ADR 002 (SetCell takes a RecordCell), and CellValue is in the view half, as Command::SetYanked.
  3. render.rs, identified_grid carried three paragraphs of doc that describe a different test — "improvise-hkt6 … three columns and a rename in the middle" is a_relabelled_column_drops_its_total, which had no doc of its own, while identified_grid is a two-column fixture. The paragraphs had been merged onto the wrong item.
  4. keymap.rs, buffer_of said "the seven modes that carry a MinibufferConfig". Eight do, since SavePrompt (improvise-lae); seven is the number of TextEntry::new call sites, which is the exact confusion repo-map gotcha 11 already warns about.

Also stale and corrected, without being wrong enough to list above: key.rs's module doc called these "crate-local key types for the command layer" — they are the protocol's, and both front ends use them.

records_named lives here (moved from improvise-command::ui::reduce by the review of PR #50). It is &ModelCommand -> Vec<RecordHash> with no App in it, and both ends need the same answer: the server to decide which records to check, a client to decide which to claim. Two copies made the client's half untestable — lockstep.rs restates the basis against its twin host, so it compares the server's answer with itself. One home makes the claim right by construction, and the exhaustive match is the guard it always was: a new record-writing variant fails the build.


§8 help.rs — where UI strings live relative to a wire enum

Three tickets asked one question three times — improvise-x7sw (which-key hints have no protocol home), improvise-ktv1 (the browser has no help overlay) and improvise-k099 (AppMode::hint_text/label put terminal UI strings on a serde enum). Answering it three times is the disease. The answer:

A UI string is keyed by a command name, never by a key, and never hangs off the enum that crosses the wire. AppMode::label and AppMode::hint_text are gone; help::mode_label and help::hint_line replace them. The enum a client deserializes now carries no English at all.

And wherever a string names a key, the key is looked up rather than spelled. Hint { keys, command, caption } is the unit: keys come off Keymaps::default_keymaps(), command is the semantic value a localized or non-terminal front end keys off, and caption is the default English — exactly the AxisColor/draw.rs::axis_fg shape, with the decision shared and only the last translation local (invariant(semantic_palette_at_the_core_boundary)).

What that bought, and what it cost

It deleted every_hint_key_resolves_in_its_mode and its 60-line tokenizer. That test existed to hold a hand-written string honest against the keymap; the string is derived now, so the property is true by construction. A consolidation that keeps its compatibility test is unfinished (doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)), and this is the deletion that measures this one. What replaced it, every_advertised_hint_names_a_binding_that_exists, checks the other direction — a mode advertising a command nothing binds — which was never checkable before.

It also deleted Keymap::binding_hints: help::which_key is the one walker, and the binding dump reads it too rather than a second one.

The cost is that the hints changed shape. They are longer, they name arrow keys beside hjkl, and two sub-lists in the help pages lost their visual indent. That is the price of the keys being true.

The three rules the rendering follows

  1. hint_line drops chorded keys — any key held with a modifier beyond shift, asked of the modifiers and never of the label (improvise-thpe). Hint::keys is (KeyCode, KeyModifiers) and compact takes the pairs, so the string it used to parse is not in scope to parse; Hint::labels and the help pages format last, through the one spelling in keymap::key_label. T advertises tile select; its four Ctrl-arrow aliases would push the rest of the line off the screen and teach nobody anything. A client wanting all of them reads hints(), which keeps them.
  2. It runs single-character keys together (hjkl) and slashes the rest (Esc/Tab), which is the idiom the hand-written hints already used.
  3. A help-page row whose command resolves to no keys is dropped, so a page can never advertise a key that does not exist. keys() returning Option is what makes that unrepresentable rather than validated.

What is not derived, and why

The wizard's. AppMode::ImportWizard's whole keymap is one Any binding into HandleWizardKey, which decides per step inside the command — so there is nothing in the table to look up, and its hint is a hand-written tail. That is a real hole and it is where it is visible, not papered over: a keymap cannot describe keys a command resolves privately.

The ex-commands. :q/:w are parsed text rather than keys, so they are HelpLine::Literal — built only by the ex! macro, whose leading colon is its own — and the parser, not the keymap, is what checks them (every_ex_command_the_help_pages_show_is_one_the_parser_accepts).

This paragraph used to say the same of gg, yy and ZZ: "a prefix sequence is two addresses and not one binding". Two addresses is true and was no reason to spell them by hand — Keymap::prefixed_keys_running looks the pair up the way keys_running looks up one key, and keys() asks both (improvise-wk18). A key string with no command name was also the one row no guard could reach.

Every row the pages ask for is visible to a guard. The page builders take the keys lookup as a parameter; pages() passes the real one, and help_bindings() (tests only) passes one that records (mode, command, args, description). So a check over the help overlay reads the same builders the overlay is drawn from, never a list kept beside them. That premise is a type, not a convention (improvise-v29m): HelpLine::Keys carries a KeyRow whose only constructor does the lookup, so a builder cannot type a key row the recorder would never see; and ex! refuses at compile time any usage with a bare word after the command name, so an ex row cannot smuggle a key either. resolve::tests::every_advertised_command_answers_in_its_own_mode walks it beside advertised, which is how the browser's black holes on the help page became visible (improvise-uln5).

The captions. i:edit where i entered records mode would still pass most tests here: a caption is English beside a command name and only a reader judges it — the same limit gotcha 14 recorded for the old hint test. Two checks now bite at its edges. The browser guard requires an advertised key to act, so a key whose caption promises an action and which only refuses must be named (improvise-3t5x); and only_the_edit_key_is_captioned_as_editing_the_cell asks by command that no other Normal row claims to edit (improvise-jhdf). A false caption in words neither looks for still passes.

The browser half

RenderModel carries help, help_page and help_titles, and an empty help is what says the overlay is closed — the page asks no question about the mode, the same shape col_groups uses for "no groups". web/app.js paints rows it does not author; src/ui/help.rs keeps its ratatui styles and lost ~310 lines of content that is now shared. Neither end restates what the keys are.