Files
improvise/context/code/protocol.md
T
Edward Langley 16f40314fd docs(context): act on the adversarial review of the repo-map split
- AGENTS.md: "Three files" is now "These files"; the table lists five.
- Digests 43 and 45 regain the traps the review found missing.
- protocol.md and formula-and-io.md cite file-inventory.md for the Wasm
  appendix and the inventory, which moved there.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 22:23:59 -07:00

2547 lines
144 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# `improvise-protocol` — the wire vocabulary and the portable session slice
The arguments that used to live in this crate's comments (improvise-9c8c).
Written for a reader who has the code open. Nothing here restates what the
code plainly says; everything here is something somebody got wrong first.
The crate crosses a process boundary, so it depends on `improvise-core` and
`serde` and nothing else — no terminal backend, no command layer, no IO.
`improvise-command` depends on *this*, never the reverse, and that direction
is what lets a client hold the view half without pulling in the host.
`boundary_tests::the_wire_types_need_nothing_but_core_and_serde` is the part
the compiler can hold; the CI `wasm` job is the other half, since a crate
that pulled in crossterm would stop building for wasm32.
Three things live here and it is worth being precise about which travels:
- **`ViewState`** (`lib.rs`) is a *projection of the command stream*, not a
wire message. The server keeps a mirror of each client's view state by
applying the same commands the client applies, through the shared
`reduce_view`. It is serialized at exactly one point — the bootstrap
`Snapshot` (improvise-q08). Steady-state traffic is commands only.
- **`Command`** (`reduce.rs`) is the wire format, in both directions: user
intent client→server, `Projection`s server→client.
- **`ClientFrame`/`ServerFrame`** (`frame.rs`) is the *framing* rather than
the vocabulary: which of the above a given JSON blob is.
---
## 1. `lib.rs` — the portable slice
### What may live on `ViewState`
Two properties gate membership, and both are load-bearing (improvise-h0b):
- **portable** — plain data from the allowlisted crates, no shared-ownership
containers. The `ViewState: Send` static assertion is the compile-time
proxy (`Rc` is not `Send`); the `Deserialize` derive rejects it too, serde's
`rc` feature being off. `Send` does **not** catch the origin-crate or
snapshot-safety halves — check those by hand.
- **snapshot-safe** — a fresh client inheriting the value is *correct*.
Staged uncommitted writes and half-open dialogs are not.
State failing either test lives on `App` instead, under the *session-local
residue* tag: `wizard` (an `improvise-io` type, and a half-open dialog is not
something to inherit) and `drill_state` (holds an `Rc`, and `pending_edits`
are uncommitted writes belonging to whoever staged them).
The constraint this puts on new fields: **no borrowed-`'static` fields
anywhere `ViewState` can reach** — `Deserialize` cannot produce one, and the
error surfaces on every *enclosing variant* rather than at the offending
field. That is why `MinibufferConfig::buffer_key` is a `String` and not a
`&'static str` (improvise-a2x).
### `ViewCursor` and the `cursors` map
Per-client (improvise-8uz). It used to live on `View`, inside the shared
workbook, which meant one client scrolling moved everyone's viewport. `View`
no longer carries it at all — nothing persisted it, so there was no
saved-position behaviour to preserve. The map is keyed by view name so that
switching views restores where you were in each one; an absent key means the
origin.
### `ViewState::active_view` is the second copy, deliberately
`Workbook::active_view` is the document's; `ViewState::active_view` is the
client's, and it is what keys `cursors`. The second copy exists because
`reduce_view` runs on a client that holds no `Workbook` (improvise-cqi). The
host keeps the two in step; whether they are *allowed* to diverge — two
clients on different views — is improvise-g0f. See repo-map gotchas 29/36.
### `AppMode::from_name` — not every variant is nameable
The list is exactly what `improvise-command`'s registry accepted before the
move, carried across unchanged, because changing it here changes what
`enter-mode` accepts in a script. It lives beside `AppMode` rather than in
the registry because the *names* are part of the keymap vocabulary and the
keymap is shared: a browser resolving `enter-mode` must decode the same
spellings, and two tables would be the mirror that move existed to delete.
Three variants are absent, for two reasons. `ItemAdd` resolves its target
category from the runtime mode and never from a script line (gotcha 20), so
naming it would produce a mode with nothing to add to. `Quit` and
`ImportWizard` are entered by their own commands — `quit` and
`start-import-wizard` — which do work beyond changing the mode, so
`enter-mode` would be a way to reach half of each.
### `AppMode::FormulaEdit` carries what it will commit to (improvise-km71)
`FormulaEdit { minibuf, slot }` is one mode for two editors. `slot` is
`FormulaSlot::New` for `a`/`n`/`o` (`enter-mode formula-edit`, which
`from_name` still builds) or `FormulaSlot::Existing { id, was }` for
`edit-formula-at-cursor`. It is one variant and not a second `AppMode` because
the keys, the buffer and the panel are identical: a second variant would add a
`ModeKey`, a keymap and a hint for no difference in what the keys do. The slot
is `ItemAdd`'s `category` again, a capture made when the mode is entered. Like
`ItemAdd` it is not nameable from `from_name`, since a script line has no
formula to open. It must be portable and snapshot-safe (§1 above). `FormulaId`
is plain serde data, and a fresh client that inherits an open edit inherits
exactly the claim the editor made. The commit re-checks that claim.
### `Panel`
`Panel::ALL`/`name()`/`key()` drive the toggle keys for Normal *and* all
three panel keymaps, so a fourth panel reaches every keymap from one `ALL`
entry. `name()` must spell what `parse_panel` accepts. `Panel::mode()` is the
inverse of `AppMode::panel()` for the three top-level panel modes only — the
sub-modes (`FormulaEdit`, `CategoryAdd`, `ItemAdd`) map back to their panel
but not forwards, which is what keeps a panel's styling active while the user
types into its minibuffer.
`AppMode::hint_text()` is checked against the keymaps by
`every_hint_key_resolves_in_its_mode` in `improvise-command`; `Quit` alone
shows nothing, because it is never rendered.
### `all()` walks a cycle, and its totality is tested rather than structural
`AppMode::all()` and `Rejection::all()` walk a total, cyclic `after()`. Both
doc comments used to claim a new variant could not be omitted, because
`after()` has no `None` arm. **That is half true and the wrong half.**
Answering `after()` *for* a new variant is compiler-forced; pointing an
existing arm *at* it is not, so a variant nothing names is simply never
walked — `all().len()` came back 17 where the enum had 18, with the suite
green (improvise-5ubs). The general statement is
`doctrine(a_list_checked_for_length_reads_like_a_list_checked_for_completeness)`:
an `assert_eq!(cases.len(), 13)` catches a case being *deleted* from a list
and never one being *absent* from it, and the two are written identically.
Closing it needs an enumeration independent of `after()`, and without one the
ticket's own first option restates the problem. There is no such enumeration
in stable Rust for an enum with data — `variant_count` is nightly, a derive
macro means a new proc-macro crate, and `strum`'s `EnumIter` cannot construct
these variants at all, since `AppMode::ItemAdd` carries a category name and
`Editing` a whole `MinibufferConfig`. What there *is* is **serde**: the
`Deserialize` derive already builds the complete variant-name list, from the
enum definition, maintained by the compiler, and publishes it in its
`unknown variant … expected one of …` message. `variants.rs` reads it back
and diffs it against the names the cycle actually reached.
Three things this costs, and they are the reason the module is defensive
rather than three lines. The message is **parsed**, so a serde whose wording
changes must fail loudly instead of reporting an empty list that passes every
cycle — hence the `unknown variant` prefix assertion and the `names.len() > 1`
floor. Both live in `parse_expected`, a seam separated from the deserialization
**because a `should_panic` pointed at a real type reaches only one of the three
refusals** and all three then shared one panic message, so `expected = …` could
not say which had fired (PR #92 review). Each now carries its own wording and is
fed a hand-written mutated message; a happy-path case keeps the three refusals
from being the only evidence the parser runs. A variant is identified by **its own serialization** rather
than by `Debug`, because an externally tagged unit variant is a string and a
struct variant a one-key object, and reading a name off `Debug` would depend
on the field list. And it checks only that every declared variant is
*reached*: an `after()` that is not injective — two arms naming one successor
— is a different defect this does not see, and would show up as a shorter
cycle only if it also orphaned something.
What it does **not** reach: the fourth instance of this shape,
`layout_geometry_effects_actually_change_document_config` in
`improvise-command/src/ui/effect.rs`, whose `assert_eq!(cases.len(), 13)` is
over a hand-built list of *effects*, not enum variants, so serde has nothing
to say about it. Filed as improvise-26dm rather than bodged, because a
fourth hand-rolled
attempt in a fourth place is the disease.
---
## 2. `reduce.rs` — the wire vocabulary
### Derived, not invented
`Command` is not a fresh invention
(`doctrine(derive_the_enum_from_the_effects)`):
- The **view half** is every `EffectKind::ViewOnly` effect in
`improvise-command` whose target is the portable slice
(`decision(improvise-cqi, command_set_derived_not_invented)`). Effects
touching host-local state (the import wizard, staged drill edits, the
storage backend, the layout cache) are deliberately absent.
- The **model half** is `ModelCommand`: every `EffectKind::Model(..)` effect,
one variant apiece, carrying that effect's own fields (improvise-kmgs).
The count is 28 model effects, minus two: `ApplyAndClearDrill` and
`AcceptWizardModel`, which are **not addressable** (improvise-3x3u). That
leaves **26**: 24, plus `ReplaceFormula` (improvise-km71) and
`RenameRecordCoordinate` (improvise-fdk1). `SwitchView`, `ViewBack` and
`ViewForward` are no longer model effects at all (improvise-jytm), so they are
not subtracted. This paragraph said "29 minus three, 26" while the enum had 24,
and each of those two PRs then corrected it to 25 without seeing the other.
Count by brace-matching the `impl Effect` blocks, as below. A variant with
no data mirrors a nullary effect whose operand is document state the
authoritative `App` already holds.
Two derivation traps worth keeping:
- The obvious `awk` extraction reports **30** — it picks up `HelpPageSet`,
which is `ViewOnly`, because that is the last `pub struct` before
`#[cfg(test)]` and the test module mentions `ModelChange::`. Brace-match
the `impl Effect` blocks instead (`doctrine(derive_then_verify)`).
- improvise-kmgs derived the set **one effect at a time**, which was correct
per element and wrong for a set: it put `SetCell` on the wire without its
`MarkDirty` companion, so "save if dirty" never fired (improvise-gv9r).
`MarkDirty` is gone; `App::apply_effects` now raises the flag off
`EffectKind::Model(_)` directly.
### The derivation runs backwards too, and `ClearBuffer` was the proof
A derivation checked only forwards — *does every effect have a command* —
misses the inverse: **a command with no effect**. `Command::ClearBuffer(String)`
was one. Nothing in `improvise-command` is named `ClearBuffer`; the host's
`clear-buffer` command composes `core::write_buffer(name, String::new())`,
which is `effect::SetBuffer` and therefore **inserts**, and `resolve.rs`'s
`write_buffer` composes the same `Command::SetBuffer`. The wire variant
**removed** the key instead. So the one behaviour both ends actually implement
disagreed with the variant that names it (improvise-m0sx).
It was deleted rather than given an effect, for three reasons.
- It is not in the derivation's image. Adding a `ClearBuffer` *effect* to
justify the variant reverses the rule — the wire would become the source of
truth for host semantics, which is the direction
`decision(improvise-cqi, command_set_derived_not_invented)` exists to forbid.
- It bought nothing. Both readers of the map — `cmd::core::read_buffer` and
`resolve.rs`'s reader — end in `unwrap_or_default()`, so an absent key and an
empty one are **indistinguishable to every reader**. The only observable
difference was `ViewState` equality between a folded client and a
snapshot-seeded one, and deleting the variant settles that equally, by
leaving exactly one way to clear.
- Nothing composed it. Its only three occurrences were the definition, the
fold arm, and two of `reduce.rs`'s own tests — which **pinned the divergent
behaviour**, asserting the key was absent. A test can hold a defect in place.
**A variant nothing composes is either dead or a promise the protocol does not
keep**, and that is the hazard the deletion generalises:
`vocabulary_is_composed.rs` reads the variant list off serde's derive — the one
enumeration the compiler maintains — and requires each name to be constructed
somewhere in non-test source, or to appear in a short `NOT_COMPOSED` list with
its verdict. The list is checked in **both** directions, so an entry that starts
being composed fails just as loudly as one that stops
(`doctrine(a_stale_list_of_what_is_missing_is_worse_than_no_list)`).
The check is a source scan, and **what counts as source has one home**:
`improvise_protocol::source_scan::production_code`, shared with the forward
guard below (improvise-c7mu). It removes three things a scan must not see.
- **Comments and literals.** Both are blanked, and newlines are kept. Before
this, a `// TODO: Command::SetYanked(...)` left where the real composer had
been deleted still passed all four tests. That is the *weaker* direction, the
one a scan weakness was supposed to be unable to go. It was live in a small
way: a `///` in `project.rs` named `Command::SwitchView`, which has real
composers, so no verdict was wrong yet. It also had a mirror: a comment
explaining that a `NOT_COMPOSED` variant is held back would have raised a
false "stale entry" alarm.
- **Test items inside a file.** Each test-only attribute's item is bounded by
**balanced brackets on the blanked code** (improvise-59q8). It ends at a `;`
at depth zero, or at the `}` closing its body unless `;`, `.`, `?` or `else`
follows. The first version was a line scan that relied on rustfmt putting an
item's closing brace at the attribute's own indentation. The #172 review
found three shapes it never bounded:
- an item ending in `];` or `);`;
- a `#[cfg(test)]` statement spanning lines;
- a one-line `#[cfg(test)] use x;`.
In each case the scan read on to the end of the file and **hid every line of
production code after it**, which is the silent direction for the forward
guard. An item the scan cannot bound is now a **panic**, never a scan to EOF.
**Which `cfg` is test-only is evaluated, not substring-matched.** The
predicate is read off the file's *text*, because a `feature` value is a
literal and blanked in the code. It is evaluated in three-valued logic for a
build without `test`: `test` is false, and so is `feature = "test-support"`,
which this repo never ships (improvise-26dm). Everything else a scan cannot
know, such as a target or an ordinary feature, is unknown. An item is
test-only when its predicate is **false**. So
`cfg(all(test, not(target_arch = "wasm32")))` is test code, which the old
"any line containing `not(`" rule got wrong. `cfg(any(test, feature =
"serde"))` and `cfg(not(test))` are production. An inner `#![cfg(test)]`
heading a file makes the whole file test code. One appearing anywhere else
is a panic, because this scan cannot bound an inline module's inner
attribute.
- **Test-only files.** A `#[cfg(test)] mod x;` makes `x.rs` and everything under
`x/` test code, even though nothing inside the file says so. Before
improvise-c7mu, this checker's own file and `improvise-io`'s
`roundtrip_tests.rs` were scanned as production.
One limit remains. A `Command::X` inside `matches!` or `if let` is read as a
construction rather than a pattern. That can only make the check *weaker* for
that variant; it never raises a false alarm. A variant nothing composes at all
is immune, since its only pattern occurrence is its own fold arm, which `=>`
identifies.
### And forwards: every effect is on the wire or has a verdict (improvise-h3y7)
`vocabulary_is_composed.rs` checks that the vocabulary does not name more than
production composes. The other direction is *does the vocabulary name
everything it should?* That was the direction `ViewBack` and `ViewForward`
failed: they were missing, and no client had a back history. Until improvise-h3y7
nothing checked it, and the audit that finally ran it by hand under-reported.
Brace-matching `impl Effect for` blocks misses the eight `simple_effect!`
expansions and `SetTransientPrefix` in `command/keymap.rs`. All nine are
`ViewOnly`, which is the half the `ViewBack` bug was in.
`improvise-command/src/ui/reduce/vocabulary_is_complete.rs` is that direction.
It scans the crate's `src/` for both ways an effect is declared, reading
each name past any attributes and doc comments before it. The macro accepts
those, and missing them hid effects (improvise-94x2). It requires
each name to be a `Command` or `ModelCommand` variant (read off serde's derive)
or to appear in `NOT_ON_THE_WIRE` with a verdict. A verdict is one of four
kinds, and the check verifies each kind:
- `Command(v)`: on the wire under another name (`HelpPageNext`/`Prev`/`Set` →
`Command::HelpPage`). `v` must be a real variant.
- `Frame(v)`: answered by a `ClientFrame` that is not a `Command` (`Save` →
`ClientFrame::Save`, gotcha 45). `v` must be a real frame.
- `Off(why)`: excluded on purpose, with the argument: session-local residue
(gotcha 30), host-local caches, or batch-relative control flow.
- `Gap(ticket)`: missing, and a ticket owns closing it (`SaveAs`, `ExportCsv` →
improvise-2mqz).
The list is also checked in reverse. An entry that is now on the wire fails,
and so does an entry that names no effect. The scan is `production_code`, so
the comments, strings and test fakes that name an effect do not count as
effects. If they did, a stale verdict could outlive the effect it names.
What it does **not** check is whether a same-named pair *agrees*: whether
`reduce_view`'s arm does what the effect does. The name match is the
completeness half. The semantic half stays a per-pair read (improvise-h3y7's
notes put it at about 21 pairs). The resolver lists in `resolve.rs`
(`declined()` and the host-only names) are also not checked against the
vocabulary yet. The audit found them stale in three places.
### No `ModelCommand` field is a position (improvise-hkbc)
improvise-6nsd closed "a stale layout tier misdirects a mutation" on one
condition: no `ModelCommand` carries a row or column index. `position_free`
in `reduce.rs`'s tests enforces that at compile time. It destructures every
variant with no `..`, down into `RecordCell`, and hands each field to a
function that accepts only a name, a path, a surrogate, a value or an axis. So
a new variant, a new field, or a field retyped to `usize` fails to build, and
the build failure forces the ADR conversation. It **cannot** see a position
spelled as a `String`. It also does not make the client position-free:
improvise-2wu3 resolves a painted `(row, col)` against the current cache before
it ever builds a command.
### Why two model effects are key-path only
A command on this wire is routed by `Sessions::apply` with **no
`SessionId`**, which is correct precisely when the change is addressed to no
one session (gotcha 36). `ApplyAndClearDrill` and `AcceptWizardModel` fail
that test: their operands — `drill_state.pending_edits` and the built wizard
workbook — are session-local residue, private to whoever staged them
(improvise-tom1). Unaddressed, client B's `ApplyAndClearDrill` commits client
A's uncommitted typing, and B's `AcceptWizardModel` replaces the document out
of a dialogue B never opened. Both were demonstrated against `Sessions::apply`
before removal.
*Portability of the command* was the criterion when they were kept;
**addressability** is the criterion that matters. Not permanent: multi-client
drill wants the addressed form (the variant carries a `SessionId`, the staging
map moves onto the session), which is where ADR 001 layer 2 goes anyway.
### The view stack is the client's, and the derivation already said so
Two user rulings, 2026-09-21: *"The state drill manipulates (the view stack)
should be local to the client"* and *"Drilling should be an ephemeral view, not
something that gets persisted."* The first one **closed a gap in the
derivation above rather than widening it** (improvise-gd28).
`effect::ViewBack` and `effect::ViewForward` are `EffectKind::ViewOnly`, and
every field they write — `active_view`, `view_back_stack`,
`view_forward_stack`, `mode` — is in the portable slice. By the view half's own
rule they were always `Command`s; they were simply missing. So are they now,
alongside `SwitchView`.
What that exposed is that the fold and the effect **disagreed**.
`effect::SwitchView` pushes the view it is leaving and clears the forward
stack; `reduce_view`'s arm set `active_view` and nothing else. A mirror
therefore never recorded any history, and neither did any client — which is
the real reason `view-back` was unavailable in a browser. The `declined()`
comment blamed the drill commit, and that was a *second*, separable reason: it
matters only for a client that stages drill edits, and the browser does not.
With the fold repaired, the decline had no reason left standing, and
improvise-cfly removed it together with the comment — leaving a false reason
behind being worse than leaving the refusal.
`reduce_view` now mirrors all three effects line for line
(`doctrine(mirror_the_cmd_line_for_line)`), with the direction carried as a
`Rewind` enum rather than a `bool` so neither arm can read the other's stack.
### A frame is somewhere you can go back to
The case a client-local stack creates. A `ViewFrame` names a view, and another
client can delete it — so `ViewState::forget_view` drops every frame naming a
view that has gone, and it is **one home**: `effect::DeleteView` calls it for
the host, and `Command::ForgetView` carries it to every client.
Which views vanished is **observed**, by diffing the document's view-name set
across `apply_command`, never predicted from the command variant — the reason
`adopt_cursor_epoch` watches a counter (gotcha 24) and `apply_model` watches
`document_generation` (gotcha 39). `DeleteView` is not the only effect that can
drop a view, and a table of variants would be a second description free to
disagree with the first.
The audience is `ToAll`. Any client may have *been* there, the asker included,
and a client that was not folds a no-op. Note this is a different question from
`adopt_deleted_views`, which moves the mirrors **sitting on** the deleted view:
a client that merely visited it is sitting somewhere else entirely, so neither
subsumes the other.
**A guard was written and then deleted, and that is the part to keep.** The
first attempt answered this in `Sessions::apply_step`, correcting a dead
landing the way `apply_switch` corrects a refused switch. It was wrong in a way
two passing injections did not show: the correction folds a `SwitchView`, which
**pushes the dead view back onto the stack**, so `<` popped it, was corrected,
and pushed it again — a back button that never progresses. Pruning makes the
dead frame unrepresentable instead, which left the guard no reachable input, so
it went: a guard whose fault cannot be injected is indistinguishable from one
that always passes (`doctrine(check_the_checker)`).
### `Command::SwitchView` has a foot in both halves
The `SwitchView` *effect* wears `EffectKind::Model(LayoutGeometry)`, so the
model-half derivation lands on it too — and `ModelCommand` deliberately does
**not** name it a second time (improvise-kmgs). `ModelCommand::ViewBack` and
`ViewForward` also move the active view and so carry the same document half.
### `Command::ResetCursor` is an invalidation, never a position
Derived from `App::reset_cursor`, the write `TransposeAxes` and `CycleAxis`
make after changing the grid's shape (gotcha 24). It is a variant rather than
a `SetSelected` plus two offsets for a reason that is not brevity: what the
server says is **"your cursor is no longer valid"**, not "be here". Where a
client then puts its cursor is its own business — the model is shared, the UI
is not — so broadcasting one client's position to everyone would be the wrong
message even though origin happens to be where each of them lands today
(improvise-gvnb).
### `Command::Model` is a struct variant
So the basis cannot be forgotten. ADR 001's check is worth nothing if a batch
may arrive without one, and the only enforceable version of "you must also
state X" is not being able to build the message without X. There is no
`Basis::default()` for the same reason — see §5.
Note what the struct variant does *not* buy: it closed omission **from the
message** and did nothing to close ignoring **on arrival**, which is the half
a struct variant is usually reached for. That hole needed a type —
`Checked`/`CheckedBatch` in `improvise-command` (improvise-ondj).
### `ModelCommand::SetCell` takes a `RecordCell` and the view that drew it
A `CellKey` here would require the client to know the coordinate space —
every row and column category plus the page and filter selections (gotcha
27), and in records mode the `_Dim` column-index convention (gotcha 1) —
which is precisely the knowledge the server exists to own. The surrogate is a
token to echo back; and because it is a *record* identity, a client holding
one for an aggregate cell cannot build this variant at all (ADR 002).
The `view` beside it is a view *name*, which the client already holds
(`ViewState::active_view`). It is not a coordinate. A record with no stored cell
exists only as a position in some grid, so the host resolves it against the grid
of the view the client composed in, not its own (improvise-wpxr,
`context/code/command.md`). This is the `view: String` convention every
view-scoped variant follows (improvise-lltt). `ClearCell` is a struct variant for
the same reason.
`named_records` is the exhaustive list of records a command writes, and
`records_named` is read off it. Each entry says where the host looks the record
up. A cell write is `NamedRecord::Drawn { view, record }`, resolved in the view
that drew it. `RenameRecordCoordinate` is `NamedRecord::Stored(record)`, and it
carries no view. Only a record that holds cells can be renamed: a coordinate
cell exists only in records mode, whose rows are the store's own records, and
a record with no cells has nothing for the rename to move. So the store is the
whole answer, and a view name would add a way to be wrong with nothing to
resolve. The destination the server adds for a rename (`records_at_stake`) is
`Stored` for the same reason.
`LoadModel` and `ImportJsonHeadless` name paths in the **host's** `Storage`,
never the client's filesystem (gotcha 2).
The variants are grouped by the `ModelChange` level their effect declares,
because that level is what `Dirty::of` turns into projections (gotcha 35).
Adding a variant here means classifying its effect there.
### `reduce_view`
The same function runs on both ends — the client folds it to draw, the server
folds it over its mirror — so the two cannot drift (improvise-cqq,
improvise-m91). Total and deterministic: no IO, no clock, no failure.
`Command::Project` and `Command::Model` are both no-ops in it, for different
reasons. A projection is routed to one widget's `WidgetCache` instead. A
model command is the *document's* business: what a client must fold arrives
as the projections it dirtied, plus — when the active view moved — the
`SwitchView` the host broadcasts.
A `ModelCommand` does not mutate anything by itself: `improvise-command`'s
`model_effects` maps it back to the effect it came from and runs it through
the same `App::apply_effects` the key path uses. Effects stay the only
mutation site, and the `Dirty` is the effect's own `EffectKind` — never a
second classification beside gotcha 35's table.
---
## 3. `render.rs` — the fold
### There is no aggregate render cache
Each widget owns its own cache type and folds its own stream of projections
(improvise-rkeq): the grid does not learn that a formula was deleted, and the
formula panel does not learn that a cell changed. A `Projection` is one such
update, addressed to exactly one widget, and `WidgetCache::apply` is the fold.
The overlap between `TileBarCache` and `CategoryTreeCache` — both carry
`(category, axis)` — is **deliberate**, so neither is woken by the other's
changes. Do not "fix" it by sharing a variant.
`WidgetCache::apply` is total and deterministic, like `reduce_view`: an
update naming something the cache does not have is **dropped, never a panic**.
Projections race on the wire, and a client that crashes on a stale index is a
client that crashes.
`Projection::Basis` is not a widget — nothing draws it — but it is folded on
exactly the same terms; see §5.
### `DocumentCache` — a widget, not a `ViewState` field (improvise-z754)
`draw_title` read `workbook.model.name`, `model_state.dirty` and
`model_state.file_path`. None was projected, so a browser could not draw a
title bar at all.
**A projection rather than a `ViewState` field**, and the argument is gotcha
30's membership test read backwards. `ViewState` is the *portable session
slice*: per-client UI a client folds its own commands into. These three are
facts about the **document**, shared by every client and writable only by the
server — on `ViewState` they would be document state wearing a session slice's
clothes, and `reduce_view` would need commands nothing composes. The
per-widget cache is exactly the shape for "a local copy of what a widget
draws, replaced by a `Projection`".
**It is the one widget no `EffectKind` can gate.** `dirty` is raised by any
`EffectKind::Model(_)` in `apply_effects` and cleared by `effect::Save`, which
is `ViewOnly` (gotcha 3) and correctly so. `SaveAs` moves `file_path` and is
also `ViewOnly`. So `Dirty::of` reports `NOTHING` for exactly the effects that
change this widget most visibly, and gating it on `Dirty` would leave every
client showing a `[+]` for a document that has been saved — the forever-wrong
half of gotcha 35. `ClientSession::document_update` therefore **observes**: it
builds the desired cache and diffs it against the mirror, the same answer
`record_versions` got for the same reason (gotcha 40), and the same reason
`cursor_epoch` beats matching on `CycleAxis`. The diff, not a flag, is what
keeps an ungated widget from projecting on every batch.
**A save is projected, not silent.** `Host::save` ends with `project_all()`
and the worker re-projects after its own `persist()`, because both clear
`model_state.dirty` *outside* any batch `Dirty` describes. gotcha 45's
"answered silently on success — nothing moved, so no widget is dirty" was true
until this cache existed and is now exactly wrong: the title bar moved. A test
that reaches for `Sessions::project_all` by hand proves nothing here, because
that is the call the production paths omitted.
**`stored_as` is a name, never a path.** The TUI already drew only
`file_name()`, and a socket server projecting `file_path` would hand a remote
client the host's directory layout — useless to it and a leak. What a client
can act on is *whether the document has a home at all*, which is the fact
`ClientFrame::Save` fails on (`NoDocumentPath`, gotcha 45), so `None` carries
it. A browser's answer is its OPFS entry name; neither end has to know which.
### `CellId`: the kind of identity is the permission
`SetCell`/`ClearCell` take a `RecordCell` (and a view name), `Drill` takes an
`AggregateHash` and nothing else. A client cannot compose a write against an
aggregate **or** a drill against a stored cell — not because either is
checked, but because neither carries an id of the other's type
(design-principles §3). It projects a fork the model already makes:
`EditOrDrill` branches on exactly this in the TUI today.
An earlier draft made this `Option<CellId>`, absence meaning aggregate. The
enum is better: an aggregate is not the *absence* of an identity, it is a
different *kind* of identity — and modelling it as `None` left `Drill` with
nothing to address.
A `RecordCell` is `(record hash, measure)` because the surrogate names the
**record**, not the cell (ADR 002). That is what lets one identity serve both
addressing and staleness: `record_versions` is keyed by the same hash, so a
client's own token is the lookup key for its own version, with no `CellKey`
rebuilt on the hot path. `measure` is absent for a record carrying no
`_Measure` coordinate at all — the shape `add-record-row` seeds.
### `CellId::Derived` — a formula owns the cell (improvise-ir7l)
A formula target is a real stored coordinate, so the record branch claimed it
and every client offered an editor over it. The write *landed* and was then
shadowed forever, because `evaluate_aggregated` reads `formula_cache` before
raw data — not a revert, a permanent silent shadow.
The fix keeps ADR 002's discipline rather than adding a second answer beside
it: a formula-owned cell projects a **different kind of identity**, so the
write is unconstructible instead of flagged. `Derived` carries the formula's
target and **no surrogate**, so there is nothing a client could spend on a
`SetCell`; that is the same reason `Aggregate` carries an `AggregateHash` and
not a `RecordHash`.
**In records mode the question is asked of the record's real coordinate.** The
synthetic `(_Index, _Dim)` key names no measure, so `formula_for` can never
match it; `GridLayout::record_cell_key` is the one mapping from a records
position to a model cell, and both `cell_id` and `CmdContext::owning_formula`
go through it. This is not defensive: a measure can be **both** an explicit
`_Measure` item and a formula target, and the records `+` column
(improvise-poia) is a one-keystroke way to build one.
Two orderings matter in `project::cell_id`. Derived is asked **before** the
record branch, since the coordinate is stored and the record branch would
otherwise win. It is asked **after** the aggregate branch, because a formula
cell that also aggregates is still drillable (gotcha 10) — `Aggregate` and
`Derived` are not alternatives for the same cell, and reversing the two turns
a drillable cell into a dead one.
The predicate itself is `Model::formula_for(&CellKey)` in `improvise-core`,
the one home of "is this cell derived". It already existed three times inside
`model/types.rs` as an open-coded loop; the TUI's `EditOrDrill` and the
producer now call the same function rather than each spelling the rule. The
TUI had to change in the same breath: it re-derives from the `Model` it holds,
which is legitimate (it is the authority), but it must give the *same* answer
or the two front ends diverge — design-principles §5.
### `CellId::Create` — the column writes no cell (improvise-poia)
The two records-mode creating columns — `+` and `+cat` (gotcha 1) — projected
`None`, which means "no projected cell". So a client could not tell a column
that creates something from a padded slot, and gotcha 42's conflation had a
third instance.
**The ticket offered two designs and both assumed a cell write.** They do not
happen: `commit.rs::add_measure` emits exactly one effect,
`AddItem { category: "_Measure", item: name }`, and `add_category` emits
`AddCategory(name)` and deliberately writes no coordinate on the current row
(improvise-2zq). The record under the cursor is not part of the operation, and
neither is any value — **what the user types is a name**. So there is no
"distinct command carrying the record surrogate plus the new measure name" to
invent, and no two-command flow to sequence: one existing `ModelCommand` does
it, and the address it needs is the name.
What was missing was therefore never an address but a **recognition**. `Create`
carries the kind and nothing else, because the kind plus the typed name is the
whole operand. It is not a `SetCell` target, so `render.rs::editable` stays
`Record`-only and `opened_at_cursor` captures no editor target for it — both
by matching, which the compiler now forces.
**No client composes it yet**, and that is one gap rather than this one:
`keys.rs::commit` returns early for every records-mode commit, because a
category column renames every measure cell in the row and that fork needs
`record_col`. The creating columns do not need it, so the early return is
broader than its reason — improvise-gcvb, which also records the two things
that make it more than a one-liner: an inserted `+cat` column means the TUI
does **not** advance, and `AddCategory` can abort.
### `CellId::Coordinate` — a drawn coordinate is not "no cell" (improvise-exdg)
A records-mode coordinate cell (the `Region` column holding `North`) used to
project `None`. That was a **third** meaning of `None`, next to the padded slot
and the blanked position: a visible cell that holds text and whose write
belongs to the host. So absence was encoding a permission, the same conflation
`Create` removed for the creating columns (improvise-poia). The pinning test,
`a_coordinate_column_is_projected_with_no_identity`, asserted the bug and is
gone.
**Since improvise-fdk1 it carries `{ record, category }`**, the operands of
`ModelCommand::RenameRecordCoordinate`. Editing a coordinate renames it across
every measure cell in the row (gotcha 1). When exdg landed, no client composed
that operation, so the variant was left empty on the grounds that the operand
belongs with the command that consumes it. That command now exists, and both
operands come from the **projection**:
- **`record` is the row's own record.** A client could borrow the hash from a
sibling `Record` cell in the same row. That would be a second derivation of
which cells share a record, which is what ADR 002 forbids. It would also have
nothing to borrow in a document with no measures, where the coordinate is
the only cell a row has. For the same reason `published_basis` counts a
`Coordinate`'s record, or a rename of such a row would be `RecordUnstated`.
- **`category` is the column's identity.** Its label is not: a category and a
measure may share a name (gotcha 1), so reading the category off the column
label would be a guess.
`RenameRecordCoordinate` is position-free (improvise-hkbc): it names the record
by surrogate and the new coordinate by value. `records_named` names the record,
because a rename moves it (gotcha 43). The destination, when it exists, is
added by the server's `records_at_stake` rather than here, because only the
document can derive it (improvise-milr, `context/code/command.md`). Creating the item is internal to the
effect. `render.rs::editable` still answers `false` for a coordinate: the page's
inline editor writes stored cells only, and the rename's producer is the
keyboard path (`context/code/clients.md`). `typed_cell_command` and
`record_cell_at_cursor` still answer `None` for it, so paste onto a coordinate
is still improvise-kkcy.
### `CellDisplay::id` — `None` is not "aggregate"
That is `Some(CellId::Aggregate(..))`, and the distinction is the whole point
of the enum. `None` means the slot holds **no projected cell**: a `GridCache`
position padded by `fit_to_labels`, or one blanked by a reframing. Every
records-mode column is some kind of cell (`Record`, `Derived`, `Create`,
`Coordinate` or `NonItem`). Conflating the two either hides a cell or offers a
write the type system exists to make unconstructible.
`NonItem { category, item }` is a stored value whose `category` coordinate
names no item of it (improvise-gk51). It is drawn, and it takes no write. The
host would refuse one, so it is not offered as a `Record`. Why it is a kind of
its own rather than one of the others: `context/code/command.md`, "The
projection asks the first rule too".
### `CellDisplay::edit_text` — the difference corrupts data
What an editor opened on this cell should start with: the value as the user
last **typed** it, not as it is **drawn**. `text` has been through
`format_f64`, so a stored `1200` is drawn `1,200` under a comma-grouping
format; seeding an editor with that and committing it parses as
`CellValue::Text("1,200")`, and the number is gone. Display rounding is
view-only and must never be fed back into the model (design-principles §4) —
and an editor's seed is exactly a feedback path.
It is the projection of `App::display_value_at_cursor`, which is what
`EditOrDrill` seeds the TUI's buffer from, so both front ends start from one
string rather than each deriving one. `None` where there is nothing to edit:
positions whose `id` is `None`, plus pivot cells with no key. A records-mode
cell always carries one, even an empty one. An error cell carries none, because its text is a diagnostic and not
a value.
A browser found this: `tests/lockstep.rs` drove `i` through the host and the
client over the same grid, and they disagreed by a comma.
**It is the value's exact spelling, not its `Display`** (improvise-umi4).
`CellValue`'s `Display` writes a non-whole number `{:.4}`, so this field
carried `1.2346` for a stored `1.23456789`, and `i` then `Enter` with nothing
typed stored the rounded number in both front ends. Every arm that seeds an
editor now goes through `CellValue::edit_text` (`core.md` §17). That is the
shortest decimal that parses back to the same `f64`.
### An untouched editor writes nothing (improvise-ixxx)
An exact seed is not enough, because the commit reclassifies it. `typed()`
reads text, not the cell it came from, so `i` then `Enter` on `Text("007")`
stored `Number(7)`. A raw `CellValue::Error` has the edit text `ERR:msg`, and
the same keys stored it as `Text("ERR:msg")`. After umi4 the seed differs from
the stored value only when `typed(edit_text(v)) != v`. In every such case the
right answer is to keep `v`, which is what an untouched commit means.
So **a commit whose text is the seed the editor captured as it opened writes
nothing**. `resolve::untouched(Option<&OpenedEditor>, &str)` is the one home
for that, beside `typed()`. Every commit path reaches it:
- **TUI, pivot and records.** `CommitAndAdvance` carries `opened`, which
`commit_at_cursor` takes from `CmdContext::opened_editor`. It skips only
`commit_cell_value`: the mode change, the cursor restore, the advance and the
re-entry all still run, so records Tab still advances. The script form
(`commit-cell-edit <mode> <value> <coords>`) carries `None` and always
writes, since no editor was opened. The drill's staging goes through the same
`commit_cell_value`, so it is covered too.
- **Browser keyboard.** `resolve::commit` → `written`, which returns an empty
write for an untouched buffer and leaves `ChangeMode` and `advanced` alone.
- **Browser mouse.** The seed is a field of #188's `EditTarget` (`opened`),
captured by `edit_target_at` from the cache's `edit_text` beside the record
and its version. `typed_target_frame` starts with the `untouched` check, and
the page passes no seed (`typed_cell(target, text)`). The `<input>` shows
`edit_seed(target)`, so what the page shows and what it compares against are
one capture (improvise-ugj4).
**The seed is captured at open, never recomputed at commit.** Where the editor
opened is where `OpenedEditor` is written. For the TUI that is `EditOrDrill`
and `EnterEditAtCursor`. For the browser it is `resolve::opened_on`, which
`edit_or_drill` and `advanced` share, and `ClientState::settle_editor` for an
editor that arrived without its buffer. For the mouse it is `edit_target_at`.
The reason is concurrency. If another client writes the cell while this editor
is open, a seed recomputed at commit has moved to the new value. The untouched buffer then differs from it, so the
stale text gets written over the other client's value. The browser's basis
claim would not stop that write, because it is taken when the commit is
composed. Comparing against the captured seed avoids that lost update as well.
`OpenedEditor` lives on `ViewState` (`opened_editor`) and travels as
`Command::SetOpenedEditor`, with the `ViewOnly` effect of the same name. It is
a struct on purpose. What else an open editor must capture, namely which record
it edits (improvise-2wu3) and at what version (improvise-0j0f), belongs in the
same object. `EnterMode` into an editing mode writes `None`, because an editor
opened that way has no seed and must not inherit an earlier one.
**Untouched means not edited, not merely equal** (improvise-2054). Comparing
content alone dropped a deliberate write. Open on `7`, let another client write
`5`, then press Backspace and `7`: the buffer spelled the seed again, so the
commit wrote nothing and the user was not told. `OpenedEditor` therefore
carries `touched`, and `untouched` requires `!touched && text == seed`. The
edited write is then composed, and if the cell moved, the basis check
(improvise-0j0f) refuses it loudly rather than dropping it silently.
`resolve::edited(opened, buffer)` is the one rule for what marks an editor. It
is an edit of **the editor's own buffer**, `edit`, by `append-char` or
`pop-char`. `AppendChar`/`PopChar` in the TUI and the browser's two arms each
call it and emit `SetOpenedEditor` once, on the first edit. Two things
deliberately do **not** mark it:
- **A key into another buffer.** Typing `:enter-mode editing` goes into
`command`. If that marked the stale editor, it would hide the stale-seed case
the `EnterMode` guard exists for, and no test could see the guard.
- **`clear-buffer`.** Esc and the post-commit clear are not the user's edits.
If they marked the editor, the guard above would again be redundant and
unpinnable.
The comparison stays exact: whitespace is content, and nothing trims.
**The mouse editor reports its first edit.** Its edits happen in a DOM
`<input>` that Rust never sees, so the page's `input` listener, registered
`{ once: true }`, swaps its target for `edited_target(target)`, which is
`EditTarget::edited`: the same capture with `touched` set (improvise-jibp).
Before that, the mouse path compared content alone, so the 2054 case survived
there. The `<input>` used to be seeded from the **painted** `editText` while
`EditTarget` captured the **cache**. `repaint` returns early while an editor is
open, so a paint that went stale then showed old text against a newer seed and
version (improvise-ugj4). The page now seeds from `edit_seed(target)`, and
`RenderCell` no longer carries `edit_text`, so the paint cannot be asked.
This does **not** change classification. `007` typed fresh is still
`Number(7)`, and `a_touched_browser_commit_still_writes` pins that.
`numeric` and `error` are decided by the server for the same reason: it is
the side that knows which it was, and re-deriving client-side is how the two
ends drift apart. `CellDisplay` is deliberately not *drawn from* a `CellValue`
— by the time a cell reaches a client the number formatting has already
happened, and `text` is what a renderer paints.
### `CellDisplay::value` — what `yank` copies (improvise-dsxq)
The value the host evaluated at this position, the same `Option<CellValue>`
that `text` was formatted from: `evaluate_aggregated` over the view's
`none_cats` in a pivot grid, `records_value` in a records grid. It exists
because the browser's `yy` has to copy what the TUI's `YankCell` copies, and
neither string on the cell can be turned back into it:
- `edit_text` was `CellValue::to_string()`, which writes a non-whole number
`{:.4}`, so `1.23456789` became `1.2346`. improvise-umi4 has since made it
exact, but the next bullet still holds.
- Re-parsing any text with `typed()` turns number-like text into a number —
`Text("007")` becomes `Number(7.0)`.
- In a pivot grid `edit_text` is the *stored* cell (`get_cell`), so an
aggregate or a formula cell has none and yanked nothing at all.
So the value is **projected**, not re-derived (gotcha 47): a client that
wants the value asks for the value. It is kept separate from `edit_text`
rather than replacing it, because the two answer different questions — what
an editor starts with is a *string*, deliberately the one the TUI's buffer
holds. (That string was itself lossy until improvise-umi4. The fix changed what
the TUI's own editor shows, which was the point.)
In a records grid the TUI does **not** yank this value: `YankCell` and
`PasteCell` resolve the synthetic `(_Index, _Dim)` key, so the TUI yanks
`None` and pastes a junk cell into the document (improvise-6p8q). The browser
is right there and the TUI is wrong, which is why the lockstep yank test stays
on a pivot grid.
### `CellDisplay`'s equality is the wire's, not `f64`'s (improvise-4w09)
`CellDisplay`'s `==` has one job: the grid diff (`project::grid_updates`) and
the seed property (`GridCache::folded(&g.seed()) == g`, gotcha 32) run on it.
The question it answers is therefore **"could a client tell these apart?"**,
not "are these the same number?".
A derived `PartialEq` answered the second question. `value` holds an `f64`, and
`NaN != NaN`. So once #178 added `value`, a cell holding NaN, which `.improv`
carries as a first-class value (improvise-55v2), was unequal to itself. The
diff resent it on every grid-dirty projection, and the seed property was false
for exactly the documents that hold one. It also cost `Eq` on `CellDisplay`,
`GridCache` and `GridUpdate`.
So `PartialEq` is written by hand and compares `value` with
`same_on_the_wire`, which has two clauses:
- **Every NaN is equal**, whatever its bits. The wire spells them all `"nan"`
(`model/cell.rs::wire_f64`), and they cannot all share a payload: `0.0 / 0.0`
on x86 sets the sign bit, and a client that parsed `"nan"` holds `f64::NAN`.
Bit equality alone would fix the self-diff and still call the host's NaN and
the client's NaN different.
- **Any other number is its bits**, so `0.0` and `-0.0` differ. serde_json
writes them differently, and a change between them is one the client does
not yet have.
That is an equivalence relation, so `Eq` is back on all three types.
`CellValue`'s own `PartialEq` is unchanged: formula evaluation and model code
keep IEEE semantics. Only the projection, whose equality means "nothing to
send", adopts the wire's.
The hand-written impl destructures `CellDisplay` exhaustively, so adding a
field fails to compile until it is compared. The dangerous failure for a diff
is **under**-reporting (gotcha 35), and a field left out of `==` is exactly
that. Evidence: `ui::project::tests::a_grid_holding_nan_quiesces` and
`render::tests::a_cell_equals_another_exactly_when_the_wire_cannot_tell_them_apart`.
### A number must read back as the number written (improvise-6oy4)
serde_json's default f64 parser is fast and best-effort: it does **not**
guarantee reading back the `f64` that serialized the text. Its
`float_roundtrip` feature does, at roughly twice the cost of float parsing.
Without it, the review of PR #178 measured about one ordinary decimal in seven
coming back one ULP off, both in `CellDisplay::value` (host to client, so `yy`
copied a neighbour) and in a `SetCell` (client to host, so a typed or pasted
number landed as a neighbour).
The feature is on for **every workspace crate with a normal dependency on
serde_json** (improvise-dz8c). Each crate's own manifest names it, because each
build is made from one crate alone: the `wasm` CI job and `wasm-pack` invoke
per crate, and feature unification cannot reach across separate invocations
(`context/file-inventory.md`, appendix "Wasm"). Enabling it in one crate and relying on the
others to inherit it works in `cargo test` and nowhere else.
**Why "can parse" and not "parses a frame".** The first version of the rule
covered only the three crates that parse frames and listed them by hand.
Frames are not the only place a user's number crosses a JSON parser.
`improvise-io`'s legacy-JSON `.improv` load (removed since, improvise-tt22), `improvise-command`'s JSON import
(`StartImportWizard`, `ImportJsonHeadless`) and the root binary's
`improvise import` all parsed with the lossy default, and in the TUI binary
nothing enabled the feature (improvise-6yvg, reproduced: `0.24815714285714285`
stored as `…283`). The worker bundle happened to be exact through unification,
so one import gave different numbers in the two front ends. "Parses JSON input"
cannot be read off a manifest, but "has a normal serde_json dependency" can, and
it over-approximates in the cheap direction: the feature changes only parsing,
so a crate that only serializes pays nothing for it. Dev-dependencies are out,
because a test build ships nowhere. That also leaves out `gp-tree`, whose only
serde_json is a dev-dependency (its example CLI reads a saved model). Every
number it persists is an `f32`, and 3M random `f32`s all read back exactly
through the lossy parser, against about 30% of random `f64`s.
**Why a declaration and not the build graph.** The first check asked
`cargo tree` whether each listed crate's *build* had the feature. That answer
can be yes by accident. `improvise-command` depends on `improvise-io`, so with
the feature removed from command's manifest, command's own test build still
has it, and its import test stays green. The rule requires the crate's own
manifest to say it, so a later change to a dependency cannot take it away.
**Why not enable it once, in `improvise-protocol`.** That removes the list, but
it would give the protocol a normal serde_json dependency it has no use for
(its repo-map entry says core and serde only). It would also reach only builds
that include the protocol, and `improvise-io` does not depend on it. Under this
rule the protocol's one serde_json dependency, the optional `test-support` one,
carries the feature too. That is off in every shipped build, so it costs
nothing.
`every_crate_that_can_parse_json_enables_float_roundtrip` (`tests/json_numbers.rs`)
reads `cargo metadata --no-deps` and names every package, and every
target-specific entry, whose normal serde_json dependency lacks the feature. A
new crate is covered the day it declares the dependency. It does not reach
`harness/opfs-contract`, which is its own workspace and only serializes.
The end-to-end tests cannot guard this alone. They run in one unified build
with `improvise-ws-server` as a dev-dependency, so they stay green with the
feature removed from the browser bundle.
### `AxisFrame` — the grid's frame, and why it is *one* type
`AxisFrame` (improvise-anr9/s1xw) replaced a `Vec<String>` of labels that
`GridLayout::row_label` built by **slash-joining a multi-level tuple** — the
exact rendering `two_row_categories_produce_cross_product_labels` asserts must
not appear. The protocol carried a form of the labels the TUI itself
considered wrong, so the browser drew `Food/Alice` where the TUI drew two
header columns, with no groups and no collapse indicators (gotcha 44).
It is one type rather than a `labels` plus a `levels` beside it, and that is
the whole point: **two answers to "what are the labels" is the disease** this
projection architecture exists to cure (improvise-edp). A renderer asks this
type and there is nothing else to ask. Note what this cure cost that
`flatten_cat_tree` did not: a `Vec<String>` had to change **type**, and the
shared derivation could not be added beside the old one.
`cats` is the categories outermost-first; `entries` interleaves
`Group(FrameGroup)` headers with `Item { tuple, collapsed }` label tuples exactly
as `GridLayout::row_items` interleaves them. On the row axis the TUI paints a
`Group` as its own row; on the column axis it paints the runs as spans above
the headers. **That difference is painting, and it is the only thing that
differs.**
`cats` may be empty — an axis carrying no category is a real layout (one
nameless row or column), not a gap, hence `levels()` returning **at least
one**. `AxisFrame::flat` leaves `cats` empty on purpose, so it is not a way to
smuggle a multi-level axis in flattened.
`FrameGroup::collapsed` is on the wire rather than left to the client because
it is a fact about the *view*, which only the server holds.
**`Item::collapsed` is on the wire too, and for a sharper reason** (improvise-4k1j).
It is the layout's own `AxisEntry::DataItem::collapsed`, which `GridLayout`
sets on a fold and on every item expanded beneath one, at any level. A client
cannot derive it from the headers. `FrameGroup` names no level, and an inner
`GroupEnd` looks the same as an outer one, so under two row categories the
nearest header can be the inner category's. That is how `H` came to hide a
group name as an item. Tuples cannot answer it either, because a group and an
item may share a name. `AxisFrame::folds(idx)` reads the flag, and it answers
exactly what `GridLayout::row_folds_a_group` answers, because it is the same
bit. improvise-e265 puts a level on headers and ends, which makes per-level
membership derivable. The flag still stays: a client reading the host's fact is
stronger than one re-deriving it, however correct the derivation. There is no
`FrameGroup::cat` any more (improvise-7xyx). Its doc comment promised that a
client echoes it back in `ToggleGroup`, but no client ever did. `resolve.rs`
has no `toggle-group-under-cursor` arm (`z` is on the still-needs-the-host
list), and neither renderer read the field. A promise about an interaction no
code performs is the stale-sibling defect, so the field went with it. It is
not a loss for `z`. The TUI's `ToggleGroupAtCursor` also places the cursor on
the layout the toggle *will produce*, which a thin client cannot compute. So
composing `z` in a browser needs its own design, and the operand should come
back with that design rather than wait for it.
### `AxisFrame`: four accessors that are **not for a draw loop**
`item`, `label`, `shows_level` and `group_of` each scan `entries` from the
start, so asking them per position is quadratic in the frame length and, in
the TUI, **grows with the absolute scroll offset**. That defect was found
three times on improvise-anr9. The per-position forms survive for one-off
lookups only, and their two-line warnings are the thing standing between a
future renderer and a measured regression.
- `shows_level` is the worst of the four: it materializes **every** item on
every call. It is deliberately not made cheaper, because a fast
per-position form is an invitation to the loop `header_labels` exists to
replace.
- `group_of` is `groups()` at one position, so it walks the whole frame
(improvise-e265).
The one-pass forms a draw loop wants:
- **`header_labels(first_rendered)`** — every header label with suppression
already applied, indexed `[data index][level]`, `""` where the label
repeats. The suppression rule still has exactly one home (`fn shows`); this
is `shows_level` applied in bulk, not a second copy of it.
- **`groups()`** — `group_of` for every position, in one pass.
- **`shares_ancestry(with)`** — the TUI's column-header underline: at the leaf
level only the selected column matches, at a higher level every column under
the same ancestor. There is deliberately **no** per-position form beside it,
because one would be a second answer paid for per (column, level) — two
frame scans apiece.
**`leaf_labels()` is not "the labels".** It is the innermost label of each
item: the single most specific name a position has, what a test or a one-line
summary calls a row by. A renderer reaching for it on a multi-level axis is
drawing the *wrong* header, not a shorter one.
**`first_rendered`** is the first data index actually on screen. That entry
always shows its full tuple, or a scrolled viewport loses its group context at
the top edge (improvise-jjx).
**`body_from(idx)`** drops the group headers that own the first drawn item: a
frame opening mid-group carries the open run at every level so `group_of` can
answer for it (improvise-e265), but those headers are *above* the first drawn
row and must not be painted as rows. This is
the same cut `GridLayout::data_row_to_visual` has always made.
Two things about its argument, and the first of them **used to be the whole
story and was wrong**. It exists partly because the two front ends scroll
differently — a browser's frame is already windowed and passes `0`, the TUI's
covers the whole grid and passes its row offset (gotcha 44). But `idx` alone
cannot say whether the frame *leads the axis*, and at the top there is no
header "above" to drop: it is content, and a collapsed first group has no
other affordance. That is `AxisFrame::first` and improvise-0o6x — §3a.
**`group_of` answers membership**, and the paragraph that stood here said the
opposite: that it answers "nearest preceding header", deliberately, because
that is what the TUI had always painted, and that encoding an end boundary
would mean changing `AxisEntry` in `improvise-core` and was "filed rather than
smuggled into" improvise-anr9. All of that was true when written. **The filed
ticket is improvise-gd83 and it has landed** — `AxisEntry::GroupEnd` closes a
run, `groups()` clears on it, and the TUI's painted span changed with it. The
mechanism and its two emission arms are §3a.
That correction is here rather than only in §3a on purpose. This paragraph
forbade, in terms, the change §3a describes; a reader arriving at the stale
copy first would conclude the rule had been smuggled past its own prohibition.
When a fact is stated twice, changing it is a grep (gotcha 48) — and the two
copies being in *one file* is what made this worth saying out loud.
### The width rule has one home
`header_level_widths` (improvise-1125): per level, the widest label **in
display cells** plus a gap, clamped to `[MIN_ROW_HEADER_W, MAX_ROW_HEADER_W]`.
It lived twice before — inline in `src/ui/grid.rs::render_grid` and again in
`improvise-command::ui::viewport::compute_row_header_width` — and a browser
sizing its own row header would have been a third.
It takes an **iterator of borrowed tuples** rather than an `AxisFrame`
because its other caller holds a `GridLayout` and is on the keypress path
(reached from `App::cmd_context`); building a frame there to ask the question
would clone every row tuple on every key (raised by Copilot on PR #23).
`AxisFrame::header_widths` is the frame-shaped entry point, derived rather
than projected as a field because the frame is the whole input — nothing about
the model is needed, so a shared function cannot fall out of step with the
data the way a separately-sent field could.
### `GridCache` is viewport-indexed, and `GridExtent` is not
A cache position is a position within the *viewport*: the client does not know
the model's coordinates, and the server is free to change what a row means as
long as it says so.
`GridExtent` is how big the **whole** grid is, of which the frames are one
window (improvise-bpq2). It is the one fact a client needs to move its own
cursor: `row_frame.len()` is what this client was *sent*, so a client holding
only its window cannot tell "the last visible row" from "the last row" and
cannot clamp — a cursor clamped against the window stops at the bottom of the
screen. It is deliberately **not framed** by the frames, unlike `cells`,
`col_widths` and `totals`: it is the size of the thing they are a window
*onto*, so `fit_to_labels` must leave it alone.
For a client whose window covers the whole grid — the TUI, which builds its
cache at offsets zero — it *equals* the frame counts, and **that coincidence
is exactly what makes a derived version look correct there and fail in a
browser.** It is a named pair rather than two `usize` on `GridCache`, because
beside `row_frame`/`col_frame` two bare counts read as "how many rows are in
the frame", which is the confusion the field exists to remove.
`GridUpdate::Extent`'s batch position is **not** load-bearing the way
`RowFrame`-before-`Cells` is: it addresses no position, so it cannot overtake
or be overtaken. It is emitted first only because outermost-first reads better.
### `GridCache::totals` — empty means "no totals", not "not caught up"
Framed by `col_frame` exactly as `cells` and `col_widths` are, with one
asymmetry: an **empty** strip means this grid has no totals to draw (records
mode, or an empty grid) and is therefore *not* padded, where a non-empty one
is. Padding an empty one would draw a blank total row under a records-mode
grid that has none.
The emptiness is the server's answer rather than a client's re-derivation,
and the totals **must** be projected rather than client-derived: the sum runs
over *every* row including the ones this viewport has scrolled past, so a
client holding only its window could not compute it.
`page_coords` is framed by **nothing** — it names categories, not positions —
so it survives a reshape that drops every row and column. It carries Page
*and* Filter (`View::filtering_cats`, gotcha 27): both restrict what is
visible, and the bar states what is being looked at, not what the user may
page through.
### `CategoryTreeUpdate::Items` and `::Axes` have no producer, deliberately
`document_projections` only ever sends `Categories(..)`, which replaces the
whole panel. The other two arms are **derived rather than invented**: the
effects that move a category tree come in three shapes — the list reorders
(`AddCategory`/`RemoveCategory`), one category's items move (`AddItem`/
`RemoveItem`), several axes move at once (`SetAxis`, and `CycleAxis` through
it) — and `doctrine(derive_the_enum_from_the_effects)` is the same recipe that
built `ModelCommand`. An arm the derivation produces is not speculation just
because the producer has not reached for it.
What the producer reaches for instead is the coarse arm, on purpose:
`category_tree_of` walks every category once, the panel is small, and
`Dirty::of` cannot say *which* category moved anyway — an `EffectKind` says
that a batch changed the tree, never which part, exactly as it cannot say
which records a `CellData` batch wrote (gotcha 40). Emitting `Items`/`Axes`
therefore needs the tree **observed and diffed**, the way `document_update`
observes the title bar, and that is a bandwidth optimisation for a widget that
costs a few hundred bytes. It is not worth doing until category-tree traffic
is measured to matter.
The contrast with the deleted `Invalidated` is the point: these are *unused*,
and `Invalidated` was *redundant* — its meaning already lived in running code.
Unused-but-derived earns a note; redundant earns a deletion.
### The fold is not commutative — three rules in `apply`
**(a) A `Cells` update that overtakes its frames is dropped, not guessed at.**
`fit_to_labels` truncates to the frames it has. A client must not reorder the
stream.
**(b) A frame change that *renames* a position blanks the cell under it.**
`fit_to_labels` preserves **by position**, which is only right where the label
did not change, so `apply` calls `invalidate_relabelled` itself rather than
trusting a producer to declare it. It blanks the totals too, because they are
contents and a stale sum under fresh cells is the one number a user takes at
face value.
**There used to be a `GridUpdate::Invalidated` arm** meaning "contents stale,
frame unchanged", and it is **deleted** (improvise-n89z). It never had a
producer: `grid_updates` diffs, so it always knows *which* cells moved and
sends `Cells`, and `GridCache::seed` compacts. There is no state in which a
server knows the contents are stale and not which ones — and if there ever
were, the answer is a fresh `Snapshot` through `rebase`, not a blanking the
client cannot act on. Meanwhile its stated meaning had migrated into running
code: `invalidate_relabelled` blanks exactly what a reframing invalidates,
totals included. So it was **redundant**, not merely unused, and three
documents described it as load-bearing, which is worse than silence — a reader
reasoning about the fold's guarantees assumed something produced it.
`the_retired_invalidated_spelling_does_not_parse` keeps it from returning as a
serde alias.
**(c) Every arm that touches a frame *or* the widths re-fits.** A renderer
zips widths with labels, and a length mismatch indexes off the end. Fitting on
the frame arms alone is what let a late `ColWidths` put the mismatch straight
back: a short update keeps each unheard-of column at its own widest level
label until `ColWidths` catches up, and a long one is dropped as having
overtaken the frame that names it, exactly as (a) holds `Cells`.
The fallback width is measured in **display cells**, the same unit
`header_widths` and the authoritative `ColWidths` use: `chars().count()` would
draw `日本` two cells wide where it needs four, so a cache caught between
`ColFrame` and `ColWidths` would render the new column too narrow. The
materialization inside `fit_to_labels` is deliberate — `AxisFrame::item`
scans, so asking it per column would make a re-fit quadratic.
### `invalidate_relabelled` — a `cats` change re-identifies *everything*
**A cell's identity is `cats` zipped with its item tuple, so a change of
`cats` re-identifies every position at once** — even the positions whose
labels are byte-identical. `Region = [North]` becoming `Department = [North]`
leaves `items` equal and names a different cell, so the tuples alone cannot
answer this and every surviving position is stale.
The sharpest half is invisible: a preserved `CellDisplay` carries an editable
`CellId::Record` (ADR 002), so a client would go on offering an edit that
addresses the record the **old** coordinate named — a misdirected write, in
the same family as gotcha 18's drill snapshot, and one no rendering shows.
Otherwise the comparison is on the **item tuples**, not the entries: a group
header appearing or collapsing changes the frame's shape without renaming any
surviving position, and blanking on that would throw away cells the very next
`Cells` update would have to resend.
Two asymmetries about totals:
- A relabelled **column** drops its total (improvise-hkt6): the total under it
is the sum of the old column. A relabelled **row** does not, because a total
spans every row and is not framed by any one of them.
- A **recategorization of either axis** blanks every total. A column total is
a sum over the rows, so re-identifying the rows leaves a number that answers
coordinates the grid no longer has.
This is what obliges the *producer* to diff the totals against the frame the
label updates will leave rather than against the mirror they change: a column
renamed while its total stayed the same number is blanked here, and a producer
comparing `mirror.totals` would find them equal and send nothing.
`grid_updates` compares `framed.totals` for exactly that reason. The blanking
stays anyway, because rule (b) exists to stop `apply` trusting a producer to
remember — **the rule lives on both sides or on neither.**
`LabelAxis` is private so the row and column cases share one invalidation
rule rather than two copies that can disagree.
### Bootstrap is a compaction, not a replay
A server does not replay history to a late client — it holds the current state
and expresses it as the same updates the steady stream carries, so bootstrap
is not a second code path and there is no whole-state shape that could
disagree with the incremental one. The property that earns it is
`GridCache::folded(&cache.seed()) == *cache`, and it is the **ordering** that
earns it: the frames frame the cells, so they go first.
`Snapshot { view, seed }` is the only thing on the wire that is not an event,
and `view` is the one point at which `ViewState` is serialized (improvise-q08).
Note the fold tests: asserting `fold(prefix) + fold(rest) == fold(whole)` is
**vacuous** — it is the definition of a left fold — so the tests that earn
their place are the order-sensitivity one and the compaction-equivalence one
(`doctrine(fold_tests_must_test_order_not_the_axiom)`).
### `CategoryTreeCache` carries every category, `_`-prefixed included
improvise-vjms: the TUI panel has always listed them, and a browser panel that
quietly did not would be a second, divergent answer to "what are the
categories". So the `_`-prefixed convention does reach the client — it is
presentation, and both ends render the same rows from this one source.
**The trap is `_Measure`, whose items are derived**: explicit items ∪ formula
targets (gotcha 7). Whoever builds these rows must go through
`Model::effective_item_names`; reading `category("_Measure").items` compiles,
looks right, and silently omits every formula target.
Three mutations reach this panel because the effects are three *shapes*:
adding or removing a category reorders the whole list, adding or removing an
item touches one category, and a transpose moves several axes at once without
touching membership. Which categories are *expanded* is
`ViewState::expanded_cats`, per-client, so the flattening is a client-side
concern and does not belong on the wire.
`FormulaCache` and `ViewListCache` take one whole-list update, because add and
delete are the only effects that touch them and both are cheap to send whole.
Which view is **active** is not in `ViewListCache`: that is
`ViewState::active_view`, carried by `Command::SwitchView`, because it is
per-client (improvise-8uz). Likewise which tile the cursor is on is
`ViewState::tile_cat_idx`.
### `WizardCache` has a shape and deliberately **no** `Projection`
improvise-764, and that absence is the ticket's answer to "where does the
wizard state live". A `Projection` is broadcast to every connected client's
widget, which is right exactly when the change is addressed to *no one*
session. A half-open import dialogue is the opposite: it belongs to precisely
the session that opened it. That is already settled doctrine elsewhere —
`ImportWizard` lives on `App` and not on `ViewState`, and `AcceptWizardModel`
was removed from `ModelCommand` for this same reason (improvise-tom1) after B
was shown swapping the document out of a dialogue B never opened. Putting the
wizard on the broadcast stream would have re-created that bug on the
*rendering* side: every client drawing a dialogue one of them opened.
So it is a cache in shape and a **local render model** in fact, built from the
authoritative `App` by the host that holds the wizard. **A cache without a
`Projection` is the shape session-local residue takes when it still has to be
drawn by two different front ends.** It lives here rather than in
`improvise-command` for the `flatten_cat_tree` reason: the browser client
depends on `improvise-protocol` alone, and when it grows a wizard it must draw
*these* rows, not a second derivation. When multi-client import arrives it
wants the addressed form, which is where ADR 001 layer 2 goes anyway.
Every field is already render-ready — the widget that used to build these
reached through `ImportWizard` into `ImportPipeline`, `FieldKind`,
`DateComponent` and a whole `Workbook`; none of those cross now. Two
substitutions are made *here* so both ends spell them the same way: an
undetected date format reads `"?"`, and the root array path reads `"(root)"`
rather than empty. `WizardProposal::kind_label` is carried rather than
re-derived from `kind`, so the two ends cannot disagree about what "Measure
(numeric)" is called.
`WizardStepKind` mirrors `improvise_io::import::WizardStep`, spelled again
rather than re-exported, because this crate must not depend on `improvise-io`
(the wizard is an io type, gotcha 30) and a client that draws the wizard must
still be able to name the step it is on. `WizardFieldKind` is the semantic
half of a colour decision: it indexes the `wizard.field.*` faces (§10), and the
terminal mapping is `draw.rs::face_style`.
### `Projection` carries no multi-widget variant
The envelope a widget subscribes by: it folds the payload of the variant that
names it and ignores the rest. There is deliberately no variant carrying
several widgets' updates together — that would be the aggregate cache in
another form.
Nothing is defined for the help overlay: its content is static and
`help_page` is already in `ViewState`.
---
### `ColWidths` stays diffed against the mirror, and the ticket's reason was wrong
improvise-44ad asked whether `grid_updates` should diff `col_widths` against the
**label-folded** cache rather than against the mirror, as improvise-hkt6 already
obliged for `totals`. Folding the labels re-fits the widths, so under a
fold-relative diff the `ColWidths` a reframe currently carries becomes redundant
and is suppressed. Declined, and the two things learned are worth more than the
change.
**The stated cost does not exist.** The ticket says the move breaks
`a_client_on_a_socket_is_seeded_and_gets_its_edit_back`, and that that assertion
is §3(c)'s standing evidence — so rewriting it inside a change §3(c) guards
would be the improvise-q0vb / PR #12 shape. Measured 2026-09-21 by deleting the
re-fit from `fit_to_labels`: **three** existing unit tests fail
(`a_col_widths_update_is_fitted_to_the_labels_it_is_framed_by`,
`a_column_without_its_width_yet_is_measured_in_display_cells`,
`col_widths_always_span_the_column_labels`) and **the socket test is not one of
them**. What it actually asserts is that a reframe *carried* widths — a
statement about the producer's diff, not about the consumer's re-fit. §3(c) is
triply guarded elsewhere, so the evidence argument was about the wrong test.
**The real reason to decline is coupling.** The widths a label-fold invents are
each column's own label width, which is an *approximation* the consumer applies
until the authority catches up. Diffing the producer's output against it makes
what the server sends depend on a fallback rule defined in the client. Today
both are in this crate and the dependency is invisible; the day a second client
implements the fold, the producer's message set silently depends on that
client's approximation. The redundant `ColWidths` per reframe is what buys out
of that, and it is the direction `Dirty::of` is deliberately sloppy in (§5,
repo-map gotcha 35): over-sending costs one projection, under-sending leaves a
client wrong with nothing in the stream to correct it.
**So the asymmetry between `totals` and `col_widths` is stated rather than
discovered.** `totals` is fold-relative because hkt6 found a real hole — a
rename that leaves the number equal sends no `Totals` and the client blanks its
own. `col_widths` has no such hole, because the re-fit is a *safe* fallback
rather than a stale value.
What was landed is the half the ticket calls "the better test either way":
`a_client_never_holds_widths_of_a_different_length_from_its_labels` asserts the
invariant §3(c) is really about, at **every step** of a fold that widens before
its widths arrive and narrows before they shrink — not merely at the end, which
is where a message-count assertion looks.
### `GridCache::records` — what kind of grid this is (improvise-e5pf)
The host answers "is this the wide records layout" with
`GridLayout::is_records_mode()`, which reads the axes. A thin client has no
`GridLayout`, and it was answering the neighbouring question instead —
`ViewState::mode.is_records()` — at three decision points in `resolve.rs`. The
two agree only while the mode and the layout agree, and
`Command::ChangeMode(AppMode::Normal)` is precisely the message that separates
them: the host settles `Normal` against the layout the batch *ends* on
(`App::settle_normal_mode`), and `reduce_view` folds the variant it was handed.
So the fact is **projected**, not derived: `GridCache::records`, carried by
`GridUpdate::Records`. Deriving it on the client from `row_frame.cats`
containing `_Index` would have been a second implementation of
`is_records_mode` — improvise-edp's disease — and deriving it from the mode is
the bug itself.
Like `GridUpdate::Extent` it **frames nothing**, so `apply` does not re-fit and
its position in a batch is not load-bearing: it describes what kind of grid the
frames describe, not any position in one.
**The settling is observed, never composed — and the first version of this fix
got that wrong.** It settled at each *composition site*, in `resolve::changed_mode`,
against the grid the client held **at the moment it resolved the key**. ChatGPT
Codex found the hole on PR #98: `switch_view_at` composes
`[SwitchView(target), ChangeMode(Normal)]`, so the settling read the **outgoing**
view's grid and records → pivot emitted `RecordsNormal` while pivot → records
emitted `Normal`. The drift the fix existed to close, by a second door.
The unreachability argument the first version leaned on covered the *toggle*
route only — `R` is a `stranding` decline in the browser — and records-ness is
auto-detected from the axes (gotcha 1), so a **view switch** changes it with
nothing in the batch saying "records". `doctrine(an_unreachability_claim_is_only_as_wide_as_the_routes_enumerated)`.
So `changed_mode` is **deleted**, every site emits the plain `AppMode::Normal`,
and `ClientState::fold` settles after every fold against the grid it **holds**:
`self.view.mode = settled_mode(&self.view.mode, self.grid.records)`. That is
route-independent — it needs no enumeration of composition sites, which is the
part nothing checks — and it is the same shape the repo already prefers
elsewhere: `cursor_epoch` over matching on `CycleAxis` (gotcha 24),
`document_generation` over matching on `LoadModel` (gotcha 39), the dirty flag
over a table of command variants (gotcha 45).
`settled_mode` is the **one rule**, in `improvise-protocol`, and all three ends
call it: `ClientState::fold`, `ClientSession::fold` (the server's mirror, so the
two cannot diverge in a field nobody currently reads) and
`App::settle_normal_mode`. `App::normal_mode` is gone — it was that function's
host-side duplicate. It settles the `Normal` pair and **nothing else**:
`Editing`/`RecordsEditing` pass through untouched, which is exactly why a
records decision asks the grid and never the mode.
There remains a **window, and it is inherent**: between the local optimistic
fold of a `SwitchView` and the arrival of the target view's projection, the
client holds the outgoing grid and cannot know the target's records-ness. The
mode settles the moment the projection lands. No design removes that window —
read-time derivation has exactly the same exposure, since it would read the same
stale grid — so the honest statement is that the mode settles *when the grid
does*, on both ends.
The three decision points ask `state.grid.records`. What that buys concretely: the
commit at a records cell **declines** rather than composing
`typed_cell_command` against a grid whose columns are
`[coords…, +cat, measures…, +]` (gotcha 1), which was a wrong write and strictly
worse than the black hole it was written to produce.
## §3a. The frame's group boundaries and its position in the axis
Two facts `AxisFrame` gained after `improvise-anr9`, both about group runs.
**`FrameEntry::GroupEnd` closes a run.** `group_of` used to answer "nearest
preceding header", which is not membership: `expand_category` emitted a header
on *entering* a group and nothing on leaving one, so an ungrouped item after a
grouped run inherited it (`improvise-gd83`). The §3 paragraph that recorded the
old rule — and forbade this change — is corrected there, not merely superseded
here. `AxisEntry::GroupEnd` is
the boundary, carried onto the wire as `FrameEntry::GroupEnd`; `groups()` closes
its level on it, and every deeper one. It is emitted only on
`Some -> None`, never between two groups — the next header already ends the
previous run — and at the end of an expansion whose last item was grouped, which
is the across-prefix arm `cross_product` creates.
Nothing draws a `GroupEnd`. Both front ends skip it and it consumes no data
index, so it is invisible except through `group_of`/`groups`.
**Runs nest, so both markers name a level (improvise-e265).** `FrameGroup`
carries `level`, an index into `AxisFrame::cats`, and `FrameEntry::GroupEnd` is
`GroupEnd { level }`. `groups()` keeps one open run per level: a group at L
opens L and closes deeper runs, and a `GroupEnd` at L closes L and deeper. Each
position's answer is its **innermost** open run. So with `Region` over `Month`,
`North/Mar` after `Q1` ends is in `Upper`, and it was in nothing while a flat
reading let `Q1`'s end close `Upper` too. The user ruled per-level membership,
and that `z` toggles the innermost group. The layout reads its markers by the
same rule (`context/code/core.md` §6), and
`the_frame_agrees_with_the_layout_on_every_rows_innermost_group` holds the two
together at every window offset.
The level goes on the `GroupEnd` as well as on the header because the markers
are cut by three producers (expansion, pruning and windowing), and a marker
that names its own level can be read correctly inside any fragment of the
sequence. The header's level also gives a client the group's category,
`cats[level]`, which a browser `z` needs to compose `ToggleGroup`. That is not
the dead field improvise-7xyx removed: `level` is read by `groups()`, and the
category name is not carried twice.
**Within one category runs nest by `depth` (improvise-felo.6).** A group's
parent (`> Q1[2024]`) puts the quarter's run inside the year's on the SAME
level, so both markers also carry `depth`, the group's number of ancestors.
A run's key is `(level, depth)`, compared in that order: a header at a key
closes every run with a greater key, and so does a `GroupEnd`. With `Date`
alone on an axis, `2024-03` after `Q1-2024` ends sits in `2024`, and after
`2024` ends in nothing. Depth rides beside `level` rather than being folded
into one flat index, because a flat index would need every category's tree
height to be known before the first marker, and `level` would stop naming
`cats[level]`. Both front ends draw a nested header exactly as they draw any
other: one inline row per `Group` entry, the innermost group in the column
band. `the_two_front_ends_draw_the_same_nested_hierarchy_on_rows` and
`..._on_columns` in `tests/grid_frame_agreement.rs` hold them together, and
`the_frame_agrees_with_the_layout_inside_one_categorys_hierarchy` does the same for the
windowed frame against the layout.
`group_runs` is unchanged. It coalesces whatever `groups()` answers, by header
identity (improvise-h0iv), so the band still paints one group per position, now
the innermost. One band per level would be a separate feature.
**`AxisFrame::first` is the absolute data index of the frame's first item.** The
windowing rule carries the open header at each level *above* a scrolled window so
`group_of` can answer, and `body_from` drops it on the way to the screen. At the
top of an axis there is no "above", so that same leading header is content — a
collapsed first group has no other affordance (`improvise-0o6x`). `idx` alone
cannot tell the two apart, because the browser's frame is already windowed and
always passes `0`; `first + idx == 0` can.
The TUI builds over the whole grid so `first` is always `0` there, and the
browser's comes from the projected window. A frame built by hand in a test must
set it, which is why it is a plain field rather than derived.
It carried `#[serde(default)]`, which was a defect rather than a decision and
is now **gone** (improvise-1wl0): `first: 0` is not an absence but the claim
"this frame leads the axis", which is the claim `body_from` acts on — so a
frame that lost the field deserialized as leading, and a scrolled browser drew
the carried header above its first row. Same argument as `Basis` having no
`Default`: a zero-valued claim that happens to be true in the common case is a
forgeable bypass rather than an absence.
**Dropping the attribute was preferred to `Option<usize>` or a two-variant
enum, and the reason is about the wire rather than the type.** There is exactly
one production producer (`project.rs::axis_frame`, which reads `window.start`),
so a frame is never partially constructed and "unknown" is not a state any
honest peer can be in — making it representable would oblige `body_from` to
answer for it, which is the same question one level down. A missing field is
now a loud parse failure at the transport, where a wire-format skew belongs.
No skew ships today, both ends being one workspace at one commit; this is about
the day there is one, and about the attribute reading as a considered
compatibility decision when it was not.
The `Default` derive still yields `first: 0`, and that is not the same hole: an
`AxisFrame::default()` has no entries, so `body_from` returns an empty slice on
either arm and the claim is vacuous. The hazard was specifically a *populated*
frame acquiring the claim by omission.
**`group_runs` / `group_run_heads` are the coalescing rule's one home.** The TUI
and `render.rs::col_groups` each coalesced adjacent columns sharing a group,
kept in step by a comment (`improvise-8qlr`). The TUI's one divergence is that
it labels its first *visible* column even mid-run, so a scrolled grid does not
draw an unlabelled group line.
**A run is opened by a HEADER, not by a name** (improvise-h0iv). Both prior
copies coalesced by name alone, and `improvise-8qlr` preserved that *because* it
was a consolidation: changing the rule in the commit that moved it would have
left the agreement test unable to tell "both ends agree" from "both ends changed
together". With one home that argument is spent, and the rule can be changed
once for both ends — which is the shape to notice, since a consolidation's
frozen behaviour reads as decided afterwards and nothing says it was only frozen
for the duration of the move.
**The ticket's own proposed fix does not work, and that is worth more than the
fix.** h0iv suggested comparing the `(cat, name)` pair; `FrameGroup` carried
`cat` then (improvise-7xyx has since removed it).
But its own reachability case is *one* category: `Month` grouped into `Q1`,
nested under `Region`, giving `North/Q1` and `South/Q1` adjacent — the two
headers are `(Month, Q1)` and `(Month, Q1)`, **equal as values**, so the pair
separates nothing. `the_two_headers_are_equal_as_values_so_comparing_them_cannot_split_them`
pins that as whole-value equality, so the next reader does not re-propose it. What separates them is
that they are two different `FrameEntry::Group` entries, and `groups()` already
borrows each from `self.entries` — so the comparison is `std::ptr::eq` on the
header, which is the literal statement of "the same header opened both".
**The case that settled the product question was not the span but the
indicator.** Two adjacent same-named runs might well be one span a user wants
spanned, which is why h0iv was filed rather than fixed. What is not arguable is
`column_groups_coalesce_by_name_as_the_tui_does`, whose fixture merged
`Month/Q1` (expanded) with `Fiscal/Q1` (**collapsed**) and whose own message
said the merged span is "drawn with the FIRST one's indicator" — a collapsed
group rendered as expanded, with no affordance saying otherwise. A span
boundary is a matter of taste; a wrong collapse glyph is a wrong fact. So: two
runs. Reversing it is one comparison, and the test is renamed
`column_groups_coalesce_per_header_as_the_tui_does` rather than deleted.
## §3b. A frame's levels are checked as it arrives
A `FrameGroup.level` and a `GroupEnd`'s `level` name a category: they index
`cats`. `AxisFrame::groups` used to keep one open run per level and size that
stack with `open.resize(g.level, None)`, so a level arriving over the wire
decided how much it allocated. A frame whose level is `4294967295` would ask for a
multi-gigabyte vector, and the client would be taken down instead of the frame
being declined. ChatGPT Codex found this on #250, the PR that gave levels to the
wire (improvise-e265).
Since improvise-felo.6 `groups()` keeps only the headers it has received,
ordered by `(level, depth)`, so no number on the wire sizes anything and the
new `depth` field needs no bound. The level check stays for its other reason:
`cats[level]` names the group's category, which a browser `z` reads.
The bound is enforced at deserialization, by `#[serde(try_from =
"UncheckedAxisFrame")]`. That is the one place a frame enters from outside. A
level that is not below `cats.len()` fails to parse, with an error that names the
level. A malformed frame therefore fails loudly: `Client::on_frame` returns the
error to the page, the same way it returns any other frame that does not parse.
It is not clamped, because a clamped level would be a guess about which group
the server meant, and a wrong group is a wrong fact.
Three alternatives were weighed and not taken:
- **Bounding inside `groups()`.** The same allocation would still happen in any
other reader, and a frame that is wrong would pass through silently.
- **Checking only the first marker.** A frame can be well-formed up to its last
group, so the check walks every marker. The test puts the bad level on the
second marker for that reason.
- **A frame built in Rust is still unchecked.** The fields are public, and the
producers build levels from `AxisEntry`, whose levels are category indices by
construction. The hazard this closes is the wire, where the input is not
ours.
## 4. `frame.rs` — one envelope pair, two deployments
### The framing adds as little as it can get away with
`doctrine(derive_the_enum_from_the_effects)`: inventing wire variants ahead of
the mutations that need them produces a protocol nobody can implement.
### There were three pairs for two deployments (improvise-xks6)
The socket's server side, a hand-kept **mirror** of it in the browser bundle,
and a third pair written for the worker (improvise-tm6) before the projection
architecture landed. The question that had to be answered before any of it
moved was whether the **worker** and the **socket** want the same vocabulary.
They do, and the test applied to each difference was whether the *argument*
for it names the thing they differ in. None did — every difference was a
**date** rather than a decision:
- **`Viewport` is deployment-independent.** The extent is the one session
input no `Command` carries by construction: it is a fact about a terminal or
a browser window, it is not in `ViewState`, and no effect produces it, so no
derivation reaches it. A worker hosting the same `Sessions` owes
`set_extent` the same answer. It is the **handshake** on both, because a
`Snapshot` seeds the *visible* cells — one built at extent zero is an empty
grid the client would immediately have to ask again for.
- **`Key` is deployment-independent too.** A keymap binding names an *intent*
resolved against a `CmdContext` a thin client does not hold (gotcha 38),
which says nothing about what the bytes travel over. The socket lacked the
variant because key forwarding had never been built on it — a gap
(improvise-bpq2), not a design.
- **`Commands(Vec<_>)` batches where a singleton cannot.** A fan-out must
arrive whole and in sequence, because a `GridUpdate::Cells` that overtakes
its frames is *dropped*, not replayed. That is a property of the **fold**,
not of the transport: an in-process `postMessage` interleaves exactly as a
socket does. `Commands(vec![c])` expresses everything a `Command(c)` variant
could.
The worker pair was not merely different but **incoherent by omission**:
written before ADR 001, it could carry `Command::Model { basis, .. }` and had
no `Rejected`, so a client speaking it composed a claim that could never be
answered. **That incoherence — not the difference — is the tell for
staleness.**
### The mirror is gone, and so are the two tests that held it honest
That deletion was the ticket's measure of success.
`the_socket_framing_matches_the_server` round-tripped hand-listed fixtures
through the other crate's types to catch a **rename**;
`every_server_variant_is_one_this_crate_mirrors` matched exhaustively to catch
a **variant added on the server and never mirrored**. With one definition both
are **type identity**: a rename is a compile error at every call site in both
crates, and the un-mirrored variant is not a constructible state. That is
stronger than either test, and it is the shape to insist on — **if a
consolidation leaves one of its compatibility tests alive, the consolidation
is not finished**
(`doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)`).
What survives is `the_retired_spellings_do_not_parse`, aimed at the one thing
still possible; see `context/test-evidence/protocol.md`.
### `ClientFrame::Commands` — the frame is the unit of atomicity
Not the command (ADR 001, improvise-955n). The host answers for *every*
command in the frame before applying any, so a frame is never applied
partially **because of a refusal**. It used to apply in order and abandon at
the first rejection, which left a hole ADR 001's "never applied, not even
partially" does not have.
**The frame states one basis**, and it is the only claim a client can
construct: a basis is published between batches and never during one, so no
client can hold one describing the document as it would be half-way through
its own frame. Commands then run in order, each against what its predecessor
left, and data dependence between them is expected — a chain may create a
record and then write it.
**But only two of the three tiers are answered once.**
`document_generation` and `layout_version` describe the *document*, so
answering them on the frame's first mutation is right. The **record tier is
asked of every mutation**, because which records are at stake is a property of
the command rather than of the frame — skipping it for later commands let one
client's chain overwrite a record another had moved, silently. The exemption
that keeps data-dependent chains working is narrower than "later commands are
not checked": a record that **neither resolves nor is claimed** is one the
frame is about to create, and only that combination is exempt. A record that
resolves is answered for however the claim is written. `CheckedBatch` in
`improvise-command` carries the derivation.
**All-or-nothing is the contract and clause 4 is not yet delivered.** That is
the other way a frame stops half-way, and it is not a refusal: a chain whose
later command *fails at apply time* leaves its predecessors applied, because
`App::apply_effects` aborts **without undoing** (gotcha 21 — an abort is not a
rollback).
### `ClientFrame::Save` is a request, not a `Command`
improvise-368h. It carries **no path**, because a client cannot see the host's
storage: `effect::Save` writes to *this host's* `file_path` through *this
host's* `Storage` (gotcha 2), and which document a host is hosting is the
host's own fact. A client naming a path would be naming a namespace it cannot
read — the same mistake `ModelCommand::SetCell` avoids by taking a surrogate
rather than a `CellKey`.
**It is a frame and not a `Command`, and the reason is atomicity.** It began
as a `Command`, which put a *runtime-fallible* operation inside a batch
`Commands` guarantees is applied whole or not at all — so `[Save, SetCell]`
reported a failed write and then applied the edit anyway, and a client reading
"refused" could not tell which. Saving also fails the test every `Command`
passes: it mutates no shared state and no client's slice, so `reduce_view` has
nothing to fold and `Checked` has no basis to answer for. Here it cannot be
composed with a mutation at all — **the batching question stops being a rule
and becomes unconstructible** — and it sits beside `Viewport`, the other
session input no `Command` carries.
Success is silent: the document did not move, so no widget is dirty and nobody
else is owed anything.
### `ClientFrame::Key` — and a correction
The doc used to say "the host runs it through the keymap against the
authoritative `App`". **It does not.** `Host::handle` answers this arm with an
explicit empty vec, because a key resolved on the host moves state no client
can hear (`a_key_resolved_on_the_host_moves_state_no_client_can_hear`): the
projection stream carries nothing of the host's own `ViewState`, so host-side
resolution *diverges*. improvise-bpq2 closes that by **avoiding** it — the
browser resolves keys against the shared keymap (§6) — so an upstream key is a
**black hole, not a slow path**, and every `KeyOutcome::Upstream` names a
binding that does nothing at all in a browser. The variant is kept because the
list of keys that still need the host is named in
`improvise-wasm-client/src/keys.rs`, so closing one has to come there and say
so.
It carries `(KeyCode, KeyModifiers)` rather than a `KeyEvent` because
`KeyEvent` is not `Serialize` — the same pair `Command::SetTransientPrefix`
already puts on the wire.
### `ServerFrame` has three variants because `Sessions::apply` has three cases
`Applied::Diff`, `Applied::Rebased`, `Applied::Rejected` (gotcha 39).
`Snapshot` is **boxed**: it carries a whole `ViewState` plus the seed that
redraws the document, and every `Commands` frame would otherwise be allocated
at that size for a variant sent once per connection. Serde sees through the
box, so the JSON is unchanged. It is sent again to *every* client whenever the
document is replaced, because a client's `cursors` are keyed by the outgoing
document's view names and only a fresh snapshot replaces them (gotcha 24).
`ServerFrame::Commands` carries both of the things a client folds, because on
the wire they are the same thing: a `Command::Project` is a widget update, and
any other `Command` is a peer's accepted move that this client's own view
slice must fold too (gotcha 36). Order is load-bearing, so a transport must
deliver a batch whole and in sequence.
`Rejected` is addressed to **the asker alone** — nothing happened, so nobody
else is owed anything — and it carries no new state, because the client
already holds what is current through the projections it has been folding all
along. That is what makes rejection cheap enough to be a *normal outcome*
rather than an error.
### `Accepted`, and why a rejection alone is not a protocol (improvise-nxah)
`ServerFrame::Rejected` told a client that a frame did nothing. Nothing told it
that a frame *did* something. That asymmetry is not cosmetic, because the
browser folds a frame's view commands **optimistically** before sending it: a
refusal must put them back, and to know *which* frame's folds to put back the
client has to be able to retire the ones that landed. With only a refusal on the
wire, a client holding two frames in flight cannot tell which of them a
`Rejected` answers — so the rollback either addresses the wrong frame or never
happens. This is the same shape as the publication gap ADR 001 originally left:
it said a batch carries a basis and never said how a client learns one, and
`Projection::Basis` had to close it. Here it said a batch may be refused and
never said how a client learns it was not.
So a **mutating** frame is answered exactly once, `Accepted` or `Rejected`, and
the two facts that make that a usable rule are already load-bearing elsewhere.
*Only* `Command::Model` can be refused — `Checked::against` says so exhaustively
and `Host::commands` only enters `mutating_frame` when one is present — so a
frame carrying no `Command::Model` is answered by neither, and a client can tell
by inspecting the frame it is about to send rather than by remembering a rule.
And `Rejection::refused_an_edit` already separates the two `Save` refusals from
the batch refusals, so a failed write cannot consume the answer owed to an edit.
`Accepted` carries **no state**, and it goes **last** in the frame's answer, for
the reason a published basis goes last (§5): everything the frame moved has
already arrived as projections, so a client discarding its undo knows exactly
what it is discarding. A client that folded the acknowledgement first would be
retiring a frame whose effects it had not yet been shown.
---
## 5. `basis.rs` — what a batch was composed against
### The problem is not ordering
The server already serialises every write — one `App`, one `apply_effects` —
so there is a total order and nothing here is about ordering. What was missing
is noticing that a client composed an edit against a **picture of the document
that has since moved**: the same stale-basis shape as the cursor under a
reshaped grid (gotcha 24) and the session mirror after a document swap (gotcha
39), one level up.
### Three tiers, and the tiering is the whole economy
| tier | moved by | a batch is stale if |
|---|---|---|
| `document_generation` | a `WholeWorkbook` effect | it moved at all |
| `layout_version` | an observed change to any view's drawn shape | it moved at all |
| `records` | a `CellData` effect on that record | any record the batch names moved |
A cell edit is stale when the **layout** moved, even though no record it names
was touched, because the shape its keys hang off changed underneath it. That
is ADR 001's class 2 — A removes the category `Region` while B commits an edit
keyed by `Region=East` — and it is **corruption** rather than a lost
keystroke. Detecting it falls out of the tiering with no lock anywhere: a
cell-level lock would not have covered it, because holding
`Region=East × Revenue` does not stop anyone deleting `Region`.
### The client understands none of this
A `Basis` is three opaque tokens — nothing in it is a structure to interpret,
the same discipline as the cell surrogate it is keyed by (ADR 002). The server
publishes the current one as a `Projection`, the client keeps it in a
`BasisCache` and hands back `claim()` when it composes a write.
### `Basis` has no `Default` and no public constructor
A zero-valued basis is not "no claim", it is the claim that **the document is
fresh** — which in a brand-new `App` is true, so a default would be a
*forgeable bypass* rather than an absence. `BasisCache::claim` is the only
door a client has, and it returns `None` until the server has published one.
Note what that does and does not buy. `Basis` derives `Deserialize` because it
must cross the wire, and its fields are public, so **a hand-rolled client can
state any tiers it likes**. This is an *anti-staleness coordination check, not
a security boundary*: it closes **omission**, not **invention**, and nothing
here tries to close invention, because a client that forges a current basis is
indistinguishable from one genuinely caught up. "The only constructor" is a
statement about clients written against this crate, which is still worth
having — it makes the honest path the easy one.
There is deliberately **no flag that skips any of this**, because "a mechanism
whose only purpose is to violate the invariant is not a feature, it is the
invariant deleted with extra steps". A *force* re-composes the same intent
against the current basis and submits a fresh batch; it never applies a stale
one.
### `claim` omits rather than invents
A record the cache has never heard of is **left out**, not defaulted to zero.
Defaulting would be a claim the client cannot support, and the server would
read it as "untouched" — the exact forged-basis case `RecordUnstated` exists
to catch.
### The publication is what ADR 001 omitted
The ADR says "a batch carries the basis it was composed against" and never
says how a client comes to know one; nothing in the projection stream carried
any of the three tiers, so **the check as written was satisfiable only by
forging**. That is the one place that document met the code and was wrong.
`Projection::Basis` + `BasisCache` close it.
It is folded like any other widget (improvise-rkeq) — not a widget, nothing
draws it, but it subscribes on exactly the same terms, and the alternative was
a second server→client channel with its own ordering rules for a payload that
changes at precisely the moments a projection already does. The update
replaces wholesale, as `Projection::Formulas` does, so "has it changed" and
"send it" collapse into one decision.
**It is emitted *after* the grid updates in a batch**, because the counters
describe the cells those updates carry: a basis arriving first would let a
client claim a version for a value it had not been shown — the mirror image of
the fold's frame-before-cells rule.
### Which records are checked is the server's decision
`reduce.rs::records_named` in `improvise-command`, an exhaustive match with
one or-pattern for the variants that name no record, so a new record-writing
variant fails the build. Reading the list off the *client's* claim would be a
**force by omission**: the record tier skipped, with no flag to remove and
nothing in the message saying so.
### `published()` takes a `BTreeMap`
So the record list is deduplicated and in a stable order. The server diffs the
published basis against what it last sent this client, and a list whose order
followed grid traversal would report a change every time a row moved.
### `Rejection` keeps structural and data staleness apart
Which is the whole reason ADR 002 put a maintained `RecordHash` index in
`DataStore`: a regenerate-on-demand scheme could not tell a record that had
scrolled out of view from one that no longer existed.
- **`RecordMoved`** is class 1. Somebody wrote the record since this client
read it. Force is coherent and should be one keystroke: the user has seen
the other value and chooses to overwrite it.
- **`RecordGone`** and **`LayoutMoved`** are class 2. There is nothing
coherent to force — the key names a coordinate that no longer exists, and
applying anyway is exactly the ghost-coordinate corruption this check exists
to prevent. **Force never creates structure**: re-instating a category
somebody deleted so an edit can land is a larger intent than the user
expressed and must be an explicit action. What the UI owes here is what
changed and the concrete alternatives, not a button.
- **`RecordUnstated`** is a case of its own because "you lost a race" and "you
were never in one" are different things to tell a user — and because naming
it is what closes the force-by-omission hole. A well-formed client cannot
produce it: it composes a write from a cell it is drawing, and the published
basis covers every record its grid names.
This is also why the wasm client stores the `Rejection` itself rather than
collapsing it into `status_msg`: a string cannot carry *which* record and
*which* kind.
### Two classes live in one type
`Rejection` is a refusal of a **frame**, not of a command — since
improvise-368h a `ClientFrame::Save` the host cannot honour is answered here
too, and that is not a refused command at all.
1. **Staleness** (ADR 001) — `DocumentReplaced`, `LayoutMoved` and the three
record variants. The only class where *force* means anything.
2. **A request the host cannot honour** — `NoDocumentPath` and `SaveFailed`
(improvise-368h). Nothing went stale; there is nothing to re-compose.
They share this type rather than getting refusal channels of their own, for
the reason the type exists: a refusal is "nothing happened, here is why",
addressed to the asker alone, and a second channel would be a second set of
ordering and addressing rules for one idea. A **third class is coming and is
not here yet**: a transaction rolled back because a later command in a chain
could not be applied (clause 4 of the frame contract). Until rollback exists
there is nothing to report, because nothing is undone.
`NoDocumentPath` is decided **before** anything runs, by the same constructor
that answers for a basis, so a save that cannot land is refused rather than
attempted. What the asker should do about it differs by front end: a TUI
prompts for a path (improvise-lae), a browser worker whose document *is* its
storage entry never sees this at all. `SaveFailed` carries no message because
`Rejection` is `Copy` and an I/O error's text is not — the host keeps the text
in its own status line, and what crosses the wire is the one fact a client can
act on: the document it is editing is not on disk. **Reporting a failed write
as success is the thing this exists to stop.**
---
## 6. `keymap.rs` — one table, both ends
This used to live in `improvise-command`, where only the terminal could reach
it, and the browser client carried a hand-written classifier answering the
same question for the subset it could resolve. That is two definitions of one
vocabulary kept in step by a lockstep test over a hand-written list of keys —
and `doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)`
says such a pair is one definition waiting to be made.
The move is the one gotcha 13 already anticipated:
`ViewState::transient_prefix` stores a prefix as an **address** rather than an
`Arc<Keymap>` precisely so `ViewState` could stay free of `improvise-command`
types while the keymap came the other way.
### The boundary that did not move
A lookup yields a command **name and arguments**. Turning that into something
that runs needs a `CmdContext` — a `Model`, a `GridLayout`, an `App` — which a
thin client does not have, so *intent → mutation* stays where it was:
`improvise-command` resolves a name through its `CmdRegistry` into `Effect`s,
and a browser client resolves the subset it can compose into `Command`s.
That is a better seam than the one it replaces. The drift risk used to be
"which key does what" — ~170 bindings, no direct test, invisible when wrong.
It is now "which command names can I execute", which is a list.
What stayed in `improvise-command`, and why each had to:
- `Keymap::dispatch` and `KeymapSet`, which pair this table with a
`CmdRegistry` and return `Box<dyn Effect>`.
- `SetTransientPrefix`, which is an `Effect` — it writes
`ViewState::transient_prefix`, and `Effect` is the command layer's trait.
The keymap's own tests live with `dispatch` in
`improvise-command/src/command/keymap.rs`, for the same reason: they need a
registry to check a bound name against.
### `ModeKey` and `AppMode` are two different enumerations
They happen to be the same size (17). `SearchMode` is a `ModeKey` and not an
`AppMode` — it is Normal plus `search_mode: bool`; `Quit` is an `AppMode` with
no keymap. **Do not "fix" one count to match the other.**
`ModeKey::from_app_mode` is exhaustive on purpose: a new `AppMode` without a
keymap silently swallows every keypress, so the compiler must flag it.
### Lookup fallback order
`exact(key, mods)` → `Char` with `NONE` (shift is implicit in the char) →
`AnyChar` → `Any` → parent. `Keymap::with_parent` is Emacs-style inheritance,
and it is what lets `RecordsNormal` reach Normal's `g`/`y`/`Z` prefixes.
`Keymaps::prefix_keymap` deliberately goes through `lookup`, which walks
parents, for exactly that reason. Resolution assumes prefixes are **flat**;
`prefix_keymaps_are_flat` fails the build if a nested one is added.
**`lookup_local` is exhausted before the parent is consulted**, and that one
line of ordering is what makes the global below cost nothing.
### The global — all seventeen modes, and the answer really is "all"
`global_keymap()` binds `?` (help), `:` (the command line) and the three panel
toggles, and every mode's keymap is built `Keymap::with_parent(global)`. The
user's ask (improvise-636d) said "almost all", and the honest answer after
naming the exceptions one at a time is **none of them** — no mode needs to
opt out, because every mode that must not answer a global key **already
shadows it locally**:
- the eight text-entry modes and `SearchMode` bind `KeyPattern::AnyChar`, so
`?` types a literal `?` and the parent is never reached;
- `ImportWizard` binds `KeyPattern::Any`, so nothing escapes it either;
- `Help` binds its own `?`, which **closes** the overlay rather than
re-entering the mode already showing. That is the one product call the
ticket left open, and it is decided here.
None of that is an override anyone had to write — the first two fall out of
the fallback order, and only Help's is deliberate.
**The global may bind Char keys only, and that is load-bearing rather than
tidy.** `AnyChar` matches `KeyCode::Char` and nothing else, so a global `F1`
would reach the parent *while the user is typing* and throw away a half-typed
cell edit. That is why `F1` stays bound on Normal alone although it runs the
same command as `?`, and why `the_global_binds_only_unmodified_char_keys`
asserts the shape rather than the list. A chord is excluded for the same
reason: `lookup_local`'s `Char`-with-`NONE` retry is what a shifted `?` relies
on, and it does not run for a `CONTROL` binding.
**`Esc` is not a candidate and should not become one.** It means something
different in nearly every mode — leave the panel, cancel the edit, close the
overlay, abandon the wizard — so a global `Esc` would be shadowed everywhere
it mattered and wrong wherever it was not.
What the change is worth: before it, `?` resolved in **2 of 17** modes
(Normal, and `RecordsNormal` through the one inheritance edge), missing from
all three panels, `TileSelect`, `SearchMode`, `ImportWizard` and `Help`
itself — exactly the modes a lost user is standing in. `bind_panel_toggles`
had **two** call sites and `:` was written into two spines while `?` was in
one; that asymmetry was the evidence that nothing kept the spines in
agreement, and it is what `no_mode_binds_a_panel_toggle_of_its_own` now keeps.
`every_modes_keymap_chain_ends_at_the_same_global` compares with
`Arc::ptr_eq` rather than by value, because "several tables that agree today"
is the thing being removed, and two equal-but-separate globals would satisfy
any value comparison.
### The layer below it: `document_keymap` (improvise-wiew)
The global above is **all seventeen modes**, and that answer is unchanged. What
changed is that a second shared layer now sits between it and some of them.
The user asked for `[`/`]` "probably in the global keymap", and then answered
the question that left open himself: *"the help display (and import wizard,
etc.) shouldn't respond to these keys, but the 'normal' modes like
categories/tiles/formulas/etc. should."*
The distinction is **is the document the subject right now**, and it is genuinely
a new one rather than the global's argument applied again. Every key the global
binds — `?`, `:`, the three panel toggles — is a **UI** action: it opens a room,
it does not change the document. `page-next`/`page-prev` resolve to
`ModelCommand::SetPageSelection`, which is `EffectKind::Model(LayoutGeometry)`.
They are the first shared keys that *mutate*, and under the Help overlay that is
a mutation the user cannot see, because the overlay is covering the grid it
just moved.
So the chain is three deep:
```
global ? : and the panel toggles — every mode
└── document [ ] — Normal, the three
├── Normal panels, TileSelect; RecordsNormal
│ └── RecordsNormal through Normal
├── FormulaPanel / CategoryPanel / ViewPanel
└── TileSelect
├── Help global only
├── ImportWizard global only
└── the text-entry modes and SearchMode, unchanged
```
**The name is the guard.** Nothing in the code stops the next person parenting
Help to this layer; what stops them is that it is called `document_keymap` and
not `common` or `shared`, so its name answers "should my new mode inherit it?"
A name describing *who* inherits a layer invites everything to join, and the
distinction dies within two waves.
**RecordsNormal inherits it once, through Normal.** It already parents Normal,
so it reaches the layer transitively; parenting it directly as well would be a
second edge with nothing keeping the two in step. That is asserted on the chain
identity rather than on behaviour, because a duplicated edge answers `page-next`
correctly on the day it is added and only diverges later.
**The layer owes the same Char-only constraint as the global**, and for the
identical reason: it is reached by *fallthrough*, so a non-Char binding on it
would fire while a descendant's `AnyChar` was typing. No descendant binds
`AnyChar` today — which is exactly why the constraint is written down as a test
rather than left to the arrangement. An unconstrained shared layer is where the
next "just put it in the shared one" lands.
**Two things that cost nothing and were checked anyway.** Text entry is
untouched: the fallback order is `exact → Char(NONE) → AnyChar → Any → parent`,
so a text-entry mode's `AnyChar` answers `[` before any parent is consulted, and
typing a bracket into a category name still types it. And `prefix_keymaps_are_flat`
is about the *sub-keymaps a prefix opens*, not about inheritance depth, so a
third level of parents violates nothing it asserts.
The browser follows for free: `resolve.rs` dispatches on the command **name**,
so every mode that now reaches `page-next` composes it in a browser too, with no
second table to update. That is the whole point of one shared keymap.
### `TextEntry` — the spine, welded
Every text-entry mode has the same spine: characters append to one named
buffer, Backspace pops from it, Esc clears it before leaving. Only the buffer,
the exit mode and the commit keys differ. Building them through here means a
new text mode cannot accidentally type into one buffer while clearing another,
or drop the clear entirely — which would leave the next entry pre-filled with
the last one.
`TextEntry::new` takes the `AppMode` and reads the buffer name off its
`MinibufferConfig`, so the buffer the prompt describes and the buffer the keys
edit are the same string **by construction** rather than by agreement
(improvise-k8h) — the keymap never spells a buffer name.
`commit(key, cmd, args)` welds the command to its `clear-buffer` so no caller
can bind a commit without its clear (improvise-w3q).
**The two cell-editing commit keys are the exception, and the clear moved rather
than disappeared** (improvise-fy9o). `commit-cell-edit` and
`commit-and-advance-right` reopen an editor on the cell they advance to, and the
welded clear ran **after** them, so it blanked the re-seeded buffer. The
reopened editor looked empty, and a second Enter committed `""` and cleared the
landed cell. The user's ruling is that the reopened editor shows the landed
cell's current value, so with improvise-ixxx's untouched rule an unchanged Enter
writes nothing. `commit_reopening(key, cmd, args)` therefore binds the command
alone, and the command clears its own buffer **at its head**:
- in the TUI, `CommitAndAdvance` does it right after the mode change;
- in the browser, `resolve::cleared` does it, reached from both `commit` and
`create_column`.
Being at the head is also gotcha 21's rule: a head clear survives an abort
where the trailing weld did not. The corner commit and the `+cat` insertion
reopen nothing, and they still leave no text behind.
**After a write, the browser's reopened editor takes its seed from the host's
answer, not from its cache** (improvise-3uzg). The first fix (improvise-3tt4)
seeded from the cache everywhere except the cell the frame had just written.
That assumed the write changed the meaning of only that one cell, which is
false. Under `prune_empty`, emptying the last value in a row takes the row
away. The advance then lands on a renumbered position, and the cache still
holds the old record there. The browser opened on South's `2` while the TUI
opened on West's `3`, and typing `5` wrote 25 into West. Any write can change
what another position means, and the client cannot tell which. So the rule is
stated once, over the write, never over a list of cases:
- **A commit that writes** reopens its editor as `OpenedEditor::awaiting()`, an
empty buffer whose seed is still to come. `create_column` is included,
because `AddItem` adds a column.
- **`ClientState::settle_awaited_seed`** fills the seed from the grid the
client holds, but only once no frame it sent is still unanswered. The answer
folds the host's projection before `Accepted`, so the grid is the host's by
then. `accepted()` therefore settles.
- **A commit that writes nothing** awaits as well while any earlier frame's
write is unanswered. Otherwise it seeds from the cache at once. "Nothing
changed, so the cache is current" was the rule here, and it was false
(improvise-mdfc). *This* commit changed nothing, but an earlier one might
have, and the cache predates it until the answer arrives. Tab Tab pressed
quickly at the rightmost column opened the second editor on the value from
before the first Tab's write: 505 where the TUI stores 5095. So the test is
"does this frame write, or is a write still in flight", which is
`writes || in_flight.iter().any(|sent| sent.awaited)`. It checks *any*
in-flight write, not the newest frame: a frame that writes nothing stays in
the queue behind an unanswered write, so the newest frame can be one of
those.
- **Every opener asks the in-flight half, because it lives in `opened_on`**
(improvise-p6v6). mdfc first put the whole test inside `advanced`, which left
`i`/`a` (`edit_or_drill`) seeding from the cache with no in-flight check.
`i 9 Tab Esc i 5 Enter`, typed faster than the answer, stored 15 in the
browser where the TUI stores 195. The two halves now live in different
places, for a reason. "A write is still in flight" is true of the *client*,
so `opened_on` asks it, and no opener can forget. "This frame writes" is
true only of the commit being resolved, which is not in `in_flight` yet, so
`advanced` still asks it itself. Folding both into `opened_on` would lose the
second: the 3tt4 and 3uzg tests fail when `advanced` defers entirely.
The click-edit path (`socket.rs::edit_target_at`, improvise-ugj4) also seeds
from the cache, but it claims the record at the version it *read*. Behind an
unanswered write the host therefore refuses its commit as `RecordMoved`: a
loud failure, not a silent overwrite.
- **The seed settles only into an open editor** (improvise-4cb5). An Esc typed
before the answer arrives leaves the awaiting `opened_editor` in place. The
late seed would then fill the edit buffer in `Normal` mode, where the TUI's is
empty.
- **`awaiting` has no `serde(default)`** (improvise-mgis), for the reason
`AxisFrame::first` has none (§3a, improvise-1wl0). `awaiting: false` is a
claim, "the seed is settled". A peer that dropped the field would hand over an
editor that stays empty forever, and whose Enter writes nothing. A missing
field is therefore a parse failure.
Asking the host to send the seed was the alternative. It would have needed a
new message, and the host already sends it: the landed cell's `edit_text` in
the projection it owes this client anyway. Two things are accepted costs:
- Until the answer arrives, the editor is empty. Over a worker that takes
well under a frame.
- A key typed before the answer arrives marks the editor touched, and the late
seed then does not overwrite it. What the user sees is then what gets
written. That is never another record's number only because of the mdfc
rule above: before it, an untouched commit behind an unanswered write opened
on the stale cache, and typing then extended the wrong number. What is left
is a divergence from the TUI rather than a wrong record. A key that beats the
answer replaces the value the TUI would have extended: back to back,
`i 9 Tab Tab 5 Enter` stores 5 or 5095 in Chromium over the worker
(improvise-n4i9).
The TUI is unchanged: its `RebuildLayout` in front of `EnterEditAtCursor`
already reads the renumbered grid (gotcha 23). This also took
`CellValue::edit_text` out of the client bundle. That call formats an `f64`,
which is what compiled to `i64.trunc_sat_f64_s`, so `a093d126`'s
`nontrapping-float-to-int` flag on the wasm-client is reverted. A `?ws=` page
loads only the client bundle, so it works without the feature.
Reordering the weld to `[clear-buffer, cmd]` was tried first and rejected. Four
readers take a `Sequence`'s **first** step as its identity: `keymap::runs`,
`help.rs`, and two resolver audits. So every hint on those keys started
reporting `clear-buffer`. `text_entry_modes_clear_the_buffer_they_type_into`
keeps its guarantee by asking a plain-`Cmd` commit key's command what it emits.
`rebind` exists for
exactly one case: CommandMode's Backspace, which exits the mode on an empty
`:` line instead of popping a character that isn't there.
**Eight `AppMode` variants carry a `MinibufferConfig`**, and `buffer_of`'s
callers are all eight. Since improvise-636d there is **one** constructor —
`TextEntry::new(mode, exit, parent)` — because every text-entry mode now has a
parent: seven take the global directly and `RecordsEditing` takes `Editing`'s
map, which reaches the global through it. `with_parent` is gone, and with it
the "a count off the `::new` call sites reads seven" trap this paragraph used
to warn about: the call sites are all eight.
`SearchMode` is hand-written on purpose and does **not** use `TextEntry`: it
has no commit key, and its query is owned by `exit-search-mode` rather than by
`clear-buffer`. "search" is not in the `buffers` map but its own `ViewState`
field, and `cmd::core::read_buffer`/`write_buffer` are the only code that
knows it (improvise-gdl).
Category and item add stay open after a commit, so Tab and Enter do the same
thing: file this one and start the next. `ItemAdd`'s keymap is built with an
empty category name because the category only shapes the prompt text, never
the buffer.
### `PanelKeymap`
The three panels share a spine — Esc/Tab leave for Normal, arrows and j/k move
that panel's cursor. The keys that act on the panel's *contents* differ in
**shape**, not just in command name (Category's `n` takes an argument, View has
no `a`, Formula has no Enter), so they arrive through chained methods rather
than a fixed-arity signature (improvise-4pg). `delete()` binds `d` and Delete
together so a panel cannot offer one without the other.
The toggle keys and `:` are **no longer part of this spine**: they are the
global's, so `PanelKeymap::new` takes the parent and binds neither.
`bind_panel_toggles` has exactly one call site — `global_keymap` — and a new
panel reaches all seventeen keymaps from one `Panel::ALL` entry rather than
four.
### Normal-mode bindings worth a note
`i`/`a` pass their target editing mode as an **argument** to `edit-or-drill`,
so the command itself stays mode-agnostic; `RecordsNormal` overrides just
those two bindings to say `records-editing`. Ctrl-S saves to the known path
and Ctrl-W asks for one — the pair a user with no file path needs, and what
the "No file path" status message points at (improvise-lae).
---
## 7. `viewmodel.rs` — the residue between a cache and a renderer
improvise-edp asked for a ViewModel *layer*, a `SurfaceViewModel` paired with
every `SurfaceCache`. **Most of that layer turned out not to exist**: after
improvise-35e each widget already takes exactly one cache plus the mode, and a
cache built per-widget is already render-shaped, so there is nothing left to
adapt. What this module holds is the residue — derivations genuinely *between*
a cache and a renderer, because they mix a cache with per-client `ViewState`.
Today that is exactly one: `flatten_cat_tree`. It lives here rather than in
`improvise-command` because two ends need it and only one of them can reach
that crate — the browser client depends on `improvise-protocol` alone
(deliberately; see its `Cargo.toml`), and `move-panel-cursor` walks this
flattening, so resolving that key client-side (improvise-bpq2) would mean
re-deriving it. **Two derivations of one flattening is how the two ends
drift.**
`CatTreeEntry` carries the axis outright, so a renderer needs neither a `View`
nor the cache the entry came from: the entry list *is* what the panel draws.
`expanded` is `ViewState::expanded_cats` — per-client, which is why this is
derived here and not sent — and the index into the result is
`ViewState::cat_panel_cursor`.
---
## 8. `key.rs`
These mirror the subset of the terminal backend's key API the command, keymap
and effect layers actually use, so those layers carry no terminal-backend
dependency. Variant names match crossterm's so call sites only need an import
change. The only place both type families may appear is the TUI boundary
(`src/draw.rs`), which converts incoming backend events into these before
calling `App::handle_key`.
`KeyCode::Null` stands in for "no key" (headless command execution) and for
any backend key code this crate does not model. `KeyEvent` is **not**
`Serialize` — `ClientFrame::Key` and `Command::SetTransientPrefix` both carry
the `(KeyCode, KeyModifiers)` pair instead.
---
## 9. Corrections made while relocating this prose (2026-09-20)
Four comments in this crate were **wrong about the code** and were fixed
rather than moved. Recorded because the campaign's own evidence is that a
stale argument reads exactly like a live one:
1. **`frame.rs`, `ClientFrame::Key`** said "the host runs it through the
keymap against the authoritative `App`". `Host::handle` returns an empty
vec for that arm. This is the fourth sibling of gotcha 48's "three comments
in one range said *not built yet*" — improvise-bpq2 moved resolution to the
client and updated one copy of the argument, not all of them.
2. **`reduce.rs`, `every_command_round_trips_through_json`** said the model
half brings "four payload types the view half never carried — `CellKey`,
`CellValue`, `Axis` and `PathBuf`". `ModelCommand` carries no `CellKey` at
all since ADR 002 (`SetCell` takes a `RecordCell`), and `CellValue` *is* in
the view half, as `Command::SetYanked`.
3. **`render.rs`, `identified_grid`** carried three paragraphs of doc that
describe a *different* test — "improvise-hkt6 … three columns and a rename
in the middle" is `a_relabelled_column_drops_its_total`, which had no doc
of its own, while `identified_grid` is a two-column fixture. The paragraphs
had been merged onto the wrong item.
4. **`keymap.rs`, `buffer_of`** said "the seven modes that carry a
`MinibufferConfig`". Eight do, since `SavePrompt` (improvise-lae); seven is
the number of `TextEntry::new` call sites, which is the exact confusion
repo-map gotcha 11 already warns about.
Also stale and corrected, without being wrong enough to list above:
`key.rs`'s module doc called these "crate-local key types for the command
layer" — they are the protocol's, and both front ends use them.
**`records_named` lives here** (moved from `improvise-command::ui::reduce` by
the review of PR #50). It is `&ModelCommand -> Vec<RecordHash>` with no `App`
in it, and both ends need the same answer: the server to decide which records
to check, a client to decide which to claim. Two copies made the client's half
untestable — `lockstep.rs` restates the basis against its twin host, so it
compares the server's answer with itself. One home makes the claim right by
construction, and the exhaustive match is the guard it always was: a new
record-writing variant fails the build.
---
## §8 `help.rs` — where UI strings live relative to a wire enum
Three tickets asked one question three times — improvise-x7sw (which-key hints
have no protocol home), improvise-ktv1 (the browser has no help overlay) and
improvise-k099 (`AppMode::hint_text`/`label` put terminal UI strings on a serde
enum). Answering it three times is the disease. The answer:
**A UI string is keyed by a command name, never by a key, and never hangs off
the enum that crosses the wire.** `AppMode::label` and `AppMode::hint_text` are
gone; `help::mode_label` and `help::hint_line` replace them. The enum a client
deserializes now carries no English at all.
**And wherever a string names a key, the key is looked up rather than spelled.**
`Hint { keys, command, caption }` is the unit: `keys` come off
`Keymaps::default_keymaps()`, `command` is the semantic value a localized or
non-terminal front end keys off, and `caption` is the default English — exactly
the shape faces now have (`Face` → `draw.rs::face_style`, §10), with the decision shared and only the
last translation local (`invariant(semantic_palette_at_the_core_boundary)`).
### What that bought, and what it cost
It **deleted** `every_hint_key_resolves_in_its_mode` and its 60-line tokenizer.
That test existed to hold a hand-written string honest against the keymap; the
string is derived now, so the property is true by construction. A consolidation
that keeps its compatibility test is unfinished
(`doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)`),
and this is the deletion that measures this one. What replaced it,
`every_advertised_hint_names_a_binding_that_exists`, checks the *other*
direction — a mode advertising a command nothing binds — which was never
checkable before.
It also deleted `Keymap::binding_hints`: `help::which_key` is the one walker,
and the binding dump reads it too rather than a second one.
The cost is that the hints changed shape. They are longer, they name arrow keys
beside `hjkl`, and two sub-lists in the help pages lost their visual indent.
That is the price of the keys being true.
### The three rules the rendering follows
1. `hint_line` **drops chorded keys** — any key held with a modifier beyond
shift, asked of the **modifiers** and never of the label (improvise-thpe).
`Hint::keys` is `(KeyCode, KeyModifiers)` and `compact` takes the pairs, so
the string it used to parse is not in scope to parse; `Hint::labels` and the
help pages format last, through the one spelling in `keymap::key_label`.
`T` advertises
tile select; its four Ctrl-arrow aliases would push the rest of the line off
the screen and teach nobody anything. A client wanting all of them reads
`hints()`, which keeps them.
2. It **runs single-character keys together** (`hjkl`) and slashes the rest
(`Esc/Tab`), which is the idiom the hand-written hints already used.
3. A help-page row whose command resolves to **no keys is dropped**, so a page
can never advertise a key that does not exist. `keys()` returning `Option`
is what makes that unrepresentable rather than validated.
### What is *not* derived, and why
**The wizard's.** `AppMode::ImportWizard`'s whole keymap is one `Any` binding
into `HandleWizardKey`, which decides per step inside the command — so there is
nothing in the table to look up, and its hint is a hand-written tail. That is a
real hole and it is where it is visible, not papered over: a keymap cannot
describe keys a command resolves privately.
**The ex-commands.** `:q`/`:w` are parsed text rather than keys, so they are
`HelpLine::Literal` — built only by the `ex!` macro, whose leading colon is its
own — and the parser, not the keymap, is what checks them
(`every_ex_command_the_help_pages_show_is_one_the_parser_accepts`).
This paragraph used to say the same of `gg`, `yy` and `ZZ`: "a prefix sequence
is two addresses and not one binding". Two addresses is true and was no reason
to spell them by hand — `Keymap::prefixed_keys_running` looks the pair up the
way `keys_running` looks up one key, and `keys()` asks both (improvise-wk18). A
key string with no command name was also the one row no guard could reach.
**Every row the pages ask for is visible to a guard.** The page builders take
the `keys` lookup as a parameter; `pages()` passes the real one, and
`help_bindings()` (tests only) passes one that records `(mode, command, args,
description)`. So a check over the help overlay reads the *same builders* the
overlay is drawn from, never a list kept beside them. That premise is a type,
not a convention (improvise-v29m): `HelpLine::Keys` carries a `KeyRow` whose
only constructor does the lookup, so a builder cannot type a key row the
recorder would never see; and `ex!` refuses at compile time any usage with a
bare word after the command name, so an ex row cannot smuggle a key either.
`resolve::tests::every_advertised_command_answers_in_its_own_mode` walks it
beside `advertised`, which is how the browser's black holes on the help page
became visible (improvise-uln5).
**The captions.** `i:edit` where `i` entered records mode would still pass most
tests here: a caption is English beside a command name and only a reader judges
it — the same limit gotcha 14 recorded for the old hint test. Two checks now
bite at its edges. The browser guard requires an advertised key to *act*, so a
key whose caption promises an action and which only refuses must be named
(improvise-3t5x); and `only_the_edit_key_is_captioned_as_editing_the_cell` asks
by command that no other Normal row claims to edit (improvise-jhdf). A false
caption in words neither looks for still passes.
### The browser half
`RenderModel` carries `help`, `help_page` and `help_titles`, and an **empty
`help` is what says the overlay is closed** — the page asks no question about
the mode, the same shape `col_groups` uses for "no groups". `web/app.js` paints
rows it does not author; `src/ui/help.rs` keeps its ratatui styles and lost
~310 lines of content that is now shared. Neither end restates what the keys
are.
## 10. `face.rs` — appearance named by role (ADR 004)
ADR 004 (`context/adr/004-faces-and-themes.md`) is the decision; this section
is where its first two migration steps landed and what they settled that the
ADR left open (improvise-nn4s.1, improvise-nn4s.2).
### What is here
- `Face`: one variant per role. Where a domain enum already exists the face is
**parameterised by it** — `Face::Axis(Axis)`, `Face::WizardField(WizardFieldKind)`,
`Face::ModePanel(Panel)`, `Face::ModeTextEntryOf(TextEntry)`,
`Face::MinibufferOf(TextEntry)` — so adding an arm to that enum adds a face by
construction (clause 2). `Face::name` spells each one as a theme file keys it
(`axis.row`, `mode.text-entry.command`, `minibuffer.category-add`), and those
names are a compatibility surface once a theme can be downloaded (clause 9).
- `Face::parent`: the inheritance, **fixed in the vocabulary rather than stated
by the theme**. A theme that omits a face must still get the face's parent's
values ("a theme written today keeps working when a face is added tomorrow",
clause 9), and that is only true if the chain does not depend on what the
theme says.
- `Colour` (`Default | Named(AnsiSlot) | Indexed(u8) | Rgb`), `Weight`, `Slant`,
and `FaceSpec`, whose six attributes are all `Option`: `None` inherits, and
`Some(Colour::Default)` states "the surface" and stops inheritance. The two
are different answers and the type keeps them apart.
- `Theme { faces }` and `Theme::resolve(Face) -> ResolvedFace`, which walks the
chain once and returns every attribute concrete. **No front end walks the
chain** (clause 3). `resolve` takes a face and nothing else.
- `Theme::shipped()`: today's terminal rendering, palette-relative throughout
(clause 4). `the_shipped_theme_states_no_absolute_colour` holds that line.
- `Face::of_mode(&AppMode)` and `Face::of_minibuffer(&AppMode)`, both
exhaustive over `AppMode`, so a new mode fails the build here as it used to
in `draw.rs::mode_style` (improvise-zx8t).
### The JSON shape
Face names are the keys and a colour is spelled for a person: `"default"`, a
slot name (`"green"`, `"bright-black"`), a bare number for an index (`235`),
`"#rrggbb"` for `Rgb`. A misspelt face or attribute is **refused**, not
ignored (`deny_unknown_fields`, and `Face`'s `Deserialize` rejects an unknown
name): a file the user edits by hand should say where it is wrong rather than
silently draw the default. The faces map is a `BTreeMap` keyed on `Face`'s
derived order, so a re-download after an edit is a reviewable diff (clause 9).
The protocol crate derives and the front ends encode, so `serde_json` stays a
dev-dependency here.
### Choices the ADR did not make, made here
- **`minibuffer.*` inherits `mode.text-entry` directly, as the ADR says, and
the shipped theme pays for it.** The mode indicator is black text on an
accent, and the minibuffer is accent text on `Indexed(235)`, bold. So under
this chain every `minibuffer.<entry>` in the shipped theme states all three of
its attributes and inherits none of them from its parent. A first version gave
them a `minibuffer` base face instead. ChatGPT Codex caught it on PR #319: a
published parent is compatibility behaviour, so a theme written to the
accepted ADR that restyles `mode.text-entry` would have silently missed every
prompt. `a_theme_that_restyles_text_entry_reaches_every_minibuffer` pins the
accepted chain. Changing it is the user's call and belongs in the ADR, not in
this file.
- **`mode.tile-select` hangs directly off `mode`.** The ADR's decomposition
covers the thirteen DarkGray modes and the text-entry chain, and does not
place `TileSelect`.
- **The mode faces still state `Named(Black)` ink.** Clause 5 wants the twelve
`fg(Black).bg(accent)` sites to become `reverse` over the accent, but that is
not a no-op on a `TestBackend` buffer, and clause 8 names only one exception.
So it waits for improvise-nn4s.6 and a ruling.
- **No slot tables yet.** `Theme` has no `slots`/`dark`/`default_scheme`: they
are the browser's substitute for a terminal palette (clause 4), and the first
consumer is improvise-nn4s.5. Adding them then is a field with a serde
default, not a format break.
- **Only the faces some call site draws with exist.** The ADR's full vocabulary
(`emphasis`, `error`, `selection`, `grid.*`, `panel.*`) arrives with the
call sites that use it, because a face with no caller is a published name
with no evidence behind it.
### The category-add exception
`minibuffer.category-add` resolves to green in the shipped theme, where the old
literal was yellow: the user ruled the split an accident (ADR 004 Q1), and it
is clause 8's one declared exception. `AppMode::category_add` now carries
`MinibufColor::Green` too, so the browser, which still reads the wire colour
until improvise-nn4s.4, agrees with the TUI rather than drifting from it.