improvise-ir7l. project::cell_id decided Record vs Aggregate purely on whether a regular category sat on Axis::None; it never asked whether the coordinate named a formula target. So a formula cell was a Record, render.rs::editable said true, and a write to it landed, was stored, and was then shadowed forever by formula_cache — not a revert, a permanent silent shadow, on the demo's front page. Rather than a second "but not really" flag beside CellId (the two-answers shape ADR 002 exists to forbid), a formula-owned cell now projects a different kind of identity. Derived carries the formula's target and no surrogate, so the write is unconstructible rather than refused. The predicate is Model::formula_for(&CellKey), one home, which already existed three times open-coded inside model/types.rs. The TUI lands in the same change: EditOrDrill calls the same predicate and refuses, or the two front ends diverge. Two orderings are load-bearing and tested: Derived is asked before the record branch (the coordinate is stored), and after the aggregate branch (gotcha 10 — an aggregating formula cell is still drillable). Two tests were passing while their names were false for the Profit column and are renamed and two-sided now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
60 KiB
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 sharedreduce_view. It is serialized at exactly one point — the bootstrapSnapshot(improvise-q08). Steady-state traffic is commands only.Command(reduce.rs) is the wire format, in both directions: user intent client→server,Projections server→client.ClientFrame/ServerFrame(frame.rs) is the framing rather than the vocabulary: which of the above a given JSON blob is.
1. lib.rs — the portable slice
What may live on ViewState
Two properties gate membership, and both are load-bearing (improvise-h0b):
- portable — plain data from the allowlisted crates, no shared-ownership
containers. The
ViewState: Sendstatic assertion is the compile-time proxy (Rcis notSend); theDeserializederive rejects it too, serde'srcfeature being off.Senddoes 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.
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.
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::ViewOnlyeffect inimprovise-commandwhose 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: everyEffectKind::Model(..)effect, one variant apiece, carrying that effect's own fields (improvise-kmgs).
The count is 29 model effects, minus three: SwitchView, which Command
already names and routes, and ApplyAndClearDrill/AcceptWizardModel, which
are not addressable (improvise-3x3u). That leaves 26. 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
awkextraction reports 30 — it picks upHelpPageSet, which isViewOnly, because that is the lastpub structbefore#[cfg(test)]and the test module mentionsModelChange::. Brace-match theimpl Effectblocks instead (doctrine(derive_then_verify)). - improvise-kmgs derived the set one effect at a time, which was correct
per element and wrong for a set: it put
SetCellon the wire without itsMarkDirtycompanion, so "save if dirty" never fired (improvise-gv9r).MarkDirtyis gone;App::apply_effectsnow raises the flag offEffectKind::Model(_)directly.
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.
Command::SwitchView has a foot in both halves
The SwitchView effect wears EffectKind::Model(LayoutGeometry), so the
model-half derivation lands on it too — and ModelCommand deliberately does
not name it a second time (improvise-kmgs). ModelCommand::ViewBack and
ViewForward also move the active view and so carry the same document half.
Command::ResetCursor is an invalidation, never a position
Derived from App::reset_cursor, the write TransposeAxes and CycleAxis
make after changing the grid's shape (gotcha 24). It is a variant rather than
a SetSelected plus two offsets for a reason that is not brevity: what the
server says is "your cursor is no longer valid", not "be here". Where a
client then puts its cursor is its own business — the model is shared, the UI
is not — so broadcasting one client's position to everyone would be the wrong
message even though origin happens to be where each of them lands today
(improvise-gvnb).
Command::Model is a struct variant
So the basis cannot be forgotten. ADR 001's check is worth nothing if a batch
may arrive without one, and the only enforceable version of "you must also
state X" is not being able to build the message without X. There is no
Basis::default() for the same reason — see §5.
Note what the struct variant does not buy: it closed omission from the
message and did nothing to close ignoring on arrival, which is the half
a struct variant is usually reached for. That hole needed a type —
Checked/CheckedBatch in improvise-command (improvise-ondj).
ModelCommand::SetCell takes a RecordCell and nothing else
A CellKey here would require the client to know the coordinate space —
every row and column category plus the page and filter selections (gotcha
27), and in records mode the _Dim column-index convention (gotcha 1) —
which is precisely the knowledge the server exists to own. The surrogate is a
token to echo back; and because it is a record identity, a client holding
one for an aggregate cell cannot build this variant at all (ADR 002).
LoadModel and ImportJsonHeadless name paths in the host's Storage,
never the client's filesystem (gotcha 2).
The variants are grouped by the ModelChange level their effect declares,
because that level is what Dirty::of turns into projections (gotcha 35).
Adding a variant here means classifying its effect there.
reduce_view
The same function runs on both ends — the client folds it to draw, the server folds it over its mirror — so the two cannot drift (improvise-cqq, improvise-m91). Total and deterministic: no IO, no clock, no failure.
Command::Project and Command::Model are both no-ops in it, for different
reasons. A projection is routed to one widget's WidgetCache instead. A
model command is the document's business: what a client must fold arrives
as the projections it dirtied, plus — when the active view moved — the
SwitchView the host broadcasts.
A ModelCommand does not mutate anything by itself: improvise-command's
model_effects maps it back to the effect it came from and runs it through
the same App::apply_effects the key path uses. Effects stay the only
mutation site, and the Dirty is the effect's own EffectKind — never a
second classification beside gotcha 35's table.
3. render.rs — the fold
There is no aggregate render cache
Each widget owns its own cache type and folds its own stream of projections
(improvise-rkeq): the grid does not learn that a formula was deleted, and the
formula panel does not learn that a cell changed. A Projection is one such
update, addressed to exactly one widget, and WidgetCache::apply is the fold.
The overlap between TileBarCache and CategoryTreeCache — both carry
(category, axis) — is deliberate, so neither is woken by the other's
changes. Do not "fix" it by sharing a variant.
WidgetCache::apply is total and deterministic, like reduce_view: an
update naming something the cache does not have is dropped, never a panic.
Projections race on the wire, and a client that crashes on a stale index is a
client that crashes.
Projection::Basis is not a widget — nothing draws it — but it is folded on
exactly the same terms; see §5.
CellId: the kind of identity is the permission
SetCell/ClearCell take a RecordCell and nothing else, Drill takes an
AggregateHash and nothing else. A client cannot compose a write against an
aggregate or a drill against a stored cell — not because either is
checked, but because neither carries an id of the other's type
(design-principles §3). It projects a fork the model already makes:
EditOrDrill branches on exactly this in the TUI today.
An earlier draft made this Option<CellId>, absence meaning aggregate. The
enum is better: an aggregate is not the absence of an identity, it is a
different kind of identity — and modelling it as None left Drill with
nothing to address.
A RecordCell is (record hash, measure) because the surrogate names the
record, not the cell (ADR 002). That is what lets one identity serve both
addressing and staleness: record_versions is keyed by the same hash, so a
client's own token is the lookup key for its own version, with no CellKey
rebuilt on the hot path. measure is absent for a record carrying no
_Measure coordinate at all — the shape add-record-row seeds.
CellId::Derived — a formula owns the cell (improvise-ir7l)
A formula target is a real stored coordinate, so the record branch claimed it
and every client offered an editor over it. The write landed and was then
shadowed forever, because evaluate_aggregated reads formula_cache before
raw data — not a revert, a permanent silent shadow.
The fix keeps ADR 002's discipline rather than adding a second answer beside
it: a formula-owned cell projects a different kind of identity, so the
write is unconstructible instead of flagged. Derived carries the formula's
target and no surrogate, so there is nothing a client could spend on a
SetCell; that is the same reason Aggregate carries an AggregateHash and
not a RecordHash.
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.
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, blanked by Invalidated, or a
records-mode column that is not a cell to write into at all (a category column
renames a coordinate across the whole row, gotcha 1, which is a different
intent from SetCell). Conflating the two either hides a cell or offers a
write the type system exists to make unconstructible.
CellDisplay::edit_text — the difference corrupts data
What an editor opened on this cell should start with: the value as the user
last typed it, not as it is drawn. text has been through
format_f64, so a stored 1200 is drawn 1,200 under a comma-grouping
format; seeding an editor with that and committing it parses as
CellValue::Text("1,200"), and the number is gone. Display rounding is
view-only and must never be fed back into the model (design-principles §4) —
and an editor's seed is exactly a feedback path.
It is the projection of App::display_value_at_cursor, which is what
EditOrDrill seeds the TUI's buffer from, so both front ends start from one
string rather than each deriving one. None where there is nothing to edit:
the same set of positions id is None for, plus cells whose value is
absent. 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.
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 a CellValue — by the
time a cell reaches a client the number formatting has already happened.
AxisFrame — the grid's frame, and why it is one type
AxisFrame (improvise-anr9/s1xw) replaced a Vec<String> of labels that
GridLayout::row_label built by slash-joining a multi-level tuple — the
exact rendering two_row_categories_produce_cross_product_labels asserts must
not appear. The protocol carried a form of the labels the TUI itself
considered wrong, so the browser drew Food/Alice where the TUI drew two
header columns, with no groups and no collapse indicators (gotcha 44).
It is one type rather than a labels plus a levels beside it, and that is
the whole point: two answers to "what are the labels" is the disease this
projection architecture exists to cure (improvise-edp). A renderer asks this
type and there is nothing else to ask. Note what this cure cost that
flatten_cat_tree did not: a Vec<String> had to change type, and the
shared derivation could not be added beside the old one.
cats is the categories outermost-first; entries interleaves
Group(FrameGroup) headers with Item(Vec<String>) label tuples exactly as
GridLayout::row_items interleaves them. On the row axis the TUI paints a
Group as its own row; on the column axis it paints the runs as spans above
the headers. That difference is painting, and it is the only thing that
differs.
cats may be empty — an axis carrying no category is a real layout (one
nameless row or column), not a gap, hence levels() returning at least
one. AxisFrame::flat leaves cats empty on purpose, so it is not a way to
smuggle a multi-level axis in flattened.
FrameGroup::collapsed is on the wire rather than left to the client because
it is a fact about the view, which only the server holds. FrameGroup::cat
travels so a client can echo it back in ToggleGroup without ever working out
which category a label came from — which slash-joined labels made impossible.
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_levelis 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 loopheader_labelsexists to replace.group_ofwalksentriestwice over, once to placeidxand once backwards.
The one-pass forms a draw loop wants:
header_labels(first_rendered)— every header label with suppression already applied, indexed[data index][level],""where the label repeats. The suppression rule still has exactly one home (fn shows); this isshows_levelapplied in bulk, not a second copy of it.groups()—group_offor every position, in one pass.shares_ancestry(with)— the TUI's column-header underline: at the leaf level only the selected column matches, at a higher level every column under the same ancestor. There is deliberately no per-position form beside it, because one would be a second answer paid for per (column, level) — two frame scans apiece.
leaf_labels() is not "the labels". It is the innermost label of each
item: the single most specific name a position has, what a test or a one-line
summary calls a row by. A renderer reaching for it on a multi-level axis is
drawing the wrong header, not a shorter one.
first_rendered is the first data index actually on screen. That entry
always shows its full tuple, or a scrolled viewport loses its group context at
the top edge (improvise-jjx).
body_from(idx) drops the group header that owns the first drawn item: a
frame opening mid-group carries that header so group_of can answer for it,
but it is above the first drawn row and must not be painted as one. This is
the same cut GridLayout::data_row_to_visual has always made. Its argument
exists only because the two front ends scroll differently — a browser's frame
is already windowed and passes 0, the TUI's covers the whole grid and passes
its row offset (gotcha 44) — not because the rule differs.
group_of answers "nearest preceding header", not membership, and
deliberately so: it is what the TUI has always painted. expand_category
emits a header on entering a group and no boundary on leaving one, so an
ungrouped item following a grouped run is answered with the run's group — and
the TUI's column-group row spans it under that label today. Encoding
membership, or an end boundary, means changing AxisEntry in
improvise-core and changing what the TUI paints: a fix to the layout, not to
this projection of it, and it is filed rather than smuggled into the migration
that made the two ends agree (improvise-anr9, raised by Copilot on PR #23).
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.
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 send Invalidated first. Invalidated therefore means
only "contents stale, frame unchanged" — and 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.
(c) Every arm that touches a frame or the widths re-fits. A renderer
zips widths with labels, and a length mismatch indexes off the end. Fitting on
the frame arms alone is what let a late ColWidths put the mismatch straight
back: a short update keeps each unheard-of column at its own widest level
label until ColWidths catches up, and a long one is dropped as having
overtaken the frame that names it, exactly as (a) holds Cells.
The fallback width is measured in display cells, the same unit
header_widths and the authoritative ColWidths use: chars().count() would
draw 日本 two cells wide where it needs four, so a cache caught between
ColFrame and ColWidths would render the new column too narrow. The
materialization inside fit_to_labels is deliberate — AxisFrame::item
scans, so asking it per column would make a re-fit quadratic.
invalidate_relabelled — a cats change re-identifies everything
A cell's identity is cats zipped with its item tuple, so a change of
cats re-identifies every position at once — even the positions whose
labels are byte-identical. Region = [North] becoming Department = [North]
leaves items equal and names a different cell, so the tuples alone cannot
answer this and every surviving position is stale.
The sharpest half is invisible: a preserved CellDisplay carries an editable
CellId::Record (ADR 002), so a client would go on offering an edit that
addresses the record the old coordinate named — a misdirected write, in
the same family as gotcha 18's drill snapshot, and one no rendering shows.
Otherwise the comparison is on the item tuples, not the entries: a group
header appearing or collapsing changes the frame's shape without renaming any
surviving position, and blanking on that would throw away cells the very next
Cells update would have to resend.
Two asymmetries about totals:
- A relabelled column drops its total (improvise-hkt6): the total under it is the sum of the old column. A relabelled row does not, because a total spans every row and is not framed by any one of them.
- A recategorization of either axis blanks every total. A column total is a sum over the rows, so re-identifying the rows leaves a number that answers coordinates the grid no longer has.
This is what obliges the producer to diff the totals against the frame the
label updates will leave rather than against the mirror they change: a column
renamed while its total stayed the same number is blanked here, and a producer
comparing mirror.totals would find them equal and send nothing.
grid_updates compares framed.totals for exactly that reason. The blanking
stays anyway, because rule (b) exists to stop apply trusting a producer to
remember — the rule lives on both sides or on neither.
LabelAxis is private so the row and column cases share one invalidation
rule rather than two copies that can disagree.
Bootstrap is a compaction, not a replay
A server does not replay history to a late client — it holds the current state
and expresses it as the same updates the steady stream carries, so bootstrap
is not a second code path and there is no whole-state shape that could
disagree with the incremental one. The property that earns it is
GridCache::folded(&cache.seed()) == *cache, and it is the ordering that
earns it: the frames frame the cells, so they go first.
Snapshot { view, seed } is the only thing on the wire that is not an event,
and view is the one point at which ViewState is serialized (improvise-q08).
Note the fold tests: asserting fold(prefix) + fold(rest) == fold(whole) is
vacuous — it is the definition of a left fold — so the tests that earn
their place are the order-sensitivity one and the compaction-equivalence one
(doctrine(fold_tests_must_test_order_not_the_axiom)).
CategoryTreeCache carries every category, _-prefixed included
improvise-vjms: the TUI panel has always listed them, and a browser panel that
quietly did not would be a second, divergent answer to "what are the
categories". So the _-prefixed convention does reach the client — it is
presentation, and both ends render the same rows from this one source.
The trap is _Measure, whose items are derived: explicit items ∪ formula
targets (gotcha 7). Whoever builds these rows must go through
Model::effective_item_names; reading category("_Measure").items compiles,
looks right, and silently omits every formula target.
Three mutations reach this panel because the effects are three shapes:
adding or removing a category reorders the whole list, adding or removing an
item touches one category, and a transpose moves several axes at once without
touching membership. Which categories are expanded is
ViewState::expanded_cats, per-client, so the flattening is a client-side
concern and does not belong on the wire.
FormulaCache and ViewListCache take one whole-list update, because add and
delete are the only effects that touch them and both are cheap to send whole.
Which view is active is not in ViewListCache: that is
ViewState::active_view, carried by Command::SwitchView, because it is
per-client (improvise-8uz). Likewise which tile the cursor is on is
ViewState::tile_cat_idx.
WizardCache has a shape and deliberately no Projection
improvise-764, and that absence is the ticket's answer to "where does the
wizard state live". A Projection is broadcast to every connected client's
widget, which is right exactly when the change is addressed to no one
session. A half-open import dialogue is the opposite: it belongs to precisely
the session that opened it. That is already settled doctrine elsewhere —
ImportWizard lives on App and not on ViewState, and AcceptWizardModel
was removed from ModelCommand for this same reason (improvise-tom1) after B
was shown swapping the document out of a dialogue B never opened. Putting the
wizard on the broadcast stream would have re-created that bug on the
rendering side: every client drawing a dialogue one of them opened.
So it is a cache in shape and a local render model in fact, built from the
authoritative App by the host that holds the wizard. A cache without a
Projection is the shape session-local residue takes when it still has to be
drawn by two different front ends. It lives here rather than in
improvise-command for the flatten_cat_tree reason: the browser client
depends on improvise-protocol alone, and when it grows a wizard it must draw
these rows, not a second derivation. When multi-client import arrives it
wants the addressed form, which is where ADR 001 layer 2 goes anyway.
Every field is already render-ready — the widget that used to build these
reached through ImportWizard into ImportPipeline, FieldKind,
DateComponent and a whole Workbook; none of those cross now. Two
substitutions are made here so both ends spell them the same way: an
undetected date format reads "?", and the root array path reads "(root)"
rather than empty. WizardProposal::kind_label is carried rather than
re-derived from kind, so the two ends cannot disagree about what "Measure
(numeric)" is called.
WizardStepKind mirrors improvise_io::import::WizardStep, spelled again
rather than re-exported, because this crate must not depend on improvise-io
(the wizard is an io type, gotcha 30) and a client that draws the wizard must
still be able to name the step it is on. WizardFieldKind is the semantic
half of a colour decision — the terminal mapping lives in draw.rs, exactly
as AxisColor/axis_fg does (gotcha 26).
Projection carries no multi-widget variant
The envelope a widget subscribes by: it folds the payload of the variant that names it and ignores the rest. There is deliberately no variant carrying several widgets' updates together — that would be the aggregate cache in another form.
Nothing is defined for the help overlay: its content is static and
help_page is already in ViewState.
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:
Viewportis deployment-independent. The extent is the one session input noCommandcarries by construction: it is a fact about a terminal or a browser window, it is not inViewState, and no effect produces it, so no derivation reaches it. A worker hosting the sameSessionsowesset_extentthe same answer. It is the handshake on both, because aSnapshotseeds the visible cells — one built at extent zero is an empty grid the client would immediately have to ask again for.Keyis deployment-independent too. A keymap binding names an intent resolved against aCmdContexta thin client does not hold (gotcha 38), which says nothing about what the bytes travel over. The socket lacked 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 aGridUpdate::Cellsthat overtakes its frames is dropped, not replayed. That is a property of the fold, not of the transport: an in-processpostMessageinterleaves exactly as a socket does.Commands(vec![c])expresses everything aCommand(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.
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 |
a LayoutGeometry effect |
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.
RecordMovedis 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.RecordGoneandLayoutMovedare 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.RecordUnstatedis 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.
- Staleness (ADR 001) —
DocumentReplaced,LayoutMovedand the three record variants. The only class where force means anything. - A request the host cannot honour —
NoDocumentPathandSaveFailed(improvise-368h). Nothing went stale; there is nothing to re-compose.
They share this type rather than getting refusal channels of their own, for the reason the type exists: a refusal is "nothing happened, here is why", addressed to the asker alone, and a second channel would be a second set of ordering and addressing rules for one idea. A third class is coming and is not here yet: a transaction rolled back because a later command in a chain could not be applied (clause 4 of the frame contract). Until rollback exists there is nothing to report, because nothing is undone.
NoDocumentPath is decided before anything runs, by the same constructor
that answers for a basis, so a save that cannot land is refused rather than
attempted. What the asker should do about it differs by front end: a TUI
prompts for a path (improvise-lae), a browser worker whose document is its
storage entry never sees this at all. SaveFailed carries no message because
Rejection is Copy and an I/O error's text is not — the host keeps the text
in its own status line, and what crosses the wire is the one fact a client can
act on: the document it is editing is not on disk. Reporting a failed write
as success is the thing this exists to stop.
6. keymap.rs — one table, both ends
This used to live in improvise-command, where only the terminal could reach
it, and the browser client carried a hand-written classifier answering the
same question for the subset it could resolve. That is two definitions of one
vocabulary kept in step by a lockstep test over a hand-written list of keys —
and doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)
says such a pair is one definition waiting to be made.
The move is the one gotcha 13 already anticipated:
ViewState::transient_prefix stores a prefix as an address rather than an
Arc<Keymap> precisely so ViewState could stay free of improvise-command
types while the keymap came the other way.
The boundary that did not move
A lookup yields a command name and arguments. Turning that into something
that runs needs a CmdContext — a Model, a GridLayout, an App — which a
thin client does not have, so intent → mutation stays where it was:
improvise-command resolves a name through its CmdRegistry into Effects,
and a browser client resolves the subset it can compose into Commands.
That is a better seam than the one it replaces. The drift risk used to be "which key does what" — ~170 bindings, no direct test, invisible when wrong. It is now "which command names can I execute", which is a list.
What stayed in improvise-command, and why each had to:
Keymap::dispatchandKeymapSet, which pair this table with aCmdRegistryand returnBox<dyn Effect>.SetTransientPrefix, which is anEffect— it writesViewState::transient_prefix, andEffectis 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.
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). 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. Seven are built by TextEntry::new; RecordsEditing
is built by TextEntry::with_parent off Editing's map, so a count taken off
the ::new call sites reads seven and is not the number of text-entry
modes. (See repo-map gotcha 11 — this exact count has already gone stale
once.)
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, every panel's toggle key is bound so focus can move
between them, and : opens command mode. 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.
bind_panel_toggles is called by PanelKeymap::new and by Normal, so a new
panel reaches all four keymaps from one Panel::ALL entry.
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:
frame.rs,ClientFrame::Keysaid "the host runs it through the keymap against the authoritativeApp".Host::handlereturns 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.reduce.rs,every_command_round_trips_through_jsonsaid the model half brings "four payload types the view half never carried —CellKey,CellValue,AxisandPathBuf".ModelCommandcarries noCellKeyat all since ADR 002 (SetCelltakes aRecordCell), andCellValueis in the view half, asCommand::SetYanked.render.rs,identified_gridcarried three paragraphs of doc that describe a different test — "improvise-hkt6 … three columns and a rename in the middle" isa_relabelled_column_drops_its_total, which had no doc of its own, whileidentified_gridis a two-column fixture. The paragraphs had been merged onto the wrong item.keymap.rs,buffer_ofsaid "the seven modes that carry aMinibufferConfig". Eight do, sinceSavePrompt(improvise-lae); seven is the number ofTextEntry::newcall 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.