Files
improvise/context/adr/001-concurrent-edits.md
T
Edward Langley 72b0d09119 docs: finish the reduce_full deletion in the prose that outlived it
Self-review caught three references the deletion commit missed, which is gotcha
48's rule arriving on schedule: when a fact is stated in more than one place,
changing it is a grep, not an edit.

- context/code/command.md still had a `### reduce_full / reduce_key` section
  calling it "the signature the three hosts share" -- the exact claim the ticket
  was filed against, two hundred lines below the paragraph that corrects it.
- ADR 001 stated in the present tense that apply_command/reduce_full do not
  check, and recorded improvise-ondj giving both a Checked signature without
  saying that one of them has since gone. The ADR now says why deleting beat
  typing: having no caller was the condition that let the defect hide, and a
  Checked on a function nobody calls fixes the type and not that.
- The test-evidence entry's "no longer compiles" is now "no longer exists".

Also records the AddItem/AddItemInGroup success gating and the four effects that
reset unconditionally because they cannot report a miss.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 11:14:28 -07:00

16 KiB
Raw Blame History

ADR 001 — Concurrent edits between clients

Status: accepted (2026-09-19); layer 1 implemented (improvise-xwi6/fkfg, 2026-09-19) · Supersedes: nothing · Decided by: the user

Context

improvise-cqq made the client/server split real: one authoritative App, a ClientSession mirror per client, and projections diffed against those mirrors. improvise-kmgs then put the 28 model mutations on the wire. Two clients can now edit the same document, and nothing decides what happens when they edit the same part of it.

There is already a total order

Every ModelCommand reaches the document through one path: Sessions::apply → apply_command → App::apply_effects, against a single App. So the server already serialises writes. We do not need to build linear history — it exists.

What we lack is a way to notice that a client composed an edit against a picture of the document that has since moved. This is a stale basis problem, not an ordering problem — the same shape as the stale cursor (gotcha 24) and the stale session mirror after a document swap (gotcha 39), one level up.

Scope: conflicts range over one document

improvise is a multi-document app, and the server is expected to host several separate models. Sessions editing independent models are trivially non-conflicting, so everything below scopes to a single document.

That falls out of the existing shape rather than needing to be built: Sessions does not own the App — connect, apply and project_all all take it as a parameter — so a Sessions is already bound to one document's generation sequence, and multi-document hosting is a registry above it mapping a document to its (App, Sessions) pair. The version tiers this ADR introduces live on the App, so they scope per document for free.

Multi-document hosting landed as improvise-ws-server::registry (improvise-j31x): a Registry of Hosts keyed by DocumentId, addressed by ClientId { document, session } because a SessionId is minted per Sessions and so is unique only within its document. The version tiers scope per document as predicted. Note one lifecycle rule that exists to protect this ADR: a document is never evicted while the server runs, because document_generation and record_versions start at zero in a fresh App, so re-opening would accept a reconnecting client's stale basis as current.

What actually conflicts

  1. Two writers, same record. Survivable under last-write-wins: someone's number is lost, the document stays coherent.
  2. Shape change vs. cell edit. A removes category Region while B commits an edit keyed by Region=East. B's CellKey names a coordinate that no longer exists. This is corruption, not a lost keystroke, and it is the class a cell-level lock does not cover — holding Region=East × Revenue does not stop anyone deleting Region.
  3. Formula vs. data. A adds a formula targeting the measure B is typing into; B's raw value is shadowed by a derived one.

Machinery that already exists

  • App::document_generation — bumped by install_workbook, consumed by ClientSession::project to reseed a stale mirror (gotcha 39).
  • ModelChange::{CellData, LayoutGeometry, WholeWorkbook} — the effect taxonomy, already the single classification for what a mutation reaches (gotcha 35).
  • record_row_identity(wide, coord_group_excluding_measure) — a record is the coordinate tuple excluding _Measure (gotcha 1).
  • Drill staging — pending_edits applied atomically by ApplyAndClearDrill, and ruled private to whoever staged them (improvise-tom1).

Machinery that did not exist, and the hole it left

Nothing in the projection stream carried any of the three tiers. This ADR says a batch carries the basis it was composed against and never says how a client comes to know one — so the check as originally specified was satisfiable only by a client forging a basis. That is the invariant deleted with extra steps, arriving through the front door rather than as a force flag.

Closed during implementation (improvise-fkfg) by Projection::Basis + BasisCache: the server publishes the two document tiers plus the write counters for the records this client's grid names — exactly the set it can address a write to, per ADR 002 — diffed against what that client was last told and seeded in the bootstrap. 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. That is the mirror image of gotcha 33.

