Files
improvise/site/docs/code/protocol.html
T
Edward Langley 787e354b9b doc-site: name context/code/pr-review.md as an omission
The `build` job fails on main and on every branch merged from it:

  doc-site: context/code/pr-review.md is neither published nor a named omission

NOT a merge loss -- scripts/build-doc-site.mjs in this branch is
byte-identical to main's, and neither lists the file. PR #96 added
context/code/pr-review.md without a manifest line, which is exactly the
gate improvise-res9 added: a doc added without a line publishes nothing
and says nothing, so the generator refuses.

Omitted rather than published, and the line is cloud-agents.md's rather
than tooling.md's. tooling.md explains checkers a reader of the code will
meet; pr-review.md is about THIS REPOSITORY'S PROCESS -- where a Codex
review appears, how to tell it from our own agents' comments, why absence
is not approval. Nobody visiting a website is about to read a PR thread.

Both homes updated, because an omission is restated beside the manifest
AND in doc-site.md section 2, and the prose there counted them ("Neither
is referenced...") so it went stale at three.

This strays into scripts/, which is Wave N's ground. Flagged in the
hand-back; it is a one-line data entry either way if the orchestrator
would rather publish it.

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

363 lines
134 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>improvise-protocol — improvise docs</title>
<link rel="stylesheet" href="../style.css" />
</head>
<body>
<div class="shell">
<nav class="side"><a class="brand" href="../index.html">improvise docs</a><input type="checkbox" id="navtoggle" class="navtoggle" /><label class="navsummary" for="navtoggle">Pages</label><div class="navlinks"><ul><li><a href="../../index.html">← Project home</a></li><li><a href="../../app/">Browser demo</a></li><li><a href="https://github.com/fiddlerwoaroof/improvise">Source on GitHub</a></li></ul><h3>Start here</h3><ul><li><a href="../design-notes.html">Design notes</a></li><li><a href="../design-principles.html">Design principles</a></li><li><a href="../repo-map.html">Repository map</a></li></ul><h3>Decision records</h3><ul><li><a href="../adr/001-concurrent-edits.html">ADR 001 — Concurrent edits</a></li><li><a href="../adr/002-naming-a-cell.html">ADR 002 — Naming a cell</a></li><li><a href="../adr/003-live-backed-models.html">ADR 003 — Live-backed models</a></li></ul><h3>The arguments, by area</h3><ul><li><a href="../code/core.html">improvise-core</a></li><li><a href="../code/formula-and-io.html">improvise-formula and improvise-io</a></li><li><a href="../code/protocol.html" class="here">improvise-protocol</a></li><li><a href="../code/command.html">improvise-command</a></li><li><a href="../code/clients.html">The clients</a></li><li><a href="../code/tui.html">The TUI</a></li><li><a href="../code/gp.html">gp-tree and improvise-gp</a></li><li><a href="../code/kb-tool.html">The kb tool</a></li><li><a href="../code/tooling.html">The repository's own checkers</a></li></ul><h3>Test evidence</h3><ul><li><a href="../test-evidence/core.html">improvise-core</a></li><li><a href="../test-evidence/formula-and-io.html">improvise-formula and improvise-io</a></li><li><a href="../test-evidence/protocol.html">improvise-protocol</a></li><li><a href="../test-evidence/command.html">improvise-command</a></li><li><a href="../test-evidence/clients.html">The clients</a></li><li><a href="../test-evidence/tui.html">The TUI</a></li><li><a href="../test-evidence/gp.html">gp-tree and improvise-gp</a></li><li><a href="../test-evidence/tooling.html">The repository's own gates</a></li></ul><h3>About this site</h3><ul><li><a href="../doc-site.html">About this site</a></li></ul><h3>Reference</h3><ul><li><a href="../tickets.html">Ticket index</a></li></ul></div></nav>
<main>
<p class="src">Source: <a href="https://github.com/fiddlerwoaroof/improvise/blob/main/context/code/protocol.md">context/code/protocol.md</a></p><details class="toc" open><summary>On this page</summary><ul><li class="l1"><a href="#improvise-protocol-the-wire-vocabulary-and-the-portable-session-slice">improvise-protocol — the wire vocabulary and the portable session slice</a></li><li class="l2"><a href="#1-lib-rs-the-portable-slice">1. lib.rs — the portable slice</a></li><li class="l3"><a href="#what-may-live-on-viewstate">What may live on ViewState</a></li><li class="l3"><a href="#viewcursor-and-the-cursors-map">ViewCursor and the cursors map</a></li><li class="l3"><a href="#viewstate-active-view-is-the-second-copy-deliberately">ViewState::active_view is the second copy, deliberately</a></li><li class="l3"><a href="#appmode-from-name-not-every-variant-is-nameable">AppMode::from_name — not every variant is nameable</a></li><li class="l3"><a href="#panel">Panel</a></li><li class="l3"><a href="#all-walks-a-cycle-and-its-totality-is-tested-rather-than-structural">all() walks a cycle, and its totality is tested rather than structural</a></li><li class="l2"><a href="#2-reduce-rs-the-wire-vocabulary">2. reduce.rs — the wire vocabulary</a></li><li class="l3"><a href="#derived-not-invented">Derived, not invented</a></li><li class="l3"><a href="#why-two-model-effects-are-key-path-only">Why two model effects are key-path only</a></li><li class="l3"><a href="#command-switchview-has-a-foot-in-both-halves">Command::SwitchView has a foot in both halves</a></li><li class="l3"><a href="#command-resetcursor-is-an-invalidation-never-a-position">Command::ResetCursor is an invalidation, never a position</a></li><li class="l3"><a href="#command-model-is-a-struct-variant">Command::Model is a struct variant</a></li><li class="l3"><a href="#modelcommand-setcell-takes-a-recordcell-and-nothing-else">ModelCommand::SetCell takes a RecordCell and nothing else</a></li><li class="l3"><a href="#reduce-view">reduce_view</a></li><li class="l2"><a href="#3-render-rs-the-fold">3. render.rs — the fold</a></li><li class="l3"><a href="#there-is-no-aggregate-render-cache">There is no aggregate render cache</a></li><li class="l3"><a href="#documentcache-a-widget-not-a-viewstate-field-improvise-z754">DocumentCache — a widget, not a ViewState field (improvise-z754)</a></li><li class="l3"><a href="#cellid-the-kind-of-identity-is-the-permission">CellId: the kind of identity is the permission</a></li><li class="l3"><a href="#cellid-derived-a-formula-owns-the-cell-improvise-ir7l">CellId::Derived — a formula owns the cell (improvise-ir7l)</a></li><li class="l3"><a href="#cellid-create-the-column-writes-no-cell-improvise-poia">CellId::Create — the column writes no cell (improvise-poia)</a></li><li class="l3"><a href="#celldisplay-id-none-is-not-aggregate">CellDisplay::id — None is not &quot;aggregate&quot;</a></li><li class="l3"><a href="#celldisplay-edit-text-the-difference-corrupts-data">CellDisplay::edit_text — the difference corrupts data</a></li><li class="l3"><a href="#axisframe-the-grid-s-frame-and-why-it-is-one-type">AxisFrame — the grid&#39;s frame, and why it is one type</a></li><li class="l3"><a href="#axisframe-four-accessors-that-are-not-for-a-draw-loop">AxisFrame: four accessors that are not for a draw loop</a></li><li class="l3"><a href="#the-width-rule-has-one-home">The width rule has one home</a></li><li class="l3"><a href="#gridcache-is-viewport-indexed-and-gridextent-is-not">GridCache is viewport-indexed, and GridExtent is not</a></li><li class="l3"><a href="#gridcache-totals-empty-means-no-totals-not-not-caught-up">GridCache::totals — empty means &quot;no totals&quot;, not &quot;not caught up&quot;</a></li><li class="l3"><a href="#categorytreeupdate-items-and-axes-have-no-producer-deliberately">CategoryTreeUpdate::Items and ::Axes have no producer, deliberately</a></li><li class="l3"><a href="#the-fold-is-not-commutative-three-rules-in-apply">The fold is not commutative — three rules in apply</a></li><li class="l3"><a href="#invalidate-relabelled-a-cats-change-re-identifies-everything">invalidate_relabelled — a cats change re-identifies everything</a></li><li class="l3"><a href="#bootstrap-is-a-compaction-not-a-replay">Bootstrap is a compaction, not a replay</a></li><li class="l3"><a href="#categorytreecache-carries-every-category-prefixed-included">CategoryTreeCache carries every category, _-prefixed included</a></li><li class="l3"><a href="#wizardcache-has-a-shape-and-deliberately-no-projection">WizardCache has a shape and deliberately no Projection</a></li><li class="l3"><a href="#projection-carries-no-multi-widget-variant">Projection carries no multi-widget variant</a></li><li class="l3"><a href="#colwidths-stays-diffed-against-the-mirror-and-the-ticket-s-reason-was-wrong">ColWidths stays diffed against the mirror, and the ticket&#39;s reason was wrong</a></li><li class="l3"><a href="#gridcache-records-what-kind-of-grid-this-is-improvise-e5pf">GridCache::records — what kind of grid this is (improvise-e5pf)</a></li><li class="l2"><a href="#3a-the-frame-s-group-boundaries-and-its-position-in-the-axis">§3a. The frame&#39;s group boundaries and its position in the axis</a></li><li class="l2"><a href="#4-frame-rs-one-envelope-pair-two-deployments">4. frame.rs — one envelope pair, two deployments</a></li><li class="l3"><a href="#the-framing-adds-as-little-as-it-can-get-away-with">The framing adds as little as it can get away with</a></li><li class="l3"><a href="#there-were-three-pairs-for-two-deployments-improvise-xks6">There were three pairs for two deployments (improvise-xks6)</a></li><li class="l3"><a href="#the-mirror-is-gone-and-so-are-the-two-tests-that-held-it-honest">The mirror is gone, and so are the two tests that held it honest</a></li><li class="l3"><a href="#clientframe-commands-the-frame-is-the-unit-of-atomicity">ClientFrame::Commands — the frame is the unit of atomicity</a></li><li class="l3"><a href="#clientframe-save-is-a-request-not-a-command">ClientFrame::Save is a request, not a Command</a></li><li class="l3"><a href="#clientframe-key-and-a-correction">ClientFrame::Key — and a correction</a></li><li class="l3"><a href="#serverframe-has-three-variants-because-sessions-apply-has-three-cases">ServerFrame has three variants because Sessions::apply has three cases</a></li><li class="l2"><a href="#5-basis-rs-what-a-batch-was-composed-against">5. basis.rs — what a batch was composed against</a></li><li class="l3"><a href="#the-problem-is-not-ordering">The problem is not ordering</a></li><li class="l3"><a href="#three-tiers-and-the-tiering-is-the-whole-economy">Three tiers, and the tiering is the whole economy</a></li><li class="l3"><a href="#the-client-understands-none-of-this">The client understands none of this</a></li><li class="l3"><a href="#basis-has-no-default-and-no-public-constructor">Basis has no Default and no public constructor</a></li><li class="l3"><a href="#claim-omits-rather-than-invents">claim omits rather than invents</a></li><li class="l3"><a href="#the-publication-is-what-adr-001-omitted">The publication is what ADR 001 omitted</a></li><li class="l3"><a href="#which-records-are-checked-is-the-server-s-decision">Which records are checked is the server&#39;s decision</a></li><li class="l3"><a href="#published-takes-a-btreemap">published() takes a BTreeMap</a></li><li class="l3"><a href="#rejection-keeps-structural-and-data-staleness-apart">Rejection keeps structural and data staleness apart</a></li><li class="l3"><a href="#two-classes-live-in-one-type">Two classes live in one type</a></li><li class="l2"><a href="#6-keymap-rs-one-table-both-ends">6. keymap.rs — one table, both ends</a></li><li class="l3"><a href="#the-boundary-that-did-not-move">The boundary that did not move</a></li><li class="l3"><a href="#modekey-and-appmode-are-two-different-enumerations">ModeKey and AppMode are two different enumerations</a></li><li class="l3"><a href="#lookup-fallback-order">Lookup fallback order</a></li><li class="l3"><a href="#textentry-the-spine-welded">TextEntry — the spine, welded</a></li><li class="l3"><a href="#panelkeymap">PanelKeymap</a></li><li class="l3"><a href="#normal-mode-bindings-worth-a-note">Normal-mode bindings worth a note</a></li><li class="l2"><a href="#7-viewmodel-rs-the-residue-between-a-cache-and-a-renderer">7. viewmodel.rs — the residue between a cache and a renderer</a></li><li class="l2"><a href="#8-key-rs">8. key.rs</a></li><li class="l2"><a href="#9-corrections-made-while-relocating-this-prose-2026-09-20">9. Corrections made while relocating this prose (2026-09-20)</a></li><li class="l2"><a href="#8-help-rs-where-ui-strings-live-relative-to-a-wire-enum">§8 help.rs — where UI strings live relative to a wire enum</a></li><li class="l3"><a href="#what-that-bought-and-what-it-cost">What that bought, and what it cost</a></li><li class="l3"><a href="#the-three-rules-the-rendering-follows">The three rules the rendering follows</a></li><li class="l3"><a href="#what-is-not-derived-and-why">What is not derived, and why</a></li><li class="l3"><a href="#the-browser-half">The browser half</a></li></ul></details><h1 id="improvise-protocol-the-wire-vocabulary-and-the-portable-session-slice"><code>improvise-protocol</code> — the wire vocabulary and the portable session slice</h1>
<p>The arguments that used to live in this crate&#39;s comments (<a class="tid" href="../tickets.html#improvise-9c8c" title="Move the reasoning out of code comments into context/ prose, cap comments at two lines">improvise-9c8c</a>). 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.</p>
<p>The crate crosses a process boundary, so it depends on <code>improvise-core</code> and <code>serde</code> and nothing else — no terminal backend, no command layer, no IO. <code>improvise-command</code> depends on <em>this</em>, never the reverse, and that direction is what lets a client hold the view half without pulling in the host. <code>boundary_tests::the_wire_types_need_nothing_but_core_and_serde</code> is the part the compiler can hold; the CI <code>wasm</code> job is the other half, since a crate that pulled in crossterm would stop building for wasm32.</p>
<p>Three things live here and it is worth being precise about which travels:</p>
<ul>
<li><strong><code>ViewState</code></strong> (<code>lib.rs</code>) is a <em>projection of the command stream</em>, not a wire message. The server keeps a mirror of each client&#39;s view state by applying the same commands the client applies, through the shared <code>reduce_view</code>. It is serialized at exactly one point — the bootstrap <code>Snapshot</code> (<a class="tid" href="../tickets.html#improvise-q08" title="improvise-ws-server binary (tokio + tungstenite session wrapper)">improvise-q08</a>). Steady-state traffic is commands only.</li>
<li><strong><code>Command</code></strong> (<code>reduce.rs</code>) is the wire format, in both directions: user intent client→server, <code>Projection</code>s server→client.</li>
<li><strong><code>ClientFrame</code>/<code>ServerFrame</code></strong> (<code>frame.rs</code>) is the <em>framing</em> rather than the vocabulary: which of the above a given JSON blob is.</li>
</ul>
<hr />
<h2 id="1-lib-rs-the-portable-slice"><a class="anchor" href="#1-lib-rs-the-portable-slice">#</a>1. <code>lib.rs</code> — the portable slice</h2>
<h3 id="what-may-live-on-viewstate"><a class="anchor" href="#what-may-live-on-viewstate">#</a>What may live on <code>ViewState</code></h3>
<p>Two properties gate membership, and both are load-bearing (<a class="tid" href="../tickets.html#improvise-h0b" title="Evict wizard and drill_state from ViewState (bootstrap-snapshot hygiene)">improvise-h0b</a>):</p>
<ul>
<li><strong>portable</strong> — plain data from the allowlisted crates, no shared-ownership containers. The <code>ViewState: Send</code> static assertion is the compile-time proxy (<code>Rc</code> is not <code>Send</code>); the <code>Deserialize</code> derive rejects it too, serde&#39;s <code>rc</code> feature being off. <code>Send</code> does <strong>not</strong> catch the origin-crate or snapshot-safety halves — check those by hand.</li>
<li><strong>snapshot-safe</strong> — a fresh client inheriting the value is <em>correct</em>. Staged uncommitted writes and half-open dialogs are not.</li>
</ul>
<p>State failing either test lives on <code>App</code> instead, under the <em>session-local residue</em> tag: <code>wizard</code> (an <code>improvise-io</code> type, and a half-open dialog is not something to inherit) and <code>drill_state</code> (holds an <code>Rc</code>, and <code>pending_edits</code> are uncommitted writes belonging to whoever staged them).</p>
<p>The constraint this puts on new fields: <strong>no borrowed-<code>&#39;static</code> fields anywhere <code>ViewState</code> can reach</strong> — <code>Deserialize</code> cannot produce one, and the error surfaces on every <em>enclosing variant</em> rather than at the offending field. That is why <code>MinibufferConfig::buffer_key</code> is a <code>String</code> and not a <code>&amp;&#39;static str</code> (<a class="tid" href="../tickets.html#improvise-a2x" title="Derive Serialize/Deserialize for the ViewState field types">improvise-a2x</a>).</p>
<h3 id="viewcursor-and-the-cursors-map"><a class="anchor" href="#viewcursor-and-the-cursors-map">#</a><code>ViewCursor</code> and the <code>cursors</code> map</h3>
<p>Per-client (<a class="tid" href="../tickets.html#improvise-8uz" title="Cursor and scroll are per-client state stored in the shared View">improvise-8uz</a>). It used to live on <code>View</code>, inside the shared workbook, which meant one client scrolling moved everyone&#39;s viewport. <code>View</code> 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.</p>
<h3 id="viewstate-active-view-is-the-second-copy-deliberately"><a class="anchor" href="#viewstate-active-view-is-the-second-copy-deliberately">#</a><code>ViewState::active_view</code> is the second copy, deliberately</h3>
<p><code>Workbook::active_view</code> is the document&#39;s; <code>ViewState::active_view</code> is the client&#39;s, and it is what keys <code>cursors</code>. The second copy exists because <code>reduce_view</code> runs on a client that holds no <code>Workbook</code> (<a class="tid" href="../tickets.html#improvise-cqi" title="Extract improvise-protocol crate (Command, ViewState, reduce_view)">improvise-cqi</a>). The host keeps the two in step; whether they are <em>allowed</em> to diverge — two clients on different views — is <a class="tid" href="../tickets.html#improvise-g0f" title="Decide whether two clients may view different views">improvise-g0f</a>. See repo-map gotchas 29/36.</p>
<h3 id="appmode-from-name-not-every-variant-is-nameable"><a class="anchor" href="#appmode-from-name-not-every-variant-is-nameable">#</a><code>AppMode::from_name</code> — not every variant is nameable</h3>
<p>The list is exactly what <code>improvise-command</code>&#39;s registry accepted before the move, carried across unchanged, because changing it here changes what <code>enter-mode</code> accepts in a script. It lives beside <code>AppMode</code> rather than in the registry because the <em>names</em> are part of the keymap vocabulary and the keymap is shared: a browser resolving <code>enter-mode</code> must decode the same spellings, and two tables would be the mirror that move existed to delete.</p>
<p>Three variants are absent, for two reasons. <code>ItemAdd</code> 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. <code>Quit</code> and <code>ImportWizard</code> are entered by their own commands — <code>quit</code> and <code>start-import-wizard</code> — which do work beyond changing the mode, so <code>enter-mode</code> would be a way to reach half of each.</p>
<h3 id="panel"><a class="anchor" href="#panel">#</a><code>Panel</code></h3>
<p><code>Panel::ALL</code>/<code>name()</code>/<code>key()</code> drive the toggle keys for Normal <em>and</em> all three panel keymaps, so a fourth panel reaches every keymap from one <code>ALL</code> entry. <code>name()</code> must spell what <code>parse_panel</code> accepts. <code>Panel::mode()</code> is the inverse of <code>AppMode::panel()</code> for the three top-level panel modes only — the sub-modes (<code>FormulaEdit</code>, <code>CategoryAdd</code>, <code>ItemAdd</code>) map back to their panel but not forwards, which is what keeps a panel&#39;s styling active while the user types into its minibuffer.</p>
<p><code>AppMode::hint_text()</code> is checked against the keymaps by <code>every_hint_key_resolves_in_its_mode</code> in <code>improvise-command</code>; <code>Quit</code> alone shows nothing, because it is never rendered.</p>
<h3 id="all-walks-a-cycle-and-its-totality-is-tested-rather-than-structural"><a class="anchor" href="#all-walks-a-cycle-and-its-totality-is-tested-rather-than-structural">#</a><code>all()</code> walks a cycle, and its totality is tested rather than structural</h3>
<p><code>AppMode::all()</code> and <code>Rejection::all()</code> walk a total, cyclic <code>after()</code>. Both doc comments used to claim a new variant could not be omitted, because <code>after()</code> has no <code>None</code> arm. <strong>That is half true and the wrong half.</strong> Answering <code>after()</code> <em>for</em> a new variant is compiler-forced; pointing an existing arm <em>at</em> it is not, so a variant nothing names is simply never walked — <code>all().len()</code> came back 17 where the enum had 18, with the suite green (<a class="tid" href="../tickets.html#improvise-5ubs" title="A cyclic-successor all() can silently omit a variant while its doc says it cannot">improvise-5ubs</a>). The general statement is <code>doctrine(a_list_checked_for_length_reads_like_a_list_checked_for_completeness)</code>: an <code>assert_eq!(cases.len(), 13)</code> catches a case being <em>deleted</em> from a list and never one being <em>absent</em> from it, and the two are written identically.</p>
<p>Closing it needs an enumeration independent of <code>after()</code>, and without one the ticket&#39;s own first option restates the problem. There is no such enumeration in stable Rust for an enum with data — <code>variant_count</code> is nightly, a derive macro means a new proc-macro crate, and <code>strum</code>&#39;s <code>EnumIter</code> cannot construct these variants at all, since <code>AppMode::ItemAdd</code> carries a category name and <code>Editing</code> a whole <code>MinibufferConfig</code>. What there <em>is</em> is <strong>serde</strong>: the <code>Deserialize</code> derive already builds the complete variant-name list, from the enum definition, maintained by the compiler, and publishes it in its <code>unknown variant … expected one of …</code> message. <code>variants.rs</code> reads it back and diffs it against the names the cycle actually reached.</p>
<p>Three things this costs, and they are the reason the module is defensive rather than three lines. The message is <strong>parsed</strong>, so a serde whose wording changes must fail loudly instead of reporting an empty list that passes every cycle — hence the <code>unknown variant</code> prefix assertion and the <code>names.len() &gt; 1</code> floor. Both live in <code>parse_expected</code>, a seam separated from the deserialization <strong>because a <code>should_panic</code> pointed at a real type reaches only one of the three refusals</strong> and all three then shared one panic message, so <code>expected = …</code> 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 <strong>its own serialization</strong> rather than by <code>Debug</code>, because an externally tagged unit variant is a string and a struct variant a one-key object, and reading a name off <code>Debug</code> would depend on the field list. And it checks only that every declared variant is <em>reached</em>: an <code>after()</code> 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.</p>
<p>What it does <strong>not</strong> reach: the fourth instance of this shape, <code>layout_geometry_effects_actually_change_document_config</code> in <code>improvise-command/src/ui/effect.rs</code>, whose <code>assert_eq!(cases.len(), 13)</code> is over a hand-built list of <em>effects</em>, not enum variants, so serde has nothing to say about it. Filed as <a class="tid" href="../tickets.html#improvise-26dm" title="The fourth length-not-completeness list: cases.len() == 13 in ui/effect.rs">improvise-26dm</a> rather than bodged, because a fourth hand-rolled attempt in a fourth place is the disease.</p>
<hr />
<h2 id="2-reduce-rs-the-wire-vocabulary"><a class="anchor" href="#2-reduce-rs-the-wire-vocabulary">#</a>2. <code>reduce.rs</code> — the wire vocabulary</h2>
<h3 id="derived-not-invented"><a class="anchor" href="#derived-not-invented">#</a>Derived, not invented</h3>
<p><code>Command</code> is not a fresh invention (<code>doctrine(derive_the_enum_from_the_effects)</code>):</p>
<ul>
<li>The <strong>view half</strong> is every <code>EffectKind::ViewOnly</code> effect in <code>improvise-command</code> whose target is the portable slice (<code>decision(improvise-cqi, command_set_derived_not_invented)</code>). Effects touching host-local state (the import wizard, staged drill edits, the storage backend, the layout cache) are deliberately absent.</li>
<li>The <strong>model half</strong> is <code>ModelCommand</code>: every <code>EffectKind::Model(..)</code> effect, one variant apiece, carrying that effect&#39;s own fields (<a class="tid" href="../tickets.html#improvise-kmgs" title="Derive the model half of the Command vocabulary from the EffectKind::Model effects">improvise-kmgs</a>).</li>
</ul>
<p>The count is 29 model effects, minus three: <code>SwitchView</code>, which <code>Command</code> already names and routes, and <code>ApplyAndClearDrill</code>/<code>AcceptWizardModel</code>, which are <strong>not addressable</strong> (<a class="tid" href="../tickets.html#improvise-3x3u" title="Resolve the two unaddressed nullary wire variants before concurrency lands">improvise-3x3u</a>). That leaves <strong>26</strong>. A variant with no data mirrors a nullary effect whose operand is document state the authoritative <code>App</code> already holds.</p>
<p>Two derivation traps worth keeping:</p>
<ul>
<li>The obvious <code>awk</code> extraction reports <strong>30</strong> — it picks up <code>HelpPageSet</code>, which is <code>ViewOnly</code>, because that is the last <code>pub struct</code> before <code>#[cfg(test)]</code> and the test module mentions <code>ModelChange::</code>. Brace-match the <code>impl Effect</code> blocks instead (<code>doctrine(derive_then_verify)</code>).</li>
<li><a class="tid" href="../tickets.html#improvise-kmgs" title="Derive the model half of the Command vocabulary from the EffectKind::Model effects">improvise-kmgs</a> derived the set <strong>one effect at a time</strong>, which was correct per element and wrong for a set: it put <code>SetCell</code> on the wire without its <code>MarkDirty</code> companion, so &quot;save if dirty&quot; never fired (<a class="tid" href="../tickets.html#improvise-gv9r" title="[bug] A wire model command never marks the document dirty">improvise-gv9r</a>). <code>MarkDirty</code> is gone; <code>App::apply_effects</code> now raises the flag off <code>EffectKind::Model(_)</code> directly.</li>
</ul>
<h3 id="why-two-model-effects-are-key-path-only"><a class="anchor" href="#why-two-model-effects-are-key-path-only">#</a>Why two model effects are key-path only</h3>
<p>A command on this wire is routed by <code>Sessions::apply</code> with <strong>no <code>SessionId</code></strong>, which is correct precisely when the change is addressed to no one session (gotcha 36). <code>ApplyAndClearDrill</code> and <code>AcceptWizardModel</code> fail that test: their operands — <code>drill_state.pending_edits</code> and the built wizard workbook — are session-local residue, private to whoever staged them (<a class="tid" href="../tickets.html#improvise-tom1" title="Drill pending edits are invisible to remote clients, and there are now three answers to &#39;what text is in this cell&#39;">improvise-tom1</a>). Unaddressed, client B&#39;s <code>ApplyAndClearDrill</code> commits client A&#39;s uncommitted typing, and B&#39;s <code>AcceptWizardModel</code> replaces the document out of a dialogue B never opened. Both were demonstrated against <code>Sessions::apply</code> before removal.</p>
<p><em>Portability of the command</em> was the criterion when they were kept; <strong>addressability</strong> is the criterion that matters. Not permanent: multi-client drill wants the addressed form (the variant carries a <code>SessionId</code>, the staging map moves onto the session), which is where ADR 001 layer 2 goes anyway.</p>
<h3 id="command-switchview-has-a-foot-in-both-halves"><a class="anchor" href="#command-switchview-has-a-foot-in-both-halves">#</a><code>Command::SwitchView</code> has a foot in both halves</h3>
<p>The <code>SwitchView</code> <em>effect</em> wears <code>EffectKind::Model(LayoutGeometry)</code>, so the model-half derivation lands on it too — and <code>ModelCommand</code> deliberately does <strong>not</strong> name it a second time (<a class="tid" href="../tickets.html#improvise-kmgs" title="Derive the model half of the Command vocabulary from the EffectKind::Model effects">improvise-kmgs</a>). <code>ModelCommand::ViewBack</code> and <code>ViewForward</code> also move the active view and so carry the same document half.</p>
<h3 id="command-resetcursor-is-an-invalidation-never-a-position"><a class="anchor" href="#command-resetcursor-is-an-invalidation-never-a-position">#</a><code>Command::ResetCursor</code> is an invalidation, never a position</h3>
<p>Derived from <code>App::reset_cursor</code>, the write <code>TransposeAxes</code> and <code>CycleAxis</code> make after changing the grid&#39;s shape (gotcha 24). It is a variant rather than a <code>SetSelected</code> plus two offsets for a reason that is not brevity: what the server says is <strong>&quot;your cursor is no longer valid&quot;</strong>, not &quot;be here&quot;. Where a client then puts its cursor is its own business — the model is shared, the UI is not — so broadcasting one client&#39;s position to everyone would be the wrong message even though origin happens to be where each of them lands today (<a class="tid" href="../tickets.html#improvise-gvnb" title="[bug] A geometry command resets the App cursor but not the session mirrors">improvise-gvnb</a>).</p>
<h3 id="command-model-is-a-struct-variant"><a class="anchor" href="#command-model-is-a-struct-variant">#</a><code>Command::Model</code> is a struct variant</h3>
<p>So the basis cannot be forgotten. ADR 001&#39;s check is worth nothing if a batch may arrive without one, and the only enforceable version of &quot;you must also state X&quot; is not being able to build the message without X. There is no <code>Basis::default()</code> for the same reason — see §5.</p>
<p>Note what the struct variant does <em>not</em> buy: it closed omission <strong>from the message</strong> and did nothing to close ignoring <strong>on arrival</strong>, which is the half a struct variant is usually reached for. That hole needed a type — <code>Checked</code>/<code>CheckedBatch</code> in <code>improvise-command</code> (<a class="tid" href="../tickets.html#improvise-ondj" title="[bug] reduce_full/apply_command apply a Command::Model without the basis check">improvise-ondj</a>).</p>
<h3 id="modelcommand-setcell-takes-a-recordcell-and-nothing-else"><a class="anchor" href="#modelcommand-setcell-takes-a-recordcell-and-nothing-else">#</a><code>ModelCommand::SetCell</code> takes a <code>RecordCell</code> and nothing else</h3>
<p>A <code>CellKey</code> 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 <code>_Dim</code> 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 <em>record</em> identity, a client holding one for an aggregate cell cannot build this variant at all (ADR 002).</p>
<p><code>LoadModel</code> and <code>ImportJsonHeadless</code> name paths in the <strong>host&#39;s</strong> <code>Storage</code>, never the client&#39;s filesystem (gotcha 2).</p>
<p>The variants are grouped by the <code>ModelChange</code> level their effect declares, because that level is what <code>Dirty::of</code> turns into projections (gotcha 35). Adding a variant here means classifying its effect there.</p>
<h3 id="reduce-view"><a class="anchor" href="#reduce-view">#</a><code>reduce_view</code></h3>
<p>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 (<a class="tid" href="../tickets.html#improvise-cqq" title="Server-side projection emission layer + per-client viewport tracking">improvise-cqq</a>, <a class="tid" href="../tickets.html#improvise-m91" title="(Stretch) Command log + native undo/replay">improvise-m91</a>). Total and deterministic: no IO, no clock, no failure.</p>
<p><code>Command::Project</code> and <code>Command::Model</code> are both no-ops in it, for different reasons. A projection is routed to one widget&#39;s <code>WidgetCache</code> instead. A model command is the <em>document&#39;s</em> business: what a client must fold arrives as the projections it dirtied, plus — when the active view moved — the <code>SwitchView</code> the host broadcasts.</p>
<p>A <code>ModelCommand</code> does not mutate anything by itself: <code>improvise-command</code>&#39;s <code>model_effects</code> maps it back to the effect it came from and runs it through the same <code>App::apply_effects</code> the key path uses. Effects stay the only mutation site, and the <code>Dirty</code> is the effect&#39;s own <code>EffectKind</code> — never a second classification beside gotcha 35&#39;s table.</p>
<hr />
<h2 id="3-render-rs-the-fold"><a class="anchor" href="#3-render-rs-the-fold">#</a>3. <code>render.rs</code> — the fold</h2>
<h3 id="there-is-no-aggregate-render-cache"><a class="anchor" href="#there-is-no-aggregate-render-cache">#</a>There is no aggregate render cache</h3>
<p>Each widget owns its own cache type and folds its own stream of projections (<a class="tid" href="../tickets.html#improvise-rkeq" title="Decide RenderCache&#39;s shape: grid-only wire type vs per-widget sub-caches">improvise-rkeq</a>): the grid does not learn that a formula was deleted, and the formula panel does not learn that a cell changed. A <code>Projection</code> is one such update, addressed to exactly one widget, and <code>WidgetCache::apply</code> is the fold.</p>
<p>The overlap between <code>TileBarCache</code> and <code>CategoryTreeCache</code> — both carry <code>(category, axis)</code> — is <strong>deliberate</strong>, so neither is woken by the other&#39;s changes. Do not &quot;fix&quot; it by sharing a variant.</p>
<p><code>WidgetCache::apply</code> is total and deterministic, like <code>reduce_view</code>: an update naming something the cache does not have is <strong>dropped, never a panic</strong>. Projections race on the wire, and a client that crashes on a stale index is a client that crashes.</p>
<p><code>Projection::Basis</code> is not a widget — nothing draws it — but it is folded on exactly the same terms; see §5.</p>
<h3 id="documentcache-a-widget-not-a-viewstate-field-improvise-z754"><a class="anchor" href="#documentcache-a-widget-not-a-viewstate-field-improvise-z754">#</a><code>DocumentCache</code> — a widget, not a <code>ViewState</code> field (<a class="tid" href="../tickets.html#improvise-z754" title="Document name, dirty flag and file path have no home a client can read">improvise-z754</a>)</h3>
<p><code>draw_title</code> read <code>workbook.model.name</code>, <code>model_state.dirty</code> and <code>model_state.file_path</code>. None was projected, so a browser could not draw a title bar at all.</p>
<p><strong>A projection rather than a <code>ViewState</code> field</strong>, and the argument is gotcha 30&#39;s membership test read backwards. <code>ViewState</code> is the <em>portable session slice</em>: per-client UI a client folds its own commands into. These three are facts about the <strong>document</strong>, shared by every client and writable only by the server — on <code>ViewState</code> they would be document state wearing a session slice&#39;s clothes, and <code>reduce_view</code> would need commands nothing composes. The per-widget cache is exactly the shape for &quot;a local copy of what a widget draws, replaced by a <code>Projection</code>&quot;.</p>
<p><strong>It is the one widget no <code>EffectKind</code> can gate.</strong> <code>dirty</code> is raised by any <code>EffectKind::Model(_)</code> in <code>apply_effects</code> and cleared by <code>effect::Save</code>, which is <code>ViewOnly</code> (gotcha 3) and correctly so. <code>SaveAs</code> moves <code>file_path</code> and is also <code>ViewOnly</code>. So <code>Dirty::of</code> reports <code>NOTHING</code> for exactly the effects that change this widget most visibly, and gating it on <code>Dirty</code> would leave every client showing a <code>[+]</code> for a document that has been saved — the forever-wrong half of gotcha 35. <code>ClientSession::document_update</code> therefore <strong>observes</strong>: it builds the desired cache and diffs it against the mirror, the same answer <code>record_versions</code> got for the same reason (gotcha 40), and the same reason <code>cursor_epoch</code> beats matching on <code>CycleAxis</code>. The diff, not a flag, is what keeps an ungated widget from projecting on every batch.</p>
<p><strong>A save is projected, not silent.</strong> <code>Host::save</code> ends with <code>project_all()</code> and the worker re-projects after its own <code>persist()</code>, because both clear <code>model_state.dirty</code> <em>outside</em> any batch <code>Dirty</code> describes. gotcha 45&#39;s &quot;answered silently on success — nothing moved, so no widget is dirty&quot; was true until this cache existed and is now exactly wrong: the title bar moved. A test that reaches for <code>Sessions::project_all</code> by hand proves nothing here, because that is the call the production paths omitted.</p>
<p><strong><code>stored_as</code> is a name, never a path.</strong> The TUI already drew only <code>file_name()</code>, and a socket server projecting <code>file_path</code> would hand a remote client the host&#39;s directory layout — useless to it and a leak. What a client can act on is <em>whether the document has a home at all</em>, which is the fact <code>ClientFrame::Save</code> fails on (<code>NoDocumentPath</code>, gotcha 45), so <code>None</code> carries it. A browser&#39;s answer is its OPFS entry name; neither end has to know which.</p>
<h3 id="cellid-the-kind-of-identity-is-the-permission"><a class="anchor" href="#cellid-the-kind-of-identity-is-the-permission">#</a><code>CellId</code>: the kind of identity is the permission</h3>
<p><code>SetCell</code>/<code>ClearCell</code> take a <code>RecordCell</code> and nothing else, <code>Drill</code> takes an <code>AggregateHash</code> and nothing else. A client cannot compose a write against an aggregate <strong>or</strong> a drill against a stored cell — not because either is checked, but because neither carries an id of the other&#39;s type (design-principles §3). It projects a fork the model already makes: <code>EditOrDrill</code> branches on exactly this in the TUI today.</p>
<p>An earlier draft made this <code>Option&lt;CellId&gt;</code>, absence meaning aggregate. The enum is better: an aggregate is not the <em>absence</em> of an identity, it is a different <em>kind</em> of identity — and modelling it as <code>None</code> left <code>Drill</code> with nothing to address.</p>
<p>A <code>RecordCell</code> is <code>(record hash, measure)</code> because the surrogate names the <strong>record</strong>, not the cell (ADR 002). That is what lets one identity serve both addressing and staleness: <code>record_versions</code> is keyed by the same hash, so a client&#39;s own token is the lookup key for its own version, with no <code>CellKey</code> rebuilt on the hot path. <code>measure</code> is absent for a record carrying no <code>_Measure</code> coordinate at all — the shape <code>add-record-row</code> seeds.</p>
<h3 id="cellid-derived-a-formula-owns-the-cell-improvise-ir7l"><a class="anchor" href="#cellid-derived-a-formula-owns-the-cell-improvise-ir7l">#</a><code>CellId::Derived</code> — a formula owns the cell (<a class="tid" href="../tickets.html#improvise-ir7l" title="[bug] A formula cell is offered as editable and silently reverts">improvise-ir7l</a>)</h3>
<p>A formula target is a real stored coordinate, so the record branch claimed it and every client offered an editor over it. The write <em>landed</em> and was then shadowed forever, because <code>evaluate_aggregated</code> reads <code>formula_cache</code> before raw data — not a revert, a permanent silent shadow.</p>
<p>The fix keeps ADR 002&#39;s discipline rather than adding a second answer beside it: a formula-owned cell projects a <strong>different kind of identity</strong>, so the write is unconstructible instead of flagged. <code>Derived</code> carries the formula&#39;s target and <strong>no surrogate</strong>, so there is nothing a client could spend on a <code>SetCell</code>; that is the same reason <code>Aggregate</code> carries an <code>AggregateHash</code> and not a <code>RecordHash</code>.</p>
<p><strong>In records mode the question is asked of the record&#39;s real coordinate.</strong> The synthetic <code>(_Index, _Dim)</code> key names no measure, so <code>formula_for</code> can never match it; <code>GridLayout::record_cell_key</code> is the one mapping from a records position to a model cell, and both <code>cell_id</code> and <code>CmdContext::owning_formula</code> go through it. This is not defensive: a measure can be <strong>both</strong> an explicit <code>_Measure</code> item and a formula target, and the records <code>+</code> column (<a class="tid" href="../tickets.html#improvise-poia" title="The records-mode + column has no projected identity">improvise-poia</a>) is a one-keystroke way to build one.</p>
<p>Two orderings matter in <code>project::cell_id</code>. Derived is asked <strong>before</strong> the record branch, since the coordinate is stored and the record branch would otherwise win. It is asked <strong>after</strong> the aggregate branch, because a formula cell that also aggregates is still drillable (gotcha 10) — <code>Aggregate</code> and <code>Derived</code> are not alternatives for the same cell, and reversing the two turns a drillable cell into a dead one.</p>
<p>The predicate itself is <code>Model::formula_for(&amp;CellKey)</code> in <code>improvise-core</code>, the one home of &quot;is this cell derived&quot;. It already existed three times inside <code>model/types.rs</code> as an open-coded loop; the TUI&#39;s <code>EditOrDrill</code> 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 <code>Model</code> it holds, which is legitimate (it is the authority), but it must give the <em>same</em> answer or the two front ends diverge — design-principles §5.</p>
<h3 id="cellid-create-the-column-writes-no-cell-improvise-poia"><a class="anchor" href="#cellid-create-the-column-writes-no-cell-improvise-poia">#</a><code>CellId::Create</code> — the column writes no cell (<a class="tid" href="../tickets.html#improvise-poia" title="The records-mode + column has no projected identity">improvise-poia</a>)</h3>
<p>The two records-mode creating columns — <code>+</code> and <code>+cat</code> (gotcha 1) — projected <code>None</code>, which means &quot;no projected cell&quot;. So a client could not tell a column that creates something from a padded slot, and gotcha 42&#39;s conflation had a third instance.</p>
<p><strong>The ticket offered two designs and both assumed a cell write.</strong> They do not happen: <code>commit.rs::add_measure</code> emits exactly one effect, <code>AddItem { category: &quot;_Measure&quot;, item: name }</code>, and <code>add_category</code> emits <code>AddCategory(name)</code> and deliberately writes no coordinate on the current row (<a class="tid" href="../tickets.html#improvise-2zq" title="Records mode has no inline way to add a category, unlike the + column for measures">improvise-2zq</a>). The record under the cursor is not part of the operation, and neither is any value — <strong>what the user types is a name</strong>. So there is no &quot;distinct command carrying the record surrogate plus the new measure name&quot; to invent, and no two-command flow to sequence: one existing <code>ModelCommand</code> does it, and the address it needs is the name.</p>
<p>What was missing was therefore never an address but a <strong>recognition</strong>. <code>Create</code> carries the kind and nothing else, because the kind plus the typed name is the whole operand. It is not a <code>SetCell</code> target, so <code>render.rs::editable</code> stays <code>Record</code>-only and <code>record_at_cursor</code> still answers <code>None</code> — both by matching, which the compiler now forces.</p>
<p><strong>No client composes it yet</strong>, and that is one gap rather than this one: <code>keys.rs::commit</code> returns early for every records-mode commit, because a category column renames every measure cell in the row and that fork needs <code>record_col</code>. The creating columns do not need it, so the early return is broader than its reason — <a class="tid" href="../tickets.html#improvise-gcvb" title="The browser cannot commit a records-mode creating column, though it is now nameable">improvise-gcvb</a>, which also records the two things that make it more than a one-liner: an inserted <code>+cat</code> column means the TUI does <strong>not</strong> advance, and <code>AddCategory</code> can abort.</p>
<h3 id="celldisplay-id-none-is-not-aggregate"><a class="anchor" href="#celldisplay-id-none-is-not-aggregate">#</a><code>CellDisplay::id</code> — <code>None</code> is not &quot;aggregate&quot;</h3>
<p>That is <code>Some(CellId::Aggregate(..))</code>, and the distinction is the whole point of the enum. <code>None</code> means the slot holds <strong>no projected cell</strong>: a <code>GridCache</code> position padded by <code>fit_to_labels</code>, blanked by a reframing, or a records-mode column that is not a cell to write into at all (a category column renames a coordinate across the whole row, gotcha 1, which is a different intent from <code>SetCell</code>). Conflating the two either hides a cell or offers a write the type system exists to make unconstructible.</p>
<h3 id="celldisplay-edit-text-the-difference-corrupts-data"><a class="anchor" href="#celldisplay-edit-text-the-difference-corrupts-data">#</a><code>CellDisplay::edit_text</code> — the difference corrupts data</h3>
<p>What an editor opened on this cell should start with: the value as the user last <strong>typed</strong> it, not as it is <strong>drawn</strong>. <code>text</code> has been through <code>format_f64</code>, so a stored <code>1200</code> is drawn <code>1,200</code> under a comma-grouping format; seeding an editor with that and committing it parses as <code>CellValue::Text(&quot;1,200&quot;)</code>, 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&#39;s seed is exactly a feedback path.</p>
<p>It is the projection of <code>App::display_value_at_cursor</code>, which is what <code>EditOrDrill</code> seeds the TUI&#39;s buffer from, so both front ends start from one string rather than each deriving one. <code>None</code> where there is nothing to edit: the same set of positions <code>id</code> is <code>None</code> for, plus cells whose value is absent. An error cell carries none, because its text is a diagnostic and not a value.</p>
<p>A browser found this: <code>tests/lockstep.rs</code> drove <code>i</code> through the host and the client over the same grid, and they disagreed by a comma.</p>
<p><code>numeric</code> and <code>error</code> 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. <code>CellDisplay</code> is deliberately not a <code>CellValue</code> — by the time a cell reaches a client the number formatting has already happened.</p>
<h3 id="axisframe-the-grid-s-frame-and-why-it-is-one-type"><a class="anchor" href="#axisframe-the-grid-s-frame-and-why-it-is-one-type">#</a><code>AxisFrame</code> — the grid&#39;s frame, and why it is <em>one</em> type</h3>
<p><code>AxisFrame</code> (<a class="tid" href="../tickets.html#improvise-anr9" title="Project the five grid-frame facts: unblocks H, z, gz and fixes Food/Alice at once">improvise-anr9</a>/s1xw) replaced a <code>Vec&lt;String&gt;</code> of labels that <code>GridLayout::row_label</code> built by <strong>slash-joining a multi-level tuple</strong> — the exact rendering <code>two_row_categories_produce_cross_product_labels</code> asserts must not appear. The protocol carried a form of the labels the TUI itself considered wrong, so the browser drew <code>Food/Alice</code> where the TUI drew two header columns, with no groups and no collapse indicators (gotcha 44).</p>
<p>It is one type rather than a <code>labels</code> plus a <code>levels</code> beside it, and that is the whole point: <strong>two answers to &quot;what are the labels&quot; is the disease</strong> this projection architecture exists to cure (<a class="tid" href="../tickets.html#improvise-edp" title="Establish ViewModel layer: derived data between RenderCache and widgets">improvise-edp</a>). A renderer asks this type and there is nothing else to ask. Note what this cure cost that <code>flatten_cat_tree</code> did not: a <code>Vec&lt;String&gt;</code> had to change <strong>type</strong>, and the shared derivation could not be added beside the old one.</p>
<p><code>cats</code> is the categories outermost-first; <code>entries</code> interleaves <code>Group(FrameGroup)</code> headers with <code>Item(Vec&lt;String&gt;)</code> label tuples exactly as <code>GridLayout::row_items</code> interleaves them. On the row axis the TUI paints a <code>Group</code> as its own row; on the column axis it paints the runs as spans above the headers. <strong>That difference is painting, and it is the only thing that differs.</strong></p>
<p><code>cats</code> may be empty — an axis carrying no category is a real layout (one nameless row or column), not a gap, hence <code>levels()</code> returning <strong>at least one</strong>. <code>AxisFrame::flat</code> leaves <code>cats</code> empty on purpose, so it is not a way to smuggle a multi-level axis in flattened.</p>
<p><code>FrameGroup::collapsed</code> is on the wire rather than left to the client because it is a fact about the <em>view</em>, which only the server holds. <code>FrameGroup::cat</code> travels so a client can echo it back in <code>ToggleGroup</code> without ever working out which category a label came from — which slash-joined labels made impossible.</p>
<h3 id="axisframe-four-accessors-that-are-not-for-a-draw-loop"><a class="anchor" href="#axisframe-four-accessors-that-are-not-for-a-draw-loop">#</a><code>AxisFrame</code>: four accessors that are <strong>not for a draw loop</strong></h3>
<p><code>item</code>, <code>label</code>, <code>shows_level</code> and <code>group_of</code> each scan <code>entries</code> from the start, so asking them per position is quadratic in the frame length and, in the TUI, <strong>grows with the absolute scroll offset</strong>. That defect was found three times on <a class="tid" href="../tickets.html#improvise-anr9" title="Project the five grid-frame facts: unblocks H, z, gz and fixes Food/Alice at once">improvise-anr9</a>. 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.</p>
<ul>
<li><code>shows_level</code> is the worst of the four: it materializes <strong>every</strong> item on every call. It is deliberately not made cheaper, because a fast per-position form is an invitation to the loop <code>header_labels</code> exists to replace.</li>
<li><code>group_of</code> walks <code>entries</code> twice over, once to place <code>idx</code> and once backwards.</li>
</ul>
<p>The one-pass forms a draw loop wants:</p>
<ul>
<li><strong><code>header_labels(first_rendered)</code></strong> — every header label with suppression already applied, indexed <code>[data index][level]</code>, <code>&quot;&quot;</code> where the label repeats. The suppression rule still has exactly one home (<code>fn shows</code>); this is <code>shows_level</code> applied in bulk, not a second copy of it.</li>
<li><strong><code>groups()</code></strong> — <code>group_of</code> for every position, in one pass.</li>
<li><strong><code>shares_ancestry(with)</code></strong> — the TUI&#39;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 <strong>no</strong> per-position form beside it, because one would be a second answer paid for per (column, level) — two frame scans apiece.</li>
</ul>
<p><strong><code>leaf_labels()</code> is not &quot;the labels&quot;.</strong> 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 <em>wrong</em> header, not a shorter one.</p>
<p><strong><code>first_rendered</code></strong> 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 (<a class="tid" href="../tickets.html#improvise-jjx" title="Scrolled grid suppresses first visible row-header label">improvise-jjx</a>).</p>
<p><strong><code>body_from(idx)</code></strong> drops the group header that owns the first drawn item: a frame opening mid-group carries that header so <code>group_of</code> can answer for it, but it is <em>above</em> the first drawn row and must not be painted as one. This is the same cut <code>GridLayout::data_row_to_visual</code> has always made.</p>
<p>Two things about its argument, and the first of them <strong>used to be the whole story and was wrong</strong>. It exists partly because the two front ends scroll differently — a browser&#39;s frame is already windowed and passes <code>0</code>, the TUI&#39;s covers the whole grid and passes its row offset (gotcha 44). But <code>idx</code> alone cannot say whether the frame <em>leads the axis</em>, and at the top there is no header &quot;above&quot; to drop: it is content, and a collapsed first group has no other affordance. That is <code>AxisFrame::first</code> and <a class="tid" href="../tickets.html#improvise-0o6x" title="A group header first on its axis is never drawn, so a leading collapsed group has no way back">improvise-0o6x</a> — §3a.</p>
<p><strong><code>group_of</code> answers membership</strong>, and the paragraph that stood here said the opposite: that it answers &quot;nearest preceding header&quot;, deliberately, because that is what the TUI had always painted, and that encoding an end boundary would mean changing <code>AxisEntry</code> in <code>improvise-core</code> and was &quot;filed rather than smuggled into&quot; <a class="tid" href="../tickets.html#improvise-anr9" title="Project the five grid-frame facts: unblocks H, z, gz and fixes Food/Alice at once">improvise-anr9</a>. All of that was true when written. <strong>The filed ticket is <a class="tid" href="../tickets.html#improvise-gd83" title="AxisEntry carries no group-end boundary, so group_of gives an ungrouped item the preceding group">improvise-gd83</a> and it has landed</strong> — <code>AxisEntry::GroupEnd</code> closes a run, <code>groups()</code> clears on it, and the TUI&#39;s painted span changed with it. The mechanism and its two emission arms are §3a.</p>
<p>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 <em>one file</em> is what made this worth saying out loud.</p>
<h3 id="the-width-rule-has-one-home"><a class="anchor" href="#the-width-rule-has-one-home">#</a>The width rule has one home</h3>
<p><code>header_level_widths</code> (<a class="tid" href="../tickets.html#improvise-1125" title="Row-header width is computed per frame in the widget">improvise-1125</a>): per level, the widest label <strong>in display cells</strong> plus a gap, clamped to <code>[MIN_ROW_HEADER_W, MAX_ROW_HEADER_W]</code>. It lived twice before — inline in <code>src/ui/grid.rs::render_grid</code> and again in <code>improvise-command::ui::viewport::compute_row_header_width</code> — and a browser sizing its own row header would have been a third.</p>
<p>It takes an <strong>iterator of borrowed tuples</strong> rather than an <code>AxisFrame</code> because its other caller holds a <code>GridLayout</code> and is on the keypress path (reached from <code>App::cmd_context</code>); building a frame there to ask the question would clone every row tuple on every key (raised by Copilot on PR #23). <code>AxisFrame::header_widths</code> 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.</p>
<h3 id="gridcache-is-viewport-indexed-and-gridextent-is-not"><a class="anchor" href="#gridcache-is-viewport-indexed-and-gridextent-is-not">#</a><code>GridCache</code> is viewport-indexed, and <code>GridExtent</code> is not</h3>
<p>A cache position is a position within the <em>viewport</em>: the client does not know the model&#39;s coordinates, and the server is free to change what a row means as long as it says so.</p>
<p><code>GridExtent</code> is how big the <strong>whole</strong> grid is, of which the frames are one window (<a class="tid" href="../tickets.html#improvise-bpq2" title="Client-side key resolution: what must the projections carry?">improvise-bpq2</a>). It is the one fact a client needs to move its own cursor: <code>row_frame.len()</code> is what this client was <em>sent</em>, so a client holding only its window cannot tell &quot;the last visible row&quot; from &quot;the last row&quot; and cannot clamp — a cursor clamped against the window stops at the bottom of the screen. It is deliberately <strong>not framed</strong> by the frames, unlike <code>cells</code>, <code>col_widths</code> and <code>totals</code>: it is the size of the thing they are a window <em>onto</em>, so <code>fit_to_labels</code> must leave it alone.</p>
<p>For a client whose window covers the whole grid — the TUI, which builds its cache at offsets zero — it <em>equals</em> the frame counts, and <strong>that coincidence is exactly what makes a derived version look correct there and fail in a browser.</strong> It is a named pair rather than two <code>usize</code> on <code>GridCache</code>, because beside <code>row_frame</code>/<code>col_frame</code> two bare counts read as &quot;how many rows are in the frame&quot;, which is the confusion the field exists to remove.</p>
<p><code>GridUpdate::Extent</code>&#39;s batch position is <strong>not</strong> load-bearing the way <code>RowFrame</code>-before-<code>Cells</code> is: it addresses no position, so it cannot overtake or be overtaken. It is emitted first only because outermost-first reads better.</p>
<h3 id="gridcache-totals-empty-means-no-totals-not-not-caught-up"><a class="anchor" href="#gridcache-totals-empty-means-no-totals-not-not-caught-up">#</a><code>GridCache::totals</code> — empty means &quot;no totals&quot;, not &quot;not caught up&quot;</h3>
<p>Framed by <code>col_frame</code> exactly as <code>cells</code> and <code>col_widths</code> are, with one asymmetry: an <strong>empty</strong> strip means this grid has no totals to draw (records mode, or an empty grid) and is therefore <em>not</em> 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.</p>
<p>The emptiness is the server&#39;s answer rather than a client&#39;s re-derivation, and the totals <strong>must</strong> be projected rather than client-derived: the sum runs over <em>every</em> row including the ones this viewport has scrolled past, so a client holding only its window could not compute it.</p>
<p><code>page_coords</code> is framed by <strong>nothing</strong> — it names categories, not positions — so it survives a reshape that drops every row and column. It carries Page <em>and</em> Filter (<code>View::filtering_cats</code>, gotcha 27): both restrict what is visible, and the bar states what is being looked at, not what the user may page through.</p>
<h3 id="categorytreeupdate-items-and-axes-have-no-producer-deliberately"><a class="anchor" href="#categorytreeupdate-items-and-axes-have-no-producer-deliberately">#</a><code>CategoryTreeUpdate::Items</code> and <code>::Axes</code> have no producer, deliberately</h3>
<p><code>document_projections</code> only ever sends <code>Categories(..)</code>, which replaces the whole panel. The other two arms are <strong>derived rather than invented</strong>: the effects that move a category tree come in three shapes — the list reorders (<code>AddCategory</code>/<code>RemoveCategory</code>), one category&#39;s items move (<code>AddItem</code>/ <code>RemoveItem</code>), several axes move at once (<code>SetAxis</code>, and <code>CycleAxis</code> through it) — and <code>doctrine(derive_the_enum_from_the_effects)</code> is the same recipe that built <code>ModelCommand</code>. An arm the derivation produces is not speculation just because the producer has not reached for it.</p>
<p>What the producer reaches for instead is the coarse arm, on purpose: <code>category_tree_of</code> walks every category once, the panel is small, and <code>Dirty::of</code> cannot say <em>which</em> category moved anyway — an <code>EffectKind</code> says that a batch changed the tree, never which part, exactly as it cannot say which records a <code>CellData</code> batch wrote (gotcha 40). Emitting <code>Items</code>/<code>Axes</code> therefore needs the tree <strong>observed and diffed</strong>, the way <code>document_update</code> 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.</p>
<p>The contrast with the deleted <code>Invalidated</code> is the point: these are <em>unused</em>, and <code>Invalidated</code> was <em>redundant</em> — its meaning already lived in running code. Unused-but-derived earns a note; redundant earns a deletion.</p>
<h3 id="the-fold-is-not-commutative-three-rules-in-apply"><a class="anchor" href="#the-fold-is-not-commutative-three-rules-in-apply">#</a>The fold is not commutative — three rules in <code>apply</code></h3>
<p><strong>(a) A <code>Cells</code> update that overtakes its frames is dropped, not guessed at.</strong> <code>fit_to_labels</code> truncates to the frames it has. A client must not reorder the stream.</p>
<p><strong>(b) A frame change that <em>renames</em> a position blanks the cell under it.</strong> <code>fit_to_labels</code> preserves <strong>by position</strong>, which is only right where the label did not change, so <code>apply</code> calls <code>invalidate_relabelled</code> 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.</p>
<p><strong>There used to be a <code>GridUpdate::Invalidated</code> arm</strong> meaning &quot;contents stale, frame unchanged&quot;, and it is <strong>deleted</strong> (<a class="tid" href="../tickets.html#improvise-n89z" title="Three Projection mutations have no producer">improvise-n89z</a>). It never had a producer: <code>grid_updates</code> diffs, so it always knows <em>which</em> cells moved and sends <code>Cells</code>, and <code>GridCache::seed</code> 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 <code>Snapshot</code> through <code>rebase</code>, not a blanking the client cannot act on. Meanwhile its stated meaning had migrated into running code: <code>invalidate_relabelled</code> blanks exactly what a reframing invalidates, totals included. So it was <strong>redundant</strong>, not merely unused, and three documents described it as load-bearing, which is worse than silence — a reader reasoning about the fold&#39;s guarantees assumed something produced it. <code>the_retired_invalidated_spelling_does_not_parse</code> keeps it from returning as a serde alias.</p>
<p><strong>(c) Every arm that touches a frame <em>or</em> the widths re-fits.</strong> 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 <code>ColWidths</code> put the mismatch straight back: a short update keeps each unheard-of column at its own widest level label until <code>ColWidths</code> catches up, and a long one is dropped as having overtaken the frame that names it, exactly as (a) holds <code>Cells</code>.</p>
<p>The fallback width is measured in <strong>display cells</strong>, the same unit <code>header_widths</code> and the authoritative <code>ColWidths</code> use: <code>chars().count()</code> would draw <code>日本</code> two cells wide where it needs four, so a cache caught between <code>ColFrame</code> and <code>ColWidths</code> would render the new column too narrow. The materialization inside <code>fit_to_labels</code> is deliberate — <code>AxisFrame::item</code> scans, so asking it per column would make a re-fit quadratic.</p>
<h3 id="invalidate-relabelled-a-cats-change-re-identifies-everything"><a class="anchor" href="#invalidate-relabelled-a-cats-change-re-identifies-everything">#</a><code>invalidate_relabelled</code> — a <code>cats</code> change re-identifies <em>everything</em></h3>
<p><strong>A cell&#39;s identity is <code>cats</code> zipped with its item tuple, so a change of <code>cats</code> re-identifies every position at once</strong> — even the positions whose labels are byte-identical. <code>Region = [North]</code> becoming <code>Department = [North]</code> leaves <code>items</code> equal and names a different cell, so the tuples alone cannot answer this and every surviving position is stale.</p>
<p>The sharpest half is invisible: a preserved <code>CellDisplay</code> carries an editable <code>CellId::Record</code> (ADR 002), so a client would go on offering an edit that addresses the record the <strong>old</strong> coordinate named — a misdirected write, in the same family as gotcha 18&#39;s drill snapshot, and one no rendering shows.</p>
<p>Otherwise the comparison is on the <strong>item tuples</strong>, not the entries: a group header appearing or collapsing changes the frame&#39;s shape without renaming any surviving position, and blanking on that would throw away cells the very next <code>Cells</code> update would have to resend.</p>
<p>Two asymmetries about totals:</p>
<ul>
<li>A relabelled <strong>column</strong> drops its total (<a class="tid" href="../tickets.html#improvise-hkt6" title="Three small totality gaps found in review">improvise-hkt6</a>): the total under it is the sum of the old column. A relabelled <strong>row</strong> does not, because a total spans every row and is not framed by any one of them.</li>
<li>A <strong>recategorization of either axis</strong> 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.</li>
</ul>
<p>This is what obliges the <em>producer</em> 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 <code>mirror.totals</code> would find them equal and send nothing. <code>grid_updates</code> compares <code>framed.totals</code> for exactly that reason. The blanking stays anyway, because rule (b) exists to stop <code>apply</code> trusting a producer to remember — <strong>the rule lives on both sides or on neither.</strong></p>
<p><code>LabelAxis</code> is private so the row and column cases share one invalidation rule rather than two copies that can disagree.</p>
<h3 id="bootstrap-is-a-compaction-not-a-replay"><a class="anchor" href="#bootstrap-is-a-compaction-not-a-replay">#</a>Bootstrap is a compaction, not a replay</h3>
<p>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 <code>GridCache::folded(&amp;cache.seed()) == *cache</code>, and it is the <strong>ordering</strong> that earns it: the frames frame the cells, so they go first.</p>
<p><code>Snapshot { view, seed }</code> is the only thing on the wire that is not an event, and <code>view</code> is the one point at which <code>ViewState</code> is serialized (<a class="tid" href="../tickets.html#improvise-q08" title="improvise-ws-server binary (tokio + tungstenite session wrapper)">improvise-q08</a>).</p>
<p>Note the fold tests: asserting <code>fold(prefix) + fold(rest) == fold(whole)</code> is <strong>vacuous</strong> — 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 (<code>doctrine(fold_tests_must_test_order_not_the_axiom)</code>).</p>
<h3 id="categorytreecache-carries-every-category-prefixed-included"><a class="anchor" href="#categorytreecache-carries-every-category-prefixed-included">#</a><code>CategoryTreeCache</code> carries every category, <code>_</code>-prefixed included</h3>
<p><a class="tid" href="../tickets.html#improvise-vjms" title="Decide whether a category panel shows the virtual categories">improvise-vjms</a>: the TUI panel has always listed them, and a browser panel that quietly did not would be a second, divergent answer to &quot;what are the categories&quot;. So the <code>_</code>-prefixed convention does reach the client — it is presentation, and both ends render the same rows from this one source.</p>
<p><strong>The trap is <code>_Measure</code>, whose items are derived</strong>: explicit items ∪ formula targets (gotcha 7). Whoever builds these rows must go through <code>Model::effective_item_names</code>; reading <code>category(&quot;_Measure&quot;).items</code> compiles, looks right, and silently omits every formula target.</p>
<p>Three mutations reach this panel because the effects are three <em>shapes</em>: 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 <em>expanded</em> is <code>ViewState::expanded_cats</code>, per-client, so the flattening is a client-side concern and does not belong on the wire.</p>
<p><code>FormulaCache</code> and <code>ViewListCache</code> 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 <strong>active</strong> is not in <code>ViewListCache</code>: that is <code>ViewState::active_view</code>, carried by <code>Command::SwitchView</code>, because it is per-client (<a class="tid" href="../tickets.html#improvise-8uz" title="Cursor and scroll are per-client state stored in the shared View">improvise-8uz</a>). Likewise which tile the cursor is on is <code>ViewState::tile_cat_idx</code>.</p>
<h3 id="wizardcache-has-a-shape-and-deliberately-no-projection"><a class="anchor" href="#wizardcache-has-a-shape-and-deliberately-no-projection">#</a><code>WizardCache</code> has a shape and deliberately <strong>no</strong> <code>Projection</code></h3>
<p><a class="tid" href="../tickets.html#improvise-764" title="Migrate import wizard widget and state to consume RenderCache">improvise-764</a>, and that absence is the ticket&#39;s answer to &quot;where does the wizard state live&quot;. A <code>Projection</code> is broadcast to every connected client&#39;s widget, which is right exactly when the change is addressed to <em>no one</em> session. A half-open import dialogue is the opposite: it belongs to precisely the session that opened it. That is already settled doctrine elsewhere — <code>ImportWizard</code> lives on <code>App</code> and not on <code>ViewState</code>, and <code>AcceptWizardModel</code> was removed from <code>ModelCommand</code> for this same reason (<a class="tid" href="../tickets.html#improvise-tom1" title="Drill pending edits are invisible to remote clients, and there are now three answers to &#39;what text is in this cell&#39;">improvise-tom1</a>) 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 <em>rendering</em> side: every client drawing a dialogue one of them opened.</p>
<p>So it is a cache in shape and a <strong>local render model</strong> in fact, built from the authoritative <code>App</code> by the host that holds the wizard. <strong>A cache without a <code>Projection</code> is the shape session-local residue takes when it still has to be drawn by two different front ends.</strong> It lives here rather than in <code>improvise-command</code> for the <code>flatten_cat_tree</code> reason: the browser client depends on <code>improvise-protocol</code> alone, and when it grows a wizard it must draw <em>these</em> rows, not a second derivation. When multi-client import arrives it wants the addressed form, which is where ADR 001 layer 2 goes anyway.</p>
<p>Every field is already render-ready — the widget that used to build these reached through <code>ImportWizard</code> into <code>ImportPipeline</code>, <code>FieldKind</code>, <code>DateComponent</code> and a whole <code>Workbook</code>; none of those cross now. Two substitutions are made <em>here</em> so both ends spell them the same way: an undetected date format reads <code>&quot;?&quot;</code>, and the root array path reads <code>&quot;(root)&quot;</code> rather than empty. <code>WizardProposal::kind_label</code> is carried rather than re-derived from <code>kind</code>, so the two ends cannot disagree about what &quot;Measure (numeric)&quot; is called.</p>
<p><code>WizardStepKind</code> mirrors <code>improvise_io::import::WizardStep</code>, spelled again rather than re-exported, because this crate must not depend on <code>improvise-io</code> (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. <code>WizardFieldKind</code> is the semantic half of a colour decision — the terminal mapping lives in <code>draw.rs</code>, exactly as <code>AxisColor</code>/<code>axis_fg</code> does (gotcha 26).</p>
<h3 id="projection-carries-no-multi-widget-variant"><a class="anchor" href="#projection-carries-no-multi-widget-variant">#</a><code>Projection</code> carries no multi-widget variant</h3>
<p>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&#39; updates together — that would be the aggregate cache in another form.</p>
<p>Nothing is defined for the help overlay: its content is static and <code>help_page</code> is already in <code>ViewState</code>.</p>
<hr />
<h3 id="colwidths-stays-diffed-against-the-mirror-and-the-ticket-s-reason-was-wrong"><a class="anchor" href="#colwidths-stays-diffed-against-the-mirror-and-the-ticket-s-reason-was-wrong">#</a><code>ColWidths</code> stays diffed against the mirror, and the ticket&#39;s reason was wrong</h3>
<p><a class="tid" href="../tickets.html#improvise-44ad" title="ColWidths is redundant under the label fold, and the test that would change is gotcha 33(c)&#39;s evidence">improvise-44ad</a> asked whether <code>grid_updates</code> should diff <code>col_widths</code> against the <strong>label-folded</strong> cache rather than against the mirror, as <a class="tid" href="../tickets.html#improvise-hkt6" title="Three small totality gaps found in review">improvise-hkt6</a> already obliged for <code>totals</code>. Folding the labels re-fits the widths, so under a fold-relative diff the <code>ColWidths</code> a reframe currently carries becomes redundant and is suppressed. Declined, and the two things learned are worth more than the change.</p>
<p><strong>The stated cost does not exist.</strong> The ticket says the move breaks <code>a_client_on_a_socket_is_seeded_and_gets_its_edit_back</code>, and that that assertion is §3(c)&#39;s standing evidence — so rewriting it inside a change §3(c) guards would be the <a class="tid" href="../tickets.html#improvise-q0vb" title="Pad the import wizard&#39;s hint line to the panel width">improvise-q0vb</a> / PR #12 shape. Measured 2026-09-21 by deleting the re-fit from <code>fit_to_labels</code>: <strong>three</strong> existing unit tests fail (<code>a_col_widths_update_is_fitted_to_the_labels_it_is_framed_by</code>, <code>a_column_without_its_width_yet_is_measured_in_display_cells</code>, <code>col_widths_always_span_the_column_labels</code>) and <strong>the socket test is not one of them</strong>. What it actually asserts is that a reframe <em>carried</em> widths — a statement about the producer&#39;s diff, not about the consumer&#39;s re-fit. §3(c) is triply guarded elsewhere, so the evidence argument was about the wrong test.</p>
<p><strong>The real reason to decline is coupling.</strong> The widths a label-fold invents are each column&#39;s own label width, which is an <em>approximation</em> the consumer applies until the authority catches up. Diffing the producer&#39;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&#39;s message set silently depends on that client&#39;s approximation. The redundant <code>ColWidths</code> per reframe is what buys out of that, and it is the direction <code>Dirty::of</code> 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.</p>
<p><strong>So the asymmetry between <code>totals</code> and <code>col_widths</code> is stated rather than discovered.</strong> <code>totals</code> is fold-relative because hkt6 found a real hole — a rename that leaves the number equal sends no <code>Totals</code> and the client blanks its own. <code>col_widths</code> has no such hole, because the re-fit is a <em>safe</em> fallback rather than a stale value.</p>
<p>What was landed is the half the ticket calls &quot;the better test either way&quot;: <code>a_client_never_holds_widths_of_a_different_length_from_its_labels</code> asserts the invariant §3(c) is really about, at <strong>every step</strong> 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.</p>
<h3 id="gridcache-records-what-kind-of-grid-this-is-improvise-e5pf"><a class="anchor" href="#gridcache-records-what-kind-of-grid-this-is-improvise-e5pf">#</a><code>GridCache::records</code> — what kind of grid this is (<a class="tid" href="../tickets.html#improvise-e5pf" title="Command::ChangeMode(Normal) is on the wire and reduce_view folds it without the records resolution">improvise-e5pf</a>)</h3>
<p>The host answers &quot;is this the wide records layout&quot; with <code>GridLayout::is_records_mode()</code>, which reads the axes. A thin client has no <code>GridLayout</code>, and it was answering the neighbouring question instead — <code>ViewState::mode.is_records()</code> — at three decision points in <code>resolve.rs</code>. The two agree only while the mode and the layout agree, and <code>Command::ChangeMode(AppMode::Normal)</code> is precisely the message that separates them: the host settles <code>Normal</code> against the layout the batch <em>ends</em> on (<code>App::settle_normal_mode</code>), and <code>reduce_view</code> folds the variant it was handed.</p>
<p>So the fact is <strong>projected</strong>, not derived: <code>GridCache::records</code>, carried by <code>GridUpdate::Records</code>. Deriving it on the client from <code>row_frame.cats</code> containing <code>_Index</code> would have been a second implementation of <code>is_records_mode</code> — <a class="tid" href="../tickets.html#improvise-edp" title="Establish ViewModel layer: derived data between RenderCache and widgets">improvise-edp</a>&#39;s disease — and deriving it from the mode is the bug itself.</p>
<p>Like <code>GridUpdate::Extent</code> it <strong>frames nothing</strong>, so <code>apply</code> 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.</p>
<p><strong>The settling is observed, never composed — and the first version of this fix got that wrong.</strong> It settled at each <em>composition site</em>, in <code>resolve::changed_mode</code>, against the grid the client held <strong>at the moment it resolved the key</strong>. ChatGPT Codex found the hole on PR #98: <code>switch_view_at</code> composes <code>[SwitchView(target), ChangeMode(Normal)]</code>, so the settling read the <strong>outgoing</strong> view&#39;s grid and records → pivot emitted <code>RecordsNormal</code> while pivot → records emitted <code>Normal</code>. The drift the fix existed to close, by a second door.</p>
<p>The unreachability argument the first version leaned on covered the <em>toggle</em> route only — <code>R</code> is a <code>stranding</code> decline in the browser — and records-ness is auto-detected from the axes (gotcha 1), so a <strong>view switch</strong> changes it with nothing in the batch saying &quot;records&quot;. <code>doctrine(an_unreachability_claim_is_only_as_wide_as_the_routes_enumerated)</code>.</p>
<p>So <code>changed_mode</code> is <strong>deleted</strong>, every site emits the plain <code>AppMode::Normal</code>, and <code>ClientState::fold</code> settles after every fold against the grid it <strong>holds</strong>: <code>self.view.mode = settled_mode(&amp;self.view.mode, self.grid.records)</code>. 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: <code>cursor_epoch</code> over matching on <code>CycleAxis</code> (gotcha 24), <code>document_generation</code> over matching on <code>LoadModel</code> (gotcha 39), the dirty flag over a table of command variants (gotcha 45).</p>
<p><code>settled_mode</code> is the <strong>one rule</strong>, in <code>improvise-protocol</code>, and all three ends call it: <code>ClientState::fold</code>, <code>ClientSession::fold</code> (the server&#39;s mirror, so the two cannot diverge in a field nobody currently reads) and <code>App::settle_normal_mode</code>. <code>App::normal_mode</code> is gone — it was that function&#39;s host-side duplicate. It settles the <code>Normal</code> pair and <strong>nothing else</strong>: <code>Editing</code>/<code>RecordsEditing</code> pass through untouched, which is exactly why a records decision asks the grid and never the mode.</p>
<p>There remains a <strong>window, and it is inherent</strong>: between the local optimistic fold of a <code>SwitchView</code> and the arrival of the target view&#39;s projection, the client holds the outgoing grid and cannot know the target&#39;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 <em>when the grid does</em>, on both ends.</p>
<p>The three decision points ask <code>state.grid.records</code>. What that buys concretely: the commit at a records cell <strong>declines</strong> rather than composing <code>typed_cell_command</code> against a grid whose columns are <code>[coords…, +cat, measures…, +]</code> (gotcha 1), which was a wrong write and strictly worse than the black hole it was written to produce.</p>
<h2 id="3a-the-frame-s-group-boundaries-and-its-position-in-the-axis"><a class="anchor" href="#3a-the-frame-s-group-boundaries-and-its-position-in-the-axis">#</a>§3a. The frame&#39;s group boundaries and its position in the axis</h2>
<p>Two facts <code>AxisFrame</code> gained after <code>improvise-anr9</code>, both about group runs.</p>
<p><strong><code>FrameEntry::GroupEnd</code> closes a run.</strong> <code>group_of</code> used to answer &quot;nearest preceding header&quot;, which is not membership: <code>expand_category</code> emitted a header on <em>entering</em> a group and nothing on leaving one, so an ungrouped item after a grouped run inherited it (<code>improvise-gd83</code>). The §3 paragraph that recorded the old rule — and forbade this change — is corrected there, not merely superseded here. <code>AxisEntry::GroupEnd</code> is the boundary, carried onto the wire as <code>FrameEntry::GroupEnd</code>; <code>groups()</code> clears <code>current</code> on it and <code>group_of</code> stops walking. It is emitted only on <code>Some -&gt; None</code>, 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 <code>cross_product</code> creates.</p>
<p>Nothing draws a <code>GroupEnd</code>. Both front ends skip it and it consumes no data index, so it is invisible except through <code>group_of</code>/<code>groups</code>.</p>
<p><strong><code>AxisFrame::first</code> is the absolute data index of the frame&#39;s first item.</strong> The windowing rule carries the nearest header <em>above</em> a scrolled window so <code>group_of</code> can answer, and <code>body_from</code> drops it on the way to the screen. At the top of an axis there is no &quot;above&quot;, so that same leading header is content — a collapsed first group has no other affordance (<code>improvise-0o6x</code>). <code>idx</code> alone cannot tell the two apart, because the browser&#39;s frame is already windowed and always passes <code>0</code>; <code>first + idx == 0</code> can.</p>
<p>The TUI builds over the whole grid so <code>first</code> is always <code>0</code> there, and the browser&#39;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.</p>
<p>It carried <code>#[serde(default)]</code>, which was a defect rather than a decision and is now <strong>gone</strong> (<a class="tid" href="../tickets.html#improvise-1wl0" title="AxisFrame::first is serde(default), so a frame with no origin claims it leads the axis">improvise-1wl0</a>): <code>first: 0</code> is not an absence but the claim &quot;this frame leads the axis&quot;, which is the claim <code>body_from</code> 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 <code>Basis</code> having no <code>Default</code>: a zero-valued claim that happens to be true in the common case is a forgeable bypass rather than an absence.</p>
<p><strong>Dropping the attribute was preferred to <code>Option&lt;usize&gt;</code> or a two-variant enum, and the reason is about the wire rather than the type.</strong> There is exactly one production producer (<code>project.rs::axis_frame</code>, which reads <code>window.start</code>), so a frame is never partially constructed and &quot;unknown&quot; is not a state any honest peer can be in — making it representable would oblige <code>body_from</code> 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.</p>
<p>The <code>Default</code> derive still yields <code>first: 0</code>, and that is not the same hole: an <code>AxisFrame::default()</code> has no entries, so <code>body_from</code> returns an empty slice on either arm and the claim is vacuous. The hazard was specifically a <em>populated</em> frame acquiring the claim by omission.</p>
<p><strong><code>group_runs</code> / <code>group_run_heads</code> are the coalescing rule&#39;s one home.</strong> The TUI and <code>render.rs::col_groups</code> each coalesced adjacent columns sharing a group, kept in step by a comment (<code>improvise-8qlr</code>). The TUI&#39;s one divergence is that it labels its first <em>visible</em> column even mid-run, so a scrolled grid does not draw an unlabelled group line.</p>
<p><strong>A run is opened by a HEADER, not by a name</strong> (<a class="tid" href="../tickets.html#improvise-h0iv" title="group_runs coalesces by name alone, so two runs under different headers merge">improvise-h0iv</a>). Both prior copies coalesced by name alone, and <code>improvise-8qlr</code> preserved that <em>because</em> it was a consolidation: changing the rule in the commit that moved it would have left the agreement test unable to tell &quot;both ends agree&quot; from &quot;both ends changed together&quot;. 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&#39;s frozen behaviour reads as decided afterwards and nothing says it was only frozen for the duration of the move.</p>
<p><strong>The ticket&#39;s own proposed fix does not work, and that is worth more than the fix.</strong> h0iv suggested comparing the <code>(cat, name)</code> pair, <code>FrameGroup</code> carrying <code>cat</code> precisely so a client need not work out which category a label came from. But its own reachability case is <em>one</em> category: <code>Month</code> grouped into <code>Q1</code>, nested under <code>Region</code>, giving <code>North/Q1</code> and <code>South/Q1</code> adjacent — the two headers are <code>(Month, Q1)</code> and <code>(Month, Q1)</code>, <strong>equal as values</strong>, so the pair separates nothing. <code>the_two_headers_are_equal_as_values_so_comparing_cat_and_name_cannot_split_them</code> pins that, so the next reader does not re-propose it. What separates them is that they are two different <code>FrameEntry::Group</code> entries, and <code>groups()</code> already borrows each from <code>self.entries</code> — so the comparison is <code>std::ptr::eq</code> on the header, which is the literal statement of &quot;the same header opened both&quot;.</p>
<p><strong>The case that settled the product question was not the span but the indicator.</strong> 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 <code>column_groups_coalesce_by_name_as_the_tui_does</code>, whose fixture merged <code>Month/Q1</code> (expanded) with <code>Fiscal/Q1</code> (<strong>collapsed</strong>) and whose own message said the merged span is &quot;drawn with the FIRST one&#39;s indicator&quot; — 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 <code>column_groups_coalesce_per_header_as_the_tui_does</code> rather than deleted.</p>
<h2 id="4-frame-rs-one-envelope-pair-two-deployments"><a class="anchor" href="#4-frame-rs-one-envelope-pair-two-deployments">#</a>4. <code>frame.rs</code> — one envelope pair, two deployments</h2>
<h3 id="the-framing-adds-as-little-as-it-can-get-away-with"><a class="anchor" href="#the-framing-adds-as-little-as-it-can-get-away-with">#</a>The framing adds as little as it can get away with</h3>
<p><code>doctrine(derive_the_enum_from_the_effects)</code>: inventing wire variants ahead of the mutations that need them produces a protocol nobody can implement.</p>
<h3 id="there-were-three-pairs-for-two-deployments-improvise-xks6"><a class="anchor" href="#there-were-three-pairs-for-two-deployments-improvise-xks6">#</a>There were three pairs for two deployments (<a class="tid" href="../tickets.html#improvise-xks6" title="One home for the client/server frame envelopes">improvise-xks6</a>)</h3>
<p>The socket&#39;s server side, a hand-kept <strong>mirror</strong> of it in the browser bundle, and a third pair written for the worker (<a class="tid" href="../tickets.html#improvise-tm6" title="Epic: Standalone static-web deployment via wasm + VFS storage">improvise-tm6</a>) before the projection architecture landed. The question that had to be answered before any of it moved was whether the <strong>worker</strong> and the <strong>socket</strong> want the same vocabulary. They do, and the test applied to each difference was whether the <em>argument</em> for it names the thing they differ in. None did — every difference was a <strong>date</strong> rather than a decision:</p>
<ul>
<li><strong><code>Viewport</code> is deployment-independent.</strong> The extent is the one session input no <code>Command</code> carries by construction: it is a fact about a terminal or a browser window, it is not in <code>ViewState</code>, and no effect produces it, so no derivation reaches it. A worker hosting the same <code>Sessions</code> owes <code>set_extent</code> the same answer. It is the <strong>handshake</strong> on both, because a <code>Snapshot</code> seeds the <em>visible</em> cells — one built at extent zero is an empty grid the client would immediately have to ask again for.</li>
<li><strong><code>Key</code> is deployment-independent too.</strong> A keymap binding names an <em>intent</em> resolved against a <code>CmdContext</code> 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 (<a class="tid" href="../tickets.html#improvise-bpq2" title="Client-side key resolution: what must the projections carry?">improvise-bpq2</a>), not a design.</li>
<li><strong><code>Commands(Vec&lt;_&gt;)</code> batches where a singleton cannot.</strong> A fan-out must arrive whole and in sequence, because a <code>GridUpdate::Cells</code> that overtakes its frames is <em>dropped</em>, not replayed. That is a property of the <strong>fold</strong>, not of the transport: an in-process <code>postMessage</code> interleaves exactly as a socket does. <code>Commands(vec![c])</code> expresses everything a <code>Command(c)</code> variant could.</li>
</ul>
<p>The worker pair was not merely different but <strong>incoherent by omission</strong>: written before ADR 001, it could carry <code>Command::Model { basis, .. }</code> and had no <code>Rejected</code>, so a client speaking it composed a claim that could never be answered. <strong>That incoherence — not the difference — is the tell for staleness.</strong></p>
<h3 id="the-mirror-is-gone-and-so-are-the-two-tests-that-held-it-honest"><a class="anchor" href="#the-mirror-is-gone-and-so-are-the-two-tests-that-held-it-honest">#</a>The mirror is gone, and so are the two tests that held it honest</h3>
<p>That deletion was the ticket&#39;s measure of success. <code>the_socket_framing_matches_the_server</code> round-tripped hand-listed fixtures through the other crate&#39;s types to catch a <strong>rename</strong>; <code>every_server_variant_is_one_this_crate_mirrors</code> matched exhaustively to catch a <strong>variant added on the server and never mirrored</strong>. With one definition both are <strong>type identity</strong>: 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 — <strong>if a consolidation leaves one of its compatibility tests alive, the consolidation is not finished</strong> (<code>doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)</code>).</p>
<p>What survives is <code>the_retired_spellings_do_not_parse</code>, aimed at the one thing still possible; see <a href="../test-evidence/protocol.html"><code>context/test-evidence/protocol.md</code></a>.</p>
<h3 id="clientframe-commands-the-frame-is-the-unit-of-atomicity"><a class="anchor" href="#clientframe-commands-the-frame-is-the-unit-of-atomicity">#</a><code>ClientFrame::Commands</code> — the frame is the unit of atomicity</h3>
<p>Not the command (ADR 001, <a class="tid" href="../tickets.html#improvise-955n" title="[bug] Host::commands applies a batch partially before rejecting">improvise-955n</a>). The host answers for <em>every</em> command in the frame before applying any, so a frame is never applied partially <strong>because of a refusal</strong>. It used to apply in order and abandon at the first rejection, which left a hole ADR 001&#39;s &quot;never applied, not even partially&quot; does not have.</p>
<p><strong>The frame states one basis</strong>, 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.</p>
<p><strong>But only two of the three tiers are answered once.</strong> <code>document_generation</code> and <code>layout_version</code> describe the <em>document</em>, so answering them on the frame&#39;s first mutation is right. The <strong>record tier is asked of every mutation</strong>, 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&#39;s chain overwrite a record another had moved, silently. The exemption that keeps data-dependent chains working is narrower than &quot;later commands are not checked&quot;: a record that <strong>neither resolves nor is claimed</strong> 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. <code>CheckedBatch</code> in <code>improvise-command</code> carries the derivation.</p>
<p><strong>All-or-nothing is the contract and clause 4 is not yet delivered.</strong> That is the other way a frame stops half-way, and it is not a refusal: a chain whose later command <em>fails at apply time</em> leaves its predecessors applied, because <code>App::apply_effects</code> aborts <strong>without undoing</strong> (gotcha 21 — an abort is not a rollback).</p>
<h3 id="clientframe-save-is-a-request-not-a-command"><a class="anchor" href="#clientframe-save-is-a-request-not-a-command">#</a><code>ClientFrame::Save</code> is a request, not a <code>Command</code></h3>
<p><a class="tid" href="../tickets.html#improvise-368h" title="[bug] No client on any transport can ask its host to save">improvise-368h</a>. It carries <strong>no path</strong>, because a client cannot see the host&#39;s storage: <code>effect::Save</code> writes to <em>this host&#39;s</em> <code>file_path</code> through <em>this host&#39;s</em> <code>Storage</code> (gotcha 2), and which document a host is hosting is the host&#39;s own fact. A client naming a path would be naming a namespace it cannot read — the same mistake <code>ModelCommand::SetCell</code> avoids by taking a surrogate rather than a <code>CellKey</code>.</p>
<p><strong>It is a frame and not a <code>Command</code>, and the reason is atomicity.</strong> It began as a <code>Command</code>, which put a <em>runtime-fallible</em> operation inside a batch <code>Commands</code> guarantees is applied whole or not at all — so <code>[Save, SetCell]</code> reported a failed write and then applied the edit anyway, and a client reading &quot;refused&quot; could not tell which. Saving also fails the test every <code>Command</code> passes: it mutates no shared state and no client&#39;s slice, so <code>reduce_view</code> has nothing to fold and <code>Checked</code> has no basis to answer for. Here it cannot be composed with a mutation at all — <strong>the batching question stops being a rule and becomes unconstructible</strong> — and it sits beside <code>Viewport</code>, the other session input no <code>Command</code> carries.</p>
<p>Success is silent: the document did not move, so no widget is dirty and nobody else is owed anything.</p>
<h3 id="clientframe-key-and-a-correction"><a class="anchor" href="#clientframe-key-and-a-correction">#</a><code>ClientFrame::Key</code> — and a correction</h3>
<p>The doc used to say &quot;the host runs it through the keymap against the authoritative <code>App</code>&quot;. <strong>It does not.</strong> <code>Host::handle</code> answers this arm with an explicit empty vec, because a key resolved on the host moves state no client can hear (<code>a_key_resolved_on_the_host_moves_state_no_client_can_hear</code>): the projection stream carries nothing of the host&#39;s own <code>ViewState</code>, so host-side resolution <em>diverges</em>. <a class="tid" href="../tickets.html#improvise-bpq2" title="Client-side key resolution: what must the projections carry?">improvise-bpq2</a> closes that by <strong>avoiding</strong> it — the browser resolves keys against the shared keymap (§6) — so an upstream key is a <strong>black hole, not a slow path</strong>, and every <code>KeyOutcome::Upstream</code> 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 <code>improvise-wasm-client/src/keys.rs</code>, so closing one has to come there and say so.</p>
<p>It carries <code>(KeyCode, KeyModifiers)</code> rather than a <code>KeyEvent</code> because <code>KeyEvent</code> is not <code>Serialize</code> — the same pair <code>Command::SetTransientPrefix</code> already puts on the wire.</p>
<h3 id="serverframe-has-three-variants-because-sessions-apply-has-three-cases"><a class="anchor" href="#serverframe-has-three-variants-because-sessions-apply-has-three-cases">#</a><code>ServerFrame</code> has three variants because <code>Sessions::apply</code> has three cases</h3>
<p><code>Applied::Diff</code>, <code>Applied::Rebased</code>, <code>Applied::Rejected</code> (gotcha 39).</p>
<p><code>Snapshot</code> is <strong>boxed</strong>: it carries a whole <code>ViewState</code> plus the seed that redraws the document, and every <code>Commands</code> 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 <em>every</em> client whenever the document is replaced, because a client&#39;s <code>cursors</code> are keyed by the outgoing document&#39;s view names and only a fresh snapshot replaces them (gotcha 24).</p>
<p><code>ServerFrame::Commands</code> carries both of the things a client folds, because on the wire they are the same thing: a <code>Command::Project</code> is a widget update, and any other <code>Command</code> is a peer&#39;s accepted move that this client&#39;s own view slice must fold too (gotcha 36). Order is load-bearing, so a transport must deliver a batch whole and in sequence.</p>
<p><code>Rejected</code> is addressed to <strong>the asker alone</strong> — 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 <em>normal outcome</em> rather than an error.</p>
<hr />
<h2 id="5-basis-rs-what-a-batch-was-composed-against"><a class="anchor" href="#5-basis-rs-what-a-batch-was-composed-against">#</a>5. <code>basis.rs</code> — what a batch was composed against</h2>
<h3 id="the-problem-is-not-ordering"><a class="anchor" href="#the-problem-is-not-ordering">#</a>The problem is not ordering</h3>
<p>The server already serialises every write — one <code>App</code>, one <code>apply_effects</code> — 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 <strong>picture of the document that has since moved</strong>: 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.</p>
<h3 id="three-tiers-and-the-tiering-is-the-whole-economy"><a class="anchor" href="#three-tiers-and-the-tiering-is-the-whole-economy">#</a>Three tiers, and the tiering is the whole economy</h3>
<table><thead><tr><th>tier</th><th>moved by</th><th>a batch is stale if</th></tr></thead><tbody><tr><td><code>document_generation</code></td><td>a <code>WholeWorkbook</code> effect</td><td>it moved at all</td></tr><tr><td><code>layout_version</code></td><td>an observed change to any view&#39;s drawn shape</td><td>it moved at all</td></tr><tr><td><code>records</code></td><td>a <code>CellData</code> effect on that record</td><td>any record the batch names moved</td></tr></tbody></table>
<p>A cell edit is stale when the <strong>layout</strong> moved, even though no record it names was touched, because the shape its keys hang off changed underneath it. That is ADR 001&#39;s class 2 — A removes the category <code>Region</code> while B commits an edit keyed by <code>Region=East</code> — and it is <strong>corruption</strong> 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 <code>Region=East × Revenue</code> does not stop anyone deleting <code>Region</code>.</p>
<h3 id="the-client-understands-none-of-this"><a class="anchor" href="#the-client-understands-none-of-this">#</a>The client understands none of this</h3>
<p>A <code>Basis</code> 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 <code>Projection</code>, the client keeps it in a <code>BasisCache</code> and hands back <code>claim()</code> when it composes a write.</p>
<h3 id="basis-has-no-default-and-no-public-constructor"><a class="anchor" href="#basis-has-no-default-and-no-public-constructor">#</a><code>Basis</code> has no <code>Default</code> and no public constructor</h3>
<p>A zero-valued basis is not &quot;no claim&quot;, it is the claim that <strong>the document is fresh</strong> — which in a brand-new <code>App</code> is true, so a default would be a <em>forgeable bypass</em> rather than an absence. <code>BasisCache::claim</code> is the only door a client has, and it returns <code>None</code> until the server has published one.</p>
<p>Note what that does and does not buy. <code>Basis</code> derives <code>Deserialize</code> because it must cross the wire, and its fields are public, so <strong>a hand-rolled client can state any tiers it likes</strong>. This is an <em>anti-staleness coordination check, not a security boundary</em>: it closes <strong>omission</strong>, not <strong>invention</strong>, and nothing here tries to close invention, because a client that forges a current basis is indistinguishable from one genuinely caught up. &quot;The only constructor&quot; is a statement about clients written against this crate, which is still worth having — it makes the honest path the easy one.</p>
<p>There is deliberately <strong>no flag that skips any of this</strong>, because &quot;a mechanism whose only purpose is to violate the invariant is not a feature, it is the invariant deleted with extra steps&quot;. A <em>force</em> re-composes the same intent against the current basis and submits a fresh batch; it never applies a stale one.</p>
<h3 id="claim-omits-rather-than-invents"><a class="anchor" href="#claim-omits-rather-than-invents">#</a><code>claim</code> omits rather than invents</h3>
<p>A record the cache has never heard of is <strong>left out</strong>, not defaulted to zero. Defaulting would be a claim the client cannot support, and the server would read it as &quot;untouched&quot; — the exact forged-basis case <code>RecordUnstated</code> exists to catch.</p>
<h3 id="the-publication-is-what-adr-001-omitted"><a class="anchor" href="#the-publication-is-what-adr-001-omitted">#</a>The publication is what ADR 001 omitted</h3>
<p>The ADR says &quot;a batch carries the basis it was composed against&quot; and never says how a client comes to know one; nothing in the projection stream carried any of the three tiers, so <strong>the check as written was satisfiable only by forging</strong>. That is the one place that document met the code and was wrong. <code>Projection::Basis</code> + <code>BasisCache</code> close it.</p>
<p>It is folded like any other widget (<a class="tid" href="../tickets.html#improvise-rkeq" title="Decide RenderCache&#39;s shape: grid-only wire type vs per-widget sub-caches">improvise-rkeq</a>) — 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 <code>Projection::Formulas</code> does, so &quot;has it changed&quot; and &quot;send it&quot; collapse into one decision.</p>
<p><strong>It is emitted <em>after</em> the grid updates in a batch</strong>, 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&#39;s frame-before-cells rule.</p>
<h3 id="which-records-are-checked-is-the-server-s-decision"><a class="anchor" href="#which-records-are-checked-is-the-server-s-decision">#</a>Which records are checked is the server&#39;s decision</h3>
<p><code>reduce.rs::records_named</code> in <code>improvise-command</code>, 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 <em>client&#39;s</em> claim would be a <strong>force by omission</strong>: the record tier skipped, with no flag to remove and nothing in the message saying so.</p>
<h3 id="published-takes-a-btreemap"><a class="anchor" href="#published-takes-a-btreemap">#</a><code>published()</code> takes a <code>BTreeMap</code></h3>
<p>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.</p>
<h3 id="rejection-keeps-structural-and-data-staleness-apart"><a class="anchor" href="#rejection-keeps-structural-and-data-staleness-apart">#</a><code>Rejection</code> keeps structural and data staleness apart</h3>
<p>Which is the whole reason ADR 002 put a maintained <code>RecordHash</code> index in <code>DataStore</code>: a regenerate-on-demand scheme could not tell a record that had scrolled out of view from one that no longer existed.</p>
<ul>
<li><strong><code>RecordMoved</code></strong> 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.</li>
<li><strong><code>RecordGone</code></strong> and <strong><code>LayoutMoved</code></strong> 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. <strong>Force never creates structure</strong>: 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.</li>
<li><strong><code>RecordUnstated</code></strong> is a case of its own because &quot;you lost a race&quot; and &quot;you were never in one&quot; 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.</li>
</ul>
<p>This is also why the wasm client stores the <code>Rejection</code> itself rather than collapsing it into <code>status_msg</code>: a string cannot carry <em>which</em> record and <em>which</em> kind.</p>
<h3 id="two-classes-live-in-one-type"><a class="anchor" href="#two-classes-live-in-one-type">#</a>Two classes live in one type</h3>
<p><code>Rejection</code> is a refusal of a <strong>frame</strong>, not of a command — since <a class="tid" href="../tickets.html#improvise-368h" title="[bug] No client on any transport can ask its host to save">improvise-368h</a> a <code>ClientFrame::Save</code> the host cannot honour is answered here too, and that is not a refused command at all.</p>
<ol>
<li><strong>Staleness</strong> (ADR 001) — <code>DocumentReplaced</code>, <code>LayoutMoved</code> and the three record variants. The only class where <em>force</em> means anything.</li>
<li><strong>A request the host cannot honour</strong> — <code>NoDocumentPath</code> and <code>SaveFailed</code> (<a class="tid" href="../tickets.html#improvise-368h" title="[bug] No client on any transport can ask its host to save">improvise-368h</a>). Nothing went stale; there is nothing to re-compose.</li>
</ol>
<p>They share this type rather than getting refusal channels of their own, for the reason the type exists: a refusal is &quot;nothing happened, here is why&quot;, addressed to the asker alone, and a second channel would be a second set of ordering and addressing rules for one idea. A <strong>third class is coming and is not here yet</strong>: 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.</p>
<p><code>NoDocumentPath</code> is decided <strong>before</strong> 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 (<a class="tid" href="../tickets.html#improvise-lae" title="ZZ / :wq with no file path should prompt for a path instead of only erroring">improvise-lae</a>), a browser worker whose document <em>is</em> its storage entry never sees this at all. <code>SaveFailed</code> carries no message because <code>Rejection</code> is <code>Copy</code> and an I/O error&#39;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. <strong>Reporting a failed write as success is the thing this exists to stop.</strong></p>
<hr />
<h2 id="6-keymap-rs-one-table-both-ends"><a class="anchor" href="#6-keymap-rs-one-table-both-ends">#</a>6. <code>keymap.rs</code> — one table, both ends</h2>
<p>This used to live in <code>improvise-command</code>, 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 <code>doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)</code> says such a pair is one definition waiting to be made.</p>
<p>The move is the one gotcha 13 already anticipated: <code>ViewState::transient_prefix</code> stores a prefix as an <strong>address</strong> rather than an <code>Arc&lt;Keymap&gt;</code> precisely so <code>ViewState</code> could stay free of <code>improvise-command</code> types while the keymap came the other way.</p>
<h3 id="the-boundary-that-did-not-move"><a class="anchor" href="#the-boundary-that-did-not-move">#</a>The boundary that did not move</h3>
<p>A lookup yields a command <strong>name and arguments</strong>. Turning that into something that runs needs a <code>CmdContext</code> — a <code>Model</code>, a <code>GridLayout</code>, an <code>App</code> — which a thin client does not have, so <em>intent → mutation</em> stays where it was: <code>improvise-command</code> resolves a name through its <code>CmdRegistry</code> into <code>Effect</code>s, and a browser client resolves the subset it can compose into <code>Command</code>s.</p>
<p>That is a better seam than the one it replaces. The drift risk used to be &quot;which key does what&quot; — ~170 bindings, no direct test, invisible when wrong. It is now &quot;which command names can I execute&quot;, which is a list.</p>
<p>What stayed in <code>improvise-command</code>, and why each had to:</p>
<ul>
<li><code>Keymap::dispatch</code> and <code>KeymapSet</code>, which pair this table with a <code>CmdRegistry</code> and return <code>Box&lt;dyn Effect&gt;</code>.</li>
<li><code>SetTransientPrefix</code>, which is an <code>Effect</code> — it writes <code>ViewState::transient_prefix</code>, and <code>Effect</code> is the command layer&#39;s trait.</li>
</ul>
<p>The keymap&#39;s own tests live with <code>dispatch</code> in <code>improvise-command/src/command/keymap.rs</code>, for the same reason: they need a registry to check a bound name against.</p>
<h3 id="modekey-and-appmode-are-two-different-enumerations"><a class="anchor" href="#modekey-and-appmode-are-two-different-enumerations">#</a><code>ModeKey</code> and <code>AppMode</code> are two different enumerations</h3>
<p>They happen to be the same size (17). <code>SearchMode</code> is a <code>ModeKey</code> and not an <code>AppMode</code> — it is Normal plus <code>search_mode: bool</code>; <code>Quit</code> is an <code>AppMode</code> with no keymap. <strong>Do not &quot;fix&quot; one count to match the other.</strong> <code>ModeKey::from_app_mode</code> is exhaustive on purpose: a new <code>AppMode</code> without a keymap silently swallows every keypress, so the compiler must flag it.</p>
<h3 id="lookup-fallback-order"><a class="anchor" href="#lookup-fallback-order">#</a>Lookup fallback order</h3>
<p><code>exact(key, mods)</code> → <code>Char</code> with <code>NONE</code> (shift is implicit in the char) → <code>AnyChar</code> → <code>Any</code> → parent. <code>Keymap::with_parent</code> is Emacs-style inheritance, and it is what lets <code>RecordsNormal</code> reach Normal&#39;s <code>g</code>/<code>y</code>/<code>Z</code> prefixes.</p>
<p><code>Keymaps::prefix_keymap</code> deliberately goes through <code>lookup</code>, which walks parents, for exactly that reason. Resolution assumes prefixes are <strong>flat</strong>; <code>prefix_keymaps_are_flat</code> fails the build if a nested one is added.</p>
<h3 id="textentry-the-spine-welded"><a class="anchor" href="#textentry-the-spine-welded">#</a><code>TextEntry</code> — the spine, welded</h3>
<p>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.</p>
<p><code>TextEntry::new</code> takes the <code>AppMode</code> and reads the buffer name off its <code>MinibufferConfig</code>, so the buffer the prompt describes and the buffer the keys edit are the same string <strong>by construction</strong> rather than by agreement (<a class="tid" href="../tickets.html#improvise-k8h" title="Tie MinibufferConfig.buffer_key to the keymap&#39;s buffer name">improvise-k8h</a>) — the keymap never spells a buffer name.</p>
<p><code>commit(key, cmd, args)</code> welds the command to its <code>clear-buffer</code> so no caller can bind a commit without its clear (<a class="tid" href="../tickets.html#improvise-w3q" title="Extract text-entry keymap template for minibuffer modes">improvise-w3q</a>). <code>rebind</code> exists for exactly one case: CommandMode&#39;s Backspace, which exits the mode on an empty <code>:</code> line instead of popping a character that isn&#39;t there.</p>
<p><strong>Eight <code>AppMode</code> variants carry a <code>MinibufferConfig</code></strong>, and <code>buffer_of</code>&#39;s callers are all eight. Seven are built by <code>TextEntry::new</code>; <code>RecordsEditing</code> is built by <code>TextEntry::with_parent</code> off <code>Editing</code>&#39;s map, so a count taken off the <code>::new</code> call sites reads <strong>seven</strong> and is not the number of text-entry modes. (See repo-map gotcha 11 — this exact count has already gone stale once.)</p>
<p><code>SearchMode</code> is hand-written on purpose and does <strong>not</strong> use <code>TextEntry</code>: it has no commit key, and its query is owned by <code>exit-search-mode</code> rather than by <code>clear-buffer</code>. &quot;search&quot; is not in the <code>buffers</code> map but its own <code>ViewState</code> field, and <code>cmd::core::read_buffer</code>/<code>write_buffer</code> are the only code that knows it (<a class="tid" href="../tickets.html#improvise-gdl" title="SetBuffer effect special-cases the &quot;search&quot; buffer name">improvise-gdl</a>).</p>
<p>Category and item add stay open after a commit, so Tab and Enter do the same thing: file this one and start the next. <code>ItemAdd</code>&#39;s keymap is built with an empty category name because the category only shapes the prompt text, never the buffer.</p>
<h3 id="panelkeymap"><a class="anchor" href="#panelkeymap">#</a><code>PanelKeymap</code></h3>
<p>The three panels share a spine — Esc/Tab leave for Normal, arrows and j/k move that panel&#39;s cursor, every panel&#39;s toggle key is bound so focus can move between them, and <code>:</code> opens command mode. The keys that act on the panel&#39;s <em>contents</em> differ in <strong>shape</strong>, not just in command name (Category&#39;s <code>n</code> takes an argument, View has no <code>a</code>, Formula has no Enter), so they arrive through chained methods rather than a fixed-arity signature (<a class="tid" href="../tickets.html#improvise-4pg" title="Extract panel mode keymap template">improvise-4pg</a>). <code>delete()</code> binds <code>d</code> and Delete together so a panel cannot offer one without the other.</p>
<p><code>bind_panel_toggles</code> is called by <code>PanelKeymap::new</code> <em>and</em> by Normal, so a new panel reaches all four keymaps from one <code>Panel::ALL</code> entry.</p>
<h3 id="normal-mode-bindings-worth-a-note"><a class="anchor" href="#normal-mode-bindings-worth-a-note">#</a>Normal-mode bindings worth a note</h3>
<p><code>i</code>/<code>a</code> pass their target editing mode as an <strong>argument</strong> to <code>edit-or-drill</code>, so the command itself stays mode-agnostic; <code>RecordsNormal</code> overrides just those two bindings to say <code>records-editing</code>. 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 &quot;No file path&quot; status message points at (<a class="tid" href="../tickets.html#improvise-lae" title="ZZ / :wq with no file path should prompt for a path instead of only erroring">improvise-lae</a>).</p>
<hr />
<h2 id="7-viewmodel-rs-the-residue-between-a-cache-and-a-renderer"><a class="anchor" href="#7-viewmodel-rs-the-residue-between-a-cache-and-a-renderer">#</a>7. <code>viewmodel.rs</code> — the residue between a cache and a renderer</h2>
<p><a class="tid" href="../tickets.html#improvise-edp" title="Establish ViewModel layer: derived data between RenderCache and widgets">improvise-edp</a> asked for a ViewModel <em>layer</em>, a <code>SurfaceViewModel</code> paired with every <code>SurfaceCache</code>. <strong>Most of that layer turned out not to exist</strong>: after <a class="tid" href="../tickets.html#improvise-35e" title="Temporary adapter: RenderCache::from_app bridge for incremental widget migration">improvise-35e</a> 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 <em>between</em> a cache and a renderer, because they mix a cache with per-client <code>ViewState</code>.</p>
<p>Today that is exactly one: <code>flatten_cat_tree</code>. It lives here rather than in <code>improvise-command</code> because two ends need it and only one of them can reach that crate — the browser client depends on <code>improvise-protocol</code> alone (deliberately; see its <a href="https://github.com/fiddlerwoaroof/improvise/blob/main/Cargo.toml"><code>Cargo.toml</code></a>), and <code>move-panel-cursor</code> walks this flattening, so resolving that key client-side (<a class="tid" href="../tickets.html#improvise-bpq2" title="Client-side key resolution: what must the projections carry?">improvise-bpq2</a>) would mean re-deriving it. <strong>Two derivations of one flattening is how the two ends drift.</strong></p>
<p><code>CatTreeEntry</code> carries the axis outright, so a renderer needs neither a <code>View</code> nor the cache the entry came from: the entry list <em>is</em> what the panel draws. <code>expanded</code> is <code>ViewState::expanded_cats</code> — per-client, which is why this is derived here and not sent — and the index into the result is <code>ViewState::cat_panel_cursor</code>.</p>
<hr />
<h2 id="8-key-rs"><a class="anchor" href="#8-key-rs">#</a>8. <code>key.rs</code></h2>
<p>These mirror the subset of the terminal backend&#39;s key API the command, keymap and effect layers actually use, so those layers carry no terminal-backend dependency. Variant names match crossterm&#39;s so call sites only need an import change. The only place both type families may appear is the TUI boundary (<a href="https://github.com/fiddlerwoaroof/improvise/blob/main/src/draw.rs"><code>src/draw.rs</code></a>), which converts incoming backend events into these before calling <code>App::handle_key</code>.</p>
<p><code>KeyCode::Null</code> stands in for &quot;no key&quot; (headless command execution) and for any backend key code this crate does not model. <code>KeyEvent</code> is <strong>not</strong> <code>Serialize</code> — <code>ClientFrame::Key</code> and <code>Command::SetTransientPrefix</code> both carry the <code>(KeyCode, KeyModifiers)</code> pair instead.</p>
<hr />
<h2 id="9-corrections-made-while-relocating-this-prose-2026-09-20"><a class="anchor" href="#9-corrections-made-while-relocating-this-prose-2026-09-20">#</a>9. Corrections made while relocating this prose (2026-09-20)</h2>
<p>Four comments in this crate were <strong>wrong about the code</strong> and were fixed rather than moved. Recorded because the campaign&#39;s own evidence is that a stale argument reads exactly like a live one:</p>
<ol>
<li><strong><code>frame.rs</code>, <code>ClientFrame::Key</code></strong> said &quot;the host runs it through the keymap against the authoritative <code>App</code>&quot;. <code>Host::handle</code> returns an empty vec for that arm. This is the fourth sibling of gotcha 48&#39;s &quot;three comments in one range said <em>not built yet</em>&quot; — <a class="tid" href="../tickets.html#improvise-bpq2" title="Client-side key resolution: what must the projections carry?">improvise-bpq2</a> moved resolution to the client and updated one copy of the argument, not all of them.</li>
<li><strong><code>reduce.rs</code>, <code>every_command_round_trips_through_json</code></strong> said the model half brings &quot;four payload types the view half never carried — <code>CellKey</code>, <code>CellValue</code>, <code>Axis</code> and <code>PathBuf</code>&quot;. <code>ModelCommand</code> carries no <code>CellKey</code> at all since ADR 002 (<code>SetCell</code> takes a <code>RecordCell</code>), and <code>CellValue</code> <em>is</em> in the view half, as <code>Command::SetYanked</code>.</li>
<li><strong><code>render.rs</code>, <code>identified_grid</code></strong> carried three paragraphs of doc that describe a <em>different</em> test — &quot;<a class="tid" href="../tickets.html#improvise-hkt6" title="Three small totality gaps found in review">improvise-hkt6</a> … three columns and a rename in the middle&quot; is <code>a_relabelled_column_drops_its_total</code>, which had no doc of its own, while <code>identified_grid</code> is a two-column fixture. The paragraphs had been merged onto the wrong item.</li>
<li><strong><code>keymap.rs</code>, <code>buffer_of</code></strong> said &quot;the seven modes that carry a <code>MinibufferConfig</code>&quot;. Eight do, since <code>SavePrompt</code> (<a class="tid" href="../tickets.html#improvise-lae" title="ZZ / :wq with no file path should prompt for a path instead of only erroring">improvise-lae</a>); seven is the number of <code>TextEntry::new</code> call sites, which is the exact confusion repo-map gotcha 11 already warns about.</li>
</ol>
<p>Also stale and corrected, without being wrong enough to list above: <code>key.rs</code>&#39;s module doc called these &quot;crate-local key types for the command layer&quot; — they are the protocol&#39;s, and both front ends use them.</p>
<p><strong><code>records_named</code> lives here</strong> (moved from <code>improvise-command::ui::reduce</code> by the review of PR #50). It is <code>&amp;ModelCommand -&gt; Vec&lt;RecordHash&gt;</code> with no <code>App</code> 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&#39;s half untestable — <code>lockstep.rs</code> restates the basis against its twin host, so it compares the server&#39;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.</p>
<hr />
<h2 id="8-help-rs-where-ui-strings-live-relative-to-a-wire-enum"><a class="anchor" href="#8-help-rs-where-ui-strings-live-relative-to-a-wire-enum">#</a>§8 <code>help.rs</code> — where UI strings live relative to a wire enum</h2>
<p>Three tickets asked one question three times — <a class="tid" href="../tickets.html#improvise-x7sw" title="Which-key hints have no protocol home (the open half of improvise-jb3)">improvise-x7sw</a> (which-key hints have no protocol home), <a class="tid" href="../tickets.html#improvise-ktv1" title="Move the help content into the shared layer so a browser can show help">improvise-ktv1</a> (the browser has no help overlay) and <a class="tid" href="../tickets.html#improvise-k099" title="AppMode::hint_text/label put terminal UI strings on a serde wire enum">improvise-k099</a> (<code>AppMode::hint_text</code>/<code>label</code> put terminal UI strings on a serde enum). Answering it three times is the disease. The answer:</p>
<p><strong>A UI string is keyed by a command name, never by a key, and never hangs off the enum that crosses the wire.</strong> <code>AppMode::label</code> and <code>AppMode::hint_text</code> are gone; <code>help::mode_label</code> and <code>help::hint_line</code> replace them. The enum a client deserializes now carries no English at all.</p>
<p><strong>And wherever a string names a key, the key is looked up rather than spelled.</strong> <code>Hint { keys, command, caption }</code> is the unit: <code>keys</code> come off <code>Keymaps::default_keymaps()</code>, <code>command</code> is the semantic value a localized or non-terminal front end keys off, and <code>caption</code> is the default English — exactly the <code>AxisColor</code>/<code>draw.rs::axis_fg</code> shape, with the decision shared and only the last translation local (<code>invariant(semantic_palette_at_the_core_boundary)</code>).</p>
<h3 id="what-that-bought-and-what-it-cost"><a class="anchor" href="#what-that-bought-and-what-it-cost">#</a>What that bought, and what it cost</h3>
<p>It <strong>deleted</strong> <code>every_hint_key_resolves_in_its_mode</code> 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 (<code>doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)</code>), and this is the deletion that measures this one. What replaced it, <code>every_advertised_hint_names_a_binding_that_exists</code>, checks the <em>other</em> direction — a mode advertising a command nothing binds — which was never checkable before.</p>
<p>It also deleted <code>Keymap::binding_hints</code>: <code>help::which_key</code> is the one walker, and the binding dump reads it too rather than a second one.</p>
<p>The cost is that the hints changed shape. They are longer, they name arrow keys beside <code>hjkl</code>, and two sub-lists in the help pages lost their visual indent. That is the price of the keys being true.</p>
<h3 id="the-three-rules-the-rendering-follows"><a class="anchor" href="#the-three-rules-the-rendering-follows">#</a>The three rules the rendering follows</h3>
<ol>
<li><code>hint_line</code> <strong>drops chorded keys</strong> — any key held with a modifier beyond shift, asked of the <strong>modifiers</strong> and never of the label (<a class="tid" href="../tickets.html#improvise-thpe" title="help::compact discriminates a chord by parsing a string it formatted">improvise-thpe</a>). <code>Hint::keys</code> is <code>(KeyCode, KeyModifiers)</code> and <code>compact</code> takes the pairs, so the string it used to parse is not in scope to parse; <code>Hint::labels</code> and the help pages format last, through the one spelling in <code>keymap::key_label</code>. <code>T</code> 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 <code>hints()</code>, which keeps them.</li>
<li>It <strong>runs single-character keys together</strong> (<code>hjkl</code>) and slashes the rest (<code>Esc/Tab</code>), which is the idiom the hand-written hints already used.</li>
<li>A help-page row whose command resolves to <strong>no keys is dropped</strong>, so a page can never advertise a key that does not exist. <code>keys()</code> returning <code>Option</code> is what makes that unrepresentable rather than validated.</li>
</ol>
<h3 id="what-is-not-derived-and-why"><a class="anchor" href="#what-is-not-derived-and-why">#</a>What is <em>not</em> derived, and why</h3>
<p><strong>The wizard&#39;s.</strong> <code>AppMode::ImportWizard</code>&#39;s whole keymap is one <code>Any</code> binding into <code>HandleWizardKey</code>, 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.</p>
<p><strong><code>gg</code>, <code>yy</code>, <code>ZZ</code> and the ex-commands.</strong> A prefix sequence is two addresses and not one binding (gotcha 13), and <code>:q</code>/<code>:w</code> are parsed text rather than keys. Both are <code>HelpLine::Literal</code>, which says out loud that the row is spelled rather than derived.</p>
<p><strong>The captions.</strong> <code>i:edit</code> where <code>i</code> entered records mode would still pass every test 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.</p>
<h3 id="the-browser-half"><a class="anchor" href="#the-browser-half">#</a>The browser half</h3>
<p><code>RenderModel</code> carries <code>help</code>, <code>help_page</code> and <code>help_titles</code>, and an <strong>empty <code>help</code> is what says the overlay is closed</strong> — the page asks no question about the mode, the same shape <code>col_groups</code> uses for &quot;no groups&quot;. <a href="https://github.com/fiddlerwoaroof/improvise/blob/main/web/app.js"><code>web/app.js</code></a> paints rows it does not author; <a href="https://github.com/fiddlerwoaroof/improvise/blob/main/src/ui/help.rs"><code>src/ui/help.rs</code></a> keeps its ratatui styles and lost ~310 lines of content that is now shared. Neither end restates what the keys are.</p>
</main>
</div>
</body>
</html>