Three findings from running /code-review over this branch. a_filter_axis_is_projected_but_not_cycled builds its own PageAxis by hand, so it says nothing about what grid_of actually emits — a test that fabricates the very data the code under test produces, which is this repo's characteristic defect. a_page_axis_carries_its_items_and_a_filter_axis_does_not is the producer half; it fails under `let navigable = true`, which is filtering_cats() leaking into the protocol, the exact trap gotcha 27 names. improvise-ir7l holds in RECORDS mode only by accident: cell_id's records branch never asks Model::formula_for, and it is safe purely because records_columns builds from ordered_item_names (explicit _Measure items) while a formula target lives only in measure_item_names. a_records_grid_has_no_column_for_a_formula_ target aims a guard one level below the fix, and fails under the plausible tidy-up that swaps the two — a Profit column appears, and it would project as a writable Record. ADR 002 specified a two-variant CellId. It now has four, and a fact stated in two places is changed by a grep. The ADR gets an Extension section rather than a rewrite: the decision is unchanged, and both new variants were arrived at by applying its clause 1 rather than departing from it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
12 KiB
ADR 002 — How a client names a cell
Status: accepted (2026-09-19), extended (2026-09-20) · Decided by: the user · Blocks: improvise-1ey · Extended by: improvise-ir7l, improvise-poia
Context
improvise-q08 established that a thin client cannot compose SetCell, and it is
the gap that blocks the browser MVP, whose stated scope is "type a number into a cell
and see the value update".
A client sees a grid of label strings indexed by viewport position. A CellKey is the
full sorted coordinate tuple — every Row and Column category, plus every Page and
Filter selection (gotcha 27). Nothing in GridCache says which category a row label
belongs to; nested row axes flatten several categories into one label; page and filter
constraints are not projected at all; and records mode encodes a column index in
_Dim (gotcha 1). There is no client-side derivation, and projecting the coordinate
space would make every client learn conventions the server exists to own.
Decision
Every projected cell carries an opaque surrogate identity, and the kind of identity says what may be done to it.
pub struct CellDisplay {
pub text: String,
pub numeric: bool,
pub error: bool,
pub id: CellId,
}
pub enum CellId {
/// A stored cell. `SetCell` takes this and nothing else.
Record(RecordHash),
/// An aggregate over stored cells. `Drill` takes this and nothing else.
Aggregate(AggregateHash),
}
An aggregate's surrogate is a hash of its coordinate constraint — the row and column
coordinates plus the page and filter selections that define which stored cells it sums.
One hash per cell, the same width and the same index machinery as a record's, and the
server resolves it back and runs drill_records when the client asks.
Three things follow, and each is the reason to prefer this over alternatives.
1. Both operations are unrepresentable-if-wrong, not checked. A client cannot compose
SetCell for an aggregate, and cannot compose Drill for a stored cell, because
neither carries an id of the other's type. That is design-principles §3 — make invalid
states unrepresentable — and it is strictly stronger than a server-side rejection, which
would be a runtime answer to a question the type can settle.
An earlier draft used Option<CellId>, absence meaning "aggregate". The enum is better
for a reason worth keeping: an aggregate is not the absence of an identity, it is a
different kind of identity. Modelling it as None made SetCell safe and left Drill
with nothing to address — which is precisely how drill addressing came to be an open
question. Two variants close it by construction.
2. The client never learns the coordinate space. No category names, no page/filter
constraints, no _Dim column-index convention. The surrogate is a token to echo, not a
structure to understand — so gotchas 1 and 27 stay knowledge the server owns, and a
second client implementation cannot get them subtly wrong.
3. It mirrors a fork that already exists. EditOrDrill (cmd/mode.rs) already
branches on exactly this in the TUI: a stored cell is edited, an aggregate is drilled.
The wire is not inventing a distinction, it is projecting one the model already
makes — the same discipline as decision(improvise-cqi, command_set_derived_not_invented).
It also follows the precedent CellDisplay::numeric set, whose doc already says the
server decides "because it is the side that knows which it was". Settability is another
such fact.
Decisions made along the way, and the traps in them
Most of what started as open questions here got answered. They are kept with their reasoning because each rejected alternative is a thing someone will propose again.
What the surrogate is — DECIDED: a hash of the normalized record. Not of the cell:
the record is the coordinate tuple minus _Measure, which is already ADR 001's unit of
atomicity and already what record_versions is keyed by. A cell is then
(record hash, measure).
The reason is that it makes stale edits cheap to identify, and it does so three ways
at once. The surrogate a client already holds is the lookup key for its own version, so
record_versions becomes HashMap<RecordHash, u64> — fixed-size, no allocation, no
CellKey reconstruction on the hot path. A basis is then a list of
(record hash, version) pairs, which is compact on the wire and O(1) to check. And a
record that no longer exists simply fails to resolve, so that whole class of
staleness needs no version comparison at all. One identity serves addressing and
staleness instead of each ADR carrying its own.
It is well-defined because CellKey already has a canonical form: CellKey::new sorts
coordinates by category name and equality and hashing rely on it (gotcha 5). "Normalized"
means that canonical sorted form with _Measure removed — the normalization is a
property the type already guarantees, not a new convention to maintain.
An interned id was rejected: it would need a table with a lifetime nobody has specified, and it buys only collision-freedom, which width buys more cheaply.
Three ways this silently breaks, all avoidable. They are listed because each produces a wrong write rather than an error, which is the failure mode this ADR exists to prevent:
- Do not use
DefaultHasheror anything built onRandomState.std's defaultHashMaphasher is randomly seeded per process, so hashes would differ across a server restart and between any two processes. Specify a fixed hash (a keyed SipHash with a constant key, or FxHash/xxHash/blake3) and write the choice down. - Do not use
#[derive(Hash)]as the definition. Rust gives no stability guarantee for derivedHashacross compiler versions or platforms. Hash an explicit byte serialization of the sorted(category, item)pairs, so the surrogate is defined by the format rather than by whatever the compiler did. - Pick the width by consequence, not by convenience. A collision is a silent write to
the wrong record — precisely why
_Indexwas rejected. 64 bits reaches birthday risk around 2³² records, which a pivot tool will not see; 128 bits costs 8 more bytes per visible cell, so on a 50×20 viewport that is 8 KB a frame. The cost is small and the failure is silent, so argue the width explicitly rather than defaulting tou64because it is convenient.
_Index was considered and rejected — right ontology, unsafe representation. It is
the coordinate that identifies a record, and a record is exactly the thing that is
settable rather than aggregated, so the instinct is sound. But CategoryKind::VirtualIndex
is documented as "Items are "0", "1", … N where N = number of matching cells" — it is
positional over the currently matching set, not an identity. So _Index = "3" names a
different record after a filter change, an insert, or a delete, and names a different one
again in another view. A client echoing a stale _Index back would write to the wrong
cell, silently — strictly worse than a surrogate that fails to resolve, and the same
hazard class as the viewport-relative-vs-absolute trap and the stale cursor under a
renamed key.
What _Index is pointing at is worth keeping, though: the surrogate should name the
record, not the cell. A record is the coordinate tuple minus _Measure
(record_row_identity), which is already ADR 001's unit of atomicity and already what
record_versions is keyed by. If a cell is addressed as (record surrogate, measure),
then one identity serves both addressing and staleness, and the two ADRs share a
mechanism instead of each inventing one.
How the server resolves a surrogate back — DECIDED: a maintained index, in
DataStore. A hash is one-way, so resolution needs a reverse map, and regenerating it
from the layout on demand would be O(visible cells) per resolve and would only ever
cover the visible window — a client may legitimately hold a surrogate for a record that
has scrolled out of view.
DataStore is the right home because it already does exactly this. It interns every
category and item name into a SymbolTable, and it already maintains a secondary index
HashMap<(Symbol, Symbol), HashSet<InternedKey>> for hot-path lookups. A
RecordHash → record index is the same shape, updated at the same points, and the
existing (Symbol, Symbol) index is what makes invalidation tractable: when a category or
item disappears, the records that mention it are findable without a scan.
Two consequences worth stating, because they fall out and are easy to miss.
The index and record_versions want to be one structure. Both are keyed by the record
hash and both are invalidated at the same moments, so RecordHash → (record, version)
gives ADR 001's basis check and this ADR's resolution in a single lookup.
Structural and data staleness separate cleanly, which is better than the regenerate-on-demand shape would have given:
| incoming surrogate | means | answer |
|---|---|---|
| does not resolve | the record is gone — a category or item it named was removed | reject; there is nothing to write to |
| resolves, version matches | the client's basis is current | apply |
| resolves, version differs | the record changed since the client read it | reject as stale (ADR 001), offer force |
Regenerating from the layout would have conflated the first two rows, because a record scrolled out of the viewport would have looked identical to a record that no longer exists.
Whether a forged or stale surrogate is an error or a rejection. Under the
regenerate-from-layout shape it is simply unresolvable, which suggests the same
Applied rejection path ADR 001 defines rather than a distinct error.
Consequences
Good. Unblocks improvise-1ey. Keeps the client thin. Makes an invalid SetCell
unconstructible rather than rejected. Composes with ADR 001 instead of duplicating it.
Projects a distinction the model already makes, so there is one source of truth for what
is settable.
Bad. CellDisplay grows a field, and it is on the hot path — every visible cell
carries it, so the surrogate's width is a wire-size decision, not only a collision one.
A client holding a surrogate across a layout change holds something that will fail; that
is correct, but the UI must handle it as a normal outcome rather than an error (the same
obligation ADR 001 already places on rejection).
Not addressed. The other composability blockers improvise-q08
listed — ToggleGroup, AddItemInGroup, RemoveFormula, LoadModel — which are
separate projection gaps and are not solved by a cell surrogate.
Extension (2026-09-20) — two more kinds, by the same reasoning
The decision above is unchanged. CellId has since grown two variants, both
arrived at by applying clause 1 rather than departing from it: a cell whose
permission differs gets a different kind of identity, so the wrong operation
stays unconstructible instead of being refused at runtime.
pub enum CellId {
Record(RecordCell),
Aggregate(AggregateHash),
/// A cell a formula owns (improvise-ir7l).
Derived { target: String },
/// A records-mode creating column (improvise-poia).
Create(CreateKind),
}
Derived — a formula target is a real stored coordinate, so the original
two-variant enum called it a Record and every client offered an editor over it.
The write landed, and was then shadowed forever by formula_cache. It carries
the formula's target and no surrogate: there is nothing a client could spend
on a SetCell, for the same reason Aggregate carries an AggregateHash.
Create — the records-mode + and +cat columns projected None, which
this ADR defines as "no projected cell". That conflation is the one clause 1's
rejected Option<CellId> draft was about, arriving from the other side: not an
aggregate mistaken for an absence, but a column the user can type into
mistaken for one. It carries only the kind, because what the user types into a
creating column is a name, not a value — the operation is
ModelCommand::AddItem / AddCategory, which already existed, so no new
command was needed and the record under the cursor is not part of it.
What the original two clauses cost, and still cost, is unchanged: a client
learns no coordinate space, and an invalid operation is unconstructible rather
than rejected. What the extension adds is that the enum is now four answers
to "what may be done here", so None finally means only what this ADR always
said it meant — no projected cell at all.