Files
improvise/context/adr/002-naming-a-cell.md
T
Edward Langley 0917d04bfd test: two guards the self-review found missing, and ADR 002's enum has grown
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>
2026-09-20 11:21:50 -07:00

12 KiB
Raw Blame History

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 DefaultHasher or anything built on RandomState. std's default HashMap hasher 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 derived Hash across 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 _Index was 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 to u64 because 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.