Read this as step (a′) of the implementation order below: publication comes before the check, because the check is unsatisfiable without it.

Decision

Record-level atomicity with optimistic concurrency per batch.

  1. The unit of atomicity is the record, i.e. the coordinate tuple excluding _Measure — the thing record_row_identity already names. Not the cell: gotcha 1 says editing a category column in records mode moves every measure cell in the row, so a cell-level unit would be violated by a single legal edit. The unit must be at least as coarse as the coarsest legal write.

  2. The unit of commit is the batch — one Vec<Box<dyn Effect>> applied by one apply_effects. This is already atomic in practice: a Binding::Sequence flattens into one batch, and a failed Save aborts the rest (gotcha 21).

  3. A batch carries the basis it was composed against, and the server rejects it if that basis moved. The basis is tiered by ModelChange, which is what makes class 2 fall out for free:

    tier bumped by a batch is stale if
    document_generation WholeWorkbook it moved at all
    layout_version LayoutGeometry it moved at all
    record_version CellData on that record any record the batch touches moved

    A CellData batch is stale if layout moved, because the shape its keys hang off changed. That is class 2, detected without a lock.

  4. Rejection is a normal outcome, not an error. The client already receives the new state through projections; it re-composes against the new basis. Applied grows a third case alongside Diff and Rebased — matching is what forces a host to handle it (gotcha 39's reasoning).

Rejection must be forceable, and force means re-compose

A rejected edit has to give the user a way through. But force is a re-composition, not a bypass — it never applies a batch whose basis is stale. A mechanism whose only purpose is to violate the invariant is not a feature, it is the invariant deleted with extra steps. Force re-composes the same intent against the current state and submits a fresh batch with a current basis.

That distinction is not pedantic, because what force can mean differs by class:

class can force? what force does
1 — two writers, same record yes, trivially re-compose and apply; the user has seen their value and chooses to overwrite it
2 — shape change vs. cell edit no the intent no longer type-checks: the key names a coordinate that no longer exists
3 — formula shadows a value yes, with a warning the write lands; the displayed value is still derived
4 — the batch names a record its basis says nothing about no — and it is not a race nothing to re-compose against; the client was never in the running

Class 4 was not in the original draft and was added by the implementation (Rejection::RecordUnstated). It exists because "any record the batch touches" had to name whose list, and the answer is the server's: reading it off the client's claim would be a force by omission — a client that simply omits a record skips the record tier, with no flag to remove and nothing in the message saying so. reduce.rs::records_named is therefore an exhaustive read of the ModelCommand, with one or-pattern for the variants that name no record, so a new record-writing variant fails the build. A well-formed client cannot produce class 4; it is a case of its own rather than folded into class 1 because "you lost a race" and "you were never in one" are different things to tell a user.

For class 1, force is one keystroke and should be. For class 2 there is nothing coherent to force: applying the batch anyway writes a CellKey naming a deleted category, which is the ghost-coordinate corruption this ADR exists to prevent. So:

  • Force never creates structure. Re-instating a category someone deleted so an edit can land is a different and larger intent than the user expressed, and it must be an explicit action, not a side effect of forcing a cell edit.
  • When an intent cannot be re-composed, the UI owes the user what changed and the concrete alternatives — re-target the edit, or re-create the structure first — rather than a refusal.

Two consequences that pull work forward from layer 2:

  • The rejected batch's values stay staged in the session, so rejection never costs the user their typing. This is the drill staging mechanism doing the job it already does, and it is what makes force cheap rather than a retype. It belongs in layer 1 even though publishing the staged keys is layer 2.
  • A forced overwrite must be visible to the client it overwrote. Otherwise force is silent data loss with a confirmation step in front of it. The loser's own edit was already committed, so this is a notification, not a conflict — but it must exist, or "someone else won" becomes indistinguishable from "my edit never happened."

Optional second layer: advisory reservations

The staged-edit mechanism from drill, generalised: a client publishes the keys it has staged so others can grey them out and warn early. The values stay private, per improvise-tom1.

This layer is advisory only. If a reservation is lost, never arrives, or leaks from a crashed client, the system degrades to "you find out at commit" — never to corruption. That is what makes it safe to be sloppy about timeouts and disconnects, and it is why correctness must not be built on it.

Why not a causal clock

Considered and rejected, for four reasons.

  1. The server already serialises. Causal clocks earn their keep where no single node orders writes — peer-to-peer, or offline-capable clients merging on reconnect. With an authoritative App we need staleness detection (one counter per unit), not concurrency detection (a vector per client).
  2. A Lamport clock is insufficient. It gives a total order but cannot tell you two edits were concurrent, which is exactly the question.
  3. A vector clock is sufficient but does not answer the question that matters. It costs O(clients), needs entries reaped for departed clients, and when it reports "these were concurrent" you still need a policy for class 2 — no clock says whether B's edit into Region=East should survive A's deletion of Region. The clock tells you there is a decision to make; it does not make it.
  4. CRDTs converge, but not necessarily to something meaningful. A LWW-register per cell is easy; the hard part is the shape — categories, items, formulas, axis assignments — where convergence can mean an orphaned cell or a resurrected category.

If offline editing ever becomes a goal, this decision should be revisited: that is the requirement that makes a vector clock worth its cost, and it is a much larger commitment than multi-client-while-connected.

Consequences

Good. No lock to acquire, so no timeout, no lease renewal, no disconnect handling, and no liveness risk — a crashed client cannot wedge anyone. Correctness does not depend on the advisory layer. The tiered basis reuses ModelChange, so there is no second classification to drift from gotcha 35's table. Class 2 is detected rather than prevented, which is cheaper and does not require predicting which shape changes matter.

Bad. A client can compose an edit and be told no, and the UI owes real work there: keep the typing, show what moved, and offer force where force is coherent. Class 2 is the expensive case — the honest answer is a small dialogue about what changed, not a button. Record-level granularity means two clients editing different measures of the same record serialise against each other even though their writes do not overlap — acceptable at the expected scale, and refinable to (record, measure) if contention shows up. A busy LayoutGeometry editor invalidates every in-flight CellData batch, which is correct but could feel aggressive.

Not solved here. Class 3 (formula shadows a raw value) is detected — the formula bumps layout_version — but the policy is unstated: the edit is rejected, and whether that is the right answer for the user is a separate question. Nor does this address a client that wants to keep editing while disconnected.

Implementation order

Deliberately two tickets, so the correctness floor lands without waiting on the UX.

  1. Basis check. Version tiers, ModelCommand carries its basis, Applied grows the rejected case. Includes the decision of where the version counters live — they are shared document state, so they belong beside document_generation on the authoritative App, not on ViewState.
  2. Advisory reservations. Generalise drill staging; publish key sets, keep values private.

The nullary wire variants ApplyAndClearDrill and AcceptWizardModel were to be resolved first or alongside. Already done: improvise-3x3u removed both from ModelCommand entirely, so they have no wire form to carry a basis or a SessionId, and layer 1 landed honestly without them (improvise-k2i4 closed as stale). What survives is the addressed form multi-client drill will want, which is layer 2 work.

What the check is not

It is an anti-staleness coordination check, not a security boundary. Basis derives Deserialize because it must cross the wire, and every field is public, so any value at all can arrive as a claim — versions start at 0 and increment by 1, so guessing one is trivial. Rejection::RecordUnstated closes omission — a client that leaves a record out of its claim — which is the bypass that would otherwise sit in the message format with nothing saying so. It does not close invention, and nothing here tries to: a hostile client that forges a current basis is indistinguishable from one that is genuinely caught up, and defending against that is authentication's job.

BasisCache::claim being the only constructor is therefore a statement about clients written against this crate, not about the wire. It is still worth having: it makes the honest path the easy one, and it is what makes "a client that has folded nothing composes nothing" true by construction.

Two places the check deliberately does not reach

Command::SwitchView carries no basis. It is LayoutGeometry but sits outside Command::Model, so apply_switch is unchecked. It cannot corrupt — the workbook refuses a view it does not have, and every mirror takes what was accepted (gotcha 36) — but it is a hole in "a batch carries its basis" and should be decided rather than left as an accident.

apply_command does not check at all (nor did reduce_full, which improvise-s17o has since deleted for having no caller), and the principle is correct: the check is a property of the session door, not of the mutation path, because a host holding the App is the truth and not a claimant. That is also why ui::reduce::caught_up(&App, cmd) is not a bypass — it takes &App, so nothing reachable from a wire can call it.

As implemented it was a second door, not an exemption — both functions took a wire Command whose Command::Model { cmd, .. } arm discarded the basis with .., while ui/reduce.rs's module doc pointed every future host at them by name. Closed by improvise-ondj as layer-1 cleanup: both now take a Checked<'_>, whose only constructor runs the check against the authoritative App and returns a Rejection. reduce_full was deleted outright shortly after (improvise-s17o): having no caller was the condition that let the defect hide, and a Checked signature on a function nobody calls fixes the type and not that. The exemption stands — a host's own ModelCommand still carries no basis — but it is no longer reachable as a door, and the unchecked path is unconstructible rather than merely discouraged.